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 <link> to one file, which the browser caches across every page of the site. There is nothing to configure and nothing to copy: sitelo’s plugin serves it in dev and writes it into the build, at the same base as the component runtime.

import { styles } from 'sitelo/ui'

head(
  title('My site'),
  styles(),
)
// <link rel="stylesheet" href="/su/ui-c9428b65.css">

The name carries a hash of the contents, so you can serve it immutable and still ship a change. Pass { hash: false } for a plain /su/ui.css, or base to point the link at a copy you host yourself.

{ inline: true } puts the whole sheet in a <style> instead — about 11 kB gzipped in every page, but no extra request and nothing that can go missing from dist/. That is the better trade for a single page; the link buys its request back on the second page a visitor reads.

head(
  title('My site'),
  styles({ inline: true }),
)

The rest of the family hands you the pieces. stylesheet() returns the raw CSS as a string — for hosting the sheet somewhere sitelo cannot reach, or writing it somewhere yourself — and stylesUrl() the href alone, for a link element of your own.

Presets

A preset changes the whole look at once. styles({ preset: 'neumorphism' }) links a second sheet straight after the core one — served, hashed and cached on the same terms — and every component on the page follows it, with nothing to change in the markup.

import { styles } from 'sitelo/ui'

head(
  title('My site'),
  styles({ preset: 'neumorphism' }),
)
// <link rel="stylesheet" href="/su/ui-c9428b65.css">
// <link rel="stylesheet" href="/su/neumorphism-5d0e7b91.css">

Each preset has a page of its own, with every component restyled live: Neumorphism, Neubrutalism, Superneon, Terminal, Blueprint.

inline inlines both sheets, stylesheet({ preset }) returns them as one string, and a name that is not a preset throws with a list of the ones there are.

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.

indigo
pink
round
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.

primary
neutral
success
warning
danger
xs
sm
md
lg
xl
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():

PropTypeDefaultDescription
preset'neumorphism' | 'neubrutalism' | 'superneon' | 'terminal' | 'blueprint'—Restyle every component with a preset, linked or inlined after the core sheet.
inlinebooleanfalseEmit the CSS itself rather than a link to it.
hashbooleantrueContent-hash the file name. Linked only.
basestring'/su/'Point the URL elsewhere; that copy is yours to host. Linked only.
minifybooleantrueStrip comments and whitespace. Inline only.
noncestring—CSP nonce for the emitted element.

stylesUrl() takes base and hash; stylesheet() takes minify and preset.

theme(tokens, options):

PropTypeDefaultDescription
selectorstring':root'Scope the overrides to a subtree.
darkobject—Overrides applied only in dark mode.
noncestring—CSP nonce.