# Themes

> The palette of seven colors with six tones each, the typography scale and elevation, and creating a theme: token overrides on :root or a subtree, the dark theme, presets, and why there is no Provider.

Page: https://svelte-lean.vercel.app/styling/themes · Markdown: https://svelte-lean.vercel.app/styling/themes.md · Index: https://svelte-lean.vercel.app/llms.txt

A theme is a set of token values on :root or on a subtree, with a built-in dark theme and no theme JavaScript, store or Provider.

## Palette

```svelte
<!-- Each color has six tones; data-color picks one palette for a subtree. -->
{#each ['primary', 'secondary', 'error', 'warning', 'info', 'success', 'neutral'] as color (color)}
	{#each ['main', 'light', 'dark', 'contrast-text', 'soft', 'soft-text'] as tone (tone)}
		<span style="background: var(--slean-{color}-{tone})"></span>
	{/each}
{/each}
```

main fills contained buttons and bars, dark is their hover, contrast-text the text on main, soft and soft-text the standard badges and alerts. On this site primary and error follow the site's own colors through the bridge below.

## Typography

```svelte
<!-- data-typography applies one step of the scale (a font shorthand token) to any element. -->
<h1 data-typography="h4">Invoices</h1>
<p data-typography="overline">Billing</p>
<p data-typography="subtitle1">Harbor Studio</p>
<p data-typography="body2">Paid on 2 October 2026.</p>
<p data-typography="caption">Updated a minute ago</p>
```

data-typography on any element applies one step. A step is three tokens a theme redefines (--slean-typography-h1-weight, -size, -line-height) and a font shorthand of them for your own rules; the family is --slean-font-sans, a family list or initial for the page's font.

## Elevation

```svelte
<!-- 0 none, 1 resting surfaces, 2 raised cards, 3 menus and popovers, 4 dialogs, 5 drawers
     and toasts. Components read them through their defaults (--slean-card-elevation, …). -->
{#each [0, 1, 2, 3, 4, 5] as level (level)}
	<div style="box-shadow: var(--slean-elevation-{level})">{level}</div>
{/each}
```

Cards rest on 1, menus and popovers on 3, dialogs on 4, drawers and toasts on 5; the dark theme deepens every shadow.

## Create a theme

A theme is one block of token overrides, unlayered so it wins over the package: the palette, `--slean-spacing`, `--slean-radius`, the typography scale, elevation and component defaults such as `--slean-button-radius`. The [token reference](https://svelte-lean.vercel.app/styling/tokens.md) lists every value.

```svelte
<script lang="ts">
	import './harbor.css'; // the block below
</script>

<div data-slean-theme="harbor">
	<button type="button" data-slean="button">Publish</button>
	<button type="button" data-slean="button" data-variant="outlined">Preview</button>
	<input data-slean="input" aria-label="Title" placeholder="Focus me" />
	<label><input type="checkbox" role="switch" data-slean="checkbox" checked /> Public</label>
	<span data-slean="badge" data-color="primary">Draft</span>
	<progress data-slean="progress" value="40" max="100" aria-label="Upload"></progress>
</div>
```

The block redefines the primary palette, the radius, the button shape and its casing; every component inside follows, focus rings included.

`harbor.css`

```css
/* harbor.css: one block of overrides. Here on a subtree (data-slean-theme); on :root it
 * themes the whole application. */
[data-slean-theme='harbor'] {
	--slean-primary-main: oklch(50% 0.11 195);
	--slean-primary-dark: oklch(43% 0.11 195);
	--slean-primary-contrast-text: oklch(100% 0 0);
	--slean-primary-soft: oklch(95% 0.03 195);
	--slean-primary-soft-text: oklch(38% 0.09 195);
	--slean-field-radius: 0.25rem;
	--slean-button-radius: 9999px;
	--slean-typography-button-transform: uppercase;
	--slean-card-elevation: var(--slean-elevation-0);
}

/* The same tokens for the dark theme, under the package's two dark conditions. */
@media (prefers-color-scheme: dark) {
	:root:not([data-theme='light'], .light) [data-slean-theme='harbor'] {
		--slean-primary-main: oklch(72% 0.11 195);
		--slean-primary-dark: oklch(65% 0.11 195);
		--slean-primary-contrast-text: oklch(18% 0.02 195);
		--slean-primary-soft: oklch(28% 0.05 195);
		--slean-primary-soft-text: oklch(86% 0.07 195);
	}
}
:is([data-theme='dark'], .dark) [data-slean-theme='harbor'] {
	--slean-primary-main: oklch(72% 0.11 195);
	--slean-primary-dark: oklch(65% 0.11 195);
	--slean-primary-contrast-text: oklch(18% 0.02 195);
	--slean-primary-soft: oklch(28% 0.05 195);
	--slean-primary-soft-text: oklch(86% 0.07 195);
}
```

The same block on `:root`, with every tone of the color and a dark palette:

`theme.css`

```css
/* A theme is one block of token overrides (packages/styles/README.md, "Create a theme").
 * Unlayered rules win over the package's layers; there is no JavaScript. */
:root {
	/* Brand color: the palette of one color has six tones. */
	--slean-primary-main: oklch(52% 0.16 160);
	--slean-primary-light: oklch(65% 0.13 160);
	--slean-primary-dark: oklch(45% 0.16 160);
	--slean-primary-contrast-text: oklch(100% 0 0);
	--slean-primary-soft: oklch(95% 0.03 160);
	--slean-primary-soft-text: oklch(40% 0.12 160);
	/* Shape, spacing, type */
	--slean-radius: 0.375rem;
	--slean-spacing: 0.25rem;
	--slean-font-sans: 'IBM Plex Sans', system-ui, sans-serif;
	--slean-typography-button-transform: uppercase;
	/* Component defaults */
	--slean-button-radius: 9999px;
	--slean-card-elevation: var(--slean-elevation-0);
}

/* Dark mode: the same tokens under the package's two dark conditions. */
@media (prefers-color-scheme: dark) {
	:root:not([data-theme='light'], .light) {
		--slean-primary-main: oklch(66% 0.14 160);
		--slean-primary-soft: oklch(28% 0.06 160);
		--slean-primary-soft-text: oklch(86% 0.08 160);
	}
}
[data-theme='dark'],
.dark {
	--slean-primary-main: oklch(66% 0.14 160);
	--slean-primary-soft: oklch(28% 0.06 160);
	--slean-primary-soft-text: oklch(86% 0.08 160);
}
```

### Overriding tokens

Tokens live in the `slean.tokens` layer, so any unlayered declaration wins. Redefine what you want on `:root`:

`app.css`

```css
/* A theme is a set of token values. Redefine them on :root to retheme every primitive. */
:root {
	--slean-primary-main: oklch(45% 0.13 150);
	--slean-primary-dark: oklch(40% 0.13 150);
	--slean-radius: 0.25rem;
	--slean-font-sans: 'IBM Plex Sans', system-ui, sans-serif;
}
```

### Color and density in the markup

`data-color` selects the current color (`--slean-color-main` and its five siblings) for an element and its subtree; every variant of every primitive is drawn from it. `data-density` changes the control heights of a whole region ([Variants](https://svelte-lean.vercel.app/styling/variants.md)).

`page.svelte`

```html
<!-- data-color picks the palette every variant is drawn from, on the element or a subtree. -->
<button type="button" data-slean="button" data-color="error">Delete</button>
<div data-color="success">
	<span data-slean="badge" data-color="success">Paid</span>
	<button type="button" data-slean="button" data-variant="outlined">Mark as sent</button>
</div>

<!-- data-density changes control heights and option rows for everything inside it. -->
<main data-density="compact">…</main>
```

### Theming a subtree

Put the overrides on any element and mark it with `data-slean-theme` (any value): the aliases derived from `--slean-primary-main` (focus ring, focused field border and halo, selected option, the current color) are recomputed only on theme scopes.

- Theme scopes: `data-slean-theme`, `data-theme`, the `light` and `dark` classes.
- A palette token (`--slean-primary-main`, the neutral scale, status colors) needs only `:root`; an alias such as `--slean-control-border` must also be overridden on the scope selectors.

## Dark theme

| Condition | Theme |
| --- | --- |
| Nothing on the root | The system (`prefers-color-scheme`) |
| `data-theme="light"` or `.light` on the root | Light |
| `data-theme="dark"` or `.dark` on any element | Dark for that subtree |

The light block and both dark blocks set the same tokens(59); the package's static check fails when the dark blocks differ.

`tokens.css (abridged)`

```css
/* 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 (the current color, borders, the focus ring) are derived again on every scope. */
	:where(:root, [data-theme], [data-slean-theme], .dark, .light) {
		--slean-color-main: var(--slean-primary-main);
		--slean-focus-ring-color: var(--slean-primary-main);
		/* … */
	}
}
```

### Following the system

The media query decides, so the first paint is in the right theme without JavaScript.

### Forcing a theme

Write `data-theme` on `<html>`; store it if it should survive a reload.

`app.html`

```html
<!-- Force a theme for the whole document; leave the attribute off to follow the system. -->
<html lang="en" data-theme="dark">
```

`theme.js`

```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). The `dark` and `light` classes work the same way, so mode-watcher and Tailwind's class-based dark variant need no bridge.

`app.html`

```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>
```

> **Warning** A class toggle that removes `dark` without adding `light` leaves the system in charge: on a dark system the primitives turn dark on a light page. Add `light` (or `data-theme="light"`) while the page is light.

### Bridging a design system

Map the tokens to your variables on `:root`, unlayered; your own light and dark values switch underneath. Map on the root, not on a wrapper without `data-slean-theme`, or aliases such as `--slean-option-selected` keep the root's palette. The option-row tokens in the sample match a listbox that marks the chosen row with its check alone.

`app.css`

```css
/* An application with its own design system (shadcn, Tailwind variables): map the palette,
   the aliases and the option rows once, on :root. Its own light and dark values switch under
   them, so nothing follows the system behind the application's back. */
:root {
	--slean-color-scheme: light;
	--slean-surface: var(--popover);
	--slean-fg: var(--foreground);
	--slean-fg-muted: var(--muted-foreground);
	--slean-border: var(--border);
	--slean-border-strong: var(--input);
	--slean-muted: var(--muted);
	--slean-primary-main: var(--primary);
	--slean-primary-soft: var(--accent);
	--slean-primary-soft-text: var(--accent-foreground);
	--slean-control-bg: var(--card);
	--slean-control-border: var(--input);
	--slean-option-active: var(--secondary);
	--slean-option-active-fg: var(--secondary-foreground);
	--slean-option-selected: var(--accent);
	--slean-option-selected-fg: var(--accent-foreground);
	--slean-option-selected-weight: 500;
	--slean-option-selected-ring: none;
	/* … the rest of the palette the primitives read … */
}
:root.dark {
	--slean-color-scheme: dark;
}
```

### Native parts

Each primitive sets `color-scheme` from `--slean-color-scheme`: dialog canvases and scrollbars follow the theme; the page's own `color-scheme` is untouched.

`base.css (excerpt)`

```css
/* 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, imported after the package. This one is ink on grey with tighter radii, like shadcn/ui's neutral theme:

`theme-neutral.css`

```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-primary-main: oklch(20.5% 0 0);
	--slean-primary-dark: oklch(30% 0 0);
	--slean-primary-contrast-text: oklch(98.5% 0 0);
	--slean-primary-soft: oklch(95% 0 0);
	--slean-primary-soft-text: oklch(20.5% 0 0);
	--slean-radius: 0.5rem;
}

@media (prefers-color-scheme: dark) {
	:root:not([data-theme='light'], .light) {
		--slean-primary-main: oklch(92% 0 0);
		--slean-primary-dark: oklch(85% 0 0);
		--slean-primary-contrast-text: oklch(20.5% 0 0);
		--slean-primary-soft: oklch(27% 0 0);
		--slean-primary-soft-text: oklch(92% 0 0);
	}
}

:root:is([data-theme='dark'], .dark) {
	--slean-primary-main: oklch(92% 0 0);
	--slean-primary-dark: oklch(85% 0 0);
	--slean-primary-contrast-text: oklch(20.5% 0 0);
	--slean-primary-soft: oklch(27% 0 0);
	--slean-primary-soft-text: oklch(92% 0 0);
}
```

## The site as an example

Every primitive on this site takes its palette from one unlayered block mapping the package tokens to the site's `--site-*` tokens; the package CSS is never edited (app.css).

`src/app.css (excerpt)`

```css
/* 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-primary-main: var(--site-accent);
	--slean-primary-dark: var(--site-accent-hover);
	--slean-primary-contrast-text: 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);
}
```

## Forced colors

Under `forced-colors: active` the platform replaces most colors; the tokens map the rest to system colors:

| Token | Value under forced colors |
| --- | --- |
| `--slean-focus-ring-color` | `Highlight` |
| `--slean-border` | `CanvasText` |
| `--slean-border-strong` | `CanvasText` |
| `--slean-elevation-1` | `none` |
| `--slean-elevation-2` | `none` |
| `--slean-elevation-3` | `none` |
| `--slean-elevation-4` | `none` |
| `--slean-elevation-5` | `none` |

Component files add buttons' `ButtonText` border and `GrayText` for disabled controls.

## Reduced motion

Transitions drop to zero; the loading indicator's `--slean-duration-indicator` grows longer instead, since a stopped ring reads as a stuck page. Components never write a raw duration (the static check rejects one).

| Token | Value under reduced motion |
| --- | --- |
| `--slean-duration-fast` | `0ms` |
| `--slean-duration-normal` | `0ms` |
| `--slean-duration-slow` | `0ms` |
| `--slean-duration-indicator` | `1600ms` |

`dialog.css (excerpt)`

```css
/* 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 (the circular progress, the busy button) reads its own token, which
 * reduced motion slows instead of zeroing. */
:where([data-slean='progress'][data-variant='circular']:indeterminate) {
	animation: slean-progress-rotate var(--slean-duration-indicator) linear infinite;
}
```

## Why there is no Provider

- Theme values are custom properties; the cascade delivers them.
- Behavior routing attaches to the document, not to component context.
- Trade-off: a theme computed from JavaScript values has to be written into custom properties by you.
