Skip to the contentSkip to the navigation
RootsGitHubMenu
On this page

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 #

  1. Architecture
  2. Using the kit
  3. Styling: skins, pass-through, unstyled
  4. Overlays and focus
  5. Components
  6. The headless layer
  7. Warnings
  8. Tests
  9. 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.js and ./surface.js resolve per target family like any per-target file (CONTRACT §1): skin.dom.lisp on web, pwa, ext, ios and android, skin.term.lisp on 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) and Textarea (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 --strict for both skin families). No eval, no innerHTML: 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 build therefore ships the components an app imports and not the others: an app that imports only Button carries 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 from roots.lisp :theme :dark, and prefers-reduced-motion turns 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-modelmodelValue + 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 notevery value works without v-model too: the component keeps it locally (controllable)
items are dataitems / options: [{ value, label, disabled? }], or strings ("a" is { value: "a", label: "a" })
panels are slotsTabs and Accordion: the slot named after the item's value (<template #specs>), else the panel slot with { item }
sizessize: sm, md, lg
tonesvariant of Badge and Tag: neutral primary success warning error info
every componentpt (pass-through) and unstyled (§3)

3. Styling: skins, pass-through, unstyled #

Each component is made of parts (root, label, panel …). A part gets:

  1. the skin's class for that part and its modifiers (unless unstyled),
  2. the component's own attributes and handlers,
  3. the pass-through of the app config, then of the pt prop.

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 placeFloating puts 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 uses position: fixed in 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, and list or panel, get the modifier under meanwhile: .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 when dismissable (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:

Buttons, badges and tagsstatic previewdemos/kit-buttons.vue
Saved0
stable
beta
0.4
Source
kit-buttons.vue
<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>
PropTypeDefault
variantprimary secondary ghost dangerprimary
sizesm md lgmd
labelstringtext when there is no default slot
icon, iconRighticon namebefore / after the label
disabledboolean
loadingbooleanspinner, aria-busy, aria-disabled: a press does nothing, the focus stays
blockbooleanfull width
typebutton submit resetbuttoninside 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" />

PropTypeDefault
modelValuestring | number
typetext password email number search tel urltext
placeholder, name, label (aria-label)string
disabled, readonly, invalid, autofocusboolean
maxlengthnumber
sizesm md lgmd

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).

PropTypeDefault
modelValuenumber | nullnull: the field is empty
min, maxnumberthe range; without them there is no limit
stepnumber1what ↑ ↓ and the buttons add; the value is rounded to it
bigStepnumber10 stepswhat PageUp / PageDown add
clearablebooleanan empty field is null; without it an empty field goes back to its value
placeholder, name, label (aria-label)string
disabled, readonly, invalidboolean
sizesm md lgmdterminal: sm is one row without a border, as a small Input is
decrementLabel, incrementLabelstringDecrease, Increasethe 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 PageDowna big step
Home Endthe minimum / maximum, when there is one
Entertake 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:

Fields of the kitstatic previewdemos/kit-form.vue
Lowercase letters, digits and dashes.
roots build Ada --target web --strict · budget 40 KB
Source
kit-form.vue
<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, hintstringthe label names the control (for and aria-labelledby), the hint describes it
requiredbooleanaria-required, a *
value, rulesany, Rule[]what to validate and how
errorstringan error from outside (a server reply); wins over the rules
idstringthe 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
Spacecheck the focused radio (native)
Tabone 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:

WhereKey
button↓, Enter, Spaceopen on the selected (or first) option
button↑open on the selected (or last) option
list↓ ↑ Home End PageDown PageUpmove (no wrap)
listEnter, Spacechoose, close, focus back on the button
lista–ztypeahead (repeat a letter to cycle)
listEscapeclose without a change, focus back on the button
listTabclose 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 #

A dialogstatic previewdemos/kit-dialog.vue
Project: my-app
Source
kit-dialog.vue
<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>
PropDefault
openboolean (v-model:open)
title, descriptionstring (or the title slot)
modalboolean: backdrop, focus trap, page scroll lockedtrue
dismissableEscape and a press on the backdrop close ittrue
sizesm md lg full (Dialog)md
sideleft right top bottom (Drawer)right
roledialog alertdialogdialog
closeLabelthe close button's nameClose

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 its key meanwhile: 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 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:

WhereKey
buttonEnter, Space, ↓open on the first item
button↑open on the last item
menu↓ ↑next / previous item (wraps; skips disabled items and separators)
menuHome Endfirst / last item
menua–ztypeahead
menuEnter, Spacerun the item, close (native button)
menuEscapeclose, focus back on the button
menuTabclose 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); slots header, 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=separator with aria-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=status with 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 region aria-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.

PropTypeDefault
modelValuenumber1the current page, from 1
pageCountnumberhow many pages; or give total and pageSize
total, pageSizenumber, 10rows in all and per page
siblingsnumber1pages shown on each side of the current one
boundariesnumber1pages shown at each end
disabledboolean
labelstringPaginationthe landmark's name
previousLabel, nextLabelstringPrevious page, Next page
pageLabel(page) => stringPage 3a page button's name
statusLabel(page, count) => stringPage 3 of 12what 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, Spacego to the focused button's page (native button)
← →previous / next page; the focus goes to the current page's button
Home Endfirst / last page
Tabevery 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). With items and the item slot ({ 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 children flex-1 for equal columns there). In every form a Grid is one root box (part root), so class, id, data-* and listeners given to <Grid> land on it.

    With minItemWidth the grid shows as many columns as fit, each at least that wide, instead of a fixed number (columns is 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)), for items and for default-slot children alike. Terminal: a wrapping row; the cells of items are at least minItemWidth cells wide and share what is left of their line (default-slot children keep their own widths).

  • Center: centers its content (inline for 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:

MachineFor
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, Typeaheadroving focus and typeahead
useDisclosure, useDelayedDisclosure, trapTarget, placeFloatingoverlays
useToggle, useRadioGroup, useTabs, useAccordion, useListbox, useMenutoggles and collections
useSlider, useNumber, useSplitter, useScrollranges
useTree, useTable, usePagination, pageEntries, useField, useForm, rules, createToasterdata 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.

CodeMeaning
R601IconButton without a label: it would have no accessible name
R602Tooltip without an element to describe (put the button or link it explains inside)
R603unknown icon name (see iconNames(), or registerIcon())

8. Tests #

bin/test-system roots-runtime-ui/tests [filter]
Test
ui-headlessevery machine in Node: keyboard tables, transitions, controlled/uncontrolled values, ARIA
ui-components-termevery interactive component on the headless terminal screen: keys, ARIA, focus trap and restore, v-model, pt/unstyled/createUI
ui-typescriptboth skin families pass tsc --strict
ui-no-evalno eval / new Function / HTML sinks; shared modules use no DOM globals
ui-gallery-buildsthe gallery builds for all six targets without warnings, each with only its skin
ui-gallery-termthe 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-ptythe gallery in a real pseudo-terminal: Tab, arrows, Enter, Escape
ui-gallery-domthe 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-domthe 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 ↑ ↓.