Using the components
LLui ships components in two halves that you assemble yourself:
@llui/components— the machines. State, keyboard handling, ARIA, focus and dismissal. No classes, no opinions about how anything looks.- the registry — the skins. shadcn/ui's class recipes, copied into your project by
llui add, spread onto the machine's parts.
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.
Everything here is live in the registry demo — every component on that page is a machine plus a registry skin, and its source is the closest thing to a reference implementation.
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 | the package, one 1748-line stylesheet |
| 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 |
override tokens in :root |
| 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 verbatim — a measured 98% class-set match, 38 of 45 components identical.
- 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/markdown-showcaseuses it for exactly this reason. - 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 theme.css / theme-dark.css imports
with tokens.css / tokens-dark.css, add Tailwind, then llui add the components you use. The
tokens 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 (§5). Wiring a machine (§3) and placing an overlay (§4) 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 and §4 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.
On the baseline path
pnpm add @llui/dom @llui/components
pnpm add -D @llui/vite-plugin
@import '@llui/components/styles/theme.css';
@import '@llui/components/styles/theme-dark.css';
That is the whole setup — no Tailwind, no CLI, no per-component files. theme.css imports
the same tokens the registry uses and adds ~207 [data-scope][data-part] rules on top, so
every component you wire is styled the moment you spread its part bag.
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
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. So are card, input,
label, badge, separator, skeleton, alert and table. Nothing to wire.
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. 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 this. tokens.css defines shadcn's token names (--background,
--primary, --primary-foreground, --radius, …) in :root, and theme.css imports the
same file — so any shadcn theme generator's output pastes in verbatim, and a theme you
build survives a move between the two paths:
@import '@llui/components/styles/tokens.css';
:root {
--primary: oklch(0.55 0.2 265);
--radius: 0.75rem;
}
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 .dark, [data-theme='dark'] and prefers-color-scheme — the
first is what shadcn tooling writes, the second is what @llui/components/theme-switch
writes.
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)])
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 registry demo — every component, wired. Its section files are the reference for any part bag you are unsure about.
- 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?".