loomkit-ui-vue
| 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
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
A comprehensive Vue 3 UI components library with modern design and accessibility...
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 |