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.
npm install @script-development/ui-inputsPeer dependency: vue ^3.5.40
Import the stylesheet once (e.g. in your app entry):
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):
.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.
<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:
<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.
<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:
<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.
<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:
<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
| Component | Purpose |
|---|---|
FormField | Label + error + required-marker composition wrapper (error-as-prop) |
FormLabel / FormError | The atoms FormField composes — usable standalone |
TextInput | Native text / email / password / search / tel / url input |
NumberInput | Native number input; owns the NaN → null empty-value guard |
DateInput | Native date input with ISO min / max bounds |
Textarea | Native textarea with rows |
Checkbox | Native checkbox, visually restyled; non-nullable boolean model, indeterminate as a visual prop |
CheckboxGroup | Fieldset/legend group of checkboxes — models an array of option ids in options order |
Switch | The checkbox chassis with role="switch" — an on/off toggle with a themeable track + thumb |
RadioGroup | Fieldset/legend radio group (role="radiogroup") — models T['id'] | null; native roving focus + arrow-key selection |
SingleSelect | Accessible button-triggered listbox, generic over your option type |
Combobox | Accessible searchable/filtering single-select; exposes an imperative focus() handle |
MultiSelect | Accessible multi-value select — models an array of option ids; toggle-in-place listbox, inline chip bar with per-chip remove |
MultiCombobox | Accessible 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
| Prop | Type | Default | Notes |
|---|---|---|---|
id | string | — | Required. Stable control id — pass useId() if you have no natural one |
label | string | — | Omit for an unlabelled field |
required | boolean | false | Renders the required marker and threads required to the slot |
error | string | — | Resolved 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:
| Component | Extra props | Model | Emits on clear |
|---|---|---|---|
TextInput | type, placeholder | string | null | '' |
NumberInput | min, max, step, placeholder | number | null | null |
DateInput | min, max (ISO YYYY-MM-DD) | string | null | '' |
Textarea | rows, placeholder | string | 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>):
| Prop | Type | Default | Notes |
|---|---|---|---|
options | T[] | — | Required |
label | LabelKey<T> | — | Required. Property name or getter for an option's display string |
id | string | — | Required. Pairs the trigger with a label/error |
placeholder | string | 'Select…' | |
disabled | boolean | false | |
alphabeticalSort | boolean | true | Sorts rendered options by display string |
required | boolean | false | Conveyed via aria-required |
invalid | boolean | false | Invalid styling + aria-invalid |
describedby | string | — | Id of the paired error element |
emptyText | string | 'No options' | Shown when the (filtered) list is empty |
optionsLabel | string | 'Options' | Accessible name for the listbox popup — a prop so you can localise |
mutedOptions | T['id'][] | — | Ids rendered visually muted (.is-muted) — still committable |
They differ in what they model:
| Component | Model | Extras |
|---|---|---|
SingleSelect | T['id'] | null | clearLabel / emptyDisplayValue (the committing clear entry — see below) |
Combobox | T['id'] | null | Text-input trigger that filters options; focus() exposed; clearLabel / emptyDisplayValue |
MultiSelect | T['id'][] | Toggle-in-place commits, chip bar, removeLabel prop (default 'Remove', localisable) |
MultiCombobox | T['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:
<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).
<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>:
| Prop | Type | Default | Notes |
|---|---|---|---|
options | T[] | — | Required. Rendered in the given order — groups never sort |
optionLabel | LabelKey<T> | — | Required. The family's display resolver — named optionLabel because label is the legend |
label | string | — | Required. The group legend |
id | string | — | Required. On the fieldset, the base for position-keyed member ids, and (radios) the name |
disabled | boolean | false | Threaded to every member |
required | boolean | false | Group-level conveyance — see below |
invalid | boolean | false | aria-invalid on the fieldset; members mirror the invalid styling |
describedby | string | — | One story: the error IDREF lives on the fieldset only — members never repeat it |
requiredLabel | string | '(required)' | CheckboxGroup only — screen-reader-only required text in the legend, localisable |
They differ in what they model, mirroring the select family:
| Component | Model | Required conveyance |
|---|---|---|
CheckboxGroup | T['id'][] | Legend marker + sr-only requiredLabel — ARIA forbids aria-required on role=group, so the legend text is the group-level conveyance |
RadioGroup | T['id'] | null | aria-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:
: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:
| Var | Fires on | Default |
|---|---|---|
--ui-control-bg-focus | :focus-visible | var(--ui-control-bg) |
--ui-control-text-focus | :focus-visible | var(--ui-control-text) |
--ui-control-border-color-focus | :focus-visible | var(--ui-control-border-color) |
--ui-control-border-width-focus | :focus-visible | var(--ui-control-border-width) |
--ui-control-bg-invalid | .is-invalid | var(--ui-control-bg) |
--ui-control-text-invalid | .is-invalid | var(--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.
Menu width clamps
--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:
: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:
@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:
.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;
}.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:
: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
nullfrom the backend binds directly — the control renders empty. No?? ''at the call site; a smuggled fallback there hides realnulls from your form logic. - Clearing a string input emits
''(the raw native value). A Laravel backend'sConvertEmptyStringsToNullmiddleware maps that back tonullon submit — the fleet convention. NumberInputis the one exception: an empty number input emitsnull(notNaN, not''), since anumbermodel can never hold''honestly. TheNaN→nullguard 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, andaria-controlswhile 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 fromoption.id, so a non-unique or whitespace-containing id can never break the IDREF linkage. aria-selectedmarks the committed value, never the option under the keyboard pointer — keyboard/hover focus stays visual (.is-active) plusaria-activedescendant; selection only moves on Enter or click.MultiSelect's andMultiCombobox's listboxes arearia-multiselectable="true";aria-selectedmarks membership, and every chip's remove button carries an accessible name ("${removeLabel} ${label}").MultiComboboxadditionally conveys the committed selection through anaria-describedbysummary (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-hiddenaria-live="polite"region — a filtered list draining to nothing is never silent. requiredandinvalidare conveyed viaaria-required/aria-invalid; pairdescribedbywith the error element's id —FormFielddoes 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:
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 — usefindComponent({name: 'SingleSelect'})instead.- Don't copy the package's internal
required || undefinedidiom into a call site whererequiredis 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.