Styling & the component registry
LLui's components are headless: connect() returns part bags carrying behaviour,
ARIA and data-* state, and never a class. Everything on this page is additive —
nothing here can change focus handling, dismissal or accessibility.
There are exactly two supported ways to make them look like something. Pick one per project; they are not meant to be combined.
| Baseline stylesheet | Registry components | |
|---|---|---|
| You write | @import '@llui/components/styles/theme.css' |
llui add button card dialog |
| You get | Every component styled, immediately | Source files you own and edit |
| Best for | Prototypes, internal tools, docs | Product UI with a design of its own |
The token contract
Both paths read the same tokens, and they follow shadcn/ui: paired surface and
foreground variables, one --radius that derives the rest of the scale, oklch values.
@import 'tailwindcss';
@import '@llui/components/styles/theme.css';
@import '@llui/components/styles/theme-dark.css';
A shadcn/ui theme — including the output of the community theme generators — pastes
over the :root / .dark blocks verbatim. Overriding a base token is all you need:
:root {
--primary: oklch(0.55 0.21 258);
--primary-foreground: oklch(0.99 0 0);
--radius: 1rem;
}
--primary-hover, --accent-strong, --border-hover, --destructive-hover and
--primary-soft-foreground are LLui additions for interaction states the baseline
stylesheet needs. Each is a color-mix() toward --foreground, so the same expression
darkens on a light theme and lightens on a dark one — do not restate them in a dark
block, and do not define them at all unless you want a different mix.
Dark mode activates three ways, all at once: a .dark class (what shadcn tooling
writes), [data-theme='dark'] (what @llui/components/theme-switch writes), and
prefers-color-scheme. [data-theme='light'] or .light opts a subtree out.
Tailwind v4 is required. The colour tokens are mapped into Tailwind's
--color-*namespace with@theme inline, and the radius/shadow/duration/z-index scales come from a plain@theme. Without a Tailwind v4 pipeline those scales emit nothing.
Namespace names are not what they look like
If you add your own scale tokens, use Tailwind's real namespace names:
| You want | Declare | Not |
|---|---|---|
duration-fast |
--transition-duration-fast |
--duration-fast |
z-dialog |
--z-index-dialog |
--z-dialog |
p-gutter |
--spacing-gutter |
--space-gutter |
The wrong spelling still emits a perfectly good custom property, so var(--duration-fast)
in a plain CSS rule works — while the duration-fast class compiles to nothing. That
asymmetry is why an earlier version of this package shipped 116 dead utility occurrences
across 55 files with a green test suite. Verify a namespace by compiling it.
Registry components
New here? Using the components is the walkthrough — install to a styled, state-wired screen, plus the customization levels and the silent traps. It opens with choosing a styling path: this registry, or the opt-in baseline stylesheet, with the trade-offs of each and why you must import one and not both. This page is the reference behind it.
@llui/cli copies component source into your project — shadcn's distribution model,
which fits LLui better than it fits React: the copied file is compiled by your
@llui/vite-plugin, so it gets view lowering, the compile-time lint rules, and the
agent metadata ($ms / $ss / __lluiVariants) that a precompiled library cannot give
you.
pnpm add -D @llui/cli
pnpm llui init
pnpm llui list
pnpm llui add button card dialog
init writes components.json:
{
"registry": "https://llui.dev/r",
"paths": { "ui": "src/components/ui", "lib": "src/lib" }
}
Add "aliases": { "ui": "@/components/ui", "lib": "@/lib" } if your tsconfig declares
those paths. Without it the CLI emits relative imports — an alias your tsconfig does
not declare resolves nowhere, so relative is the default rather than the fallback.
llui add never overwrites an existing file. The copied file is your source and is
expected to have been edited; pass --overwrite when you really mean it.
Two things overlay() does not give you
Both are invisible while the baseline stylesheet is doing the work, and both bite the moment you style with utilities:
- The positioner needs
fixed inset-0from you.overlay()builds the floating wrapper div, but the part bag it spreads carries onlydata-*— nothing positions it. Pass it, with the z-index, aspositionerClass. - The backdrop is yours to render, inside
content(). The engine does not emit one. It sits inside the positioner, so it wantsabsolute inset-0, notfixed.
dialogOverlay({
state, send, parts,
positionerClass: 'fixed inset-0 z-dialog grid place-items-center p-4',
content: () => [DialogBackdrop({ ...parts.backdrop }), DialogContent({ ...parts.content }, [...])],
})
Presentational vs. skin items
- Presentational —
button,card,input,textarea,label,badge,separator,skeleton,alert,table. Plain element helpers, no state. - Skins —
switch,tabs,accordion,dialog,popover,tooltip. Classes and the right tag for@llui/componentsparts. The state machine stays in the package.
A skin is used by spreading the part bag into it:
const parts = switchConnect(state.at('enabled'), switchSend)
Switch({ ...parts.root }, [SwitchThumb({ ...parts.thumb })])
State-driven styling: use data-*
Every part bag already emits data-state, data-disabled, data-orientation and
data-side. Write the variant in the recipe:
'data-[state=open]:bg-muted data-[disabled]:opacity-50'
The alternative — reading state in the view to build a class — is a build error:
class: cn('base', state.at('open') && 'is-open') // ✗ operator-on-signal
class: state.at('open').map((open) => cn('base', open && 'is-open')) // ✓ one binding
mergeClass (in the registry's lib/utils) handles the second form for you: pass a
Signal as class and it maps rather than stringifying it. cn() alone would turn the
handle into "[object Object]" — a silently stuck attribute.
Note that cn is not cx from @llui/components/styles. cx concatenates, so your
class: 'p-2' loses to a recipe's p-4 by source order; cn resolves the conflict,
which is what makes class a real override.