sveltelean
Versionv0.2.0 GitHub

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.

app.css
/* 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.

Tokens overridden on a theme scope
<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 left group reads the tokens of this site; the right group sits inside a data-slean-theme element that redefines the accent and the medium radius. Focus the fields: each ring takes the accent of its own scope. No class on the controls changed.

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.

tokens.css (abridged)
/* 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.

app.html
<!-- Force a theme for the whole document; leave the attribute off to follow the system. -->
<html lang="en" data-theme="dark">
theme.js
// 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.

app.html
<!-- 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.

base.css (excerpt)
/* 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.

theme-neutral.css
/* 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.

src/app.css (excerpt)
/* 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:

TokenValue under forced colors
--slean-focus-ring-colorHighlight
--slean-borderCanvasText
--slean-border-strongCanvasText
--slean-shadow-smnone
--slean-shadow-mdnone
--slean-shadow-lgnone

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.

TokenValue under reduced motion
--slean-duration-fast0ms
--slean-duration-normal0ms
--slean-duration-slow0ms
--slean-duration-indicator1600ms
dialog.css (excerpt)
/* 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.