Using the components
LLui has two related product surfaces that you can assemble:
@llui/components/<name>— the machines, imported as a package dependency. State, keyboard handling, ARIA, focus and dismissal. No classes, no opinions about how anything looks.llui add <name>— the registry, copied into your project as source you own: skins (shadcn/ui class recipes spread onto a machine's parts), the adapters of composed patterns, and presentational elements. Presentational items do not invent a machine merely to make the names line up.
add and import are different artifacts. Importing @llui/components/menu gives you
behaviour and state; llui add dropdown-menu copies the styled menu skin that spreads it. A
component may have one, both, or names that differ between the two — the
component catalog lists every case.
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.
You can use either half alone. A machine with no skin is a fully accessible headless component; a skin with no machine is a styled element. This page walks the normal case: both, together.
Every component is live in the Component Gallery: search for it once and open its deterministic scenarios (disabled, invalid, open, loading, …) on either path — Baseline theme or Registry skins — side by side, in two isolated documents.
Choosing a styling path
The machines carry no classes, so the CSS has to come from somewhere. There are two places it can come from, and you pick one — see the warning at the end of this section for why they cannot be combined.
Registry (llui add) |
Baseline (theme.css) |
|
|---|---|---|
| Where the CSS lives | your repo, one file per component | published family modules + one complete entry |
| Needs Tailwind | yes | no |
| Looks like | shadcn/ui, verbatim | LLui's own look |
| To restyle a component | edit your copy | override its [data-scope][data-part] rules |
| To restyle everything | override tokens in :root (+ dark, see Retheme) |
override tokens in :root (+ dark) |
| Upgrades | you own the file; llui add --overwrite to re-pull |
arrives with the package |
Both drive the identical machines through the identical data-* contract. Nothing about your
component wiring changes between them; only the source of the classes does.
When the registry is the right answer
- You want shadcn's look, or you want to paste a shadcn theme, tutorial or screenshot and
have it still describe what you get. Recipes are ported from shadcn/ui, changing only what
LLui needs (logical RTL utilities, forced-colors variants, LLui's
data-*attributes); the registry's fidelity notes list the few classes that could not come across and why. - You expect to restyle. The copied file is yours: change a class, delete a variant, add one. No override layer, no specificity fight, no waiting on the package.
- You are already using Tailwind. The recipes are Tailwind utilities; without a Tailwind build they produce nothing.
The cost is that you own what you copied. llui add never overwrites, so an upstream recipe
fix does not reach you until you re-pull it deliberately — which is the same trade shadcn makes,
and the reason it is a distribution model rather than a dependency.
When the baseline is the right answer
- You do not want a Tailwind pipeline. One
@importand every component looks finished.examples/baseline-cssbuilds this path with no Tailwind package or plugin. - You are prototyping, or the app's look is not the point yet.
- You want restyling to arrive with the package rather than being your maintenance.
The cost is that you are further from the CSS. Restyling means overriding
[data-scope][data-part] rules rather than editing a recipe, and the look is LLui's rather
than something a designer will recognise.
They cannot be combined
Import one, not both.
theme.cssstyles components with UNLAYERED[data-scope][data-part]rules, and unlayered CSS beats@layer utilities— so with both imported, every registry recipe silently loses to the baseline. Both stylesheets are present and correct; the wrong one wins.This is not a specificity problem you can out-write: layer precedence ignores specificity entirely. Measured, not theorised — the registry
Switch's thumb rendered at the baseline's 20px and ignored its ownsize-4until the demo's import was narrowed.
llui initandllui addwarn when they findtheme.cssimported in your project, because nothing else will.
If you are on the baseline and want to move: replace the single theme.css import with
tokens.css / tokens-dark.css, add Tailwind v4, then llui add the components you use. The
semantic values are the same in both, so your theme survives the move.
The rest of this page assumes the registry. Only three parts of it differ by path — setup (below), copying components (§2), and customizing (§6). Wiring a machine (§3), placing an overlay (§4) and building a form (§5) are identical either way, because the machine and its
data-*contract are identical. Baseline readers: skim §1's second tab, skip §2, and read §3–§5 as written.
1. Set up
On the registry path
pnpm add @llui/dom @llui/components clsx tailwind-merge
pnpm add -D @llui/cli @llui/vite-plugin tailwindcss @tailwindcss/vite tw-animate-css
pnpm llui init
init writes components.json and prints the CSS you need. Your app stylesheet:
@import 'tailwindcss';
@import 'tw-animate-css';
@import '@llui/components/styles/tokens.css';
@import '@llui/components/styles/tokens-dark.css';
tw-animate-css is not optional polish. animate-in, fade-in-0, zoom-in-95 and
slide-in-from-* are the entire enter/exit vocabulary of every overlay recipe, and they are
not Tailwind core. The registry items that use them declare tw-animate-css as a dependency,
so llui add names it in its Install line whenever an item you add (or one of its registry
dependencies) animates; the @import above is still yours to write.
On the baseline path
pnpm add @llui/dom @llui/components
pnpm add -D @llui/vite-plugin
@import '@llui/components/styles/theme.css';
That is the whole setup — no Tailwind dependency, plugin, config, CLI, or preprocessing.
theme.css composes semantic light/dark tokens, shared state foundations, component-family
modules, and motion, so every component you wire is styled when you spread its part bag.
Dark mode is included. To ship only the families you use, import the modules individually
instead — Styling lists every entry point in
order. Upgrading from an older release? See the migration guide.
Import one set or the other, never both — see choosing a styling path.
2. Copy a component
Registry path only. On the baseline there are no per-component files to copy — skip to §3.
pnpm llui list
pnpm llui add button
llui list makes the two surfaces explicit. ADD NAME is what the CLI can copy;
MACHINE IMPORT is the headless package subpath, when one exists. The artifact column
distinguishes machine, skin, presentational item, pattern and alias, and the styling column
states baseline, registry/Tailwind and styleless support. The two names may match, differ,
or exist on only one surface. In particular, llui add form copies the adapter for the
form-field pattern; @llui/components/form remains its own lower-level machine.
You now own src/components/ui/button.ts. It is your source — edit it. llui add never
overwrites an existing file; pass --overwrite when you really mean to discard your edits.
import { text } from '@llui/dom'
import { Button } from './components/ui/button'
Button({ variant: 'outline' }, [text('Cancel')])
Button({ variant: 'destructive', size: 'sm' }, [text('Delete')])
button is presentational — a styled element with no state and therefore no machine to
wire. Other machine-free items are labelled the same way by llui list.
3. Wire a machine
Identical on both paths. The machine, its
connect, its part bags and itsdata-*contract do not know how you are styling. The only difference below is whereSwitchandSwitchThumbcome from: your copied./components/ui/switchon the registry path, or plainbutton/spanelement helpers from@llui/domon the baseline, wheretheme.cssstyles them from thedata-scope/data-partthe bag already carries.
Most components are a skin: classes and the right tag for a machine's parts. The state lives in your app, like any other TEA slice.
pnpm llui add switch
import { component, div, text, type Mountable } from '@llui/dom'
import * as switchC from '@llui/components/switch'
import { Switch, SwitchThumb } from './components/ui/switch'
import { Label } from './components/ui/label'
interface State {
wifi: switchC.SwitchState
}
type Msg = { type: 'wifi'; msg: switchC.SwitchMsg }
export const App = component<State, Msg, never>({
name: 'App',
init: () => [{ wifi: switchC.init({ checked: true }) }, []],
update: (state, msg) => [{ ...state, wifi: switchC.update(state.wifi, msg.msg)[0] }, []],
view: ({ state, send }): readonly Mountable[] => {
// `connect` projects the state slice into part bags. Spread each bag onto
// the matching skin element — that is the whole contract.
const wifi = switchC.connect(state.at('wifi'), (msg) => send({ type: 'wifi', msg }))
return [
div({ class: 'flex items-center gap-2' }, [
Switch({ ...wifi.root, id: 'wifi' }, [SwitchThumb({ ...wifi.thumb })]),
Label({ for: 'wifi' }, [text('Wi-Fi')]),
]),
]
},
})
Three rules that cover almost every skin:
- Spread the bag, do not nest it.
{ ...parts.root }, never{ parts: parts.root }. A part factory that returns a bag OF bags —accordion.item(v)gives{ item, trigger, content }— needs the inner one:{ ...parts.item(v).trigger }. Spreading the wrapper emitstrigger="[object Object]"and drops every real attribute. - The machine owns state, you own layout. No
connect()emits aclass, which is what makes a skin purely additive: it cannot regress focus, dismissal or ARIA. state.at('slice')keeps the reactivity narrow. A part bag built fromstate.at('wifi')only re-commits when that slice changes.
4. Overlays
Dialogs, popovers, menus, tooltips and selects go through overlay(), which portals the
content and owns focus, dismissal and floating position.
Identical on both paths, with one asymmetry worth knowing: the two omissions below are what the baseline stylesheet fills in for you. It targets
[data-part='positioner']and paints a backdrop directly, which is exactly why they are invisible until you style with utilities — and why they bite on the registry path.
Two things overlay() deliberately does not give you:
import * as dialogC from '@llui/components/dialog'
import { DialogBackdrop, DialogContent, DialogTitle } from './components/ui/dialog'
dialogC.overlay({
state: state.at('confirm'),
send: confirmSend,
parts,
// 1. The POSITIONER is yours to place. `overlay()` builds the wrapper div,
// but its part bag carries only `data-*` — nothing positions it, and
// nothing gives it a z-index.
positionerClass: 'fixed inset-0 z-50 grid place-items-center p-4',
content: () => [
// 2. The BACKDROP is yours to render, INSIDE `content()`. The engine emits
// none. It sits inside the positioner, so it wants `absolute inset-0`.
DialogBackdrop({ ...parts.backdrop }),
DialogContent({ ...parts.content }, [DialogTitle({ ...parts.title }, [text('Sure?')])]),
],
})
DialogContent positions itself (fixed top-[50%] left-[50%] translate-x-[-50%]), as
shadcn's does. For that one, pass positionerClass: 'contents' so the wrapper drops out
of layout entirely and the content behaves exactly like upstream.
5. Forms
A validated form is the one composition most apps need on day one, and it is where LLui
differs most from what a shadcn tutorial will show you. React's answer is
react-hook-form: a Controller per field, a context deriving ids and error state, and
a resolver seam for a schema. LLui's answer is that validation is a reducer concern —
it runs in update and produces errors as ordinary data.
Identical on both paths.
formis a registry item, but everything below it — the machine, the messages, the ARIA — is the same on the baseline.
@llui/components/patterns/form-field composes the whole thing. Your values stay ordinary
state; the pattern holds validity, touched, submission status, and every derived id.
pnpm llui add form input label
The state
import { formField, type FormFieldState } from '@llui/components/patterns/form-field'
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Enter a valid email address.'),
name: z.string().min(2, 'Name must be at least 2 characters.'),
})
interface State {
values: { email: string; name: string }
ff: FormFieldState
}
const init = (): [State, never[]] => [
{
values: { email: '', name: '' },
ff: formField.init({ id: 'signup', fields: ['email', 'name'] }),
},
[],
]
Any Standard Schema works — Zod, Valibot, ArkType. The
pattern never imports one; it reads the ~standard interface.
The reducer
function update(state: State, msg: Msg): [State, Effect[]] {
switch (msg.type) {
case 'setValue': {
const values = { ...state.values, [msg.field]: msg.value }
// Re-validate on every keystroke. Errors do not BECOME VISIBLE yet — the
// pattern gates that on touched-or-submitted — so this only means a field
// stops showing an error the moment it is fixed.
const [ff] = formField.update(state.ff, { type: 'validate', schema, values })
return [{ ...state, values, ff }, []]
}
case 'submit': {
const [validated] = formField.update(state.ff, {
type: 'validate',
schema,
values: state.values,
})
const invalid = Object.values(validated.fields).some((f) => f.invalid)
if (invalid) {
// Reveal every error at once, including on fields never focused.
const [ff] = formField.update(validated, { type: 'touchAll' })
return [{ ...state, ff }, []]
}
const [ff] = formField.update(validated, { type: 'submit' })
return [{ ...state, ff }, [saveEffect(state.values)]]
}
}
}
submitSuccess / submitError close the lifecycle from onEffect.
The view
import { Form, FormItem, FormLabel, FormDescription, FormMessage } from './components/ui/form'
import { Input } from './components/ui/input'
view: ({ state, send }) => {
const parts = formField.connect(state.at('ff'), (m) => send({ type: 'ff', msg: m }), {
id: 'signup',
fields: ['email', 'name'],
})
const email = parts.formField('email', { hasDescription: true })
return [
Form(
{
...parts.root,
onSubmit: (e) => {
e.preventDefault()
send({ type: 'submit' })
},
},
[
FormItem({ ...email.root }, [
FormLabel({ ...email.label }, [text('Email')]),
Input({
...email.control,
type: 'email',
value: state.at('values').at('email'),
onInput: (e) => send({ type: 'setValue', field: 'email', value: e.target.value }),
}),
FormDescription({ ...email.description }, [text('We only use this to sign you in.')]),
FormMessage({ ...email.errorText }, [text(email.error.message)]),
]),
Button({ ...parts.submit }, [text('Create account')]),
],
),
]
}
Nothing here computes an id, an aria-describedby, or when to show an error. The bag does
all of it, reactively.
What maps to what
| shadcn / react-hook-form | LLui |
|---|---|
useForm + zodResolver |
a validate message + any Standard Schema |
<Form> (FormProvider) |
one FormFieldState slice in your own state |
<FormField> (Controller) |
parts.formField(name) |
useFormField() |
the returned bag — ids, ARIA, error, touched, pending |
<FormControl> (a Slot) |
{...field.control} spread onto your control |
Three things that bite
There is no FormControl. Upstream's is a Radix Slot: it renders nothing and
forwards id / aria-describedby / aria-invalid onto its child. field.control already
carries exactly those, so spread it onto your Input directly.
FormMessage stays mounted. errorText carries its own reactive hidden, so the live
region is registered before it has anything to say. Wrapping it in show(...) unmounts and
rebuilds it on every transition, and a screen reader may announce nothing.
Errors are gated on touched-or-submitted. email.error.visible is
invalid && (touched || status === 'submitted'). That is why validating on every keystroke
is safe: a field that has never been focused stays quiet until submit calls touchAll.
Async validation
For a uniqueness check, dispatch validateAsync with a requestId, run
formField.validateSchemaAsync in an effect, and dispatch validateResult with the same
id. A result whose id no longer matches is dropped — a slow earlier validation can
never overwrite a newer one. Every field reports aria-busy while one is in flight.
6. Customize
Four levels, cheapest first.
Pass a class
Every part takes one, and it wins over the recipe — the registry routes class through
mergeClass, which is tailwind-merge, so p-2 beats a recipe's p-4 rather than losing
to it by source order.
Button({ variant: 'outline', class: 'w-full' }, [text('Save')])
A reactive class works too, but the conditional goes inside the .map body — the
compiler rejects an operator applied to a Signal:
// wrong — `&&` applied to a Signal, and a build error
class: cn('base', state.at('open') && 'is-open')
// right — one binding, plain values inside
class: state.at('open').map((open) => cn('base', open && 'is-open'))
Style from data-* — the idiomatic way
You should rarely need a computed class. Every part already publishes its state as attributes, so the branch belongs in the recipe:
'data-[state=open]:bg-muted data-[disabled]:opacity-50 data-[orientation=vertical]:flex-col'
Check what the machine actually publishes before writing the selector. A recipe naming
an attribute nobody emits is valid CSS that never matches — no error, no warning, just a
rule that does nothing. The part bag's TypeScript type is the list. The most common
mismatches are between similar-looking spellings: bare presence (data-highlighted) versus
an enum (data-[state=highlighted]), and data-axis versus data-orientation.
Edit the recipe
It is your file. Change the classes.
The one thing to know: the repo's class checker reads recipes from named positions —
arguments to cn / mergeClass / classPart, and createVariants's base / variants /
compoundVariants[].class. A recipe assembled some other way still works, it is just no
longer checked. Prefer createVariants over a template literal for anything conditional; a
template contributes only its static text.
Restyle on the baseline path
There is no recipe to edit, so overrides go in your own CSS against the parts the machine publishes:
[data-scope='dialog'][data-part='content'] {
max-width: 32rem;
}
[data-scope='switch'][data-part='root'][data-state='checked'] {
background: var(--primary);
}
Your rules are unlayered like the sheet's, so plain source order decides — import
theme.css first, then your overrides. Most restyling should not need this: the tokens
below reach every rule in the sheet.
Retheme
Both paths share semantic values. semantic-tokens.css owns shadcn's token names
(--background, --primary, --primary-foreground, --radius, …) in :root.
theme.css imports it directly; the registry's tokens.css imports it alongside an isolated
Tailwind mapping. A shadcn theme generator's :root half pastes in verbatim, and a theme
you build survives a move between paths. The dark half needs one find-and-replace: this
package never writes the .dark class, so scope your dark overrides to
[data-theme='dark'] and repeat them under the media query.
:root {
--primary: oklch(0.55 0.2 265);
--radius: 0.75rem;
}
/* Overriding a SURFACE token? Override it, AND ITS PAIRED FOREGROUND, in all
three places. A `:root`-only surface override is paired with the LIBRARY's
dark foreground on a dark OS, which is how a legible pair becomes an
illegible one. Check the contrast of each pair you override. */
[data-theme='dark'] {
--primary: <your dark surface>;
--primary-foreground: <its paired ink>;
}
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme='light'])):where(:not(.light)) {
--primary: <your dark surface>;
--primary-foreground: <its paired ink>;
}
}
The media twin is not optional: under the 'system' preference no attribute is set, so a
[data-theme='dark'] block matches nothing. See
Styling → overriding a base token on a dark OS for the full six-cell matrix.
Derived interaction tokens (--primary-hover, --accent-strong, --border-hover) are
color-mix() expressions toward --foreground, so they follow a base token automatically
and darken in light mode while lightening in dark. Do not restate them per theme.
Dark mode activates on [data-theme='dark'], .dark and prefers-color-scheme. The
first is what @llui/components/theme-switch writes and is the supported spelling; .dark
is honoured for consumers who set that class themselves, but nothing in this package ever
writes it — applyTheme manages data-theme only, and removes it entirely for 'system'
so the media query answers.
7. Charts
llui add chart gives you shadcn's container, tooltip and legend recipes over
@llui/components/chart — a plotting machine that derives geometry as data and
lets you render it with ordinary SVG.
Identical on both paths, except that the recipes are a registry item. The machine, the geometry and the
data-*are the same either way.
One field decides the coordinate system
send({ type: 'setCoord', coord: 'polar' })
That is the whole switch. Every mark, gridline, tick, hit test and tooltip anchor re-projects from the same series and the same data:
| Series | Cartesian | Polar |
|---|---|---|
mark: 'line' |
a polyline | a radar outline |
mark: 'area' |
a filled band | a filled radar polygon |
mark: 'bar' |
a bar | a wedge (rose / donut) |
| gridlines | horizontal rules | rings, or a radar web |
horizontal is the other projection flag, and it means the same thing in both
systems: the independent axis moves off its default screen axis. Cartesian, that
turns a column chart on its side. Polar, it moves the categories off the angle
and onto the radius — concentric rings whose arc length is the magnitude, which
is a radial bar chart.
Your view never asks which one it has. Everything the coordinate system touches
lives in utils/projection.ts, behind a Projection interface with two
implementations.
Setting one up
import * as chartC from '@llui/components/chart'
chartC.init({
series: [
{ key: 'desktop', label: 'Desktop', mark: 'bar' },
{ key: 'mobile', label: 'Mobile', mark: 'bar' },
],
rows: [
{ label: 'Jan', values: { desktop: 186, mobile: 80 } },
{ label: 'Feb', values: { desktop: 305, mobile: 200 } },
],
label: 'Visitors by device',
description: 'Desktop and mobile visitors this year.',
})
Colours come from a ChartConfig on the container, which publishes them as
--color-<key> custom properties — upstream's ChartStyle, without the
generated id:
ChartContainer({ ...parts.root, config: {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
mobile: { label: 'Mobile', color: 'var(--chart-2)' },
} }, [ … ])
--chart-1 … --chart-5 are already in the theme, light and dark.
Rendering the geometry
connect returns derived arrays as signals. Render them with each — this is
ordinary reactive DOM, not a black box:
ChartSvg({ ...parts.svg }, [
ChartTitle({ ...parts.title }, [text(state.at('label'))]),
ChartDesc({ ...parts.desc }, [text(state.at('description'))]),
ChartLayer({ ...parts.layer }, [
each(parts.gridLines, {
key: (l) => String(l.value),
render: (l) => [ChartGrid({ ...parts.grid, d: l.at('d') })],
}),
]),
ChartLayer({ ...parts.layer }, [
each(parts.marks, {
key: (m) => `${m.seriesKey}:${m.index ?? 'all'}`,
render: (m) => {
const mark = m.peek()
return [
ChartMark({
...parts.markProps(mark),
d: m.at('d'),
style: `--mark-color:var(--color-${mark.seriesKey})`,
}),
]
},
}),
]),
])
Sizing
width / height are user units and go straight into the viewBox, so the
geometry is a pure function of state — no layout read, no ResizeObserver, and
an SSR render matches the client. CSS scales it. For true 1:1 pixels, observe
your container and dispatch setSize; for a polar chart, a square box uses the
space a wide one wastes.
Accessibility is a table
The <svg> is role="img", named through its own <title> and <desc>. The
numbers live in a visually-hidden <table> beside it — render it from the same
rows and spread parts.table. That is the fallback that works today; support
for the WAI-ARIA graphics roles is still thin enough that a chart relying on
them announces a name and nothing else.
Arrow keys walk the rows, Home/End jump to the ends, Escape clears — and the
tooltip is a role="status" live region, so the cursor is announced as it moves.
Two things that surprise people
Polar draws monotone as linear, on purpose. Monotone cubic's guarantee
is that it never overshoots between two samples, and it is defined on a function
y = f(x) with increasing x. A closed angular loop has no such ordering, so
honouring the request would draw values nobody measured. projection.curves
tells you what is supported.
Unstacked bar series sit side by side, not on top of each other. Overlaying
them hides the shorter series and reads as a stacked chart — the picture would
not merely omit data, it would misstate it. setStacked is the other layout.
Pie and donut: there is no pie mark
A pie is a bar whose magnitude has moved from the value axis onto the
independent one. domain picks which:
domain |
The independent axis | Magnitude is |
|---|---|---|
'value' |
one equal slot per category | v — a bar's height |
'share' |
a slot proportional to the value | u — a slice's extent |
chartC.init({
series: [{ key: 'visitors', label: 'Visitors', mark: 'bar' }],
rows: [
{ label: 'Chrome', values: { visitors: 275 } },
{ label: 'Safari', values: { visitors: 200 } },
],
domain: 'share',
coord: 'polar',
innerRadius: 0.5, // 0 is a pie, 0.5 a donut
})
One series, many rows — the slices are the rows, which is how a pie is actually
shaped. Because it is a domain rather than a mark type, coord still
re-projects one dataset: flip it to 'cartesian' and the same state is a
single full-width 100%-share bar, with the segment widths matching the wedge
angles exactly.
Three things it declines, each for the same reason polar declines monotone:
- No padding between slices. The slot is the datum, so a gap would make every slice misstate its share and a full turn stop being 100%. Separate them with a stroke in the skin, which is what shadcn's own pie does.
- A negative value takes no arc. A share of a negative is undefined, and both silent readings are wrong: using the magnitude draws a slice for a number nobody measured, and letting it subtract pushes the total past 1.
lineandareaare not drawn. An axis whose spacing already encodes the magnitude would place every point at a position meaning something else.
There are no value gridlines under 'share' — the magnitude is the spacing, so
an iso-magnitude ring would say nothing about the data. tooltipRows carries a
share alongside each value (and null under 'value'), which is what a pie
tooltip shows as a percentage.
8. Icons
Any Iconify glyph is one call away, on both styling
paths, from @llui/components/icon:
import { icon } from '@llui/components/icon'
icon('lucide:star', { class: 'size-5' })
icon('simple-icons:github') // any Iconify set, by prefix
icon(state.at('status').map((s) => (s === 'ok' ? 'lucide:check' : 'lucide:x')))
name is prefix:name (a bare name is a Lucide glyph) or a Signal<string>, in
which case the glyph follows the state: a change clears the box and paints the new
body when it arrives, and a slow response for the previous name never lands over
the new one. props spread onto the <svg>; there is no recipe, so class is
passed through as given.
llui add icons layers the shadcn-shaped surface on top for the copied-registry
path — the glyphs shadcn/ui bakes into its own components (SelectTrigger's
chevron, Checkbox's tick, DialogClose's ✕) as factories, the way a recipe
renders <CheckIcon />, with the caller's class merged through tailwind-merge
so an override beats a default:
import { CheckIcon, icon } from '@/ui/icons'
CheckIcon({ class: 'size-4' })
icon('lucide:star')({ class: 'size-5' })
Glyphs are fetched from the Iconify HTTP API, batched into one request per
prefix per tick; loadIcon('lucide:star') warms the cache ahead of a mount.
What you get in the DOM is a real <svg> — not an <img> and not the
iconify-icon web component, either of which would slip past the
[&_svg:not([class*='size-'])]:size-4 hook every recipe uses to size its icons.
A glyph carries no size and no paint of its own. That is deliberate: the
recipe (or your class) sizes it, and colour comes from currentColor, so it
inherits from whatever it sits in. Iconify normalizes paint into the body —
Lucide's elements carry stroke="currentColor", a filled set's carry
fill="currentColor" — so a default on the wrapper would ruin every other set.
Two consequences worth planning for:
- Icons are async. An SSR render emits the sized, empty
<svg>and the glyph arrives after hydration. The box is sized up front, so nothing reflows. - They need the network. A CSP that does not allow
api.iconify.design, or an offline user, gets the empty box and one console warning. PointiconConfig.apiat a self-hosted Iconify to remove the third-party dependency — the path shape is identical.
The API response is third-party markup and is never assigned to innerHTML:
every node is rebuilt from an element and attribute allowlist, so a <script>,
an onload or an href in the response is dropped with its subtree.
9. Color
Two machines share one color engine (@llui/components/utils/color: a CSS Color 4
parser, conversions, gamut mapping and spec-exact interpolation):
color-picker— one color, edited in HSV or OKLCH.gradient-picker— linear, radial or conic gradients, with an embeddedcolor-pickerfor the selected stop.
llui add color-picker / llui add gradient-picker copy the recipes; the baseline
stylesheet styles the same parts.
The color picker
import * as colorPickerC from '@llui/components/color-picker'
colorPickerC.init({ model: 'oklch', color: 'oklch(0.7 0.15 250)' })
color takes any CSS color (#hex, rgb(), hsl(), lab(), oklch(),
color(display-p3 …), named colors). The state stores the color in the active
model, as floats, so nothing drifts: a hex round-trips exactly, and hue survives
on grays (drag to black and back, and the hue is still where you left it).
setModel converts between HSV and OKLCH.
toCss(state) gives hex for HSV and an exact oklch() for OKLCH. An OKLCH color
may sit outside sRGB; it stays exact in state and root publishes
data-out-of-gamut so you can flag it. maxChroma (default 0.37) bounds the
chroma axis.
Dragging the 2D area is built in; spread area and it tracks the pointer. Two
things are not automatic — place both, or they do nothing:
const cp = colorPickerC.connect(state.at('color'), send)
colorPickerC.eyeDropperSupportMount(send), // flips eyeDropperSupported after mount
...colorPickerC.areaCanvasBinding(state.at('color'), 'my-canvas'), // repaints the OKLCH plane
div({ ...cp.area }, [
canvas({ ...cp.areaCanvas, id: 'my-canvas', width: 240, height: 128 }),
div({ ...cp.areaThumb }),
]),
button({ ...cp.eyeDropperTrigger }, [text('Pick')]),
- Eyedropper.
eyeDropperSupportedstartsfalseso the server and the first client paint agree; the mount helper sets it where the browser has the EyeDropper API (Chromium). Until then the trigger ishidden. Without the helper, it stays hidden everywhere. - OKLCH plane. The sRGB boundary is irregular, so the area can't be a CSS
gradient.
areaCanvasBindingpaints it into the canvas with that id on every hue change. The blank region is colors sRGB cannot show at that hue — expected.
The gradient picker
import * as gradientPickerC from '@llui/components/gradient-picker'
gradientPickerC.init({ css: 'linear-gradient(90deg in oklch, #ff5f5b, #5b9bff)' })
const gp = gradientPickerC.connect(state.at('gradient'), send, { id: 'bg' })
gradientPickerC.toCss(state.peek().gradient) // → the CSS you ship
Stops have ids. Two stops can share a position, so a row key is stop.id,
never an index:
div({ ...gp.track }, [
each(state.at('gradient.stops'), {
key: (s) => s.id,
render: (item) => [div({ ...gp.stop(item.peek().id) })],
}),
]),
Pressing the empty track adds a stop and starts dragging it. Each stop is a
role="slider": arrows move it, Home/End jump, Delete removes it (never below
minStops, default 2).
gp.picker is a full color-picker for the selected stop. Place its parts like
any color picker; edits change only the selected stop, in that stop's own model.
There is no second state to sync.
The track shows the real gradient. It uses the same serializer as toCss, hue
method and repeat included, and a new stop takes exactly the color the browser
paints at that spot (colorAt). The picker always writes an explicit
in <space>; the default is oklab.
Show only what applies. The center area only means something for radial and
conic, shape and size only for radial. Place them under show/branch on
state.at('gradient.kind').
parseGradient refuses rather than guesses
gradientPickerC.parseGradient('linear-gradient(red 10px, blue)')
// → { ok: false, reason: '…' }
Anything the model can't hold exactly is rejected with a reason: px lengths, color
hints, fewer than two stops. Nothing is clamped or dropped — a wide-gamut
oklch() stop stays exact. What it accepts keeps its meaning: to top right stays a corner keyword
(its angle depends on the box), and a gradient with no in <space> is read the way
browsers read it — sRGB when every stop is a legacy color, oklab otherwise.
cssInput commits on Enter or blur, not per keystroke. A failure sets
aria-invalid and fills cssError. Like a live region's text, cssError's
visible and message are signals, not attributes — don't spread the bag:
const { visible, message, ...errorAttrs } = gp.cssError
show(visible, () => [div({ ...errorAttrs }, [text(message)])])
10. Pickers, editing and tools
The specialized-tools family (date and time pickers, file upload, image cropper, signature pad, QR code, scroll area, splitter, sortable, floating panel, tour, timer, clipboard, async list and the rest) is styled on both paths, with three honest exceptions the product contract states and a test holds to:
- Partial:
signature-padstyles the pad frame, guide, placeholder, states and triggers, but you draw the ink (canvas or SVG) from the stroke points the machine records.tourstyles the card, backdrop, spotlight and navigation, but you position the card and spotlight against each step's target (typically withattachFloating), because the machine only knows a selector. The baselinewizardstyles its steps indicator and its triggers take the foundation.btnclasses; step content and layout are yours. The registry icon helper owns the glyph and its loading; the caller owns size and colour. - Styleless:
in-viewandpresenceare behaviour-only. They publish state for another component to act on and have no surface of their own, so there is nothing to paint. Use them by composition (see below).
Every state the family can be in is drawn on both paths, including the ones that are easy to forget: a date that is unavailable, a range, an upload in progress or failed with a retry, a refused clipboard write, a timer that has finished, an exhausted or empty async list, an empty QR code. Those states are also reachable from the keyboard, not only the pointer.
Deterministic dates
A date picker reads the clock once in init() to find "today". Pin it wherever
determinism matters (a test, a server render, a screenshot), and compute it in the user's
zone when that is not the browser's:
import { datePicker, todayInTimeZone } from '@llui/components/date-picker'
const state = datePicker.init({
today: '2026-03-14', // or omit, and dispatch setToday below
unavailable: ['2026-03-17', '2026-03-18'],
})
send({ type: 'setToday', today: todayInTimeZone('Asia/Tokyo') })
An unavailable date publishes data-unavailable and cannot be selected, and a range that
would cross one is refused. PageUp/PageDown move the focused date by a month, so the new
month always has a keyboard stop.
Uploads, crops and panels
file-upload tracks each file's upload by id: dispatch uploadProgress (0 to 1),
uploadSucceeded and uploadFailed from your upload effect, and retryUpload comes back
from the item's retry trigger for you to re-issue. Place itemProgress (with
itemProgressRange inside it), itemErrorText and itemRetryTrigger in each item; each
hides itself when it does not apply.
The image cropper's crop box and a floating panel's handles are focus stops. Arrows move them
(Shift for bigger steps), +/- zoom a crop, and the same messages (nudge, zoom,
moveBy, resizeBy) are available to your own code. Canvas geometry stays physical under
RTL: right means right.
Clipboard failure is a state
The write can be refused (no permission, an insecure context). Report it:
copyToClipboard(value).then(
() => send({ type: 'copied' }),
() => send({ type: 'copyFailed' }),
)
copyFailed puts data-failed on the root, the trigger and the polite indicator, whose
text you set, for example "Copy blocked: select the text and copy it". Never claim a copy
that did not happen.
Composing the styleless two
in-view gates a heavy child until it scrolls in. Here a QR code is built only when it
becomes visible:
const watch = inView.connect(state.at('inView'), toInView, { id: 'share-qr' })
div({ ...watch.root, style: 'min-height: 9rem' }, [
onMount((root) => {
const el = root.querySelector('[data-scope="in-view"][data-part="root"]')
return el === null ? undefined : inView.createObserver(el, toInView, { once: true })
}),
show(state.at('inView.visible'), () => [
div({ ...code.root }, [
svg({ ...code.svg }, [rect({ ...code.background }), path({ ...code.foreground })]),
]),
]),
])
presence keeps an element mounted through its exit animation. Drive it from the clipboard's
result so a "Copied" confirmation fades out instead of vanishing: open it on copied, close
it on reset, and render it while presence.isMounted(state).
Gotchas
These are the ones that have actually cost people time. Each is silent: the component works, the types check, and something looks wrong or does nothing.
Some machines do not track the pointer, on purpose. slider and splitter are
keyboard-complete out of the box but ignore the mouse until you wire the drag, because only
your view knows which element's rect a percentage is measured against. Each exports the
helper (valueFromPoint, positionFromPoint) and expects an onMount that attaches
pointermove/pointerup to the window — a drag routinely outruns the handle and
pointerup lands anywhere on the page. Symptom: arrow keys work, the mouse does nothing.
Some parts do not hide themselves. Radix unmounts a radio indicator when unchecked;
LLui keeps it in the DOM and publishes data-state on the item, so the dot is gated in CSS
(group-data-[state=unchecked]/radio-item:invisible). Same shape for a command palette's
empty state (hidden data-empty:block). Ungated, you get every radio filled, or "No
results" above a full list.
A live region's text is a CHILD, not an attribute. combobox's liveRegion bag
carries text: Signal<string>; spreading the whole bag emits a literal text="…" on an
aria-live element with no content, which announces nothing:
const { text: liveText, ...liveAttrs } = parts.liveRegion
ComboboxLiveRegion({ ...liveAttrs }, [text(liveText)])
A portaled overlay follows its trigger's direction. Menus, selects, popovers and
tooltips portal to <body>, but read ltr/rtl from their trigger. Put dir="rtl" on any
container and the overlays opened from inside it mirror too: placement, text, arrow keys and
submenus. Pass dir to init() only to force a direction regardless of the page.
An async list keeps its old rows while it refetches. combobox and searchableSelect
are stale-while-revalidate: loadStart never clears items, so the previous results stay
on screen, filterable and selectable, until the matching loadSuccess/loadError lands.
Read parts.loadState ('initial-empty' | 'loading' | 'stale-results' | 'success' | 'error') instead of combining status with items.length yourself. Three things follow:
loadSuccessreplaces the whole list. Omittinggroupsresets to no groups; it does not keep the previous load's groups. A highlight the new list drops (filtered out, or now disabled) moves to the first enabled match while open, and tonullwhile closed.- A closed control never gets a highlight from a background load. Opening sets it.
searchableSelect'semptypart and "No results" announcement appear only when the list is settled ('success'or'initial-empty'). While'loading'or'stale-results'the live region says nothing andemptystays hidden. On'error'it announces the error text andemptystays hidden. An empty list mid-fetch is not "no results".
Do not wrap a field in a panel recipe. ComboboxRoot is the Command recipe — a full
palette surface with overflow-hidden, for the dropdown. Wrapping a labelled input in it
clips the input's focus ring on three sides, which paints as a thick dark band along one
edge and looks like a border bug.
A live region must stay mounted. Toggle it with hidden or a class, never show —
unmounting and remounting an aria-live element announces nothing.
Keyed rows need a stable element root. A row in each must be one or more real
elements, never a bare fragment or a top-level show/branch. Without a stable handle,
reorder throws NotFoundError or duplicates rows.
Where to look next
- The component catalog — every component's import,
llui addnames, aliases, styling support and gallery link, generated from the product contract. - The Component Gallery — every component's scenarios on both styling paths.
- Styling & Registry — the token contract and the registry's own rules.
- Composition Patterns — factoring views, and when a child component boundary is worth it.
- API Reference — every part bag's exact type, which is the authoritative answer to "what does this publish?".