npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@llui/components

v0.21.0

Published

Headless UI component machines for LLui — accordion, dialog, tabs, select, tree-view, timer, tour, and more

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 parts you 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 onEffect handler; 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-state hooks; 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/requestAnimationFrame ticks (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 + isPresent and wait for an animationend; 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/interactions

Peer 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

  1. init(opts?) — creates the initial state
  2. update(state, msg) — pure reducer, returns [newState, effects[]]
  3. 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 }, [...])
  4. 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:

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') on init and a setDir message 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 (or dir/direction) remains the consumer's responsibility.

directionSync — place it, or automatic direction goes stale. tabs, carousel, pagination, navigation-menu, menu, context-menu, and menubar additionally track the ambient DOM dir when no explicit setDir has been sent (init's default dirSource: 'dom'), by observing the live ancestor chain after mount. That observation is a Mountable — 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 why ConnectOptions.id is REQUIRED on all of these (or, for menu/context-menu, the equivalent always-mounted anchor — their own trigger.id): directionSync locates 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/menubar no longer have a second, independently-resolved direction. menu-machine.ts — the shared item-tree machine behind menu, context-menu, and (via menu.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, isolated resolveDir(trigger) call while keyboard handling and an explicit setDir used a completely different, nullable dir: TextDirection | null field: an explicitly-configured direction could silently disagree with what a submenu opened toward. Three consequences: (1) MenuState/ContextMenuState/MenubarState's dir is now always a concrete 'ltr' | 'rtl' (never null), with a dirSource: 'dom' | 'explicit' field recording whether it is DOM-observed or was explicitly set — there is no longer a way to clear an explicit setDir back to "follow the page"; (2) menu.floatingDir is REMOVED — menu.overlay's floating placement now reads state.dir directly, unconditionally; (3) ContextMenuParts.trigger gained a REQUIRED id, and MenubarParts.root gained a REQUIRED id (mirroring navigation-menu's own root.id requirement) — both are what their new directionSync part observes.

Submenus are positioned by the engine (#265 A4). Previously menu, context-menu, and menubar had no submenu positioning: a hand-rolled show(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 exports subOverlay(opts), built on the same createOverlay engine as their own overlay(): call it once per children-bearing item, alongside its subTrigger, 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 ?? []))],
  })
}

subOverlay builds the subpositioner wrapper itself (from parts.subPositioner(value)), gates mount on openPath membership (menubar's variant additionally takes a menuId and reaches into state.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 own subTrigger — 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 no dismiss config: 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 the subContent element, since createOverlay's positioner-selector only recognizes data-part="positioner", not this machine's "subpositioner"; style the wrapper with subOverlay's positionerClass if you need to.

The engine itself gained two small, generally-useful pieces backing this: OverlayFloatingConfig.placement now accepts a thunk (Placement | (() => Placement), resolved at attach time, mirroring the existing dir thunk), and OverlayFloatingConfig.reattachKey re-runs floating attach when a caller-rendered data-llui-reattach-key marker attribute changes while mounted — needed because placement/dir are otherwise captured once at attach and attachFloating's own autoUpdate never 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 as data-state="closing", aria-hidden, and inert until its own animationend/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 own animationstart event directly — a custom skin that never adds that one custom property no longer gets stuck closing + inert forever 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) while animated: true is set, no animationstart/animationend ever fires. A safety net checked synchronously right after a USER-INITIATED close (the trigger's click or Enter/Space) detects this via Element.getAnimations() (which forces a style flush, so it already reflects what the just-applied closing state triggered) and completes immediately instead of hanging.
    • A forgotten parts.exitCompletion placement 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 own opts.id (already required and unique per page), mutated directly by parts.exitCompletion's own mount/cleanup — never through send, never serialized, never part of AccordionState/CollapsibleState, and init() stays fully deterministic. The one new fact that DOES travel as a message is a plain retain?: boolean field on every closing-capable message (close/toggle/open/setValue for accordion; close/toggle/setOpen for collapsible), stamped by connect()'s trigger click/keydown handlers from that registry AT DISPATCH TIME — the reducer only ever retains closing when animated && msg.retain === true, so a raw send({ type: 'close', value }) from app/agent code (with no retain) closes INSTANTLY, even with animated: true and the part placed — documented, fail-safe. For an animated PROGRAMMATIC close, use parts.close(value) (accordion) / parts.close() (collapsible): it stamps retain from the same registry the trigger handlers read, so it retains correctly whenever a real watcher is attached. An instance whose view never places parts.exitCompletion therefore closes every item INSTANTLY regardless of retain, since the registry never counts an attach for that id — exactly as animated: false would, never hanging closing + inert. Place parts.exitCompletion anywhere in the same build, as a sibling of the root ([div({ ...parts.root }, [...]), parts.exitCompletion]) — it renders nothing, like directionSync. It resolves each closing content element by its own opts.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 TWO connect() calls sharing the same id — 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-mode console.warn fires SYNCHRONOUSLY, once per id, the first time a closing message is dispatched while animated is true and that id's watcher is not attached — no timer, no deadline. The registry's Accordion/Collapsible root 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 satisfy ElProps'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 id settles every still-closing entry for that id immediately, through the ordinary exitComplete message (there is no dedicated "detach" message) — so removing parts.exitCompletion from the view mid-exit (e.g. via a conditional show) 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 already closing in a restored snapshot with no exitCompletion ever 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); placing exitCompletion and 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 Animation object captured for the item's own most recent enter and excludes it from the exit's candidate search.
  • field — label/description/error ARIA wiring for a single control: derives stable control/label/description/error ids from one base id, exposes a control bag (id, htmlFor, aria-labelledby, reactive aria-describedby/aria-invalid/aria-required, disabled, readOnly) plus a description hint and a polite errorText live region — zero manual ids.
  • fieldset — group wiring: native <fieldset>/<legend> (role group, aria-labelledby), group-level disabled propagation (mirrored to aria-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 until animationend/transitionend, then unmounts; when no animation is configured (the default), close unmounts synchronously with no hang. Use the exposed isPresent helper 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): ToastPatch is narrowed to mutable PRESENTATION fields only. toast.update's patch argument used to accept Partial<Toast>, which could silently patch lifecycle-owned fields (id, remainingMs, status) out from under the reducer's own countdown/presence bookkeeping. ToastPatch is now Partial<Omit<Toast, 'id' | 'remainingMs' | 'status' | 'pausedBy'>> — type, title, description, duration, dismissable, and ariaLive remain patchable, id cannot be changed (it identifies the row), and remainingMs/status/pausedBy are reducer-owned. undefined on a required field (type, duration, dismissable) is ignored, like an absent key ({ duration: undefined } leaves the countdown alone; patch duration: null to make a toast sticky); on an optional field (title, description, ariaLive) it clears the field. dismissable: false hides the close trigger (closeTrigger.hidden) and ignores its click; dismiss from code still works. Patching duration RE-SEEDS remainingMs from the new value (a sticky duration: null toast moving to a finite duration starts counting down fresh, rather than staying frozen at the 0 it held while sticky) — registry/test/toast-live-demos.browser.test.ts drives 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 dir option, no setDir), every floating overlay resolves ltr/rtl from its placement ANCHOR — the trigger, inside your app's own container — not from <body>, where the portal lands. The engine passes that to attachFloating and writes it as dir on the portaled positioner, so placement, CSS logical properties, rtl: variants, arrow keys and nested submenus all agree with the trigger, and it follows a dir change on any ancestor of the trigger while open. Precedence, most specific first: a dir you put on the content itself (the engine never writes over it), then an explicit dir, 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 baseline foundation.css no longer sets direction: inherit on parts (it overrode exactly that dir attribute).
  • Status tokens (#265). semantic-tokens.css adds --info and --success beside --warning (whose light value is now amber-600, dark enough to clear 3:1 as a glyph) and --destructive; Tailwind gets info/success colours. 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. custom keeps a decorative violet: it has no status meaning.
  • Breaking (#265): toast pause is a set of REASONS, not one flag. Toast.paused: boolean is replaced by pausedBy: ToastPauseReason[] ('focus' | 'hover' | 'manual'; read it with isPaused(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), and pause/resume/pauseAll/resumeAll take an optional reason that 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 negative tick is ignored.
  • Breaking (#265): ToastItemParts' role, aria-live, and data-type are Signals, not one-shot strings. toast.connect(...).toast(item) used to resolve role/aria-live from item.peek().type at CALL TIME, so a mounted toast's update patching type repainted its color (CSS reads data-type reactively) but never its announced role/politeness — a loading toast patched to error kept announcing as role="status"/aria-live="polite". All three are now item.map(...) derivations bound to the live row, so an update on a mounted toast keeps its DOM, ARIA, and announced politeness in agreement.
  • Breaking (registry): the Toast/Sonner recipe in registry/llui/ui/sonner.ts no longer takes a variant prop. The six ToastType visuals (background/border tint, icon, forced-colors border style) are driven entirely by the machine's own reactive data-type attribute via data-[type=…]: selectors — never a variant resolved once from a peeked value at the call site, which is exactly the freeze bug the two bullets above close. Spread parts.root straight through; there is nothing else to pass.
  • Breaking: createOverlay now THROWS LluiFrameworkError for a misconfigured two-phase overlay (#265 A5) — floating configured together with visibleWhen but floating.persistent not set to true. 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. Set floating: { ...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.root directly inside an element carrying parts.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) accept Reactive<number> for the row-position argument (a plain number OR the row's own Signal<number> handle, exactly the way each/virtualEach hand a row's index to its render callback). Passing a plain number still compiles and behaves exactly as before. What changes is the RETURNED prop shape: aria-rowindex and data-row-index are now always Signal<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 keyed each/virtualEach REUSES a row (moves it, does not rebuild it) on reorder, so a table whose consumer resorts rows and feeds them back in (the sort contract above) needs the row's LIVE index — passing the row's index Signal straight through keeps aria-rowindex, data-row-index, and the row's own toggleRow/selectRange/activateRow dispatch 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 as TABLE_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 whose columnheader hosts it, and Enter/Space on that focused header sends toggleAll. The id is a required argument rather than a connect() 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 also sortable, select-all wins on Enter/Space in multiple mode and the header falls back to sorting in every other mode — put the checkbox in a column of its own to avoid the ambiguity. A columnId not present in columns never takes the roving stop and never toggles.

  • The checkbox parts carry tabindex="-1" by design. A role="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 toggleAll toggles 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 to first … last N items (with an expandable ellipsis trigger) when maxVisible is exceeded.

  • Carousel structure — Put every slide directly inside the transform-bearing parts.track, and put that track inside parts.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 as parts.bands / parts.band(id) with a marker at the reading; low/high/optimum are init options compiling to the native three segments. data-state is 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: dispatch setToday with it. unavailable: string[] (init or setUnavailable) marks individual ISO dates un-selectable — they publish data-unavailable and aria-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. uploads is keyed by file id: uploadProgress { id, progress } (0..1), uploadSucceeded { id }, uploadFailed { id, error }, and retryUpload { id } (only from error; your effect re-issues it). Per item: itemProgress (a progressbar, hidden unless uploading), itemProgressRange (its width is written inline), itemErrorText (role="alert") and itemRetryTrigger; items publish data-upload-status and the root data-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 / zoom messages); 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. clear keeps the strokes it removed; undo on an empty pad restores them (canUndo(state) drives the button), and a new stroke forgets them.
  • clipboard — a refused write is a state. Dispatch copyFailed from the rejected branch of copyToClipboard; data-failed lands on the root, trigger and the polite indicator, and copy/reset clear 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 with presence.

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 the steps component with per-step validation gating. next validates the current step (sync predicate / Standard Schema, or async via the validateStep effect → stepValid/stepInvalid) before advancing; pass marks completed + advances, fail marks errored + stays.
  • prev is never gated; goTo/stepTrigger jumps respect linear-mode + completion gating. nextTrigger is 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:loadPage effect ({ page, pageSize, sort, queryId }); the consumer fetches and replies pageLoaded { queryId, rows, total }.
  • A queryId version 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 plus loadingOverlay (aria-busy), emptyState (role=status), and errorState (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 by searchableSelect): both replace items/disabled atomically with value/highlightedValue pruned in the same step — never left dangling for a render in between. groups is different on loadSuccess: omitting it RESETS to no groups (the same as init() with no groups option), 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 to null while 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 option aria-activedescendant could 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 independent isLoading/isEmpty/hasError booleans 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 matching loadSuccess/loadError lands. searchableSelect derives its liveRegion and empty parts from it: "No results" (and a visible empty) only when the list is settled ('success'/'initial-empty'); nothing while 'loading'/'stale-results'; the error text on 'error', with empty hidden.

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-group
  • disclosure-navigation.css — accordion, breadcrumbs, carousel, collapsible, pagination, steps, tabs, toc, tree-view
  • menus-overlays.css — combobox, command-menu, context-menu, dialog, drawer, hover-card, menu, popover, searchable-select, select, toast, tooltip
  • data-display.css — avatar, chart, chip, data-table, marquee, meter, progress, sparkline, table
  • specialized-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 tabs

llui 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 string

Imports

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.