The UI kit: roots/ui
Tier 1 of the Roots UI kit: 40 components that work the same on the web (and every DOM target: PWA, extension, iOS, Android) and in the terminal. Each component is a headless machine (state, transitions, a keyboard interaction table from the WAI-ARIA Authoring Practices, focus management, ARIA attributes) rendered through a skin for the surface: accessible HTML with CSS from the theme tokens on the DOM, borders, glyphs and focus styles in the terminal.
<script setup lang="ts">
import { ref } from "roots/runtime"
import { Button, Dialog, Input, useToast } from "roots/ui"
const open = ref(false)
const name = ref("")
const toast = useToast()
</script>
<template>
<Button label="Rename" icon="edit" @click="open = true" />
<Dialog v-model:open="open" title="Rename project">
<Input v-model="name" label="Name" />
<template #footer>
<Button label="Save" @click="open = false; toast.success('Saved')" />
</template>
</Dialog>
<Toaster />
</template>examples/gallery shows every component; roots run examples/gallery --target term runs it in the terminal, roots dev examples/gallery in the browser.
Contents #
- Architecture
- Using the kit
- Styling: skins, pass-through, unstyled
- Overlays and focus
- Components
- The headless layer
- Warnings
- Tests
- Not in tier 1
1. Architecture #
runtime/ui/
headless/*.lisp state machines: controllable values, keyboard tables (data),
roving focus, typeahead, focus trap, floating placement,
validation, toasts, table, tree (reactivity only: Node-testable)
components/*.lisp the components: a machine + host primitives (Box, Text, Button,
Input, Checkbox, lowercase tags), written once for every surface
h.lisp, kit.lisp hyperscript h(), parts (skin class + pt + unstyled), contexts
skin.dom.lisp + .css DOM skin: class names, SVG icons, track/thumb visuals; its CSS
skin.term.lisp terminal skin: utility classes per part, glyph visuals
surface.dom.lisp focus, tabbables, rects in px, position: fixed, pointer drags
surface.term.lisp the same in cells: the screen's focus and tab order, absolute layers
icons.lisp the icon set: SVG path, terminal glyph, ASCII fallback- One component, two surfaces, no platform
ifs../skin.jsand./surface.jsresolve per target family like any per-target file (CONTRACT §1):skin.dom.lispon web, pwa, ext, ios and android,skin.term.lispon term. A target ships only its own skin. Two components need a different implementation per surface and have per-family files:ScrollArea(native scrolling on the DOM, a drawn scrollbar in the terminal) andTextarea(a native<textarea>; a small multi-line editor in the terminal). - Native first. Buttons are
<button>/ the Button primitive, checkboxes and radios are native checkboxes and radios on both surfaces, so Enter and Space, labels and focus are native. Composite widgets (tabs, listbox options, menu items, tree items, slider, switch, splitter) follow the APG keyboard tables in the headless layer. - Written in typed Roots Lisp, compiled to JavaScript (and to TypeScript that passes
tsc --strictfor both skin families). Noeval, noinnerHTML: the kit builds every node with the runtime's template helpers, so it is as fine-grained as a compiled template. - Tree-shakeable: named exports only, and importing a module has no side effects: the component definitions and the kit's module-level builders are marked pure, and a test (
bundle-real-runtime-purity-invariant) runs the bundler's own side-effect analysis over every runtime module.roots buildtherefore ships the components an app imports and not the others: an app that imports onlyButtoncarries no Table, Tree, Dialog or Menu (bundle-real-one-component-app). - CSS through the pipeline: the DOM skin's stylesheet (
skin.dom.css) is shipped by the build because its module is in the graph (build.md, CSS), between the base styles and the utilities, so utility classes on a kit component still win. Colors are the theme tokens (--roots-primary…), with dark-mode values fromroots.lisp :theme :dark, andprefers-reduced-motionturns animations off. The terminal skin uses CONTRACT §6 utilities and the terminal's own (inverse,border-0,border-t,z-N…).
2. Using the kit #
TypeScript SFCs import components and use them as tags. Button, Input and Checkbox are also host primitives: importing them from roots/ui makes the template use the kit's (a setup binding wins over a primitive, templates.md §2).
Lisp components import them too. In a Lisp template (:Button …) is always the primitive, so import the kit's under another name:
(use-macros "roots.ui" "^0.1")
(import "roots/ui" Card Switch (Button UiButton))
(defcomponent Settings ()
(setup (props ctx) (defvar on (ref t)))
(template
(:Card :title "Settings"
(:Switch :model on :label "Notifications")
(fill footer () (:UiButton :label "Save")))))Conventions.
| v-model | modelValue + update:modelValue (Input, NumberInput, Textarea, Checkbox, Switch, RadioGroup, Select, Slider, Tabs, Accordion, List, Tree, Splitter, Pagination); overlays use v-model:open; Table uses v-model:sort, v-model:page, v-model:selection; Tree v-model:expanded |
| controlled or not | every value works without v-model too: the component keeps it locally (controllable) |
| items are data | items / options: [{ value, label, disabled? }], or strings ("a" is { value: "a", label: "a" }) |
| panels are slots | Tabs and Accordion: the slot named after the item's value (<template #specs>), else the panel slot with { item } |
| sizes | size: sm, md, lg |
| tones | variant of Badge and Tag: neutral primary success warning error info |
| every component | pt (pass-through) and unstyled (§3) |
3. Styling: skins, pass-through, unstyled #
Each component is made of parts (root, label, panel …). A part gets:
- the skin's class for that part and its modifiers (unless unstyled),
- the component's own attributes and handlers,
- the pass-through of the app config, then of the
ptprop.
DOM class names are derived: part root of button is r-button, part label is r-button__label, the modifier primary adds r-button--primary. They are stable API: style them in your own CSS (the build knows r-* classes) or replace the skin.
Pass-through (pt). An object per part, or a function of { props, state } returning one. class and style are added; onX handlers run after the component's; other attributes (aria-, data-, title) win. pt and unstyled are reactive: a part's class follows a pt given, changed or taken away at run time, and a pt function runs again when the props or state it read change. Which other attributes and handlers a part gets from pt is decided when the component is set up. A part without pass-through costs nothing extra: its class is the skin's string as it is.
<Button label="Save" :pt="{ root: { class: 'w-full', 'data-test': 'save' }, label: { class: 'italic' } }" />
<Badge :value="n" :pt="{ root: ({ props }) => ({ title: `${props.value} unread` }) }" />Unstyled. unstyled drops every skin class of the component (the DOM gets plain semantic HTML, the terminal the primitives' defaults); pass-through still applies, so a design system can style the parts itself.
App defaults. createUI() is a plugin; provideUI() sets defaults for a subtree.
app.use(createUI({ unstyled: true, pt: { button: { root: { class: "btn" } }, dialog: { panel: { class: "sheet" } } } }))Theme. The DOM skin uses only the theme tokens (primary accent muted surface border focus success warning error info text bg), so roots.lisp :theme (and :dark) restyles it; the terminal maps the same tokens to its color table. The DOM skin also defines --r-radius, --r-radius-sm and --r-shadow. Text on a filled control is --roots-on-primary (a primary Button or Badge, an Avatar, the current page of a Pagination), --roots-on-error (a danger Button, an error Badge), --roots-on-success and --roots-on-info (Badges). The build derives them from the theme for each mode: white where white reaches 4.5:1 on the fill, else a dark ink (build.md, "Text on a fill"). With a stylesheet that lacks them the skin falls back to --r-on-accent (white).
:root { --roots-on-primary: #ffffff; } /* your own choice, in your own CSS */The boundary of an Input, Textarea, NumberInput, Select and secondary Button is the theme's --roots-control-border (about 3:1 against the background), and warning text (a warning Tag, a warning toast's icon) is --roots-warning-text; with a stylesheet that has neither variable the skin falls back to --roots-border and --roots-warning.
Terminal look. Bordered controls are the Button and Input primitives' rounded borders; a focused borderless control turns bold and inverse (focus is never shown by color alone); dialogs and drawers have double borders; bars are █/░, the slider ━━●──, the switch ○── / ──●, spinners braille frames. ROOTS_ASCII=1 (or TERM=linux) uses ASCII glyphs; ROOTS_REDUCED_MOTION=1 stops the spinner and progress animations.
4. Overlays and focus #
Dialog, Drawer, Popover, Menu, Select, Tooltip and Toaster render into the overlay layer with the runtime's portal() (runtime.md): <body> on the DOM, the screen's root in the terminal, above the app, while the component stays where it was written (it updates and unmounts with its owner).
Placement (Popover, Menu, Select, Tooltip): the headless
placeFloatingputs the panel below (or above, left, right) its trigger without measuring it: it is anchored with top/bottom and left/right insets and limited to the room on its side, flipping when the side has less than 160 px (6 rows) and the other more. The DOM usesposition: fixedin pixels, the terminal absolute cells.Layers, bottom to top: dialogs and drawers, then pop-ups (Select, Menu, Popover), then tooltips, then toasts. So a Select, Menu or Popover inside a Dialog or Drawer opens above it and takes clicks and keys there. The other way round, a Dialog or Drawer that opens while a pop-up is open is the newer layer: that pop-up stays open but lies under the dialog and its backdrop until the dialog closes.
<Popover label="Danger zone"> <Button variant="danger" label="Delete everything…" @click="confirm = true" /> </Popover> <Dialog v-model:open="confirm" title="Delete everything?" role="alertdialog">…</Dialog>The dialog covers the popover; the first press on one of its buttons reaches the button. (Parts:
dismiss, andlistorpanel, get the modifierundermeanwhile:.r-popover__panel--under.)Focus management. Opening a Dialog or Drawer moves the focus inside: to the control marked
autofocus, else the first control of the body, else the first control of the footer, else the Close button, else the panel. Close is never first when there is another control, so Enter right after opening does not close it again. Modal dialogs and drawers trap Tab and Shift+Tab (trapTarget); the trap lets the focus go into another overlay (the list of a Select inside the dialog, a dialog opened from it, a toast). Closing gives the focus back to the element that had it. A press outside a popup closes it (a transparent dismiss layer under the popup); outside a modal dialog it closes it whendismissable(default), else the focus stays inside.Terminal. The same rules: a dialog is centered in the screen, a drawer at its edge; the terminal's own Tab order is used inside the trap; mouse presses work on every overlay.
5. Components #
Every component also takes pt and unstyled. "Keys" lists the keyboard interaction table (exported as data, e.g. TABS_KEYS); "native" means the native button or checkbox does it.
Button #
<Button label="Save" @click="save" />
<Button variant="secondary" icon="plus">Add item</Button>
<Button type="submit" :loading="saving">Create account</Button>Running, with a Badge and three Tags beside them:
Source
<script setup lang="ts">
import { ref } from "roots/runtime"
import { Badge, Button, IconButton, Tag } from "roots/ui"
const saving = ref(false)
const saved = ref(0)
function save(): void {
saving.value = true
setTimeout(() => { saving.value = false; saved.value++ }, 900)
}
</script>
<template>
<Box class="kit-buttons gap-3">
<Box class="flex-row items-center gap-2 wrap">
<Button label="Save" icon="check" :loading="saving" @click="save" />
<Button variant="secondary" label="Secondary" />
<Button variant="ghost" label="Ghost" />
<Button variant="danger" label="Delete" icon="trash" />
<IconButton icon="settings" label="Settings" />
</Box>
<Box class="flex-row items-center gap-2 wrap">
<Text>Saved</Text><Badge :value="saved" />
<Tag variant="success">stable</Tag>
<Tag variant="warning">beta</Tag>
<Tag variant="info">0.4</Tag>
</Box>
</Box>
</template>
<style>
.kit-buttons { max-width: 40rem; }
</style>| Prop | Type | Default | |
|---|---|---|---|
variant | primary secondary ghost danger | primary | |
size | sm md lg | md | |
label | string | text when there is no default slot | |
icon, iconRight | icon name | before / after the label | |
disabled | boolean | ||
loading | boolean | spinner, aria-busy, aria-disabled: a press does nothing, the focus stays | |
block | boolean | full width | |
type | button submit reset | button | inside a Form: submits / resets it (the native type stays button) |
Events: click. Slots: default (the label). Parts: root, label, spinner. Keys: Enter, Space (native). ARIA: a native <button>; while loading it is aria-busy and aria-disabled (not disabled: a disabled button loses the keyboard focus to the page, so a keyboard user would have to find the button again after every save). disabled is the native attribute. Terminal: the Button primitive (rounded border); sm and ghost are borderless.
IconButton #
<IconButton icon="settings" label="Settings" />: icon (required), label (required: the accessible name, also title), variant (ghost), size, disabled. Events: click. Without a label it warns R601. Terminal: the glyph with a cell of padding.
Icon #
<Icon name="star" />: name, label (when set: role=img + aria-label, else aria-hidden), size. The set: check x plus minus chevron-down chevron-up chevron-left chevron-right arrow-up arrow-down arrow-left arrow-right menu more search info warning error success star heart home settings user trash edit copy external-link calendar bell mail eye lock sun moon folder file refresh download upload filter sort dot (iconNames()). registerIcon(name, { path, glyph, ascii }) adds one. DOM: inline SVG (24×24, round 2px strokes in currentColor). Terminal: one cell (✓ ✕ ▾ ⚙ …, ASCII v x v * …). Unknown names warn R603.
Input #
<Input v-model="email" type="email" placeholder="name@example.com" />
| Prop | Type | Default |
|---|---|---|
modelValue | string | number | |
type | text password email number search tel url | text |
placeholder, name, label (aria-label) | string | |
disabled, readonly, invalid, autofocus | boolean | |
maxlength | number | |
size | sm md lg | md |
Events: update:modelValue, change, focus, blur. ARIA: inside a Field it gets the field's id, aria-labelledby, aria-describedby, aria-invalid, aria-required. DOM: a focused input has a solid 2px outline in the focus colour, an invalid one the error colour as its border (also while focused). Terminal: the Input primitive (cursor, readline keys, password masking), stretched to its container.
type="number". The model is a number while the text is one and the empty string when the field is empty, so its type is number | string:
const amount = ref<number | string>(5) // <Input v-model="amount" type="number" />
const cents = computed(() => (amount.value === "" ? null : Math.round(amount.value * 100)))For a number with a minimum, a maximum, steps and buttons use NumberInput: its model is number | null.
NumberInput #
<NumberInput v-model="quantity" :min="1" :max="99" label="Quantity" />
<NumberInput v-model="weight" :step="0.5" clearable placeholder="kg" label="Weight" />A text field for a number between a − and a + button (WAI-ARIA APG: spinbutton).
| Prop | Type | Default | |
|---|---|---|---|
modelValue | number | null | null: the field is empty | |
min, max | number | the range; without them there is no limit | |
step | number | 1 | what ↑ ↓ and the buttons add; the value is rounded to it |
bigStep | number | 10 steps | what PageUp / PageDown add |
clearable | boolean | an empty field is null; without it an empty field goes back to its value | |
placeholder, name, label (aria-label) | string | ||
disabled, readonly, invalid | boolean | ||
size | sm md lg | md | terminal: sm is one row without a border, as a small Input is |
decrementLabel, incrementLabel | string | Decrease, Increase | the buttons' names |
Events: update:modelValue, change (the value after a step, blur or Enter changed it), focus, blur. Parts: root, input, decrement, increment. Attributes given to <NumberInput> go to its root box; inside a Field the input gets the field's id and ARIA.
Typing works as in any text field. While you type, the model follows whenever the text is a number inside the range (a decimal comma is read as a point). Blur and Enter take the text for good: clamped to min..max and rounded to step; text that is no number goes back to the value. Enter then submits the Form around it. Keys (NUMBER_INPUT_KEYS):
| Key | |
|---|---|
| ↑ ↓ | one step up / down (from an empty field: the minimum, else 0) |
| PageUp PageDown | a big step |
| Home End | the minimum / maximum, when there is one |
| Enter | take the typed text as the value |
ARIA: the input is role=spinbutton with aria-valuenow, aria-valuemin, aria-valuemax (aria-readonly, aria-invalid). The buttons are named, are not tab stops (the arrow keys do the same) and are aria-disabled at the end of the range; pressing one leaves the focus on the field. DOM: one border around the three, the focus outline on the whole control, buttons at least 24 px. Terminal: − ╭────╮ + around the Input primitive.
Textarea #
<Textarea v-model="notes" :rows="4" />: modelValue, placeholder, rows (3), disabled, readonly, invalid, maxlength, label. Events: update:modelValue, change (on blur when changed), blur. DOM: a native <textarea>. Terminal: a focusable role=textbox aria-multiline=true editor: text and Enter insert, ← → Home End move (in the line), ↑ ↓ change line keeping the column, Backspace/Delete, paste; Tab leaves.
Label, Field #
A Field with an Input, a Select, a Switch and a Slider, bound with v-model:
Source
<script setup lang="ts">
import { ref } from "roots/runtime"
import { Field, Input, Select, Slider, Switch } from "roots/ui"
const name = ref("Ada")
const target = ref("web")
const strict = ref(true)
const size = ref(40)
const targets = [
{ value: "web", label: "Web" }, { value: "pwa", label: "PWA" },
{ value: "ios", label: "iOS" }, { value: "term", label: "Terminal" },
]
</script>
<template>
<Box class="kit-form gap-3">
<Field label="Project name" hint="Lowercase letters, digits and dashes.">
<Input v-model="name" />
</Field>
<Field label="Target">
<Select v-model="target" :options="targets" />
</Field>
<Switch v-model="strict" label="Treat unknown classes as errors" />
<Field label="Bundle budget (KB)">
<Slider v-model="size" :min="10" :max="100" :step="5" />
</Field>
<Text class="kit-form-out">roots build {{ name }} --target {{ target }}{{ strict ? " --strict" : "" }} · budget {{ size }} KB</Text>
</Box>
</template>
<style>
.kit-form { max-width: 24rem; }
.kit-form-out { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.85rem; color: var(--roots-muted); }
</style><Label for="id" required>Email</Label>: a <label> (a styled Text in the terminal).
<Field label="Email" hint="We never share it." required :value="email" :rules="[rules.required(), rules.email()]">
<Input v-model="email" />
</Field>| Prop | ||
|---|---|---|
label, hint | string | the label names the control (for and aria-labelledby), the hint describes it |
required | boolean | aria-required, a * |
value, rules | any, Rule[] | what to validate and how |
error | string | an error from outside (a server reply); wins over the rules |
id | string | the control's id (generated by default) |
The control inside (any kit control) gets the field's id, aria-labelledby (the label's id), aria-describedby (hint and error ids), aria-invalid. aria-labelledby is set on every surface, so a terminal test or a screen reader can tell the inputs of a form apart, and it is what names a control that a <label for> cannot: a RadioGroup or a Slider is a box, so its field's label has no for.
<Field label="Shipping"><RadioGroup v-model="shipping" :options="['Standard', 'Express']" /></Field>A control with a label of its own keeps that name: <Field label="Price"><Input label="Price of this book in euros" /></Field> is read with the longer one. A Select in a Field needs no label.
The error appears once the control was left (blur) or the form submitted, in an aria-live=polite element, and updates while typing after that. When a Form finds the field invalid, the focus goes to its control (for a group: its tab stop). The field's root is marked data-reveal: a terminal ScrollArea that scrolls a focused control into view shows the whole field, label included. Rules (rules export): required(msg?), minLength(n, msg?), maxLength(n, msg?), pattern(re, msg?), email(msg?), min(n, msg?), max(n, msg?); any function (value) => true | "message" is a rule.
Form #
<Form label="Sign up" @submit="create" @invalid="explain" @reset="clear"> … <Button type="submit" label="Create" /> </Form>A <form novalidate> (a Box in the terminal) that lays its fields out in a column with a gap of 1rem (one row in the terminal). Enter in a one-line text field or a type="submit" Button submits: every Field is validated; if all pass it emits submit({ valid: true }), else invalid and the focus goes to the first invalid control. A type="reset" Button clears the validation state and emits reset (the app resets its values). It never reloads the page.
Checkbox #
<Checkbox v-model="agree" label="I agree" />
<Checkbox v-for="f in fruits" :key="f" v-model="picked" :value="f" :label="f" />
<Checkbox :model-value="all" :indeterminate="some" label="All" @change="toggleAll" />modelValue (boolean, or an array that holds value when checked), value, label (or the default slot), disabled, indeterminate, invalid, name. Events: update:modelValue, change. Keys: Space (native). DOM: <label><input type=checkbox> (indeterminate is the DOM property). Terminal: [x] label, [ ], [-] (indeterminate).
Radio, RadioGroup #
<RadioGroup v-model="size" label="Size" :options="[{ value: 's', label: 'Small' }, 'medium']" />
<RadioGroup v-model="plan" label="Plan"> <Radio value="free" label="Free" /> <Radio value="pro" label="Pro" /> </RadioGroup>RadioGroup: modelValue, options (or Radio children), label (aria-label), disabled, orientation (vertical), name. Events: update:modelValue, change. Radio: value, label, disabled (alone: modelValue). Keys (RADIO_GROUP_KEYS):
| Key | |
|---|---|
| ↓ → | check the next radio (wraps, skips disabled) |
| ↑ ← | check the previous radio |
| Space | check the focused radio (native) |
| Tab | one tab stop for the group: the checked radio (else the first) |
ARIA: role=radiogroup with aria-orientation; native radios with a roving tabindex. Terminal: (*) label / ( ) label.
Switch #
<Switch v-model="wifi" label="Wi-Fi" />: modelValue, label (or slot), disabled. Events: update:modelValue, change. Keys: Space, Enter (native button). ARIA: <button role=switch aria-checked>; the label is its name. DOM: track and thumb. Terminal: ○── Wi-Fi / ──● Wi-Fi.
Select #
<Select v-model="country" label="Country" placeholder="Choose…" :options="countries" />
modelValue, options, placeholder (Select…), label (the name of the button and list; not needed inside a Field with a label), disabled, invalid, size. Events: update:modelValue, change.
Values. An option's value is compared with === and may be any string or number, also the empty string and 0. So "no choice" needs no sentinel value: the option with value: "" shows its label, and the model keeps numbers as numbers.
const category = ref<number | "">("")
const options = [{ value: "", label: "All categories" }, { value: 1, label: "Crime" }, { value: 2, label: "Poetry" }]A model value that matches no option shows the placeholder. Keys:
| Where | Key | |
|---|---|---|
| button | ↓, Enter, Space | open on the selected (or first) option |
| button | ↑ | open on the selected (or last) option |
| list | ↓ ↑ Home End PageDown PageUp | move (no wrap) |
| list | Enter, Space | choose, close, focus back on the button |
| list | a–z | typeahead (repeat a letter to cycle) |
| list | Escape | close without a change, focus back on the button |
| list | Tab | close and move on |
ARIA: the button has aria-haspopup=listbox, aria-expanded, aria-controls. Its accessible name is the label followed by the chosen value (aria-labelledby: the Field's label, or the button's own aria-label, then the element that shows the value), so a screen reader says "Country Brazil, button" after a choice, not only "Country". In a test: page.getByRole("button", { name: "Country Brazil" }) ({ name: "Country" } still finds it: names match by substring). The popup is role=listbox with role=option aria-selected options (roving focus). Terminal: a bordered button with ▾, the list in a bordered box below (or above) it.
Slider #
<Slider v-model="volume" :step="5" label="Volume" show-value unit="%" />: modelValue, min (0), max (100), step (1), bigStep (10 steps), disabled, label, showValue, unit. Events: update:modelValue, change. Keys (SLIDER_KEYS): → ↑ +step, ← ↓ −step, PageUp/PageDown ± big step, Home/End min/max. A press on the track jumps there; dragging follows the pointer (the mouse in the terminal). ARIA: role=slider with aria-valuenow/min/max, aria-valuetext (value + unit), aria-orientation. Terminal: ━━━━●───── across the available width; in a flex row it shares the row with its neighbours (at least 10 cells).
Dialog, Drawer #
Source
<script setup lang="ts">
import { ref } from "roots/runtime"
import { Button, Dialog, Input } from "roots/ui"
const open = ref(false)
const name = ref("my-app")
const draft = ref("")
function show(): void {
draft.value = name.value
open.value = true
}
function rename(close: () => void): void {
if (draft.value.trim() !== "") name.value = draft.value.trim()
close()
}
</script>
<template>
<Box class="kit-dialog flex-row items-center gap-3">
<Text>Project: <strong>{{ name }}</strong></Text>
<Button variant="secondary" label="Rename…" @click="show" />
<Dialog v-model:open="open" title="Rename the project" description="Escape closes without a change.">
<Input v-model="draft" label="New name" autofocus />
<template #footer="{ close }">
<Button variant="ghost" label="Cancel" @click="close()" />
<Button label="Rename" @click="rename(close)" />
</template>
</Dialog>
</Box>
</template>
<style>
.kit-dialog { flex-wrap: wrap; }
</style><Dialog v-model:open="open" title="Rename project" description="Choose a new name.">
<Input v-model="name" />
<template #footer="{ close }"><Button label="Done" @click="close()" /></template>
</Dialog>
<Drawer v-model:open="filters" title="Filters" side="right"> … </Drawer>| Prop | Default | |
|---|---|---|
open | boolean (v-model:open) | |
title, description | string (or the title slot) | |
modal | boolean: backdrop, focus trap, page scroll locked | true |
dismissable | Escape and a press on the backdrop close it | true |
size | sm md lg full (Dialog) | md |
side | left right top bottom (Drawer) | right |
role | dialog alertdialog | dialog |
closeLabel | the close button's name | Close |
Events: update:open, open, close. Slots: default and footer (both get { close }), title. The first focus goes to the control marked autofocus, else to the first control of the body, else of the footer; the Close button gets it only when there is nothing else:
<Dialog v-model:open="confirm" title="Delete the list?" role="alertdialog">
<template #footer="{ close }">
<Button variant="danger" label="Delete" @click="remove(); close()" />
<Button variant="secondary" label="Keep" autofocus @click="close()" /> <!-- Enter keeps -->
</template>
</Dialog>Keys (DIALOG_KEYS): Escape closes (when dismissable), Tab and Shift+Tab wrap inside. ARIA: role=dialog (or alertdialog), aria-modal=true, aria-labelledby (the title), aria-describedby (the description); the focus moves in and comes back. Terminal: a double-bordered box centered on the screen (Drawer: full height at the side).
Toaster, useToast #
const toast = useToast()
toast.success("Saved", "Your changes are stored.")
toast.show({ title: "Deleted", variant: "warning", actionLabel: "Undo", onAction: undo, duration: 8000 })Put <Toaster /> once in the app. useToast(toaster?) returns show(options | title), info/success/warning/error(title, description?), dismiss(id), dismissNewest(), clear(), toasts. Options: title, description, variant, duration (ms; 0: stays until dismissed), key, actionLabel, onAction.
- Time. A toast closes after its
duration: 5 s by default, 10 s for errors. Its timer pauses while it is hovered or focused, also when the toast is replaced through itskeymeanwhile: the replacement's time starts when the pointer or the focus leaves. key: one toast per subject. A toast shown with the key of a toast that is still there replaces it in place (same id, same position, a fresh timer) instead of adding another, so pressing "Add to cart" five times leaves one toast:toast.show({ key: "cart", variant: "success", title: `${book.title}: ${qty} in the cart` })max. At most 5 toasts are shown (the oldest leave first):<Toaster :max="3" />changes that.- Escape dismisses the focused toast; from anywhere else on the page it dismisses the newest one, when nothing else used the key (an open dialog, menu or popover closes first).
createToaster({ max, duration, errorDuration }) makes an independent queue with its own defaults (<Toaster :toaster="t" />); errorDuration: 0 keeps errors until dismissed. Toaster props: position (bottom-end, bottom-start, top-end, top-start), max, label (Notifications). ARIA: the region is role=region; toasts are role=status (errors role=alert). Terminal: a toast is one row (icon, title, description, ✕); a description that does not fit is cut with ….
Tooltip #
<Tooltip text="Copies the link"><Button icon="copy" label="Copy" /></Tooltip>: text (or the content slot), side (top), delay (500 ms), disabled. It shows on hover after the delay and at once on keyboard focus; hides on leave, blur and Escape. ARIA: role=tooltip; the first focusable element inside gets aria-describedby (R602 when there is none). It never takes the focus. Terminal: a one-line box above the element (keyboard focus and mouse hover).
Popover #
<Popover label="Settings" title="Quick settings"> … </Popover>: a button (label or the trigger slot, variant) that opens a non-modal dialog (side, align). The focus moves inside; Tab stays inside; Escape or a press outside closes it and the focus returns to the button. ARIA: aria-haspopup=dialog, aria-expanded, aria-controls; the panel is role=dialog named by title. The default slot gets { close }.
Menu #
<Menu label="File" :items="[{ value: 'new', label: 'New', icon: 'file' }, { separator: true }, { value: 'quit', label: 'Quit', danger: true }]" @select="run" />
Items: { value, label, icon?, disabled?, danger?, separator?, type?, checked? }. Props: items, label (or trigger slot), icon, variant, align. Events: select(value, item).
Items that are choices. type: "radio" (one of a group) or type: "checkbox" (on or off) with checked makes an item a menuitemradio / menuitemcheckbox with aria-checked and a check mark; an item with a boolean checked and no type is a checkbox. Items are data: the app keeps the state and sets checked.
<Menu label="Theme" :items="[
{ value: 'light', label: 'Light', type: 'radio', checked: theme === 'light' },
{ value: 'dark', label: 'Dark', type: 'radio', checked: theme === 'dark' },
]" @select="(v) => (theme = v)" />Keys:
| Where | Key | |
|---|---|---|
| button | Enter, Space, ↓ | open on the first item |
| button | ↑ | open on the last item |
| menu | ↓ ↑ | next / previous item (wraps; skips disabled items and separators) |
| menu | Home End | first / last item |
| menu | a–z | typeahead |
| menu | Enter, Space | run the item, close (native button) |
| menu | Escape | close, focus back on the button |
| menu | Tab | close and move on |
ARIA: aria-haspopup=menu, aria-expanded, aria-controls; role=menu labelled by the button, role=menuitem items (menuitemradio, menuitemcheckbox with aria-checked for choices), role=separator.
Tabs #
<Tabs v-model="tab" label="Product" :items="[{ value: 'overview', label: 'Overview' }, { value: 'specs', label: 'Specs' }]">
<template #overview>…</template>
<template #specs>…</template>
</Tabs>modelValue (default: the first item), items, orientation (horizontal), activation (automatic: moving selects; manual: Enter/Space select), label. Events: update:modelValue, change. Keys (TABS_KEYS): → ← (↓ ↑ when vertical) move and wrap, skipping disabled tabs; Home/End; Enter/Space select (native). ARIA: role=tablist with aria-orientation; role=tab with aria-selected, aria-controls and a roving tabindex; every role=tabpanel exists (hidden unless selected), labelled by its tab. Terminal: a row of tabs over a line; the selected one bold, underlined, in the primary color.
Accordion #
<Accordion v-model="open" :items="faq" multiple> <template #what>…</template> … </Accordion>: modelValue (array of open values), items, multiple, collapsible (true: the last open section may close), headingLevel (3). Keys (ACCORDION_KEYS): Enter/Space toggle (native), ↓ ↑ wrap between headers, Home/End. ARIA: header buttons inside <hN> with aria-expanded and aria-controls; role=region panels labelled by their header. Terminal: ▸ header / ▾ header with the panel indented.
Card, Badge, Tag, Avatar, Divider #
- Card:
title,subtitle,variant(outlined elevated filled),headingLevel(3); slotsheader, default,footer. Terminal: a rounded (elevated: double) border; the footer under a line. - Badge:
value(string or number),max(99 →99+),variant(primary),dot; default slot. Terminal: inverse colored text with a cell of padding, also inside a Link or a Text (Cart 2). - Tag:
label(or slot),variant(neutral),icon,removable(a remove IconButton named "Remove label", at least 24 by 24 px). Events:remove. - Avatar:
name(initials and the accessible name:role=img aria-label),src(the DOM shows the picture),size,shape(circle square). Terminal: initials. - Divider:
orientation(horizontal),label. ARIA:role=separatorwitharia-orientation. Terminal:──── label ────or a vertical│line.
ProgressBar, Spinner, Skeleton #
- ProgressBar:
value,max(100),indeterminate,label,showValue,variant(primary success warning error). ARIA:role=progressbar,aria-valuenow/min/max,aria-valuetext(35%); no value while indeterminate. Terminal:█████░░░░░; an indeterminate bar moves a block (static with reduced motion). - Spinner:
size,label(Loading). ARIA:role=statuswith the label. DOM: a rotating ring (slower with reduced motion). Terminal: braille frames⠋⠙⠹…. - Skeleton:
shape(text rect circle),lines(text),width,height(steps). Decorative (aria-hidden): mark the loading regionaria-busy. Terminal: rows of░.
List #
<List :items="files" selectable="multiple" v-model="picked" label="Files"> <template #item="{ item, selected }">…</template> </List>
items, selectable (none: a plain role=list; single, multiple: a listbox), modelValue (value, or array when multiple), label. Keys (LISTBOX_KEYS): ↓ ↑ Home End PageDown PageUp move (no wrap), Enter/Space select (toggle when multiple), Ctrl+A select all (multiple), typeahead. ARIA: role=listbox with aria-multiselectable, role=option with aria-selected and a roving tabindex. Terminal: a check column ✓, the focused option inverse.
Table #
<Table :columns="[{ key: 'name', label: 'Name', sortable: true }, { key: 'age', label: 'Age', align: 'right', width: 8 }]"
:rows="people" row-key="id" selection-mode="multiple" v-model:selection="picked" v-model:sort="sort" :page-size="10">
<template #cell-name="{ row, value }"><Text class="bold">{{ value }}</Text></template>
</Table>Columns: { key, label?, sortable?, width? (steps), align?, compare?(a, b, rowA, rowB) }. Props: columns, rows, rowKey (id), sort ({ key, direction: 'ascending' | 'descending' } or null), page, pageSize (0: no paging), selection, selectionMode (none single multiple), label, emptyText. Slots: cell-<key> ({ row, value }), empty. Events: update:sort, update:page, update:selection. Sorting cycles ascending → descending → unsorted and is stable; it goes back to page 1.
compare. A column's compare(a, b, rowA, rowB) gets the two cell values first (rowA[key], rowB[key]), then the two rows. Use the values for the usual case and the rows to order by something the column does not show:
{ key: "price", label: "Price", sortable: true, compare: (a, b) => a - b } // the two values
{ key: "title", label: "Title", sortable: true, compare: (_a, _b, x, y) => x.sortKey.localeCompare(y.sortKey) } // the two rows(a, b) => a.price - b.price would be wrong: a is already the price. "Select all" selects the visible page and is tri-state (indeterminate). Keys (TABLE_KEYS): sort buttons and checkboxes are native tab stops. ARIA: a native <table> (aria-label, aria-rowcount), <th scope=col aria-sort>, aria-selected rows, checkboxes named "Select row N", a role=navigation pager with an aria-live page count; sort buttons are at least 24 px high. Terminal: rows of flex cells (width → fixed cells), a header line, [x] checkboxes.
Pagination #
<Pagination v-model="page" :page-count="12" label="Results" />
<Pagination v-model="page" :total="rows.length" :page-size="20" />A pager on its own, for lists the Table does not page (rows from LIMIT/OFFSET, a card grid): previous, page numbers, next.
| Prop | Type | Default | |
|---|---|---|---|
modelValue | number | 1 | the current page, from 1 |
pageCount | number | how many pages; or give total and pageSize | |
total, pageSize | number | , 10 | rows in all and per page |
siblings | number | 1 | pages shown on each side of the current one |
boundaries | number | 1 | pages shown at each end |
disabled | boolean | ||
label | string | Pagination | the landmark's name |
previousLabel, nextLabel | string | Previous page, Next page | |
pageLabel | (page) => string | Page 3 | a page button's name |
statusLabel | (page, count) => string | Page 3 of 12 | what is announced |
Events: update:modelValue, change. Parts: root, prev, next, page, gap, status. Pages left out show as … (never for a single page). Keys (PAGINATION_KEYS):
| Key | |
|---|---|
| Enter, Space | go to the focused button's page (native button) |
| ← → | previous / next page; the focus goes to the current page's button |
| Home End | first / last page |
| Tab | every button is a tab stop |
ARIA: role=navigation with the label; the current page's button is aria-current="page"; buttons are named ("Page 3", "Next page"); an aria-live status says "Page 3 of 12" when the page changes. Previous and Next at the ends are aria-disabled, not disabled, so the focus stays on Next when it reaches the last page. Terminal: ◂ 1 … 4 5 6 … 12 ▸, the current page bold and underlined.
Tree #
<Tree :nodes="nodes" v-model="file" v-model:expanded="open" label="Files" /> with nodes { id, label, children?, disabled? }. Props: nodes, modelValue, expanded, multiple, label. Events: update:modelValue, update:expanded, select. Keys (TREE_KEYS): ↓ ↑ visible nodes; → expands a closed node, else enters its first child; ← collapses an open node, else goes to its parent; Home/End; Enter/Space select; * expands all siblings; typeahead. A click on the chevron toggles. ARIA: role=tree; visible nodes are flat role=treeitems with aria-level, aria-setsize, aria-posinset, aria-expanded (branches), aria-selected and a roving tabindex.
ScrollArea #
<ScrollArea :height="10" label="Log"> … </ScrollArea>: height (lines of text: 1.5rem on the DOM, rows in the terminal; 10), fill, label. A focusable role=region. DOM: native scrolling (thin themed scrollbars); keys are the browser's. Terminal (SCROLL_KEYS): ↓ ↑ a line, PageDown/PageUp/Space a page, Home/End; the mouse wheel; a scrollbar column (│ track, ┃ thumb) and a border that turns the focus color. Exposes measure() and scrollTo(n).
fill. Instead of a row count the area takes the height its flex parent has left (flex: 1 with a minimum of 0), on both surfaces; height is then not used. The parent must be a flex column with a height of its own (the screen, h-full, a fixed height): in a parent that is as tall as its content there is nothing left to fill. This is how a terminal page scrolls: the terminal has no page scrolling, so what is below the last row is cut off.
<Box class="h-full"> <!-- the screen -->
<Header />
<ScrollArea fill label="Page"><RouterView /></ScrollArea>
<Footer />
</Box>In the terminal the area keeps up with its content by itself: it looks at the content's height a few times a second, so rows that arrive later (a query, a fetch) get their scrollbar, shorter content pulls the offset back to its end, and content that was replaced (none of the previous top-level nodes is left: another page, another result page) starts at the top. A control inside that gets the focus is scrolled into view together with the nearest box around it marked data-reveal; a Field marks itself, so its label is shown with its input. Mark your own groups the same way: <Box data-reveal>.
Splitter #
<Splitter v-model="size" class="h-64"> <template #first>…</template> <template #second>…</template> </Splitter>: modelValue (first pane, %), direction (horizontal: side by side), min (10), max (90), step (5), label (Resize). Keys (SPLITTER_KEYS): ← → (↑ ↓ when vertical) move by step, Home/End min/max, Enter collapses the first pane and restores it; dragging with the mouse. ARIA: role=separator (focusable) with aria-valuenow/min/max, aria-controls (the first pane), aria-orientation (vertical for side-by-side panes). Terminal: a │ line, heavy ┃ when focused.
Stack, Grid, Center #
Stack:
direction(column),gap(steps, 2),align,justify(start center end stretch between around),wrap,as(tag).Grid:
columns(2),gap(2),minItemWidth(steps). Withitemsand theitemslot ({ item, index }) it lays out rows of equal cells, the same on every surface. With children in the default slot it is a CSS grid on the DOM and a wrapping row in the terminal (give the childrenflex-1for equal columns there). In every form a Grid is one root box (partroot), soclass,id,data-*and listeners given to<Grid>land on it.With
minItemWidththe grid shows as many columns as fit, each at least that wide, instead of a fixed number (columnsis ignored):<Grid :min-item-width="56" :gap="4" :items="books"> <!-- 56 steps = 14rem --> <template #item="{ item }"><BookCard :book="item" /></template> </Grid>DOM:
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr)), foritemsand for default-slot children alike. Terminal: a wrapping row; the cells ofitemsare at leastminItemWidthcells wide and share what is left of their line (default-slot children keep their own widths).Center: centers its content (
inlinefor an inline box).
Gaps are spacing steps (CONTRACT §6): 0.25rem on the DOM, one cell in the terminal.
6. The headless layer #
A custom skin, or a component the kit does not have, can use the machines directly (runtime/ui/headless/*; the tables and controllable, matchKey, handleKey are exported from roots/ui). Each machine takes getters (so props stay reactive), a focus(i) callback (the skin knows the nodes) and returns state refs, actions, keydown(e, …) and ARIA attribute objects:
| Machine | For |
|---|---|
controllable(get, initial, onChange) | a v-model value that also works uncontrolled |
matchKey(table, e, conditions), handleKey(table, e, actions, conditions) | keyboard tables (data); rows without an action are native behaviour |
nextIndex, firstEnabled, lastEnabled, Typeahead | roving focus and typeahead |
useDisclosure, useDelayedDisclosure, trapTarget, placeFloating | overlays |
useToggle, useRadioGroup, useTabs, useAccordion, useListbox, useMenu | toggles and collections |
useSlider, useNumber, useSplitter, useScroll | ranges |
useTree, useTable, usePagination, pageEntries, useField, useForm, rules, createToaster | data and forms |
const tabs = useTabs(() => items, () => props.modelValue, "a", (v) => emit("update:modelValue", v), (i) => nodes[i].focus())
tabs.tabAttrs(0) // { role: "tab", id, "aria-selected": true, "aria-controls", tabindex: 0 }
tabs.keydown(event, 0) // ArrowRight → focus(1) and select it (automatic activation)7. Warnings #
Warnings go to app.config.warnHandler (else console.warn) with a component trace, like the runtime's.
| Code | Meaning |
|---|---|
| R601 | IconButton without a label: it would have no accessible name |
| R602 | Tooltip without an element to describe (put the button or link it explains inside) |
| R603 | unknown icon name (see iconNames(), or registerIcon()) |
8. Tests #
bin/test-system roots-runtime-ui/tests [filter]| Test | |
|---|---|
ui-headless | every machine in Node: keyboard tables, transitions, controlled/uncontrolled values, ARIA |
ui-components-term | every interactive component on the headless terminal screen: keys, ARIA, focus trap and restore, v-model, pt/unstyled/createUI |
ui-typescript | both skin families pass tsc --strict |
ui-no-eval | no eval / new Function / HTML sinks; shared modules use no DOM globals |
ui-gallery-builds | the gallery builds for all six targets without warnings, each with only its skin |
ui-gallery-term | the gallery at 80×24: a text snapshot of every page and open overlays (tests/snapshots/, ROOTS_UPDATE_SNAPSHOTS=1 rewrites them), keyboard flows |
ui-gallery-pty | the gallery in a real pseudo-terminal: Tab, arrows, Enter, Escape |
ui-gallery-dom | the web build in Chromium: keyboard operation of every component, ARIA, focus trap and restore, zero console errors under the strict CSP, dark mode, reduced motion; screenshots in $ROOTS_UI_SHOTS |
ui-kit-dom | the kit's probe page (runtime/ui/tests/kit-app) in Chromium: what only a browser shows: pop-ups above dialogs (the option is the element at its own centre point), where the focus goes, computed borders and outlines, accessible names, sizes of pointer targets |
9. Not in tier 1 #
Submenus, multi-select and searchable Select (Autocomplete, MultiSelect are tier 2), grid keyboard navigation inside Table cells, column resizing, virtualized lists, tree drag and drop, a vertical Slider and range (two-thumb) sliders, inert on the page behind a modal (the focus trap keeps the focus inside), right-to-left layouts, animations of closing overlays, and the terminal's Textarea has no selection, undo or soft-wrap-aware ↑ ↓.