@llui/components
v0.21.0
Published
Headless UI component machines for LLui — accordion, dialog, tabs, select, tree-view, timer, tour, and more
Maintainers
Readme
@llui/components
Headless UI components for LLui. Pure state machines with no DOM opinions — you own the markup and styling via data-scope / data-part attributes.
Scope & philosophy
Each component is a pure state machine: init / update(state, msg) → [state, effects] / connect(state, send) → parts. The machine owns behavior — keyboard interaction, ARIA semantics, selection/focus/validation logic, lifecycle — and nothing else.
What lives in the machine:
- Discriminated-union messages and JSON-serializable state (no DOM nodes, timers, or class instances in state).
- ARIA roles/attributes and WAI-ARIA APG keyboard patterns, exposed as reactive
partsyou spread onto your elements. - Direction-aware keyboard semantics: direction-sensitive components take an optional
dir('ltr' | 'rtl') and flip horizontal-arrow keys + logical floating placement accordingly. - Side effects as data — anything async (HTTP, lazy loading, debounce) is returned as an effect for your
onEffecthandler; the machine never performs I/O.
Numeric inputs preserve that JSON-state contract. Grid values use each component's documented
clamping behavior, invalid initialization options fall back to the field's ordinary default, and
runtime position/measurement messages containing NaN or infinity are ignored atomically.
Divisors such as pagination pageSize and a numeric image-cropper aspectRatio must be finite and
greater than zero; aspectRatio: null remains the explicit unconstrained crop.
What stays the consumer's responsibility (deliberately not shipped):
- Markup and CSS. Components emit
data-scope/data-part/data-statehooks; visual styling — including CSS logical-property mirroring for RTL — is yours (an opt-in baseline stylesheet and design tokens are available under@llui/components/styles/*; Tailwind class recipes ship as registry source you copy with@llui/cli). - Driving time and observers:
setInterval/requestAnimationFrameticks (timer, toast countdown),IntersectionObserver(in-view, async-list sentinel, toc scroll-spy), and pointer-event wiring for drag (sortable, slider, carousel swipe, image-cropper) — the machine exposes pure helpers and tick/drag messages; you own the listeners. - Bring-your-own heavy deps: a QR encoder (qr-code), canvas rendering (signature-pad), and the actual data fetch behind every
load*effect. - Exit-animation timing: overlays expose a
'closing'data-state+isPresentand wait for ananimationend; the CSS transition itself is yours (and unmount is synchronous when no animation is configured).
Roadmap. There's no frozen set — planned enhancements and new components are tracked as GitHub issues under the components label.
Install
pnpm add @llui/components @llui/dom @llui/interactionsPeer dependencies: @llui/dom and @llui/interactions. Both carry shared runtime registries,
so the application and its libraries must resolve one instance of each.
Usage
Each component exports init, update, connect, and a barrel object:
import { component, div, button, text } from '@llui/dom'
import { tabs, type TabsState, type TabsMsg } from '@llui/components/tabs'
type State = { tabs: TabsState }
type Msg = { type: 'tabs'; msg: TabsMsg }
const App = component<State, Msg, never>({
name: 'App',
init: () => [{ tabs: tabs.init({ items: ['a', 'b', 'c'], value: 'a' }) }, []],
update: (s, m) => {
const [next] = tabs.update(s.tabs, m.msg)
return [{ ...s, tabs: next }, []]
},
view: ({ state, send }) => {
const t = tabs.connect(state.at('tabs'), (m) => send({ type: 'tabs', msg: m }), { id: 'demo' })
return [
div({ ...t.root }, [
div({ ...t.list }, [
button({ ...t.item('a').trigger }, [text('Tab A')]),
button({ ...t.item('b').trigger }, [text('Tab B')]),
button({ ...t.item('c').trigger }, [text('Tab C')]),
]),
div({ ...t.item('a').panel }, [text('Content A')]),
div({ ...t.item('b').panel }, [text('Content B')]),
div({ ...t.item('c').panel }, [text('Content C')]),
]),
]
},
})Pattern
init(opts?)— creates the initial stateupdate(state, msg)— pure reducer, returns[newState, effects[]]connect(state: ReadSignal<Slice>, send, opts?)— takes a signal handle for the component's state slice (e.g.state.at('tabs')), returns parts objects with reactive props, ARIA attributes, and event handlers. Spread parts onto your elements:div({ ...parts.root }, [...])- Overlay helpers (dialog, popover, menu, etc.) —
overlay()wires up portals, focus traps, dismiss layers, and positioning
Components
The product contract lists 95 components. @llui/components/<name> imports a component's headless machine (behaviour, state, ARIA) when it has one; llui add <name> copies styled source into your app. They are different artifacts, and a component may have either, both, or names that differ between the two.
| Kind | Components | Headless import | Copied by llui add |
| --------------------- | ---------- | --------------- | -------------------------------------- |
| machine | 69 | 69 | 64 |
| pattern | 6 | 6 | 3 |
| presentational | 19 | none, by design | 19 |
| skin, app-owned state | 1 | none, by design | 1 |
| all | 95 | 75 | 89 add names, 6 aliases among them |
Every component, with its import, add names, styling support and a stable Component Gallery link, is in the component catalog.
75 headless machine and pattern subpaths, each imported as @llui/components/<subpath>, grouped by category:
- Controls —
accordion,angle-slider,checkbox,clipboard,collapsible,color-picker,editable,gradient-picker,listbox,number-input,password-input,pin-input,radio-group,rating-group,search-field,select,slider,switch,tags-input,theme-switch,time-picker,timer,toggle,toggle-group - Forms —
cascade-select,combobox,date-input,date-picker,field,fieldset,file-upload,form - Navigation —
breadcrumbs,menu,menubar,navigation-menu,pagination,tabs,toc,toolbar,tree-view - Overlays —
alert-dialog,context-menu,dialog,drawer,floating-panel,hover-card,popover,tooltip,tour - Feedback —
async-list,meter,progress,toast - Data display —
avatar,carousel,chart,marquee,qr-code,sparkline,table - Layout —
scroll-area,sortable,splitter,steps - Media —
image-cropper,signature-pad - Patterns —
patterns/command-menu,patterns/confirm-dialog,patterns/data-table,patterns/form-field,patterns/searchable-select,patterns/wizard - Utilities —
in-view,presence
The notes below are grouped by family. They are behaviour notes, not the inventory — the lists above and the component catalog are.
RTL / direction. Direction-sensitive components (tabs, radio-group, toggle-group, rating-group, slider, angle-slider, splitter, carousel, menu, context-menu, navigation-menu, pagination, …) accept an optional
dir('ltr' | 'rtl', default'ltr') oninitand asetDirmessage to change it at runtime. Under'rtl'they swap horizontal-arrow keyboard semantics (ArrowLeft ↔ ArrowRight; vertical arrows and Home/End are never flipped) and flip logical floating placement (*-start/*-end). This covers keyboard and placement only — visual mirroring via CSS logical properties (ordir/direction) remains the consumer's responsibility.
directionSync— place it, or automatic direction goes stale.tabs,carousel,pagination,navigation-menu,menu,context-menu, andmenubaradditionally track the ambient DOMdirwhen no explicitsetDirhas been sent (init's defaultdirSource: 'dom'), by observing the live ancestor chain after mount. That observation is aMountable—parts.directionSync— and it does nothing until it is PLACED somewhere in the same build (div({ ...parts.root }, [parts.directionSync, /* … */]), anywhere in the tree works; it renders nothing). Omitting it is not an error and produces no warning — the component simply never updates from'ltr'regardless of the page's actual direction, which is easy to miss in an LTR-only manual test. This is whyConnectOptions.idis REQUIRED on all of these (or, formenu/context-menu, the equivalent always-mounted anchor — their owntrigger.id):directionSynclocates the component's own root by that id to walk its live ancestor chain, so there is no id-less way to make it work. The resolver itself is@llui/interactions'resolveDir— the one shared DOM-direction lookup every direction-sensitive component and structural primitive routes through, including a future component adding the same automatic-direction support.Breaking (#265 finding 6):
menu/context-menu/menubarno longer have a second, independently-resolved direction.menu-machine.ts— the shared item-tree machine behindmenu,context-menu, and (viamenu.ts)menubar— is migrated onto the same seam as the paragraph above, closing a gap where a menu's floating submenu geometry resolved direction from a bare, isolatedresolveDir(trigger)call while keyboard handling and an explicitsetDirused a completely different, nullabledir: TextDirection | nullfield: an explicitly-configured direction could silently disagree with what a submenu opened toward. Three consequences: (1)MenuState/ContextMenuState/MenubarState'sdiris now always a concrete'ltr' | 'rtl'(nevernull), with adirSource: 'dom' | 'explicit'field recording whether it is DOM-observed or was explicitly set — there is no longer a way to clear an explicitsetDirback to "follow the page"; (2)menu.floatingDiris REMOVED —menu.overlay's floating placement now readsstate.dirdirectly, unconditionally; (3)ContextMenuParts.triggergained a REQUIREDid, andMenubarParts.rootgained a REQUIREDid(mirroringnavigation-menu's ownroot.idrequirement) — both are what their newdirectionSyncpart observes.Submenus are positioned by the engine (#265 A4). Previously
menu,context-menu, andmenubarhad no submenu positioning: a hand-rolledshow(isOpen, [subPositioner, subContent])block rendered pinned to its container's top-left corner. That block still compiles and renders, unpositioned. Each of the three now exportssubOverlay(opts), built on the samecreateOverlayengine as their ownoverlay(): call it once perchildren-bearing item, alongside itssubTrigger, in place of that hand-rolled block —
import { div, show } from '@llui/dom'
import type { Renderable, ReadSignal } from '@llui/dom'
import { menu, type MenuItem, type MenuParts, type MenuState } from '@llui/components/menu'
// Signature only — a real call site closes over its own `state`/`send`;
// the parameters below just name the types each argument has.
function renderSubTrigger(
state: ReadSignal<MenuState>,
parts: MenuParts,
it: MenuItem,
openPath: ReadSignal<readonly string[]>,
renderChildren: (children: readonly MenuItem[]) => Renderable,
) {
// before
show(
openPath.map((p) => p.includes(it.value)),
() => [
div({ ...parts.subPositioner(it.value) }, [
div({ ...parts.subContent(it.value) }, renderChildren(it.children ?? [])),
]),
],
)
// after
menu.subOverlay({
value: it.value,
state,
parts,
content: () => [div({ ...parts.subContent(it.value) }, renderChildren(it.children ?? []))],
})
}
subOverlaybuilds the subpositioner wrapper itself (fromparts.subPositioner(value)), gates mount onopenPathmembership (menubar's variant additionally takes amenuIdand reaches intostate.menuStates[menuId].openPath), and owns floating (flip/shift/collision padding, a physical side resolved from the reading direction in effect when the level opens), nested-layer registration (owner: that level's ownsubTrigger— keeping #171's modal-isolation fix scoped per level), and re-placement if the direction changes at runtime while the level stays open. It declares nodismissconfig: Escape/outside-click stay owned by the root overlay's dismissable layer plus this file's own subContent/subTrigger key handlers. Real floating geometry attaches directly to thesubContentelement, sincecreateOverlay's positioner-selector only recognizesdata-part="positioner", not this machine's"subpositioner"; style the wrapper withsubOverlay'spositionerClassif you need to.The engine itself gained two small, generally-useful pieces backing this:
OverlayFloatingConfig.placementnow accepts a thunk (Placement | (() => Placement), resolved at attach time, mirroring the existingdirthunk), andOverlayFloatingConfig.reattachKeyre-runs floating attach when a caller-rendereddata-llui-reattach-keymarker attribute changes while mounted — needed becauseplacement/dirare otherwise captured once at attach andattachFloating's ownautoUpdatenever re-polls them.
Form controls
- accordion / collapsible exit motion — Closing is synchronous by default, so a missing stylesheet or end event cannot strand content. Opt in with
init({ animated: true }); the content then stays mounted asdata-state="closing",aria-hidden, andinertuntil its ownanimationend/animationcancel. Spread the complete content part so those completion handlers are preserved.- The completion contract, precisely. A skin only needs to run a REAL CSS animation on
[data-state='closing']; publishing the--llui-disclosure-exit-animation: <name>custom property is now OPTIONAL, used only to disambiguate when the element runs more than one simultaneous animation. Without it, the exit is armed from the browser's ownanimationstartevent directly — a custom skin that never adds that one custom property no longer gets stuckclosing+inertforever the first time its own (unnamed) exit animation ends. - If the skin runs no exit animation at all (a dropped rule, a media query that doesn't match,
animation: none) whileanimated: trueis set, noanimationstart/animationendever fires. A safety net checked synchronously right after a USER-INITIATED close (the trigger's click or Enter/Space) detects this viaElement.getAnimations()(which forces a style flush, so it already reflects what the just-appliedclosingstate triggered) and completes immediately instead of hanging. - A forgotten
parts.exitCompletionplacement is FAIL-SAFE, structurally, not diagnostically (#264 item F1, mechanism finalized in #264 review-264j). Whether a watcher is CURRENTLY mounted is a fact about the running view tree, not domain state: it lives in a module-level RUNTIME REGISTRY keyed by the machine's ownopts.id(already required and unique per page), mutated directly byparts.exitCompletion's own mount/cleanup — never throughsend, never serialized, never part ofAccordionState/CollapsibleState, andinit()stays fully deterministic. The one new fact that DOES travel as a message is a plainretain?: booleanfield on every closing-capable message (close/toggle/open/setValuefor accordion;close/toggle/setOpenfor collapsible), stamped byconnect()'s trigger click/keydown handlers from that registry AT DISPATCH TIME — the reducer only ever retainsclosingwhenanimated && msg.retain === true, so a rawsend({ type: 'close', value })from app/agent code (with noretain) closes INSTANTLY, even withanimated: trueand the part placed — documented, fail-safe. For an animated PROGRAMMATIC close, useparts.close(value)(accordion) /parts.close()(collapsible): it stampsretainfrom the same registry the trigger handlers read, so it retains correctly whenever a real watcher is attached. An instance whose view never placesparts.exitCompletiontherefore closes every item INSTANTLY regardless ofretain, since the registry never counts an attach for that id — exactly asanimated: falsewould, never hangingclosing+inert. Placeparts.exitCompletionanywhere in the same build, as a sibling of the root ([div({ ...parts.root }, [...]), parts.exitCompletion]) — it renders nothing, likedirectionSync. It resolves each closing content element by its ownopts.id-scoped id, never a scope-wide[data-scope][data-part]query, so a sibling instance sharing the same enclosing build (two accordions, or an accordion and a collapsible, rendered inside one parent view) can never settle each other's exit early; and TWOconnect()calls sharing the sameid— even with two textually-identical-but-referentially-distinct inline dispatcher closures — share ONE registry count, so neither can spuriously warn or drop the other's retention. A dev-modeconsole.warnfires SYNCHRONOUSLY, once perid, the first time a closing message is dispatched whileanimatedis true and that id's watcher is not attached — no timer, no deadline. The registry'sAccordion/Collapsibleroot skins still make placing it a compile-time obligation: it is a REQUIRED third argument (options: { exitCompletion: Mountable }, kept separate from the attribute props bag so a caller's object literal never has to satisfyElProps's own index signature), and the root wrapper appends it after the caller's children itself —Accordion({ ...parts.root }, [...], { exitCompletion: parts.exitCompletion })is the whole call, and omitting the third argument is a type error. That requirement is no longer load-bearing for SAFETY (a hand-rolled skin that forgets it degrades gracefully, with a warning) — it is kept because silently losing the requested animation is still worth catching at compile time in the registry's own skins. - Detaching the last watcher for an
idsettles every still-closingentry for that id immediately, through the ordinaryexitCompletemessage (there is no dedicated "detach" message) — so removingparts.exitCompletionfrom the view mid-exit (e.g. via a conditionalshow) never leaves anything stuck. - A snapshot restored from a persistence layer, bypassing
init(), carries NOTHING watcher-related — there is nothing to self-heal, because nothing about "is a watcher mounted" was ever written into state to begin with. An item alreadyclosingin a restored snapshot with noexitCompletionever mounted this session persists until the next real interaction on that item (a documented, accepted residual — it never hangs the component, since any ordinary reducer transition on that value resolves it); placingexitCompletionand then letting it detach (or a real completion event) settles it through the paths above. - A canceled ENTER animation can never be mistaken for completing an EXIT, including when no name is published: the tracker remembers the concrete
Animationobject captured for the item's own most recent enter and excludes it from the exit's candidate search.
- The completion contract, precisely. A skin only needs to run a REAL CSS animation on
- field — label/description/error ARIA wiring for a single control: derives stable control/label/description/error ids from one base id, exposes a
controlbag (id,htmlFor,aria-labelledby, reactivearia-describedby/aria-invalid/aria-required,disabled,readOnly) plus adescriptionhint and a politeerrorTextlive region — zero manual ids. - fieldset — group wiring: native
<fieldset>/<legend>(role group,aria-labelledby), group-leveldisabledpropagation (mirrored toaria-disabled), and an optional polite group error region for cross-field validation. - toolbar — roving-tabindex container for grouping buttons, toggles, and menu triggers. Single tab stop with arrow-key roving (orientation-aware), Home/End, separator/disabled skipping, optional focus wrap, and labelled groups. Interaction-agnostic: it only manages focus, items supply their own behavior.
- search-field — role="search" landmark with a type="search" input and clear button; Escape clears (when non-empty), Enter submits the current value; debounce live search consumer-side with @llui/effects
Overlays
- Exit animations (presence lifecycle). Overlay components (dialog, drawer, alert-dialog, popover, hover-card, tooltip, menu, context-menu, toast, …) support exit animations via a shared presence lifecycle. A closing overlay exposes
data-state="closing"on its content and stays mounted untilanimationend/transitionend, then unmounts; when no animation is configured (the default), close unmounts synchronously with no hang. Use the exposedisPresenthelper to gate the structural block so the node stays mounted through its exit transition. - menubar — desktop-style application menu bar (File/Edit/View) that composes N menu machines with WAI-ARIA APG keyboard: ArrowLeft/Right move between top-level triggers, open-mode arrow/hover switching, roving tabindex (single tab stop), ArrowDown/Enter/Space open-and-focus-first-item, Escape closes and restores trigger focus.
- Delegates per-menu content/item/checkbox/radio/group/submenu parts to the menu machine via menubar.menu(id); render each dropdown with menubar.overlay({ menuId }).
- Breaking (#265):
ToastPatchis narrowed to mutable PRESENTATION fields only.toast.update'spatchargument used to acceptPartial<Toast>, which could silently patch lifecycle-owned fields (id,remainingMs,status) out from under the reducer's own countdown/presence bookkeeping.ToastPatchis nowPartial<Omit<Toast, 'id' | 'remainingMs' | 'status' | 'pausedBy'>>—type,title,description,duration,dismissable, andariaLiveremain patchable,idcannot be changed (it identifies the row), andremainingMs/status/pausedByare reducer-owned.undefinedon a required field (type,duration,dismissable) is ignored, like an absent key ({ duration: undefined }leaves the countdown alone; patchduration: nullto make a toast sticky); on an optional field (title,description,ariaLive) it clears the field.dismissable: falsehides the close trigger (closeTrigger.hidden) and ignores its click;dismissfrom code still works. PatchingdurationRE-SEEDSremainingMsfrom the new value (a stickyduration: nulltoast moving to a finite duration starts counting down fresh, rather than staying frozen at the0it held while sticky) —registry/test/toast-live-demos.browser.test.tsdrives that loading→success flow live on both styling paths. - A portaled overlay takes its direction from its trigger (#265). While a component's direction is automatic (no
diroption, nosetDir), every floating overlay resolvesltr/rtlfrom its placement ANCHOR — the trigger, inside your app's own container — not from<body>, where the portal lands. The engine passes that toattachFloatingand writes it asdiron the portaled positioner, so placement, CSS logical properties,rtl:variants, arrow keys and nested submenus all agree with the trigger, and it follows adirchange on any ancestor of the trigger while open. Precedence, most specific first: adiryou put on the content itself (the engine never writes over it), then an explicitdir, then the trigger's direction. So<div dir="rtl">around one region of an LTR page now works for menus, selects and popovers without<html dir>. The baselinefoundation.cssno longer setsdirection: inheriton parts (it overrode exactly thatdirattribute). - Status tokens (#265).
semantic-tokens.cssadds--infoand--successbeside--warning(whose light value is now amber-600, dark enough to clear 3:1 as a glyph) and--destructive; Tailwind getsinfo/successcolours. Both toast skins tint their info/success/warning/error surfaces from these tokens, and the registry skin also draws its glyphs in them (baseline glyphs keep the toast's text colour), so a theme that restyles status colours restyles toasts too.customkeeps a decorative violet: it has no status meaning. - Breaking (#265): toast pause is a set of REASONS, not one flag.
Toast.paused: booleanis replaced bypausedBy: ToastPauseReason[]('focus' | 'hover' | 'manual'; read it withisPaused(toast)). The row's pointer handlers pause/resume'hover', focus entering/leaving the row pauses/resumes'focus'(moving focus between two elements inside the row does not resume), andpause/resume/pauseAll/resumeAlltake an optionalreasonthat defaults to'manual'. Each resume releases only its own reason, so moving the pointer away no longer restarts a toast whose close button still has keyboard focus. A negativetickis ignored. - Breaking (#265):
ToastItemParts'role,aria-live, anddata-typeare Signals, not one-shot strings.toast.connect(...).toast(item)used to resolverole/aria-livefromitem.peek().typeat CALL TIME, so a mounted toast'supdatepatchingtyperepainted its color (CSS readsdata-typereactively) but never its announced role/politeness — aloadingtoast patched toerrorkept announcing asrole="status"/aria-live="polite". All three are nowitem.map(...)derivations bound to the live row, so anupdateon a mounted toast keeps its DOM, ARIA, and announced politeness in agreement. - Breaking (registry): the
Toast/Sonnerrecipe inregistry/llui/ui/sonner.tsno longer takes avariantprop. The sixToastTypevisuals (background/border tint, icon, forced-colors border style) are driven entirely by the machine's own reactivedata-typeattribute viadata-[type=…]:selectors — never avariantresolved once from a peeked value at the call site, which is exactly the freeze bug the two bullets above close. Spreadparts.rootstraight through; there is nothing else to pass. - Breaking:
createOverlaynow THROWSLluiFrameworkErrorfor a misconfigured two-phase overlay (#265 A5) —floatingconfigured together withvisibleWhenbutfloating.persistentnot set totrue. A two-phase overlay (mount-scoped floating, visibility-scoped interaction wiring) tears its floating attachment down and reattaches it mid-exit-animation unless positioning is retained across the visibility phase, and this is an AUTHORING invariant rather than a data surprise (mirroring@llui/dom's framework-error taxonomy), so it must stay fatal rather than being silently contained by any mount error boundary. Setfloating: { ...opts, persistent: true }on a two-phase overlay.
Data display
table — Headless table / data-grid machine: sortable columns, row selection, and WAI-ARIA grid keyboard navigation — row DATA stays in the consumer (the machine tracks row IDs, sort, selection, and the focused cell only).
The table viewport is part of the public structure. Render the native table carrying
parts.rootdirectly inside an element carryingparts.viewport:div({ ...parts.viewport }, [table({ ...parts.root }, rows)]). The table remains the semantic grid; the owned viewport is the local horizontal scrollport that prevents a wide grid from overflowing the page.BREAKING:
row(id, index)/cell(rowIndex, colIndex)/rowCheckbox(id, index)acceptReactive<number>for the row-position argument (a plainnumberOR the row's ownSignal<number>handle, exactly the wayeach/virtualEachhand a row's index to itsrendercallback). Passing a plain number still compiles and behaves exactly as before. What changes is the RETURNED prop shape:aria-rowindexanddata-row-indexare now alwaysSignal<number>, even when you passed a plain number — a caller who read those fields directly off the returned object (rather than spreading them onto an element, the documented usage) sees a Signal object where a number used to be. The fix this widening exists for: a keyedeach/virtualEachREUSES a row (moves it, does not rebuild it) on reorder, so a table whose consumer resortsrowsand feeds them back in (the sort contract above) needs the row's LIVE index — passing the row's index Signal straight through keepsaria-rowindex,data-row-index, and the row's owntoggleRow/selectRange/activateRowdispatch correct after reorder;.peek()-ing it (the previous only option) froze all three at the row's original position forever.Sorting cycles asc→desc→none (configurable via descFirst); the machine stores and emits sort state while the consumer performs the actual sort, so server-side sort works by feeding pre-sorted rows back in.
Single/multiple row selection with tri-state select-all checkbox and Shift+click range selection.
APG grid keyboard nav (arrows, Home/End, Ctrl+Home/End, PageUp/Down, Space to select the row, Enter to activate) with a single roving tab stop; works with rows rendered via each or virtualEach.
The header row is part of the roving sequence (
rowIndex: -1, exported asTABLE_HEADER_ROW_INDEX), as in APG's data-grid examples where "the column headers are focusable because the columns provide sort functionality". Arrow up from row 0 to reach it; Enter/Space there toggles that column's sort. It is also the grid's tab stop when there are no rows at all, so an empty grid stays reachable.Select-all is reached through the header.
selectAllCheckbox(columnId)takes the id of the column whosecolumnheaderhosts it, and Enter/Space on that focused header sendstoggleAll. The id is a required argument rather than aconnect()option precisely because the checkbox is keyboard-unreachable without it: as an option it could be forgotten, and forgetting it failed silently. Where the named column is alsosortable, select-all wins on Enter/Space inmultiplemode and the header falls back to sorting in every other mode — put the checkbox in a column of its own to avoid the ambiguity. AcolumnIdnot present incolumnsnever takes the roving stop and never toggles.The checkbox parts carry
tabindex="-1"by design. Arole="grid"has exactly ONE tab stop, so every selection control is operated from the roving cell or header — Space on the cell for the row, Enter/Space on the select-all header for all of them. Their own Space handlers still apply when a checkbox is focused programmatically.Ctrl/Cmd+A is deliberately not bound. APG's "Control + A: selects all cells" is a cell-selection idiom; this grid selects rows and
toggleAlltoggles rather than selects, so binding it would hijack the browser's select-all for different semantics.Breadcrumbs — hierarchical navigation trail with WAI-ARIA landmark/list semantics,
aria-current="page"on the active (last) item, and automatic middle-collapse tofirst … last N items(with an expandable ellipsis trigger) whenmaxVisibleis exceeded.Carousel structure — Put every slide directly inside the transform-bearing
parts.track, and put that track insideparts.viewport. The viewport publishes the live--carousel-drag-offset; both baseline and registry skins consume it on the track so content follows the pointer before snapping or committing.meter — role="meter" gauge for a scalar measurement within a known range (disk usage, battery, a lab result against its reference range), distinct from progressbar. Reports aria-valuemin/max/now plus a formatted aria-valuetext that NAMES the band the reading is in. State carries
bands— N named regions, each with its own tone, laid out across the track and exposed asparts.bands/parts.band(id)with amarkerat the reading;low/high/optimumare init options compiling to the native three segments.data-stateis the current band's tone (optimal/suboptimal/critical/neutral). Read-only (no keyboard).
Pickers
- date-picker — deterministic "today", time zones and unavailable dates (#266).
init({ today })pins the reference date (a scenario, a test, a server render); without it the machine reads the clock once.todayInTimeZone(timeZone, now?)computes the ISO date in an IANA zone, so a calendar for Tokyo says "today" on the right day: dispatchsetTodaywith it.unavailable: string[](init orsetUnavailable) marks individual ISO dates un-selectable — they publishdata-unavailableandaria-disabled, and a RANGE that would cross one is refused.PageUp/PageDown(moveFocusMonths) move the FOCUSED date by a month, so the new grid always keeps its roving tab stop.dayCell(cell)re-derives every flag from live state; the cell you pass supplies identity only.
Media / canvas
- file-upload — the upload lifecycle.
uploadsis keyed by file id:uploadProgress { id, progress }(0..1),uploadSucceeded { id },uploadFailed { id, error }, andretryUpload { id }(only fromerror; your effect re-issues it). Per item:itemProgress(aprogressbar, hidden unless uploading),itemProgressRange(its width is written inline),itemErrorText(role="alert") anditemRetryTrigger; items publishdata-upload-statusand the rootdata-uploading. Removing or replacing a file prunes its entry. - image-cropper and floating-panel — keyboard parity. The crop box is a focus stop: arrows nudge 1% (Shift: 10%),
+/-zoom about the centre (nudge/zoommessages); the geometry stays physical under RTL. A floating panel's drag handle and resize handles move/resize by 10px (Shift: 50px) with the arrows (moveBy/resizeBy; a resize is clamped to the size limits like a drag, and both are refused while maximized). - signature-pad — clear is undoable.
clearkeeps the strokes it removed;undoon an empty pad restores them (canUndo(state)drives the button), and a new stroke forgets them. - clipboard — a refused write is a state. Dispatch
copyFailedfrom the rejected branch ofcopyToClipboard;data-failedlands on the root, trigger and the politeindicator, andcopy/resetclear it. Never claim a copy that did not happen. - qr-code, timer, async-list publish their empty/complete/exhausted states (
data-empty,data-complete,data-exhausted,aria-busy), and qr-code's download trigger serializes ITS OWN code (it used to take the page's first). - in-view and presence are deliberately styleless — behaviour-only primitives with no parts to paint. Compose them: gate a heavy child on
inView(e.g. build a QR code only once scrolled into view), or keep a "Copied" confirmation mounted through its exit withpresence.
Patterns
Composed patterns import from @llui/components/patterns/<name>.
- confirmDialog — a pre-wired alert-dialog for destructive confirmations.
- formField — field ARIA wiring + form touched-tracking + Standard Schema error display, pre-wired: one composed slice, one
formField(name)spread per input, with touch-gated error visibility (touched || submitted) and sync/async validation built in wizard— multi-step flow combining thestepscomponent with per-step validation gating.nextvalidates the current step (sync predicate / Standard Schema, or async via thevalidateStepeffect →stepValid/stepInvalid) before advancing; pass marks completed + advances, fail marks errored + stays.previs never gated;goTo/stepTriggerjumps respect linear-mode + completion gating.nextTriggeris disabled and aria-busy while an async validation is pending, guarding against double-advance.- command-menu — a ⌘K command palette composing dialog + combobox (grouped, filtered listbox)
- Filters by label and keywords, maintains a most-recent-first recents ranking in the reducer
- execute{commandId} is the single @intent surface, so agents drive the palette without keyboard simulation
- watchHotkey(send, combo?) mount helper binds a global hotkey (default mod+k) and returns a cleanup fn
- Double-Escape behavior (clear query, then close) and an empty-state part for no-match rendering
- dataTable — server-paginated, sortable, selectable data table composing the
table,pagination, and async-list-status machines. - Sort/page/pageSize changes emit a
data-table:loadPageeffect ({ page, pageSize, sort, queryId }); the consumer fetches and repliespageLoaded { queryId, rows, total }. - A
queryIdversion counter gives stale-response protection: a slow response for an older request is dropped, so it can never clobber a newer page. - Selection policy
clearOnPageChange(default true) scopes select-all to the current page and clears on page/sort change; set false for cross-page persistent selection. connect()re-exports wired table + pagination parts plusloadingOverlay(aria-busy),emptyState(role=status), anderrorState(role=alert) live regions.- searchableSelect — a select with a filter input ("a select, but searchable"), preset over
combobox. Filter-only input (typed text never becomes the committed value), shadcn-style anatomy (trigger button shows the selection; search input inside the popup above the listbox), single/multiple modes, clearable, empty-state live region, plus combobox async/groups passthrough. - combobox async replacement (
setItems/loadSuccess, shared bysearchableSelect): both replaceitems/disabledatomically withvalue/highlightedValuepruned in the same step — never left dangling for a render in between.groupsis different onloadSuccess: omitting it RESETS to no groups (the same asinit()with nogroupsoption), it does not carry the previous load's groups forward. Highlight pruning depends on whether the listbox is open: a highlight that no longer survives the fresh list (filtered out, or newly disabled) moves to the first enabled match while open, or tonullwhile closed — a background prefetch or poll must never manufacture a highlight before the control has ever been opened, since the listbox content is unmounted and there is no optionaria-activedescendantcould correctly name. Re-opening always reseeds the highlight itself. - combobox load projection (
loadProjection(state)/parts.loadState) — a single mutually-exclusive summary of the async lifecycle ('initial-empty' | 'loading' | 'stale-results' | 'success' | 'error') instead of independentisLoading/isEmpty/hasErrorbooleans that can read true at the same time.'stale-results'is the stale-while-revalidate state: a fetch is in flight while a previous list is still on screen and remains filterable/selectable until the matchingloadSuccess/loadErrorlands.searchableSelectderives itsliveRegionandemptyparts from it: "No results" (and a visibleempty) only when the list is settled ('success'/'initial-empty'); nothing while'loading'/'stale-results'; the error text on'error', withemptyhidden.
Utilities
Shared helpers used internally and exported for advanced use. New code that needs interaction
primitives without component machines should install @llui/interactions and import them directly:
import { attachFloating, pushDismissable, pushFocusTrap } from '@llui/interactions'The existing @llui/components/utils subpath remains supported for compatibility and is
tree-shakeable: importing it does not pull component modules into the bundle.
Granular utility and formatter subpaths are also available when a library wants to expose the smallest possible dependency edge:
import { TreeCollection } from '@llui/components/utils/tree-collection'
import { formatNumber } from '@llui/components/format/format-number'The formatter extraction question from
#49 was evaluated separately. The in-repo
runtime consumers are the dashboard and i18n-lazy examples, which also import LLui component
machines, plus the date-picker implementation inside this package. GitHub code searches for
@llui/components/format, @llui/components/format/format-number, and external root imports of
formatNumber found no standalone consumer. Without a separate install-graph use case, the
formatters remain in @llui/components; the granular ./format/* exports keep that decision
reversible without making consumers pull the component graph.
| Utility | Purpose |
| ---------------- | ------------------------------------------------------------------------ |
| typeahead | First-letter search across menu, select, listbox, tree-view |
| TreeCollection | Indexed tree traversal — visibleItems, labels, indeterminate computation |
| floating | @floating-ui/dom wrapper for popover/menu positioning (dir-aware) |
| direction | RTL helpers — flipArrow, resolveDir/resolveTextDirection |
| focus-trap | Stack-based focus containment for modals |
| dismissable | Esc / outside-click dismiss layer stack |
| aria-hidden | aria-hidden on siblings of a modal for screen readers |
| remove-scroll | Body scroll lock for modals/drawers |
| nested-layer | registerNestedLayer — count a sibling-portal overlay as "inside" |
Nested overlays inside a dialog (registerNestedLayer)
The three "outside-aware" utilities above (dismissable, aria-hidden, focus-trap) define inside as a single content element. But an interactive overlay opened from within a dialog — a select listbox, a markdown-editor floating toolbar, any typeahead/menu — is often portal()-ed to a body-level sibling, so it is logically nested but physically outside the dialog's content. Left unhandled, clicking it dismisses the dialog, inert makes it dead, and Tab can't reach it.
A portaled surface declares both its portal root and the element it logically belongs to:
import { onMount } from '@llui/dom'
import { registerNestedLayer } from '@llui/components/utils'
onMount((owner) => registerNestedLayer(() => overlayRootElement(), { owner }))While registered, the element (and its descendants) is treated as inside only for an asking layer that contains owner, directly or through another nested layer. Prefer resolver forms for live portal roots and owners. Missing or unresolved ownership fails closed: it grants no scoped focus/modal/outside exemption and warns once per registration in development. Unscoped getNestedLayers() remains a registry-wide inspection view.
Registration is per-aspect and per-owner. A registration names the aspects it participates in — outside (dismissable), focus (focus-trap), hide (aria-hidden) — defaulting to all three. It is exempt only when its owner belongs to the asking layer. This preserves transitive nesting without letting an unrelated or unattributable portal escape every modal on the page.
@llui/components' own non-modal portaled overlays (popover/select/combobox/menu/context-menu/menubar/hover-card/tooltip/searchable-select) register themselves automatically while open, for focus + hide only: their outside-click cooperation comes from the dismissable stack, which is ordered (only the topmost layer claims an outside-click) and therefore strictly better than the registry's global answer. The exception is an overlay that pushes no dismissable layer at all — a tooltip with closeOnEscape: false — which registers outside too, because nothing on the stack speaks for it.
Modal surfaces (dialog, drawer, a popover with trapFocus) never register. They are the layer everything else nests inside; registering one would let a trap on the layer beneath Tab into it, and would make its own content read as "inside a nested layer" for an overlay open on top of it — a click anywhere in the dialog's panel would stop dismissing an inner select. (The registered element is the overlay's content, and dialog renders backdrop, positioner and content as three separate parts, so the dialog's background is outside the registration either way.) Their place in the ordering comes from always occupying the dismissable stack — including when both closeOnEscape and closeOnOutsideClick are false, where the layer claims nothing but still stops the layer beneath from misreading a click inside the modal as an outside interaction.
hide is weaker than focus. aria-hidden snapshots its exempt set once, when the sweep runs, and nothing re-runs it. A registration therefore only reaches the sweep if it is live before the modal opens — in the usual ordering (dialog opens, then a select inside it) the select's portal content does not exist yet. focus-trap re-reads the registry on every Tab, so focus is the half that actually carries a non-modal overlay's registration.
Live regions and the modal sweep
aria-hidden never hides a live region — aria-live="polite"/"assertive", role="alert", role="status", role="log", and <output> (implicit role="status", unless it carries an explicit role) — because a hidden live region is simply never read out, so a toast raised while a dialog is open would be silent for screen-reader users. This needs no registration and covers your own regions as well as toast/async-list/clipboard/date-input/field. Exemption is precise: the sweep descends through an ancestor that merely contains a live region and still hides everything hanging off the path down to it.
Two caveats. inert cannot be split from aria-hidden, so an exempt region keeps its whole subtree reachable: a role="log" transcript containing links is Tab-reachable from behind a modal. Keep live regions to announcement text and put controls outside them. And the match does not pierce shadow roots — a live region inside one is not exempt (the sweep only walks light-DOM ancestors of the modal, so this bites only when the two live in different trees).
Escape and the dismissable stack
Escape is offered to the layers top-down until one claims it. A layer declines by setting disableEscape or returning false from its onEscape router; the key then falls through to the layer beneath instead of being swallowed.
Icons
@llui/components/icon renders any Iconify glyph as a real
<svg>, fetched by prefix:name and batched to one request per prefix per tick:
import { icon } from '@llui/components/icon'
icon('lucide:star', { class: 'size-5' })
icon('simple-icons:github')name may be a ReadSignal<string>, in which case the glyph follows the state. The
response is untrusted markup and is rebuilt from an element/attribute allowlist —
never assigned to innerHTML. Icons are async (SSR emits the sized empty box)
and need the network; point iconConfig.api at a self-hosted Iconify to drop the
third-party dependency.
Every <svg> carries data-glyph="<prefix:name>", following a signal name. It is the
glyph's identity before (or without) the fetch: select on it in CSS, or assert on it
in a test, instead of comparing paths.
Styling (opt-in)
Components are fully headless by default. Two independent styling paths are available; choose one per app. The styling guide covers tokens, dark mode, RTL, reduced motion and forced colors on both.
Baseline theme — theme.css
Import once at your app root for a complete default look based on data-scope/data-part
attribute selectors:
import '@llui/components/styles/theme.css'This is ordinary published CSS. It requires no Tailwind dependency, plugin, configuration, or preprocessing step. Override semantic tokens in plain CSS:
:root {
--primary: oklch(0.55 0.21 258);
--llui-radius-lg: 1rem;
}Dark tokens are included. The theme follows prefers-color-scheme, .dark, and
[data-theme='dark']; [data-theme='light'] or .light opts out. Every stylesheet this
package exports:
theme.css is the complete Baseline theme: ordinary CSS, no Tailwind. It imports, in order: semantic-tokens.css → semantic-tokens-dark.css → foundation.css → form-controls.css → disclosure-navigation.css → menus-overlays.css → data-display.css → specialized-tools.css → motion.css.
| Stylesheet (@llui/components/styles/…) | Path | Role |
| ---------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| theme.css | Baseline theme | The complete Baseline theme: every module below, in dependency order. |
| semantic-tokens.css | both paths | Light semantic tokens — the shadcn/ui names (--background, --primary, --radius, …) plus the --llui-* scales. Values only, no selectors. |
| semantic-tokens-dark.css | both paths | Dark values for the base tokens, active under [data-theme='dark'], .dark and prefers-color-scheme: dark. |
| foundation.css | Baseline theme | Cross-family rules every module relies on: focus ring, disabled state, reduced motion, forced colors and the .btn classes. |
| form-controls.css | Baseline theme | Form controls. |
| disclosure-navigation.css | Baseline theme | Disclosure and navigation. |
| menus-overlays.css | Baseline theme | Menus, overlays and transient surfaces. |
| data-display.css | Baseline theme | Data display and status. |
| specialized-tools.css | Baseline theme | Pickers, editors, upload and canvas tools, and utilities. |
| motion.css | Baseline theme | Enter/exit keyframes and component motion. |
| tokens.css | Registry skins | The Registry skins entry for your Tailwind v4 stylesheet: the semantic tokens plus tailwind.css. |
| tokens-dark.css | Registry skins | Dark values for the Registry skins entry. |
| tailwind.css | Registry skins | The @theme inline mapping of the semantic tokens into Tailwind utility namespaces (reached through tokens.css). |
A modular Baseline theme bundle keeps that order: semantic-tokens.css, semantic-tokens-dark.css, foundation.css first, then only the family modules you use, then motion.css:
@import '@llui/components/styles/semantic-tokens.css';
@import '@llui/components/styles/semantic-tokens-dark.css';
@import '@llui/components/styles/foundation.css';
@import '@llui/components/styles/form-controls.css'; /* …one line per family you use */
@import '@llui/components/styles/motion.css';Which component scopes (data-scope) each family module styles:
form-controls.css—angle-slider,checkbox,field,fieldset,form,form-field,listbox,number-input,password-input,pin-input,radio-group,rating-group,slider,switch,tags-input,toggle,toggle-groupdisclosure-navigation.css—accordion,breadcrumbs,carousel,collapsible,pagination,steps,tabs,toc,tree-viewmenus-overlays.css—combobox,command-menu,context-menu,dialog,drawer,hover-card,menu,popover,searchable-select,select,toast,tooltipdata-display.css—avatar,chart,chip,data-table,marquee,meter,progress,sparkline,tablespecialized-tools.css—async-list,cascade-select,clipboard,color-picker,date-input,date-picker,editable,file-upload,floating-panel,gradient-picker,image-cropper,qr-code,scroll-area,signature-pad,sortable,splitter,time-picker,timer,tour
Modules carrying forced-colors rules: foundation.css, form-controls.css, disclosure-navigation.css, menus-overlays.css, data-display.css, specialized-tools.css. Modules carrying prefers-reduced-motion rules: foundation.css, disclosure-navigation.css.
Upgrading from an older release (removed theme-dark.css / layout.css, renamed --llui-*
scales)? See the migration guide.
Registry skins — copied source, Tailwind v4
There are no per-component xClasses() helpers. Class recipes ship as registry source you
own and edit, copied into your project by the CLI and verified against a real Tailwind build:
npx @llui/cli add tabsllui add <name> copies a skin, pattern adapter or presentational component; the
@llui/components/<name> import is the headless machine a skin spreads. They are different
artifacts, and their names can differ (llui add dropdown-menu copies the skin for
@llui/components/menu). The copied component carries the recipe itself, so a skin change is
an edit in your own tree rather than a version bump here. Import tokens.css and
tokens-dark.css through your Tailwind v4 pipeline — not theme.css, whose unlayered
baseline rules would override every recipe.
Variant engine
createVariants builds a typed variant-to-class function for your own recipes (the registry's copied recipes use it too):
import { createVariants, cx } from '@llui/components/styles'
const button = createVariants({
base: 'inline-flex items-center font-medium',
variants: {
size: { sm: 'px-2 py-1 text-sm', md: 'px-4 py-2' },
intent: { primary: 'bg-primary text-white', ghost: 'bg-transparent' },
},
defaultVariants: { size: 'md', intent: 'primary' },
compoundVariants: [{ size: 'sm', intent: 'ghost', class: 'font-normal' }],
})
button({ size: 'sm', intent: 'ghost' }) // → class stringImports
Three forms, in order of preference:
// ✓ best — sub-path import. Bypasses the barrel entirely; smallest
// bundle, fastest cold builds (no parse cost for unused
// components).
import { dialog } from '@llui/components/dialog'
// ✓ ok — named component-object import from the root. The root uses
// named leaf re-exports, so unrelated components tree-shake.
// A subpath still avoids parsing the aggregate export surface.
import { dialog } from '@llui/components'
// ✗ bad — namespace import. Defeats tree-shaking: drags every
// component's state machine into the bundle. Avoid it —
// prefer a sub-path or named import.
import * as C from '@llui/components'Every component ships its own entry point — sub-path is the right default for a new file:
import { tabs } from '@llui/components/tabs'
import { dialog } from '@llui/components/dialog'
import { timer } from '@llui/components/timer'Validation
Input components accept an optional validate callback on ConnectOptions that gates state changes:
import { editable } from '@llui/components/editable'
const parts = editable.connect(state.at('name'), send, {
validate: (value) => {
if (value.length < 3) return ['Too short']
return null // valid
},
})Supported on: editable, number-input, tags-input, pin-input, file-upload.
