Themes
A theme is a set of token values. Override them on :root for the whole application or on a theme scope for a subtree; the dark theme is built in and follows the system, a data-theme attribute or the dark class. The package contains no theme JavaScript, no store and no Provider.
On this page
Overriding tokens
The tokens live in the slean.tokens cascade layer. Any unlayered declaration in
your CSS wins, so a theme is a plain :root block that redefines the values you want to
change and leaves the rest to the package.
/* A theme is a set of token values. Redefine them on :root to retheme every primitive. */
:root {
--slean-accent: oklch(45% 0.13 150);
--slean-accent-hover: oklch(40% 0.13 150);
--slean-accent-active: oklch(36% 0.12 150);
--slean-radius-md: 0.25rem;
--slean-font-sans: 'IBM Plex Sans', system-ui, sans-serif;
}Theming a subtree
Custom properties inherit, so an override on any element reaches the primitives below it. The
package derives part of its tokens from others: the focus ring, the hovered and focused field
border and its halo, the selected option and the info tone read --slean-accent.
Those aliases are computed where they are declared, so the package declares them on the root and
again on every theme scope. Mark the element that carries the override with data-slean-theme (any value names it) and the aliases below it follow the new accent
too; without the attribute, the buttons change and a focused field keeps the root's accent in its
ring.
<div data-slean-theme="brand">
<button type="button" data-slean="button">Save</button>
<button type="button" data-slean="button" data-variant="outline">Cancel</button>
<input type="text" data-slean="input" aria-label="Project name" placeholder="Focus me" />
</div>
<style>
/* data-slean-theme marks a theme scope: the package derives its aliases (the focus ring, the
* hovered and focused field border, the selected option) again on it, so they follow the
* accent redefined here. The foreground is set as well, so the pair keeps its contrast. */
[data-slean-theme='brand'] {
--slean-accent: oklch(45% 0.13 150);
--slean-accent-hover: oklch(40% 0.13 150);
--slean-accent-active: oklch(36% 0.12 150);
--slean-accent-fg: oklch(100% 0 0);
--slean-radius-md: 999px;
}
</style><script lang="ts">
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/input.css';
</script>
<div data-slean-theme="brand">
<button type="button" data-slean="button">Save</button>
<button type="button" data-slean="button" data-variant="outline">Cancel</button>
<input type="text" data-slean="input" aria-label="Project name" placeholder="Focus me" />
</div>
<style>
/* data-slean-theme marks a theme scope: the package derives its aliases (the focus ring, the
* hovered and focused field border, the selected option) again on it, so they follow the
* accent redefined here. The foreground is set as well, so the pair keeps its contrast. */
[data-slean-theme='brand'] {
--slean-accent: oklch(45% 0.13 150);
--slean-accent-hover: oklch(40% 0.13 150);
--slean-accent-active: oklch(36% 0.12 150);
--slean-accent-fg: oklch(100% 0 0);
--slean-radius-md: 999px;
}
</style>The theme scopes are data-slean-theme, data-theme and the light and dark classes. A scope re-derives the aliases from the
palette, so an alias overridden on :root (for example --slean-control-border) has to be overridden on the scope selectors as well; a
palette token (--slean-accent, the neutral scale, the status colors) needs only :root.
Dark theme
Light is the default. The dark values apply under prefers-color-scheme: dark unless
the root carries data-theme="light" or the light class, and always on
an element that carries data-theme="dark" or the dark class, the root
or any other. The light and the two dark blocks set the same tokens (39 of them), so a light region inside a dark page resets
every one of them, and the package's static check fails when the dark blocks differ.
/* packages/styles/css/tokens.css, abridged. Every selector is zero-specificity; the source
* order decides inside the slean.tokens layer. */
@layer slean.tokens {
/* The light palette: the default, and any subtree marked data-theme="light" or .light. */
:where(:root, [data-theme='light'], .light) {
--slean-color-scheme: light;
/* … light values … */
}
/* Follows the system, unless the root forces light. */
@media (prefers-color-scheme: dark) {
:where(:root:not([data-theme='light'], .light)) {
--slean-color-scheme: dark;
/* … dark values … */
}
}
/* Forced: data-theme="dark" or class="dark", on the root or on any subtree. The two dark
* blocks are identical by contract. */
:where([data-theme='dark'], .dark) {
--slean-color-scheme: dark;
/* … the same dark values … */
}
/* The aliases (borders, the field shell, the focus ring) are derived again on every scope. */
:where(:root, [data-theme], [data-slean-theme], .dark, .light) {
--slean-control-border-focus: var(--slean-accent);
--slean-focus-ring-color: var(--slean-accent);
/* … */
}
}Following the system
With no attribute and no class on the root, the media query decides. Nothing has to run: the first paint is already in the right theme, before and without JavaScript.
Forcing a theme
Set data-theme on <html>. The attribute is the whole mechanism;
a toggle writes it and, if the choice should survive a reload, a storage key.
<!-- Force a theme for the whole document; leave the attribute off to follow the system. -->
<html lang="en" data-theme="dark">// A blocking script in <head>, before the first paint: no flash, no framework, no Provider.
try {
const theme = localStorage.getItem('theme');
if (theme === 'dark' || theme === 'light') document.documentElement.dataset.theme = theme;
} catch {
// Storage unavailable: the document follows the system.
}
// The toggle writes the attribute and the storage key. Primitives read the tokens; nothing else
// needs to know.
function setTheme(theme) {
document.documentElement.dataset.theme = theme;
localStorage.setItem('theme', theme);
}This site's toggle is that script: theme.js runs before the first paint and the button in the top navigation writes the attribute. The package
plays no part in it. The dark and light classes are read the same way, so
mode-watcher and Tailwind's class-based dark variant need no bridge, and either form on an inner element
puts that region in the other theme.
<!-- The class convention of mode-watcher and Tailwind's dark variant works as well. -->
<html lang="en" class="dark">
<!-- A region in the other theme: the attribute or the class on any element. -->
<aside data-theme="dark">…</aside>
<aside class="light">…</aside>Native parts
Each primitive sets color-scheme from --slean-color-scheme, so a
dialog's canvas and a scrollable panel's scrollbar follow the theme without the page's own color-scheme being touched.
/* packages/styles/css/base.css: native parts (scrollbars, the dialog canvas) follow the theme
* of each primitive, and the page's own color-scheme is left alone. */
@layer slean.base {
:where([data-slean]) {
color-scheme: var(--slean-color-scheme);
}
}Presets
A preset is a stylesheet of token overrides. This one replaces the blue accent with ink on grey and tightens the radii, the look of shadcn/ui's neutral theme; copy it into the application stylesheet after the package imports. The dark block repeats the package's two dark conditions, so the preset follows the same attribute, class and media query.
/* A neutral preset: an ink accent on grey, smaller radii. Unlayered, so it wins over the
* package defaults; the dark block mirrors the package's own two dark conditions. */
:root {
--slean-accent: oklch(20.5% 0 0);
--slean-accent-hover: oklch(30% 0 0);
--slean-accent-active: oklch(36% 0 0);
--slean-accent-fg: oklch(98.5% 0 0);
--slean-accent-soft: oklch(95% 0 0);
--slean-accent-soft-fg: oklch(20.5% 0 0);
--slean-radius-sm: 0.25rem;
--slean-radius-md: 0.5rem;
--slean-radius-lg: 0.75rem;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light'], .light) {
--slean-accent: oklch(92% 0 0);
--slean-accent-hover: oklch(85% 0 0);
--slean-accent-active: oklch(80% 0 0);
--slean-accent-fg: oklch(20.5% 0 0);
--slean-accent-soft: oklch(27% 0 0);
--slean-accent-soft-fg: oklch(92% 0 0);
}
}
:root:is([data-theme='dark'], .dark) {
--slean-accent: oklch(92% 0 0);
--slean-accent-hover: oklch(85% 0 0);
--slean-accent-active: oklch(80% 0 0);
--slean-accent-fg: oklch(20.5% 0 0);
--slean-accent-soft: oklch(27% 0 0);
--slean-accent-soft-fg: oklch(92% 0 0);
}The site as an example
sveltelean.dev defines its own --site-* palette and maps the package tokens onto it in
one unlayered block. Every primitive rendered on this site, the install switch and the source tabs
of each example included, takes its colors, radius and font from that bridge and from nothing else;
the package CSS is never edited.
/* sveltelean.dev, src/app.css: the site's own tokens are mapped onto the package tokens.
* Unlayered, so it wins over the slean.tokens layer; every primitive on this site inherits
* the site palette through this block and nothing else. The aliases are mapped on the theme
* scopes too, because the package derives them again there. */
:root,
[data-slean-theme] {
--slean-bg: var(--site-bg);
--slean-fg: var(--site-text);
--slean-fg-muted: var(--site-text-muted);
--slean-border: var(--site-border);
--slean-control-border: var(--site-border-control);
}
:root {
--slean-surface: var(--site-surface);
--slean-accent: var(--site-accent);
--slean-accent-hover: var(--site-accent-hover);
--slean-accent-fg: var(--site-on-accent);
--slean-focus-ring-color: var(--site-accent);
--slean-font-sans: var(--site-font-sans);
--slean-radius-md: var(--site-radius-2);
}The full block is in apps/docs/src/app.css. The same pattern fits an application that already has a design token layer: point the package tokens at it.
Forced colors
Under forced-colors: active (Windows High Contrast) the platform replaces most colors
itself. The tokens file maps only what stays author-controlled to system colors:
| Token | Value under forced colors |
|---|---|
--slean-focus-ring-color | Highlight |
--slean-border | CanvasText |
--slean-border-strong | CanvasText |
--slean-shadow-sm | none |
--slean-shadow-md | none |
--slean-shadow-lg | none |
Component files add what a token cannot express: buttons get a ButtonText border so
solid variants keep an outline, and disabled controls of every primitive show as GrayText instead of reduced opacity.
Reduced motion
Under prefers-reduced-motion: reduce the transition durations become zero, and the
loading indicator's duration (--slean-duration-indicator, one turn of the spinner
and of a busy button) becomes longer instead: a ring that stops reads as a stuck page. Component
files read those tokens and never a raw duration (the static check rejects one), so no extra
rule is needed.
| Token | Value under reduced motion |
|---|---|
--slean-duration-fast | 0ms |
--slean-duration-normal | 0ms |
--slean-duration-slow | 0ms |
--slean-duration-indicator | 1600ms |
/* Component files never write a raw duration. A transition reads the token, so the
* reduced-motion block in tokens.css stops it without a second rule. */
:where([data-slean='dialog']) {
transition:
opacity var(--slean-duration-normal) var(--slean-ease),
scale var(--slean-duration-normal) var(--slean-ease);
}
/* A loading indicator reads its own token, which reduced motion slows instead of zeroing. */
:where([data-slean='spinner']) {
animation: slean-spinner-rotate var(--slean-duration-indicator) linear infinite;
}Why there is no Provider
A Provider component exists in most UI libraries because theme values and behavior routing are held in component context. Here neither needs context: theme values are CSS custom properties that the cascade delivers to every element, and behavior routing attaches to the document runtime without knowing which component rendered a root. Installing the primitives therefore requires no wrapper around the application, and a theme change is a change of attribute or stylesheet that server-rendered markup already reflects on first paint.
The trade-off is stated plainly: a theme cannot be computed at runtime from JavaScript values without writing them into CSS custom properties yourself, which is the mechanism the package expects.