@syahandri/ui
@syahandri/ui
Vue 3 + Tailwind v4 form components. Neutral palette, light mode, no headless UI dependency.
Forms — Button Input Textarea Label FormField Checkbox Radio
RadioGroup FileUpload Select MultiSelect Combobox
Overlays — Dialog Drawer Toaster + toast() Tooltip Popover
Navigation & disclosure — Dropdown (+ DropdownItem DropdownSeparator
DropdownLabel) · Tabs (+ TabList Tab TabPanel) · Accordion
(+ AccordionItem) · Pagination
Data display — Badge · Avatar (+ AvatarGroup) · Card (+ CardHeader
CardTitle CardDescription CardContent CardFooter) · Table
(+ TableHeader TableBody TableFooter TableRow TableHead TableCell
TableEmpty)
Install
npm i @syahandri/ui
Requires Vue ^3.5 (uses useId() for SSR-stable label wiring) and
Tailwind CSS ^4.
Setup — all three lines are required
@import "tailwindcss";
@import "@syahandri/ui/styles";
@source "../node_modules/@syahandri/ui/dist";
The
@sourceline is not optional. Tailwind v4 generates CSS by scanning source files for class names, and it deliberately skipsnode_modules. Without that line the components render completely unstyled — no error, no warning, just naked HTML. Adjust the relative path so it resolves from your CSS file tonode_modules.
Then:
<script setup>
import { FormField, Input, Select } from '@syahandri/ui'
</script>
<template>
<FormField label="Country" hint="Where you're based." required>
<Select v-model="country" :options="countries" clearable />
</FormField>
</template>
Theming
Every colour routes through a --ui-* custom property. Override any of them
after the import and the whole kit follows:
:root {
--ui-primary: oklch(0.55 0.21 262); /* blue instead of near-black */
--ui-radius: 0.25rem; /* tighter corners everywhere */
}
Override --ui-*, never --color-*. The latter are internal Tailwind-facing
aliases and may be restructured.
Full token list
--ui-background --ui-foreground --ui-card --ui-card-foreground
--ui-popover --ui-popover-foreground --ui-primary
--ui-primary-foreground --ui-secondary --ui-secondary-foreground
--ui-muted --ui-muted-foreground --ui-accent --ui-accent-foreground
--ui-destructive --ui-destructive-foreground --ui-destructive-accent
--ui-success --ui-success-foreground --ui-success-accent
--ui-warning --ui-warning-foreground --ui-warning-accent
--ui-border --ui-input --ui-ring --ui-radius
Status hues carry a fill and an accent, because they are not the same colour: a green light enough to sit behind white text is too light to be text on white, and readable amber is far darker than any usable amber fill. Accents clear WCAG AA (4.5:1) on the light background.
The kit ships a single light palette.
Overriding classes
Every component merges your class through tailwind-merge, configured to
understand the kit's own tokens. Your class wins, no !important needed:
<Button class="bg-emerald-600 hover:bg-emerald-700">Green</Button>
<Input class="rounded-none border-2" />
Components
Responsive behaviour
The kit is mobile-first. Controls are fluid (w-full) and step down to
compact desktop sizing at the sm breakpoint (40rem):
| mobile | desktop | |
|---|---|---|
size="sm" |
36px | 32px |
size="md" |
40px | 36px |
size="lg" |
44px | 40px |
| body text | 16px | 12–14px |
The 16px mobile font is not cosmetic. Safari on iOS force-zooms the page
whenever a focused input's font-size is under 16px, and it does not zoom back
out. Every focusable control is text-base below sm for that reason — if
you override a control's font size, keep it at 16px or larger on mobile.
Small glyph buttons (chip ×, clear) keep their 16px icon but expand their tap
area to ~36px on touch devices via a pointer-coarse pseudo-element, so
layout is unchanged on desktop.
Panels measure visualViewport rather than the layout viewport, so an open
on-screen keyboard shrinks the option list instead of leaving it rendered
behind the keyboard.
Shared conventions
sizeis'sm' | 'md' | 'lg'on every control (32 / 36 / 40 px), and deliberately shadows the nativesizeattribute.InputexposesnativeSizeif you genuinely need the HTML one.namemakes a component participate in native form submission. The custom controls render hidden inputs sonew FormData(form)sees them. Withoutname, no hidden inputs render.invalid,disabled, andrequiredare inherited from a surroundingFormFieldunless set explicitly on the control.- Options accept primitives (
['a','b']) or objects, withoptionLabel/optionValue/optionDisabledto adapt any shape.
Button
variant solid|soft|outline|ghost|link · color · textColor · size ·
iconOnly · block · loading · disabled · as · type ·
slots #icon #trailing
Treatment and colour are separate axes. variant styles the surface,
color supplies the hue — so any colour works with any treatment, and there
is no combinatorial explosion of outline-success-style names.
<Button>Save</Button>
<Button variant="outline" color="destructive">Delete</Button>
<Button variant="soft" color="secondary">Cancel</Button>
color takes a token name (primary secondary destructive foreground)
or any CSS colour. Hover, focus ring, and the soft/outline tints are all
derived from it via color-mix, so one value styles every state:
<Button color="#16a34a">Brand green</Button>
<Button color="oklch(0.55 0.21 262)">Any colour space</Button>
<Button color="var(--brand)">Wire it to your own token</Button>
<Button color="#facc15" text-color="#1c1917">Light bg needs dark text</Button>
Fill and accent are separate
A colour plays two roles: the fill behind solid, and the accent used
as text and border by soft, outline, ghost, and link. For primary
and destructive they're the same value. For light colours they must not be —
pale text on a white page is unreadable.
textColor— label colour on thesolidfill. Defaults to white.accentColor— text/border for the non-solid variants. Defaults tocolor.
<!-- A pale fill needs a darker accent, or soft/ghost/link vanish -->
<Button color="#facc15" text-color="#1c1917">Solid</Button>
<Button variant="soft" color="#facc15" accent-color="#a16207">Soft</Button>
color="secondary" handles this automatically: its fill is the 0.97-lightness
surface token, but its accent is --ui-muted-foreground, which stays readable
at roughly 4.7:1 on white while still reading as de-emphasised.
Three content shapes
<!-- icon + label -->
<Button>
<template #icon><PlusIcon /></template>
Add item
</Button>
<!-- label only -->
<Button>Save changes</Button>
<!-- icon only — aria-label is required, there is no visible text -->
<Button icon-only aria-label="Add item"><PlusIcon /></Button>
#trailing puts an icon after the label instead. Icons are auto-sized to
size-4 unless they carry their own size-* class.
type defaults to "button", not the HTML default of "submit" — an
unmarked button inside a form otherwise submits it. Pass type="submit"
explicitly. loading implies disabled, and swaps the leading icon for a
spinner so the button's width doesn't jump.
Input
size · nativeSize · type · invalid · disabled · slots #prefix #suffix
type="number" emits a real number (or null when empty, never NaN).
type="file" cannot be v-model-bound — listen to @change and read
event.target.files, or use FileUpload instead.
Textarea
rows · autoresize · maxHeight · invalid · disabled
Label / FormField
FormField owns the id, aria-describedby, and error wiring for whatever
control it wraps:
<FormField label="Email" hint="Never shared." error="Already taken." required>
<Input type="email" />
</FormField>
The error renders with role="alert" so it's announced on appearance.
Checkbox
value · indeterminate · size · invalid · disabled · name
Model is a boolean standalone, or bind several to one array for a group:
<Checkbox v-model="perms" value="read">Read</Checkbox>
<Checkbox v-model="perms" value="write">Write</Checkbox>
Radio / RadioGroup
<RadioGroup v-model="plan" orientation="horizontal">
<Radio value="free">Free</Radio>
<Radio value="pro">Pro</Radio>
</RadioGroup>
RadioGroup supplies the shared name that native arrow-key navigation
depends on. These are real <input type="radio"> elements, so keyboard
behaviour and form submission come from the browser.
FileUpload
multiple · accept · maxSize (bytes) · maxFiles · preview ·
maxPreview · showList · hint · name · invalid · disabled ·
slots #zone #item · emits @reject · exposes open() clear()
File count is unlimited by default. Omit maxFiles and any number of
files can be selected; set it only when you want a cap.
A drag-and-drop dropzone. The model is a plain File[]:
<FileUpload
v-model="files"
multiple
accept="image/*,.pdf"
:max-size="5 * 1024 * 1024"
:max-files="4"
@reject="handleRejections"
/>
Selection only — it hands you File objects and does not perform the
upload. Transport, auth, and progress belong to your app, not to a component
kit. Drive progress UI through the #item slot.
@reject fires with { file, reason, message }[], where reason is one of
type size count duplicate directory.
Three things worth knowing:
acceptis enforced manually. The native attribute only filters the file-picker dialog; drag & drop bypasses it entirely. This component re-checks every dropped file, which the browser will not do for you.- Native submission works. Hidden text inputs cannot carry a
File, so withnameset the component syncs the real<input type="file">via aDataTransferobject.new FormData(form)then sees the files. - Image previews are revoked. Object URLs pin their blob in memory until released; previews are revoked on removal and unmount rather than leaking.
With maxFiles unlimited, the practical ceiling is memory and render cost,
not the component: the list is not virtualized, and every image thumbnail
holds a decoded blob until revoked. A few hundred documents is fine; a few
hundred photos is not. Set maxPreview to cap thumbnail generation while
still accepting every file:
<FileUpload v-model="files" multiple :max-preview="12" />
Dialog
title · description · size (sm|md|lg|xl|full) · closeOnEscape ·
closeOnBackdrop · hideClose · v-model · @close · slots #header #footer
A modal layer: focus trapped, page scroll-locked, aria-modal. Escape closes
only the topmost overlay — a Select opened inside a Dialog closes itself
first and leaves the Dialog standing. Both slots receive { close }.
<Dialog v-model="open" title="Edit profile" description="Saves immediately.">
<FormField label="Email"><Input type="email" /></FormField>
<template #footer="{ close }">
<Button variant="outline" @click="close">Cancel</Button>
<Button @click="close">Save</Button>
</template>
</Dialog>
Drawer
side (right|left|top|bottom) · size · plus every Dialog prop
Same modal contract, anchored to an edge.
Toast
import { toast } from '@syahandri/ui'
toast('Saved')
toast.success({ title: 'Published', description: 'Live shortly.' })
toast.error({ title: 'Upload failed' }) // sticky by default
toast({ title: 'Deleted', action: { label: 'Undo', onClick: restore } })
Render the viewport once, near your app root:
<Toaster position="bottom-right" :limit="5" />
The queue is a module singleton, not an injected store — toasts are fired from
API handlers and route guards, none of which run inside a component's
setup(). Errors default to duration: 0 because a message you can't act on
before it vanishes is worse than none. Timers pause on hover and focus.
Tooltip
content · side · align · openDelay (300) · closeDelay · disabled
Opens on hover and focus; Escape dismisses. The delay stops tooltips flashing as the pointer crosses a toolbar.
Popover
side · align · offset · matchWidth · closeOnEscape · v-model ·
slots #trigger #default
Interactive content anchored to a trigger. Deliberately does not trap focus or lock scrolling — it isn't modal, so Tab should move out naturally.
Badge
variant (solid|soft|outline|ghost) · color · textColor · accentColor ·
size · shape (pill|rounded) · dot · removable · as · @remove
Same two axes as Button, so status badges get success and warning free.
link is absent — a badge is a label, not a navigation target; use as="a".
<Badge variant="soft" color="success" dot>Paid</Badge>
<Badge removable shape="rounded" @remove="drop(tag)">{{ tag }}</Badge>
dot carries the status at a glance in a dense table, where a full coloured
pill per row is visual noise.
Avatar
src · alt · name · fallback · size · shape · status
AvatarGroup — size · max · total · overlap
<Avatar name="Ada Lovelace" status="online" />
<AvatarGroup :max="3" :total="9">
<Avatar name="Ada Lovelace" />
<Avatar name="Grace Hopper" />
<Avatar name="Alan Turing" />
</AvatarGroup>
Initials derive from name, so fallback="AL" never drifts from
alt="Ada Lovelace". alt is empty by default — most avatars sit beside the
name they duplicate, and alt="Avatar" is pure screen-reader noise.
Sizes track the control heights exactly, so an avatar beside a Button lines up.
For a large display avatar, override the class (class="size-16").
Card
interactive · as · sub-components CardHeader CardTitle
CardDescription CardContent CardFooter, plus a #action slot on the
header.
<Card as="a" href="/invoice/1" interactive>
<CardHeader>
<CardTitle>Monthly invoice</CardTitle>
<CardDescription>Issued 1 August 2026</CardDescription>
<template #action><Badge variant="soft" color="success" dot>Paid</Badge></template>
</CardHeader>
</Card>
Padding mirrors Dialog, so a Card and a Dialog read as the same surface.
interactive only styles — pair it with as="a" or a real button, since a div
that looks clickable but can't be reached by keyboard is worse than one that
looks inert.
Table
Table — variant (borderless|plain|bordered|grid) · striped ·
headerFill · headerColor · headerTextColor · stripeColor · dense · stickyHeader ·
maxHeight · caption · captionTop
TableHeader — divider
TableHead — scope · sortable · sort · align · numeric · @sort
TableCell — align · numeric · TableRow — selected ·
TableEmpty — colspan · message
<Table caption="Invoices" striped dense sticky-header max-height="24rem">
<TableHeader>
<TableRow>
<TableHead sortable :sort="dir" @sort="dir = $event">Reference</TableHead>
<TableHead numeric>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableEmpty v-if="!rows.length" :colspan="2" />
<TableRow v-for="r in rows" :key="r.id" :selected="r.id === current">
<TableCell>{{ r.ref }}</TableCell>
<TableCell numeric>{{ r.amount }}</TableCell>
</TableRow>
</TableBody>
</Table>
variant owns border treatment only. Striping and header fill are separate
props because they are separate choices — folding them in would mean you could
not have a bordered striped table, the same conflation Button was refactored to
remove.
<Table variant="grid" striped header-color="#dbeafe" />
<Table variant="borderless" :header-fill="false" />
Colours travel as CSS variables on the table, so headerColor and
stripeColor take a token name or any CSS value — the same contract as
Button's color.
Header text follows the fill. A token name carries its paired foreground,
so a near-black primary header gets light text while an amber warning
header gets dark. The pair is looked up, never guessed — CSS has no usable
contrast function, which is exactly why a raw CSS headerColor needs
headerTextColor and a token does not.
That pairing lives in one place (resolveSurface, exported) and backs
Button's color, Badge's color, and this — the three components that
independently needed it. Keep headerColor opaque if you use stickyHeader: it is
the th elements that stick, and a translucent header lets rows scroll
through it.
Compound rather than a props-driven <DataTable :columns>, deliberately:
sorting, selection and custom cells vary per screen, and a monolithic table
gets forked the first time one of them doesn't fit.
sortable emits the next direction — asc → desc → none, so a third click
clears. Sorting stays with the parent; the component has no idea what your rows
are. stickyHeader needs a bounded maxHeight, or there is nothing to scroll.
Dropdown
side · align · offset · disabled · v-model · slots #trigger #default
DropdownItem — danger · keepOpen · href · disabled · @select
The ARIA menu pattern, which is not the listbox behind Select: a menu
moves real focus between items, where a listbox keeps focus on its input and
tracks the active option with aria-activedescendant.
<Dropdown>
<template #trigger><Button variant="outline">Actions</Button></template>
<DropdownLabel>Manage</DropdownLabel>
<DropdownItem @select="edit">Edit</DropdownItem>
<DropdownSeparator />
<DropdownItem danger @select="remove">Delete</DropdownItem>
</Dropdown>
Tabs
orientation · activationMode · variant (underline|pill) · v-model
<Tabs v-model="tab">
<TabList>
<Tab value="overview">Overview</Tab>
<Tab value="activity">Activity</Tab>
</TabList>
<TabPanel value="overview">…</TabPanel>
<TabPanel value="activity">…</TabPanel>
</Tabs>
activationMode defaults to automatic — a tab activates as soon as focus
reaches it, per ARIA. Use manual when a panel fetches data, so arrowing past
three tabs doesn't fire three requests. TabPanel accepts keepAlive to stay
mounted while hidden.
Accordion
type (single|multiple) · collapsible · v-model
AccordionItem — value · title · disabled · slot #title
In multiple mode the model is an array. Height animates by transitioning
grid-template-rows between 0fr and 1fr, which resolves to the content's
real height at both ends — no measuring, exact for any content. Collapsed
panels are inert, so links inside stay out of the tab order.
Pagination
total · perPage · siblingCount · boundaryCount · showEdges ·
compact · size · disabled · v-model
<Pagination v-model="page" :total="240" :per-page="10" :sibling-count="2" show-edges />
total is the item count, not the page count — pass an API total straight
through. The current page carries aria-current="page".
Select
options · placeholder · size · searchable ('auto' by default) ·
searchThreshold (10) · filter · clearable · loading · emptyText ·
name · slots #option #selected #empty
The search field appears only once the list exceeds searchThreshold, so
short lists stay a single click. Set searchable to true/false to force.
MultiSelect
options · maxChips (3) · max · searchable (true) · clearable ·
filter · name · slots #option #empty
Selections show as chips, collapsing to +N more past maxChips. Backspace
on an empty search removes the last chip. The panel stays open while picking.
Combobox
options · multiple · allowCustom · maxChips · filter · clearable ·
name · slots #option #empty
The styleable replacement for <datalist> — native datalist renders its
dropdown in browser chrome, so no CSS reaches it and behaviour varies per
browser. This implements the ARIA 1.2 combobox pattern: the text input is the
trigger, typing filters inline, and allowCustom permits values that aren't
in the list. multiple adds checkboxes and chips.
Keyboard
Identical across Select, MultiSelect, and Combobox — they share one core.
| Key | Behaviour |
|---|---|
↓ / ↑ |
Open, then move the active option (wraps) |
Home / End |
First / last option |
Enter |
Select. Single-value closes; multiple stays open |
Space |
Select — unless a text input has focus, where it types |
Esc |
Clears the query first, closes on the second press |
Tab |
Closes and moves focus onward — never trapped |
Backspace |
Removes the last chip when the query is empty |
| a–z | Typeahead, when no search field is present |
The highlighted option is tracked with aria-activedescendant, so DOM focus
never leaves the trigger or search input and the caret stays put.
Development
Live: https://syahandri-ui.vercel.app — pushing to dev auto-deploys.
For local iteration (faster than waiting on a deploy):
npm install
npm run dev # http://localhost:5179
vercel.json builds the library first, then the playground into
playground/dist. The library build must run first — the playground imports
from src/, but virtual:kit-facts measures the built bundle to show its
gzipped size, and hides that chip when dist/ is absent.
The dev server serves two views:
#/docs |
Full documentation — setup, theming, live prop tables, a runtime theme switcher |
#/playground |
Every component in every state, for eyeballing changes |
Both render the real components, so no example can drift from the
implementation. Docs sources live in playground/views/Docs.vue, with the
Demo / CodeBlock / PropTable helpers in playground/parts/.
Type declarations
The kit is plain JavaScript, but it ships .d.ts files so consumers get prop
autocomplete and type errors. They are generated by vue-tsc from the
runtime defineProps({...}) already in the components — not hand-written — so
they cannot drift from the source:
npm run build # vite build, then vue-tsc emits dist/**/*.d.ts
npm run check:types # typechecks typecheck/Consumer.vue against dist/
typecheck/Consumer.vue is a simulated TypeScript consumer that must compile
with zero errors. If the generated types ever regress to any or contradict
the runtime, that command fails here rather than in someone's project.
Enum-ish props (variant, size, orientation, color) carry a JSDoc
PropType cast so they narrow to unions — a runtime validator function is
opaque to the compiler and would otherwise widen to plain string:
size: { type: /** @type {import('vue').PropType<'sm' | 'md' | 'lg'>} */ (String), default: 'md' }
typescript and vue-tsc are devDependencies only; neither ships.
Publishing
npm run build emits dist/index.js + index.css plus the generated
.d.ts tree. The prepare script runs it automatically before npm publish,
so a broken build aborts the release rather than shipping a stale dist/.
Only dist/ and README.md ship; verify with npm pack --dry-run.
An npm token in ~/.npmrc means npm publish needs no browser approval.
Known limits
- No virtualization. Panels render every option; expect jank past roughly 300–500 items.
- Light mode only. The kit ships a single light palette.
- Model values use
undefined, notnull, for "no selection" — Vue's type layer stripsnullfrom prop unions, so shippingnullwould mean types that contradict the runtime. - Mobile selects use the custom panel rather than the native iOS wheel picker, a consequence of supporting in-panel search.
Related Packages
Vue 3 design system with tokens, Tailwind CSS 4, light/dark mode, and 60+ compon...
Vue 3 components cut from hexagons: 45° bevels drawn in CSS layers, an iridescen...
A professional Vue 3 component library with modern design principles
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.7.0 | ^3.5.0 | MIT | |
| 0.6.0 | ^3.5.0 | MIT | |
| 0.5.1 | ^3.5.0 | MIT | |
| 0.5.0 | ^3.5.0 | MIT | |
| 0.4.1 | ^3.5.0 | MIT | |
| 0.4.0 | ^3.5.0 | MIT | |
| 0.3.0 | ^3.5.0 | MIT | |
| 0.2.1 | ^3.5.0 | MIT | |
| 0.2.0 | ^3.5.0 | MIT | |
| 0.1.1 | ^3.5.0 | MIT | |
| 0.1.0 | ^3.5.0 | MIT |