loomkit-ui-vue

Accessible, headless Vue 3 UI components: text field, dropdown/select, date picker, file upload, popup, checkbox, toggle, avatar and people picker. Themed with CSS variables, works with Nuxt and SSR, no third-party dependencies.
367
Install
npm install loomkit-ui-vue
Latest Version:0.1.3
Vue:3
License:MIT
Last Updated:Oct 21, 2026
Links: npm

loomkit-ui-vue

npm license

Vue 3 components for LoomKit — a headless, fully-customizable suite of modern UI components. Native Vue implementations (composables, v-model support) built on shared logic and theming from loomkit-ui.

Install

npm install loomkit-ui-vue
# vue >=3.3 is a peer dependency

Quick start

<script setup lang="ts">
import { ref } from "vue";
import { TextField } from "loomkit-ui-vue";

const name = ref("");
const error = ref<string>();
</script>

<template>
  <TextField
    label="Full name"
    required
    v-model="name"
    :status="error ? 'error' : 'default'"
    :status-message="error"
    helper-text="As it appears on your ID"
  />
</template>

There's no stylesheet to import: the first LoomKit component you import adds the default theme to the page itself (see Styles). Unrecognized attributes (placeholder, type, @blur, ...) fall through to the underlying <input>.

<TextField /> props

Prop Type Default Description
label string — Required. Always programmatically associated with the input via for/id, even when hidden.
label-hidden boolean false Visually hides the label; stays in the accessibility tree.
helper-text string — Shown below the input when status="default".
status-message string — Shown instead of helper-text when status is "error"/"success"; sets aria-invalid and role="alert" on error.
status "default" | "success" | "error" "default" Validation state.
size "sm" | "md" | "lg" "md" Visual size.
disabled boolean false
required boolean false Adds a visible * marker and the native required attribute.
modelValue string — Use via v-model.

<Dropdown />

Searchable single- or multi-select dropdown.

<script setup lang="ts">
import { ref } from "vue";
import { Dropdown, type DropdownValue } from "loomkit-ui-vue";

const fruits = [
  { value: "apple", label: "Apple" },
  { value: "banana", label: "Banana" },
  { value: "cherry", label: "Cherry", disabled: true },
];
const value = ref<DropdownValue>([]);
</script>

<template>
  <Dropdown label="Fruit" :options="fruits" multiple v-model="value" />
</template>
Prop Type Default Description
options DropdownOption[] — Required. { value, label, disabled? }. Values must be unique — duplicates log a dev warning.
multiple boolean false Enables multi-select with removable tags in the trigger.
modelValue string | string[] | undefined — Use via v-model. A string for single-select, a string array for multi-select.
placeholder string "Select…"
searchable boolean true Shows a filter input inside the open panel.
clearable boolean true Shows a button to clear the current selection.
noOptionsMessage string "No options found" Shown when a search matches nothing.
filterFn (options, query) => DropdownOption[] substring match Override for fuzzy/custom filtering.
searchPlaceholder string "Search…"
clearButtonLabel string "Clear selection" aria-label for the clear button.
removeTagLabel (optionLabel) => string `Remove ${label}` aria-label for a tag's remove button.
loading boolean false Shows loadingMessage instead of the option list — for async/remote options.
loadingMessage string "Loading…"
virtualized boolean false Renders only the visible rows of a large option list (100s–1000s). Not combined with grouped options — see below.
rowHeight number 36 Fixed row height in pixels, used for virtualization math. Should match each row's actual rendered height.

Plus label, labelHidden, helperText, statusMessage, status, size, disabled, required — the same field props <TextField /> takes — and any other attribute (style, data-*, title, ...), forwarded straight through to the trigger. @search-change fires on every query change, independent of filterFn — the hook for triggering a remote/ debounced fetch (see async options below).

Use the #option="{ option, selected, active }" scoped slot to fully replace a row's content — e.g. an avatar next to a name:

<Dropdown label="Assignee" :options="people">
  <template #option="{ option, selected }">
    <img :src="https://raw.githubusercontent.com/santhoshkumarhari/loomkit-ui/HEAD/option.value.photoUrl" class="avatar" />
    {{ option.label }} <span v-if="selected">✓</span>
  </template>
</Dropdown>

Keyboard: ArrowUp/Down move the highlighted option, Home/End jump to the first/last enabled option, Enter selects, Escape closes and returns focus to the trigger.

State as data-attributes: the trigger carries data-state="open"|"closed" and data-disabled; each option row carries data-selected, data-active, data-disabled — style or animate against these in plain CSS instead of reaching for JS.

Grouped options — set group on any DropdownOption and matching rows are collected under a header, in first-appearance order of each distinct group name:

const options = [
  { value: "apple", label: "Apple", group: "Fruit" },
  { value: "carrot", label: "Carrot", group: "Vegetable" },
  { value: "banana", label: "Banana", group: "Fruit" }, // grouped with Apple, not a second "Fruit" header
];

Async/remote options — Dropdown never fetches anything itself; pair loading with @search-change to drive your own fetch:

<script setup lang="ts">
import { ref } from "vue";
import { Dropdown } from "loomkit-ui-vue";
import type { DropdownOption } from "loomkit-ui";

const options = ref<DropdownOption[]>([]);
const loading = ref(false);

async function onSearchChange(query: string) {
  loading.value = true;
  options.value = await fetchUsers(query);
  loading.value = false;
}
</script>

<template>
  <Dropdown label="Search users" :options="options" :loading="loading" @search-change="onSearchChange" />
</template>

Virtualization — set virtualized for option sets in the hundreds or thousands; only the rows in view are rendered, using fixed-height windowing (rowHeight). Not combined with grouping in v1 — group headers have a different height than option rows, which this fixed-row-height math doesn't account for (a dev warning fires if both are set).

<Avatar />

Shows a person's photo when available; falls back to their initials on a deterministic background color (the same name always gets the same color).

<Avatar name="Ada Lovelace" :photo-url="user.photoUrl" :size="32" />
Prop Type Default Description
name string — Required. Drives the initials fallback and the accessible label.
photoUrl string — Shown when present and loadable; falls back to initials if it fails to load (tracked automatically).
color string derived from name Background color for the initials fallback.
size number 24 Diameter in pixels.

<PeoplePicker />

A Dropdown specialized for picking people — each row shows an Avatar next to the name and email. It's a thin wrapper: every Dropdown prop (multiple, searchable, status, ...) applies here too.

<script setup lang="ts">
import { ref } from "vue";
import { PeoplePicker } from "loomkit-ui-vue";
import type { PersonOption } from "loomkit-ui";

const people: PersonOption[] = [
  { value: "ada", label: "Ada Lovelace", email: "ada@example.com", photoUrl: "/ada.jpg" },
  { value: "grace", label: "Grace Hopper", email: "grace@example.com" }, // no photo -> initials
];
const assignee = ref<string>();
</script>

<template>
  <PeoplePicker label="Assignee" :options="people" v-model="assignee" />
</template>

To customize the row further than name/email/avatar, use <Dropdown /> directly with your own #option slot.

<Checkbox /> / <Toggle />

Both are real <input type="checkbox"> elements (Toggle adds role="switch") styled via appearance: none — no custom ARIA-role workaround needed, since neither has interactive children conflicting with <label for>. Both support v-model (boolean) plus the shared field props (label, helperText, statusMessage, status, size, disabled, required).

<Checkbox label="Accept the terms" required v-model="accepted" />
<Checkbox label="Select all" :indeterminate="someButNotAllSelected" v-model="allSelected" />
<Toggle label="Dark mode" v-model="darkMode" />

Checkbox's indeterminate prop is set imperatively on the underlying <input> (the DOM indeterminate property has no HTML attribute equivalent) and is purely visual — it doesn't affect v-model.

<Popup />

A generic floating panel anchored to a reference element — the same positioning Dropdown's panel uses, exposed directly for arbitrary content (menus, popovers, custom pickers). Popup owns positioning, outside-click/Escape dismissal, and an optional focus trap; you own open state — it only emits close, never closes itself.

<script setup lang="ts">
import { ref } from "vue";
import { Popup } from "loomkit-ui-vue";

const anchorRef = ref<HTMLElement | null>(null);
const open = ref(false);
</script>

<template>
  <button ref="anchorRef" @click="open = !open">Info</button>
  <Popup :open="open" @close="open = false" :anchor-ref="anchorRef" role="dialog" aria-label="Info">
    <p>Some details, positioned next to the button.</p>
  </Popup>
</template>
Prop Type Default Description
open boolean — Required. The popup unmounts entirely when false.
anchor-ref Ref<HTMLElement | null> — Required. Template ref the popup is positioned relative to.
placement Placement "bottom-start" Same placement values as Dropdown.
trap-focus boolean true Traps Tab within the popup; restores focus on close.
close-on-escape boolean true
close-on-outside-click boolean true
backdrop boolean false Renders a semi-transparent overlay that also closes on click — for a modal-like popup rather than a lightweight popover.
role string — e.g. "dialog" or "menu". No default — plain popovers don't require one.

@close fires on Escape/outside-click/backdrop-click. It doesn't close the popup — you set open to false yourself.

<FileUpload />

A labeled, accessible drag-and-drop file picker: click or drop to select, with accept/size/count validation and image thumbnail previews (object URLs created and revoked automatically — no leaks).

<script setup lang="ts">
import { ref } from "vue";
import { FileUpload } from "loomkit-ui-vue";
import type { FileRejection } from "loomkit-ui";

const files = ref<File[]>([]);
</script>

<template>
  <FileUpload
    label="Attachments"
    accept="image/*,.pdf"
    :max-size-bytes="5 * 1024 * 1024"
    :max-files="3"
    v-model="files"
    @reject="(rejections: FileRejection[]) => console.warn(rejections)"
  />
</template>
Prop Type Default Description
modelValue File[] [] Use via v-model.
multiple boolean true false replaces the single file instead of appending.
accept string — Comma-separated MIME types, type/* wildcards, or .ext extensions. Also set on the native <input accept>.
max-size-bytes number —
max-files number —
progress Record<string, number> — Per-file upload progress in [0, 100], keyed by fileKey(file) (exported from loomkit-ui) — actually performing the upload is up to you.
dropzone-text string "Drag and drop files here, or click to browse"
hint-text string auto-generated from accept/max-size-bytes
remove-button-label (fileName: string) => string `Remove ${name}`

@reject fires with any files rejected by accept/max-size-bytes/max-files from the most recent drop or selection — FileRejection is { file, reason: "type" | "size" | "count" }.

Plus label, labelHidden, helperText, statusMessage, status, disabled, required — the same field props <TextField /> takes.

Keyboard: the dropzone is a real tab stop; Enter/Space opens the file picker.

<DatePicker />

A labeled date picker: a read-only text input (no free-text date parsing ambiguity) that opens a calendar popup on click. Built from scratch on Date/Intl — no date library.

<script setup lang="ts">
import { ref } from "vue";
import { DatePicker } from "loomkit-ui-vue";

const date = ref<Date>();
const isWeekend = (d: Date) => d.getDay() === 0 || d.getDay() === 6;
</script>

<template>
  <DatePicker label="Due date" v-model="date" :min-date="new Date()" :is-date-disabled="isWeekend" />
</template>
Prop Type Default Description
mode "single" | "range" "single" "range" selects a start/end pair via v-model:range-value instead of v-model.
modelValue Date — mode="single": use via v-model.
range-value { start: Date | undefined; end: Date | undefined } — mode="range": use via v-model:range-value.
min-date / max-date Date —
is-date-disabled (date: Date) => boolean — Custom predicate — holidays, specific weekdays, etc.
week-starts-on 0–6 0 (Sunday)
locale string runtime locale BCP 47 locale for month/weekday labels and the input's display format.
format-date (date: Date, locale?: string) => string locale-formatted numeric date mode="single": overrides the input's display text.
format-range (range, locale?: string) => string "<start> – <end>" mode="range": overrides the input's display text.
show-time boolean false Adds an hour/minute time input. mode="single" only.
format-date-time (date: Date, locale?: string) => string locale-formatted date + time Overrides the input's display text when show-time is set.
time-label string "Time" Accessible label for the time input.
done-button-label string "Done" Label for the button that closes the calendar when show-time is set.
clearable boolean true Shows a "Clear" link inside the calendar when there's a selection.
placeholder string "Select a date…"

Plus label, labelHidden, helperText, statusMessage, status, size, disabled, required — the same field props <TextField /> takes.

Range mode uses the conventional two-click UX: the first click starts the range, the second completes it — chronologically, regardless of click order — and hovering between the two previews the range. A third click after a completed range starts a new one. v-model:range-value is Vue's convention for a second, independently-named v-model:

<script setup lang="ts">
import { ref } from "vue";
import { DatePicker } from "loomkit-ui-vue";
import type { DateRange } from "loomkit-ui";

const range = ref<DateRange>({ start: undefined, end: undefined });
</script>

<template>
  <DatePicker label="Trip dates" mode="range" v-model:range-value="range" />
</template>

Keyboard, once the calendar is open: arrow keys move by day/week, Home/End jump to the start/end of the week, PageUp/PageDown change month, Enter selects (in range mode, same two-click logic as a click), Escape closes.

Time selection — show-time adds a native <input type="time"> below the calendar grid. Picking a day no longer closes the calendar (the time usually needs setting too); a "Done" button closes it explicitly. Not combined with mode="range" (a dev warning fires if both are set):

<DatePicker label="Meeting" show-time v-model="dateTime" />

Picking a new day preserves whatever time was already set (defaulting to midnight on a first pick); changing the time input preserves the day.

Styles

When a LoomKit component is first imported in the browser, it adds the default theme to the page as a single <style id="loomkit-ui-styles"> tag at the start of <head>. Because it comes first, your own stylesheets still win at equal specificity. No CSS import or bundler configuration is needed.

You can still import the stylesheet yourself, which helps in two cases:

  • Server rendering (Nuxt, Vite SSR). The style tag is added in the browser, so a server-rendered page shows unstyled components until the JavaScript loads. Import the CSS globally to style the first paint.
  • A strict Content Security Policy that blocks inline <style> tags.
// main.ts, or in Nuxt: css: ["loomkit-ui/styles.css"] in nuxt.config.ts
import "loomkit-ui/styles.css";

The injected tag then repeats the same rules, which is harmless.

Overriding styles

Everything is themed through CSS custom properties (see loomkit-ui's reference) — override them globally, per-theme, or per-instance:

<TextField label="Name" style="--lk-color-accent: #059669" />

Or pass your own class, which is appended alongside the built-in classes (lk-text-field lk-text-field--md your-class), so you can target it with normal CSS specificity or utility classes.

License

MIT

Related Packages

vue-superselect

Headless, accessible, TypeScript-first select/combobox for Vue 3

708 37
va-ui-components

A comprehensive Vue 3 UI components library with modern design and accessibility...

9,434
@juxtui/ui

Accessible, polished UI components for Nuxt & Vue 3.

1,648 0
vue-rolekit

Componentes Vue 3 con estilo propio y modo claro y oscuro: formularios, datos, n...

0 0

Version History

Version Released Vue License
0.1.3 >=3.3 MIT
0.1.2 >=3.3 MIT
0.1.1 >=3.3 MIT
0.1.0 >=3.3 MIT