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 theme Registry skins
You write @import '@llui/components/styles/theme.css' llui add button card dialog
You get Every machine styled, immediately Source files you own and edit
CSS pipeline none — ordinary CSS, no Tailwind Tailwind v4 (tailwindcss, tw-animate-css, tokens.css)
Covers machine parts, selected by data-scope machine skins, pattern adapters and presentational elements
Best for Prototypes, internal tools, docs Product UI with a design of its own

Both paths style the same machines through the same data-* contract, and both read the same semantic tokens. What differs is where the rules live and whether a Tailwind build produces them. The component catalog states, per component, how far each path covers it; the Component Gallery renders both paths side by side, each in its own document so they can never share a cascade.

Baseline CSS distribution

The convenient complete entry is ordinary published CSS:

@import '@llui/components/styles/theme.css';

It needs no Tailwind package, PostCSS plugin, Vite plugin, configuration, or preprocessing. The entry includes light and dark tokens, focus-visible and disabled conventions, RTL-sensitive logical defaults, reduced-motion handling, forced-colors treatment, every component family, and motion in a deterministic order.

Baseline stylesheet entry points

Every stylesheet @llui/components exports, generated from the package's exports map and the stylesheets' own @imports and selectors:

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.

A family module assumes the tokens and foundation.css are already loaded; it does not import them itself, so the modular bundle's first lines are not optional. Keep motion.css last: it carries the enter/exit keyframes the family modules name.

Upgrading from an older release (the removed theme-dark.css and layout.css entries, the --llui-* scale renames)? See the migration guide.

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.

The baseline imports the semantic modules directly. The registry keeps its separate, explicit Tailwind v4 entry:

@import 'tailwindcss';
@import 'tw-animate-css';
@import '@llui/components/styles/tokens.css';
@import '@llui/components/styles/tokens-dark.css';

tokens.css composes the same semantic values with tailwind.css, which owns the Tailwind namespace mapping and shadcn base layer. It imports no baseline component selector.

A shadcn/ui theme — including the output of the community theme generators — pastes over the :root block verbatim. Its dark half needs one edit: generator output scopes everything under .dark alone, and nothing in @llui/components writes that class, so add [data-theme='dark'] to the selector. See dark-mode overrides. 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: [data-theme='dark'] (what @llui/components/theme-switch writes — the only one this package ever writes), a .dark class (the shadcn/ui convention, supported for consumers whose own tooling sets it — next-themes, an SSR-rendered class), and prefers-color-scheme. [data-theme='light'] or .light opts a subtree out.

applyTheme() publishes the user's preference, so 'system' REMOVES data-theme rather than resolving prefers-color-scheme in JS and pinning the answer — that is the state the media query exists to answer, it keeps watchSystemTheme off the critical path, and it is the only spelling SSR can render. It deliberately writes no .dark class (#242): .dark names a resolved theme, so maintaining it would reintroduce that JS resolve, and a class that goes stale when the OS flips does not merely fail to help — it suppresses the media query that was correct with no JS at all. Measured with the preference on 'system' throughout:

class on <html> OS light OS dark
(none) light ✓ dark ✓
.dark (stale) dark — wrong dark ✓
.light (stale) light ✓ light — wrong

Overriding a base token in dark mode

Write your dark override under both selectors, and add a twin under the media guard if you support the 'system' preference:

.dark,
[data-theme='dark'] {
  --primary: oklch(0.7 0.15 258);
}
@media (prefers-color-scheme: dark) {
  :root:where(:not([data-theme='light'])):where(:not(.light)) {
    --primary: oklch(0.7 0.15 258);
  }
}

Why each half is needed, measured in real Chromium across all six (preference × OS) cells:

  • [data-theme='dark'] is what the theme switch writes. A generator's .dark-only block matches nothing unless your own code sets that class (#242). One find-and-replace.
  • The twin covers 'system' on a dark OS. There the preference writes no attribute and this package writes no class, so there is nothing for your selector to match at all — no specificity change can repair that cell. The twin's guard is true whenever the attribute is absent or 'dark', so it covers it.
  • The derived color-mix() tokens need no twin — they re-resolve from --foreground in whichever block wins.

Fixed in the shipped stylesheet: the media guard used to be :root:not([data-theme='light']):not(.light) — specificity (0,3,0) — which outranked every consumer override of a base token on a dark OS, including [data-theme='dark'] under an explicit 'dark' preference, where the selector matches perfectly well and still lost. It is now :root:where(:not(…)):where(:not(…)), which is (0,1,0): it ties with :root, .dark and [data-theme='dark'], so a block imported after this stylesheet wins on source order (#241). Note the consequence, if you were relying on the old behaviour: a plain :root override now leaks into dark mode consistently, where before it leaked on a light OS and was overruled on a dark one. Override a surface token in the dark block too, not in :root alone.

Tailwind v4 is required only for copied registry skins. Their explicit mapping uses @theme inline to expose the shared semantic values as utilities. The baseline entry never imports that mapping and requires no Tailwind processing.

Namespace names are not what they look like

If you add your own registry utility 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.

Direction, motion and contrast

Both paths honour the same three platform preferences. The machines handle the behaviour; the stylesheets handle what it looks like.

Right-to-left

  • Machines. Direction-sensitive machines take dir in init() and a setDir message, or follow the page when you place their parts.directionSync. A portaled overlay reads its direction from its trigger, so dir="rtl" on any container mirrors the overlays opened from inside it.
  • Baseline theme. Rules are written with logical properties (inline-start, text-align: start), so setting dir on an ancestor is all it takes. Canvas geometry — an image crop, a signature, a floating window — deliberately stays physical.
  • Registry skins. Recipes use logical utilities (ms-*, ps-*, start-*) where upstream uses physical ones, plus rtl: variants where a glyph must flip. Keep them logical when you edit a recipe.

Reduced motion

  • Baseline theme. foundation.css shortens every animation and transition on [data-scope][data-part] and .btn to 0.01ms under prefers-reduced-motion: reduce.
  • Registry skins. The copied overlay skins share one motion-reduce: rule (floating-motion.ts); other animated recipes carry their own motion-reduce: variants.
  • In your own CSS, shorten rather than remove. A presence machine (data-state="opening" / "closing") waits for its animation to end, so animation: none or a zero duration can leave it stuck mid-transition; a tiny non-zero duration, as both paths use, cannot.

Forced colors

  • Baseline theme. foundation.css maps the focus ring to Highlight and disabled parts to GrayText, and family modules add forced-colors rules wherever a state would otherwise be carried by colour alone (the modules are listed under entry points).
  • Registry skins. Recipes use Tailwind's forced-colors: variant for the same states.
  • In your own CSS, when a state is only a background colour, give it a border or outline in system colours under @media (forced-colors: active).

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-0 from you. overlay() builds the floating wrapper div, but the part bag it spreads carries only data-* — nothing positions it. Pass it, with the z-index, as positionerClass.
  • The backdrop is yours to render, inside content(). The engine does not emit one. It sits inside the positioner, so it wants absolute inset-0, not fixed.

A floating surface (menu, select, popover, tooltip) should cap its height at the space it actually has, not at the viewport. The positioning engine writes that space as --llui-floating-available-height on the positioner, and the shipped recipes use it as max-h-[var(--llui-floating-available-height,calc(100dvh-2rem))]. Keep that when you edit a recipe: with only the 100dvh cap, a menu opened halfway down the page runs off the bottom.

dialogOverlay({
  state, send, parts,
  positionerClass: 'fixed inset-0 z-dialog grid place-items-center p-4',
  content: () => [DialogBackdrop({ ...parts.backdrop }), DialogContent({ ...parts.content }, [...])],
})

Presentational, skin and pattern items

What llui add <name> copies depends on the component, and the component catalog lists it for every name:

  • Presentational items (button, card, input, …) are styled element helpers with no state. There is no machine to import, by design — see machine-free components.
  • Skins are classes and the right tag for a machine's parts. The state machine stays in @llui/components; you import it and spread its part bags into the skin.
  • Pattern adapters are the skins of a composed pattern from @llui/components/patterns/*. Their add names need not match: llui add form copies the form-field pattern's adapter, while @llui/components/form is a different, lower-level machine.

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.