Skip to content

ui-inputs

Headless, themeable Vue 3 form input components, styled entirely through --ui-* CSS custom properties.

ui-inputs opens the ui-* family: where fs-* packages are frontend services, ui-* packages are shared UI components. The components ship no token vocabulary and no hard-coded brand colour — you map your design tokens onto the --ui-* contract once, and every component follows. Soft and rounded or hard and brutalist, light or dark, from one component set.

bash
npm install @script-development/ui-inputs

Peer dependency: vue ^3.5.40

Import the stylesheet once (e.g. in your app entry):

typescript
import '@script-development/ui-inputs/style.css';

Live Demo

Every control below is the real component, rendered by this page. The demo container maps a handful of --ui-* variables onto this site's own theme variables — which is exactly the adoption step your app performs with its design tokens (see Theming):

css
.ui-demo {
    --ui-control-bg: var(--vp-c-bg);
    --ui-control-text: var(--vp-c-text-1);
    --ui-control-border-color: var(--vp-c-divider);
    --ui-menu-bg: var(--vp-c-bg-elv);
    /* … */
}

That is the whole trick: the components read variables, the consumer supplies values. Toggle this site's dark mode — the controls follow, because the mapped tokens do.

SingleSelect

A button-triggered, keyboard-navigable listbox, generic over your option type. Open it with click, Enter, Space, or the arrow keys; navigation is announced to assistive tech via aria-activedescendant.

vue
<FormField id="fruit" label="Fruit" #default="{controlId, describedby, invalid}">
    <SingleSelect :id="controlId" v-model="fruit" :options="fruits" label="name" :invalid="invalid" :describedby="describedby" />
</FormField>

Combobox

The searchable single-select: a text input that filters the listbox as you type. On Escape, Tab, or a click outside, the input snaps back to the committed label — a half-typed non-match never lingers. Try typing ma:

vue
<FormField id="city" label="City" #default="{controlId, describedby, invalid}">
    <Combobox :id="controlId" v-model="city" :options="cities" label="name" :invalid="invalid" :describedby="describedby" />
</FormField>

Combobox also exposes an imperative focus() handle via a template ref (box.value?.focus()) — the piece a focus-trap or command-palette integration needs.

MultiSelect

Models an array of option ids. Committing an option toggles its membership and the listbox stays open, so picking several values is one open/close cycle. Committed values render as chips with per-chip remove buttons; Backspace on the focused trigger pops the last value.

vue
<FormField id="toppings" label="Toppings" #default="{controlId, describedby, invalid}">
    <MultiSelect :id="controlId" v-model="toppingIds" :options="toppings" label="name" :invalid="invalid" :describedby="describedby" />
</FormField>

MultiCombobox

MultiSelect's searchable sibling: the same array model, toggle-in-place commits, and chip bar — but the trigger is Combobox's filter-as-you-type text input (the list opens on focus, click, or typing). On every toggle-commit the popup stays open, the query clears so the full list is re-offered, and focus returns to the input — so picking several values is type, Enter, type, Enter. Backspace with an empty query pops the last chip. Try typing ca:

vue
<FormField id="tags" label="Toppings (searchable)" #default="{controlId, describedby, invalid}">
    <MultiCombobox :id="controlId" v-model="tagIds" :options="toppings" label="name" :invalid="invalid" :describedby="describedby" />
</FormField>

The checkbox family

Checkbox, Switch, CheckboxGroup, and RadioGroup sit on a native input chassis — a real <input type="checkbox"> / <input type="radio"> restyled through the same --ui-* contract, never a div-with-role — so keyboard and assistive-tech behaviour come from the platform. The radio group's arrow-key selection below is the browser's own roving focus; the component hand-rolls none of it.

vue
<Checkbox id="terms" v-model="accepted" label="Accept the terms" />
<Switch id="notify" v-model="notifications" label="Email notifications" />
<CheckboxGroup id="extras" v-model="extraIds" :options="toppings" option-label="name" label="Extras" />
<RadioGroup id="size" v-model="size" :options="sizes" option-label="name" label="Size" required />

Checkbox and Switch model a non-nullable boolean — a checkbox is never "empty", unchecked is false (the one deliberate exception to the family's nullable-model rule). Checkbox additionally takes indeterminate as a prop, mirrored onto the element's DOM property and drawn as a dash — purely visual, it never touches the model.

Composing with FormField — error state

FormField wires label, control, and error together (ids, aria-describedby, invalid flag) through its slot scope. The error is a prop, never a service — resolve the message in your app and pass it down. Clear the field below to see the invalid treatment appear:

vue
<FormField id="email" label="Email" required :error="errors.email" #default="{controlId, describedby, invalid}">
    <TextInput :id="controlId" v-model="email" type="email" :invalid="invalid" :describedby="describedby" />
</FormField>

Components

ComponentPurpose
FormFieldLabel + error + required-marker composition wrapper (error-as-prop)
FormLabel / FormErrorThe atoms FormField composes — usable standalone
TextInputNative text / email / password / search / tel / url input
NumberInputNative number input; owns the NaNnull empty-value guard
DateInputNative date input with ISO min / max bounds
TextareaNative textarea with rows
CheckboxNative checkbox, visually restyled; non-nullable boolean model, indeterminate as a visual prop
CheckboxGroupFieldset/legend group of checkboxes — models an array of option ids in options order
SwitchThe checkbox chassis with role="switch" — an on/off toggle with a themeable track + thumb
RadioGroupFieldset/legend radio group (role="radiogroup") — models T['id'] | null; native roving focus + arrow-key selection
SingleSelectAccessible button-triggered listbox, generic over your option type
ComboboxAccessible searchable/filtering single-select; exposes an imperative focus() handle
MultiSelectAccessible multi-value select — models an array of option ids; toggle-in-place listbox, inline chip bar with per-chip remove
MultiComboboxAccessible searchable multi-value select — MultiSelect's model + chips with Combobox's filtering input as the trigger

Two types complete the public surface: SelectItem ({id: string | number} — the minimal shape every option must satisfy) and LabelKey<T> (keyof T | ((option: T) => string) — how to derive an option's display string).

Contracts

FormField

PropTypeDefaultNotes
idstringRequired. Stable control id — pass useId() if you have no natural one
labelstringOmit for an unlabelled field
requiredbooleanfalseRenders the required marker and threads required to the slot
errorstringResolved error string (error-as-prop); renders FormError when present

The default slot receives {controlId, errorId, required, invalid, describedby} — spread them onto the control as shown in the demos, and the label/error/aria wiring is complete.

Text-like inputs

TextInput, DateInput, and Textarea model string | null; NumberInput models number | null. All four share id (required), disabled, required, invalid, and describedby props, plus:

ComponentExtra propsModelEmits on clear
TextInputtype, placeholderstring | null''
NumberInputmin, max, step, placeholdernumber | nullnull
DateInputmin, max (ISO YYYY-MM-DD)string | null''
Textarearows, placeholderstring | null''

See Nullable values for why the models are nullable and what each input emits when cleared.

The select family

SingleSelect, Combobox, MultiSelect, and MultiCombobox share one generic contract (<T extends SelectItem>):

PropTypeDefaultNotes
optionsT[]Required
labelLabelKey<T>Required. Property name or getter for an option's display string
idstringRequired. Pairs the trigger with a label/error
placeholderstring'Select…'
disabledbooleanfalse
alphabeticalSortbooleantrueSorts rendered options by display string
requiredbooleanfalseConveyed via aria-required
invalidbooleanfalseInvalid styling + aria-invalid
describedbystringId of the paired error element
emptyTextstring'No options'Shown when the (filtered) list is empty
optionsLabelstring'Options'Accessible name for the listbox popup — a prop so you can localise
mutedOptionsT['id'][]Ids rendered visually muted (.is-muted) — still committable

They differ in what they model:

ComponentModelExtras
SingleSelectT['id'] | nullclearLabel / emptyDisplayValue (the committing clear entry — see below)
ComboboxT['id'] | nullText-input trigger that filters options; focus() exposed; clearLabel / emptyDisplayValue
MultiSelectT['id'][]Toggle-in-place commits, chip bar, removeLabel prop (default 'Remove', localisable)
MultiComboboxT['id'][]MultiSelect's extras + the filtering input trigger; focus() exposed; query clears + input refocuses on every commit

The #option scoped slot

All four selects render each option's plain display string by default; the option slot replaces that content with your own (colour swatches, icons, rich labels). The payload is {option: T, index, selected, active} — typed against your option type. Highlight and selection chrome (.is-active, aria-selected) stay on the option row, outside the slot, so custom content never has to re-create it:

vue
<SingleSelect id="label" v-model="labelId" :options="labels" label="name">
    <template #option="{option}">
        <span class="swatch" :style="{background: option.color}" /> {{ option.name }}
    </template>
</SingleSelect>

The committing clear entry (SingleSelect / Combobox)

clearLabel renders a committing entry above the options — choosing it commits null and closes, exactly like choosing an option. It lives outside the option index space: its own keyboard slot between "nothing highlighted" and the first option, its own ${id}-clear id for aria-activedescendant, and aria-selected="true" while the model is null. In the Combobox it also sits outside the filter — it renders whatever the query says.

Pair it with emptyDisplayValue: the string the trigger (or the Combobox input) renders as a value when the model is null ("No sprint (backlog)") instead of the muted placeholder / blank input. has-value styling stays keyed on an actual selection. The entry is danger-toned by default (--ui-clear-text, chaining to --ui-danger-text).

vue
<SingleSelect
    id="sprint"
    v-model="sprintId"
    :options="sprints"
    label="name"
    clear-label="No sprint"
    empty-display-value="No sprint (backlog)"
/>

The checkbox family

Checkbox and Switch share id (required), label (inline label text; the default slot overrides it for rich content), disabled, required, invalid, and describedby. Both model a non-nullable boolean. Checkbox adds indeterminate (visual prop → the element's DOM property). Native required is never set — aria-required is the conveyance, as everywhere in the family.

CheckboxGroup and RadioGroup are generic over T extends SelectItem and render a chrome-less <fieldset> with a <legend>:

PropTypeDefaultNotes
optionsT[]Required. Rendered in the given order — groups never sort
optionLabelLabelKey<T>Required. The family's display resolver — named optionLabel because label is the legend
labelstringRequired. The group legend
idstringRequired. On the fieldset, the base for position-keyed member ids, and (radios) the name
disabledbooleanfalseThreaded to every member
requiredbooleanfalseGroup-level conveyance — see below
invalidbooleanfalsearia-invalid on the fieldset; members mirror the invalid styling
describedbystringOne story: the error IDREF lives on the fieldset only — members never repeat it
requiredLabelstring'(required)'CheckboxGroup only — screen-reader-only required text in the legend, localisable

They differ in what they model, mirroring the select family:

ComponentModelRequired conveyance
CheckboxGroupT['id'][]Legend marker + sr-only requiredLabel — ARIA forbids aria-required on role=group, so the legend text is the group-level conveyance
RadioGroupT['id'] | nullaria-required on the fieldset — it carries role="radiogroup", which legitimately supports the attribute

CheckboxGroup keeps its array in options order (not click order); an id whose option has not arrived yet (async options) is preserved at the tail. RadioGroup's radios share one generated name (the group id), so the browser provides the roving tabindex and arrow-key selection — the component only mirrors the model from the native change event.

Attribute fall-through

Props the components do not declare — name, autocomplete, inputmode, data-*, … — fall through to the underlying native control via Vue's attribute inheritance. You do not need a declared prop to make a field participate in autofill or a native form post. (Checkbox and Switch re-aim attrs at the native input — their root is the wrapping <label>.)

Theming — the --ui-* contract

Every visual rule in the shipped stylesheet keys on a --ui-* custom property — colours and structure: --ui-control-border-width, --ui-control-radius, --ui-control-shadow, --ui-label-transform, and so on. The defaults are declared under :where(:root), carrying zero specificity, so any selector you write overrides them. Remap under :root for an app-wide theme, or under any scoping selector for a per-section theme.

The variable surface groups into:

  • Field / label--ui-field-gap, --ui-field-margin, --ui-label-color, --ui-label-size, --ui-label-weight, --ui-label-transform, --ui-label-tracking
  • Control (inputs + select triggers) — --ui-control-bg, --ui-control-text, --ui-control-text-muted, --ui-control-border-width, --ui-control-border-color, --ui-control-border-open, --ui-control-radius, --ui-control-pad-x, --ui-control-pad-y, --ui-control-shadow, --ui-control-shadow-hover, --ui-control-bg-disabled, --ui-focus-ring, --ui-control-font-size, --ui-control-line-height, --ui-control-min-height
  • Listbox menu--ui-menu-bg, --ui-menu-border-width, --ui-menu-border-color, --ui-menu-radius, --ui-menu-pad, --ui-menu-shadow, --ui-menu-max-height, --ui-menu-min-width, --ui-menu-max-width, --ui-menu-font-size
  • Option--ui-option-radius, --ui-option-pad, --ui-option-bg-active, --ui-option-min-height, --ui-option-text-muted (.is-muted), --ui-option-bg-selected / --ui-option-text-selected (MultiSelect [aria-selected="true"]), --ui-clear-text (the clear entry)
  • Chip (MultiSelect) — --ui-chip-bg, --ui-chip-text, --ui-chip-radius, --ui-chip-pad, each defaulting to an existing resting token so chips are neutral until you opt in
  • Check (Checkbox / CheckboxGroup / RadioGroup) — --ui-check-size, --ui-check-border-width (shorthand-valued, like the control's), --ui-check-border-color, --ui-check-bg, --ui-check-bg-checked, --ui-check-mark-color, --ui-check-radius, --ui-check-gap (control ↔ label), --ui-check-item-gap (group rows) — every colour default derives from an existing resting token (--ui-control-bg, --ui-control-border-color, --ui-control-border-open), so your token map themes the family with no new mappings
  • Switch--ui-switch-track-width, --ui-switch-track-height, --ui-switch-track-radius, --ui-switch-track-bg, --ui-switch-track-bg-checked, --ui-switch-thumb-size, --ui-switch-thumb-bg — the thumb travels track-width − track-height, so geometry stays coherent under any override
  • Error / danger--ui-danger-text, --ui-danger-border, --ui-danger-shadow, --ui-error-size, --ui-error-weight

The shipped styles.css is the authoritative list — every variable is declared there with its default.

Structural variables take shorthand values

--ui-control-border-width feeds a border-width declaration, so it accepts the full shorthand grammar. An underline-only field style — no side or top borders — is one line:

css
:root {
    --ui-control-border-width: 0 0 1px; /* bottom border only */
    --ui-control-radius: 0;
}

--ui-field-margin is likewise a full margin shorthand (default 0 0 1.25rem). This is what makes the contract structural: radically different field shapes are variable maps, not CSS overrides.

State-variant hooks

Each interactive state has background/text/border hooks. Every hook defaults to its resting counterpart, so the contract is a no-op until you opt in:

VarFires onDefault
--ui-control-bg-focus:focus-visiblevar(--ui-control-bg)
--ui-control-text-focus:focus-visiblevar(--ui-control-text)
--ui-control-border-color-focus:focus-visiblevar(--ui-control-border-color)
--ui-control-border-width-focus:focus-visiblevar(--ui-control-border-width)
--ui-control-bg-invalid.is-invalidvar(--ui-control-bg)
--ui-control-text-invalid.is-invalidvar(--ui-control-text)

The .is-open and .is-invalid state classes follow :focus-visible in source order, so they keep winning their border/background — the focus hooks only take effect on a plain focused control.

Typography escape hatch

--ui-control-font-size (default inherit) sizes control text, and --ui-control-line-height (default inherit) completes the decomposition. The control's font is decomposed into longhands — all inheriting except the two var-keyed ones — so both read from their variable rather than from a consumer utility class, which would otherwise lose the source-order tie against the package stylesheet. The listbox popup gets its own hook, --ui-menu-font-size (default inherit — it sizes by inheritance from the component root), so an adapter never needs a text-[13px] utility on the popup.

--ui-menu-min-width (default 100% — of the positioned ancestor, i.e. at least the trigger) and --ui-menu-max-width (default none) clamp the listbox popup without a specificity fight:

css
:root {
    --ui-menu-min-width: max(100%, 240px);
    --ui-menu-max-width: calc(100vw - 16px);
}

Touch targets

--ui-control-min-height and --ui-option-min-height (both default auto — the measured status quo) put a floor under the control and the listbox options. WCAG 2.5.5's 44px minimum target is deliberately the consumer's call — assign the floor under your own coarse-pointer media query:

css
@media (hover: none) and (pointer: coarse) {
    :root {
        --ui-control-min-height: 2.75rem;
        --ui-option-min-height: 2.75rem;
    }
}

Two Themes, One Component Set

The centerpiece of the contract: the panels below render the same components, bound to the same state — select a fruit in one panel and the other follows. Only the --ui-* map differs. Note this is not just palette: border width, radius, shadow shape, label casing, and chip geometry all diverge.

The two maps, in full — each is nothing but variable assignments:

css
.demo-soft {
    --ui-label-color: #6b7280;
    --ui-label-size: 0.8125rem;
    --ui-control-bg: #f9fafb;
    --ui-control-bg-focus: #ffffff; /* state-variant hook in action */
    --ui-control-text: #1f2937;
    --ui-control-border-color: #e5e7eb;
    --ui-control-border-color-focus: #a5b4fc;
    --ui-control-border-open: #6366f1;
    --ui-control-radius: 14px;
    --ui-control-shadow: inset 0 1px 2px rgba(17, 24, 39, 0.04);
    --ui-focus-ring: 0 0 0 4px rgba(99, 102, 241, 0.18);
    --ui-menu-bg: #ffffff;
    --ui-menu-border-color: #e5e7eb;
    --ui-menu-radius: 14px;
    --ui-menu-shadow: 0 12px 32px rgba(17, 24, 39, 0.12);
    --ui-option-radius: 10px;
    --ui-option-bg-active: #eef2ff;
    --ui-chip-bg: #eef2ff;
    --ui-chip-text: #4338ca;
    --ui-chip-radius: 999px;
}
css
.demo-brutalist {
    --ui-label-color: #111111;
    --ui-label-size: 0.75rem;
    --ui-label-weight: 700;
    --ui-label-transform: uppercase;
    --ui-label-tracking: 0.08em;
    --ui-control-bg: #ffffff;
    --ui-control-text: #111111;
    --ui-control-border-width: 2px;
    --ui-control-border-color: #111111;
    --ui-control-border-open: #111111;
    --ui-control-radius: 0;
    --ui-control-shadow: 4px 4px 0 #111111;
    --ui-control-shadow-hover: 4px 4px 0 #111111;
    --ui-focus-ring: 0 0 0 3px #ffd43b;
    --ui-menu-bg: #ffffff;
    --ui-menu-border-width: 2px;
    --ui-menu-border-color: #111111;
    --ui-menu-radius: 0;
    --ui-menu-shadow: 8px 8px 0 #111111;
    --ui-option-radius: 0;
    --ui-option-bg-active: #ffd43b;
    --ui-chip-bg: #111111;
    --ui-chip-text: #ffffff;
    --ui-chip-radius: 0;
    --ui-danger-text: #c2255c;
    --ui-danger-border: #c2255c;
    --ui-danger-shadow: 4px 4px 0 #c2255c;
}

Adoption Playbook

Lessons from live adoptions, distilled. Following these keeps an adoption to a few hours instead of a few days.

Map tokens, never bake hex

Write one token map that assigns your design system's variables to the --ui-* contract:

css
:root {
    --ui-control-bg: var(--app-surface);
    --ui-control-text: var(--app-text-primary);
    --ui-control-border-color: var(--app-border);
    --ui-danger-text: rgb(var(--app-danger-rgb));
    /* … */
}

Never copy resolved hex values into the map. When the map points at your tokens, everything your token layer already does — dark/light switching, density modes, per-tenant palettes — travels to the components for free. A map of baked hex values freezes one snapshot of your theme and silently detaches from every future token change.

One map per app in a multi-design-system codebase

If one repository serves multiple apps with different design languages, write one map per app, colocated with that app — and keep any shared/unbranded layer free of design-system-specific maps. The maps will usually differ in palette but agree in structure; that is the contract working as intended. A single "shared" map naming several design systems couples layers that are deliberately separate.

Keep your components as thin adapters

Adopting does not mean rewriting every call site. The proven pattern: reshape your existing component (AppSelect, BaseInput, …) into a thin adapter over the ui-inputs atom — preserving your call-site API and absorbing any value-type or boolean impedance inside the adapter. Call sites stay untouched; the behaviour, a11y wiring, and theming migrate underneath them.

Nullable values

Every text-like input models string | null and NumberInput models number | null, matching how a backend serialises a nullable column:

  • A null from the backend binds directly — the control renders empty. No ?? '' at the call site; a smuggled fallback there hides real nulls from your form logic.
  • Clearing a string input emits '' (the raw native value). A Laravel backend's ConvertEmptyStringsToNull middleware maps that back to null on submit — the fleet convention.
  • NumberInput is the one exception: an empty number input emits null (not NaN, not ''), since a number model can never hold '' honestly. The NaNnull guard lives in the component — delete your local ones.
  • A field that is nullable on the wire but non-null in your domain (e.g. a quantity defaulting to 1) should use a decoupled local ref coerced at submit time — widen the local form state, never the wire type.

The accessibility model

The select family keeps DOM focus on the trigger and conveys the keyboard-focused option via aria-activedescendant, so arrow-key navigation is announced rather than silent. The wiring, so you know what you are getting:

  • The trigger carries role="combobox", aria-haspopup="listbox", aria-expanded, and aria-controls while open (the IDREF only resolves inside the listbox it owns).
  • Option ids are position-keyed (${id}-opt-${index}) — derived from the option's position in the rendered list, not from option.id, so a non-unique or whitespace-containing id can never break the IDREF linkage.
  • aria-selected marks the committed value, never the option under the keyboard pointer — keyboard/hover focus stays visual (.is-active) plus aria-activedescendant; selection only moves on Enter or click.
  • MultiSelect's and MultiCombobox's listboxes are aria-multiselectable="true"; aria-selected marks membership, and every chip's remove button carries an accessible name ("${removeLabel} ${label}"). MultiCombobox additionally conveys the committed selection through an aria-describedby summary (its input's accessible value is the query).
  • Home/End jump the keyboard highlight to the first/last option while the listbox is open, and the empty state (emptyText) is announced through a persistent, visually-hidden aria-live="polite" region — a filtered list draining to nothing is never silent.
  • required and invalid are conveyed via aria-required / aria-invalid; pair describedby with the error element's id — FormField does all of this for you.

Preserve this model when writing adapters: pass id, invalid, and describedby through, don't re-create them.

Errors are a prop, never a service

The components never import an error service. Resolve the message in your app — from a validation-error bag, a translation layer, wherever — and pass error (to FormField) or invalid + describedby (to the inputs). This keeps the package agnostic to how your app produces validation errors, and composes cleanly with fs-form's 422 error bag.

Testing in a consumer (shallowMount architectures)

Because the atoms live inside FormField's scoped slot, a codebase whose unit tests standardise on shallowMount needs a targeted unstub to reach the real controls:

typescript
shallowMount(MyFormSection, {global: {stubs: {FormField: false, TextInput: false, SingleSelect: false}}});

Let integration tests (mount) own real composition. Two more test-surface notes:

  • findComponent(SingleSelect) trips TypeScript on the generic component object — use findComponent({name: 'SingleSelect'}) instead.
  • Don't copy the package's internal required || undefined idiom into a call site where required is constant-true — on a branch-coverage-gated codebase that's a permanently dead branch. Bind the literal.

What the package does not cover

No file or range atoms; no date picker (DateInput wraps the native control); no headless combobox-input primitive; no imperative focus handle on TextInput (only Combobox exposes focus()). When you need one of these, inline native markup inside FormField's slot — the slot hands you controlId, describedby, and invalid, so a native control composes with the label/error chrome without waiting on a package atom.

Built by Script Development & Back to Code