Theming
Every component reads the same custom properties, so a theme is a set of overrides on :root — no build step, no configuration file, and no component that has to be told about it.
Getting the styles in
styles() returns a <style> element holding the whole sheet, minified — about 7 kB gzipped. It is the default because it cannot go missing from dist/ and costs no extra request.
import { styles } from 'sitelo/ui'
head(
title('My site'),
styles(),
)If you would rather link it once and let the browser cache it across pages, import the CSS from a bundled entry file instead and Vite will emit it:
import 'sitelo/ui/styles.css'Use one or the other, not both. stylesheet() returns the raw CSS as a string, for writing it somewhere yourself.
Overriding tokens
theme() writes the overrides. Keys are camelCase token names, palette objects, or literal custom properties — and it goes after styles(), so it wins.
import { styles, theme } from 'sitelo/ui'
head(
styles(),
theme({
primary: { base: '#5b5bd6', hover: '#4a4ac4', active: '#3f3fb0', fg: '#ffffff' },
radiusMd: '2px',
fontSans: '"Inter", system-ui, sans-serif',
}),
)Scoped themes
A selector scopes the overrides to a subtree instead of the whole page. That is what the three panels below do — same components, three different palettes, one page.
fragment(
theme({ primary: { base: '#5b5bd6', hover: '#4a4ac4', fg: '#ffffff', soft: '#e6e6fa', softFg: '#33338f', border: '#b9b9ee' } }, { selector: '.theme-indigo' }),
theme({ primary: { base: '#b0357a', hover: '#962e68', fg: '#ffffff', soft: '#fbe4f0', softFg: '#7d1f53', border: '#f0a9ce' } }, { selector: '.theme-pink' }),
theme({ radiusMd: '999px', radiusLg: '1.5rem' }, { selector: '.theme-round' }),
grid({ min: '11rem' },
div({ class: 'theme-indigo' },
card(cardBody(stack({ gap: 'sm' },
text({ variant: 'caption', tone: 'muted' }, 'indigo'),
button({ block: true }, 'Primary'),
button({ variant: 'soft', block: true }, 'Soft'),
))),
),
div({ class: 'theme-pink' },
card(cardBody(stack({ gap: 'sm' },
text({ variant: 'caption', tone: 'muted' }, 'pink'),
button({ block: true }, 'Primary'),
button({ variant: 'soft', block: true }, 'Soft'),
))),
),
div({ class: 'theme-round' },
card(cardBody(stack({ gap: 'sm' },
text({ variant: 'caption', tone: 'muted' }, 'round'),
button({ block: true }, 'Primary'),
button({ variant: 'soft', block: true }, 'Soft'),
))),
),
),
)Dark mode
Dark resolves from prefers-color-scheme on its own. An explicit data-theme or data-su-theme of light or dark on any ancestor overrides it — which is how the demos on this site follow the toggle in the top bar.
Pass dark for overrides that should only apply there. It covers the attribute and the media query in one go.
theme({
primary: { base: '#5b5bd6' },
}, {
dark: { primary: { base: '#8f8ff0' } },
})What there is to override
Five palettes of nine slots each, a spacing scale, type, radii, shadows and the surface colours. Every one is a custom property — open the stylesheet, or your browser’s inspector, and they are all on :root.
stack({ gap: 'md' },
stack({ direction: 'row', gap: 'sm', wrap: true },
...['primary', 'neutral', 'success', 'warning', 'danger'].map((color) =>
stack({ gap: 'xs', align: 'center' },
div({ style: 'width: 3.5rem; height: 2rem; border-radius: 0.4rem; background: var(--su-' + color + ')' }),
text({ variant: 'caption', tone: 'muted' }, color),
),
),
),
stack({ direction: 'row', gap: 'sm', wrap: true, align: 'flex-end' },
...['xs', 'sm', 'md', 'lg', 'xl'].map((step) =>
stack({ gap: 'xs', align: 'center' },
div({ style: 'width: var(--su-space-' + step + '); height: 2rem; border-radius: 0.2rem; background: var(--su-neutral)' }),
text({ variant: 'caption', tone: 'muted' }, step),
),
),
),
)Naming
A camelCase key becomes a kebab-case property: radiusMd is --su-radius-md, fontSans is --su-font-sans. A nested object expands the same way — { primary: { softFg: … } } sets --su-primary-soft-fg — and a key already starting with -- is used exactly as written, which is the escape hatch for anything the mapping does not cover.
A palette has nine slots: base, hover, active, fg, soft, softHover, softFg, border and ring. Set only the ones you are changing.
Contrast
The shipped palettes clear WCAG AA against the surfaces they sit on, in both themes, and there is a test in the repository that fails the build if that stops being true. A theme of your own is not covered by it — check your fg against your base before shipping.
Props
styles() and stylesheet():
| Prop | Type | Default | Description |
|---|---|---|---|
minify | boolean | true | Strip comments and whitespace. |
nonce | string | — | CSP nonce for the emitted style element. styles() only. |
theme(tokens, options):
| Prop | Type | Default | Description |
|---|---|---|---|
selector | string | ':root' | Scope the overrides to a subtree. |
dark | object | — | Overrides applied only in dark mode. |
nonce | string | — | CSP nonce. |