@syahandri/ui

Vue 3 + Tailwind v4 form components. Token-driven, neutral, zero headless dependencies.
Install
npm install @syahandri/ui
Latest Version:0.7.0
Vue:3
License:MIT
Last Updated:Aug 12, 2026
Links: npm
Maintainer: syahandri

@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 @source line is not optional. Tailwind v4 generates CSS by scanning source files for class names, and it deliberately skips node_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 to node_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

  • size is 'sm' | 'md' | 'lg' on every control (32 / 36 / 40 px), and deliberately shadows the native size attribute. Input exposes nativeSize if you genuinely need the HTML one.
  • name makes a component participate in native form submission. The custom controls render hidden inputs so new FormData(form) sees them. Without name, no hidden inputs render.
  • invalid, disabled, and required are inherited from a surrounding FormField unless set explicitly on the control.
  • Options accept primitives (['a','b']) or objects, with optionLabel / optionValue / optionDisabled to 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 the solid fill. Defaults to white.
  • accentColor — text/border for the non-solid variants. Defaults to color.
<!-- 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:

  • accept is 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 with name set the component syncs the real <input type="file"> via a DataTransfer object. 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, not null, for "no selection" — Vue's type layer strips null from prop unions, so shipping null would 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

@netragate/design-system

Vue 3 design system with tokens, Tailwind CSS 4, light/dark mode, and 60+ compon...

3,214
fuxsto-design

Monochrome zinc, Tailwind CSS v4 component library for Vue 3 (fuxsto-ui)

2,089
bismuth-ui

Vue 3 components cut from hexagons: 45° bevels drawn in CSS layers, an iridescen...

2,071
otai-vue-ui

Vue 3 component library with Tailwind CSS design system by Open Ticket AI

701
jobins-frontend-components

A professional Vue 3 component library with modern design principles

6,037

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