# Theming

> Change the brand colours, switch between light and dark, set density, and see exactly what createTheme guarantees.

Source: https://platform.azmara.io/docs/guide/theming

`@azmr/ui` is themed with CSS custom properties: the [design tokens](https://platform.azmara.io/docs/guide/tokens.md). You can override a token in plain CSS, or call `createTheme` to derive a whole accessible set from your brand colours. The examples with an `// =>` line on this page are run by the docs tests, so the output shown is what the package returns.

## Change the brand colour with createTheme

```ts
import { createTheme } from "@azmr/ui/theme";

const theme = createTheme({ primary: "#1E88E5", radius: "0.5rem" });
const changed = Object.keys(theme.cssVariables);
// => ["--primary","--primary-foreground","--ring","--sidebar-primary","--sidebar-primary-foreground","--sidebar-ring","--radius"]
```

Setting `primary` sets six variables: the colour, the text on it, the focus ring, and the same three for the sidebar. Each colour value is a `light-dark()` pair, so it follows the page's mode. These are the options:

| Prop          | Type                                                    | Default | Required | Description                                                                                                                                                                                              |
| ------------- | ------------------------------------------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `primary`     | `string \| Oklch \| undefined`                          |         | No       | Brand colour as `#rgb`, `#rrggbb`, `oklch(L C H)` or `{ l, c, h }`.                                                                                                                                      |
| `secondary`   | `string \| Oklch \| undefined`                          |         | No       | Secondary surfaces (secondary Button, Badge). Same accepted formats as `primary`.                                                                                                                        |
| `success`     | `string \| Oklch \| undefined`                          |         | No       | Status colours. Each is derived so its foreground is AA on it in both modes, and so it stays readable as text on the page and on cards (`Text tone`, form errors). `destructive` is the "danger" colour. |
| `warning`     | `string \| Oklch \| undefined`                          |         | No       |                                                                                                                                                                                                          |
| `info`        | `string \| Oklch \| undefined`                          |         | No       |                                                                                                                                                                                                          |
| `destructive` | `string \| Oklch \| undefined`                          |         | No       |                                                                                                                                                                                                          |
| `radius`      | `string`                                                |         | No       | `rem` or `px`, e.g. `0.5rem`.                                                                                                                                                                            |
| `font`        | `string`                                                |         | No       | A CSS font-family list, e.g. `Inter, "Segoe UI", sans-serif`.                                                                                                                                            |
| `density`     | `"compact" \| "comfortable" \| "spacious" \| undefined` |         | No       |                                                                                                                                                                                                          |

Every option is optional and keeps its default when left out. Colours are `#rgb`, `#rrggbb`, `oklch(L C H)` or `{ l, c, h }`.

### What it guarantees

The colour you pass is a starting point. `createTheme` moves its lightness, and picks white or near-black text, until these hold in both light and dark mode:

- Each `-foreground` is at least **4.5:1** on its colour, **including on a solid button's hover surface**. A button blends with the page on hover, which can pull a colour that only just passes below 4.5:1, so the derivation checks the blended colour too.
- `success`, `info` and `destructive` are also readable as **text** on the page and on cards (`Text tone`, form errors). `warning` is a yellow surface, so it gets a separate, darker `--warning-text` for text.
- The focus ring is at least **3:1** on the page and on cards.

Because of this, the result can differ from your input. The light-mode primary above is darker than `#1E88E5`, so that white text on it stays readable when the pointer is over it. A very light brand colour such as `#FFEB3B` goes the other way and gets dark text:

```ts
const theme = createTheme({ primary: "#FFEB3B" });
const foreground = theme.cssVariables["--primary-foreground"];
// => "light-dark(oklch(0.145 0 0), oklch(0.145 0 0))"
```

Print `theme.cssVariables` to see what you get. The package's tests check these ratios for a range of brand colours, and the [token reference](https://platform.azmara.io/docs/guide/tokens.md#contrast-the-tests-guarantee) lists the ratios for the default tokens.

### Invalid input

`createTheme` validates every option and throws an `Error` that names it, because its output is CSS:

```ts
let message;
try {
  createTheme({ primary: "blue" });
} catch (error) {
  message = error.message;
}
// => "createTheme: primary must be #rgb, #rrggbb, oklch(L C H) or { l, c, h }; got \"blue\""
```

`radius` must be a number in `rem` or `px`, `font` must be a short font-family list (letters, digits, spaces, commas, quotes, dots, hyphens and underscores, 200 characters at most), and `density` must be `compact`, `comfortable` or `spacious`.

## Apply the theme

A theme is a set of custom properties, so apply it to `<html>`, or to any element to theme only what is inside it:

```tsx
import { createTheme } from "@azmr/ui/theme";

const theme = createTheme({ primary: "#1E88E5", destructive: "#D32F2F" });

// Whole app: the variables as a style attribute, density as data-density.
<html lang="en-NZ" style={theme.cssVariables} {...theme.htmlAttributes}>

// One section only.
<section style={createTheme({ primary: "#7B1FA2" }).cssVariables}>…</section>
```

You can also emit the variables as CSS:

```ts
const theme = createTheme({ radius: "0.5rem", font: "Inter, sans-serif" });
const css = theme.css;
// => ":root {\n  --radius: 0.5rem;\n  --font-sans-stack: Inter, sans-serif;\n}"
```

Prefer the `style` attribute: an inline style always wins. If you output `theme.css` in a `<style>` tag, put it **after** the `@azmr/ui` stylesheet. The tokens' own scopes (`.dark`, `[data-theme]`) have the same specificity as `:root`, so for the same element the later rule wins.

A theme's colours set on an outer element do not reach inside a nested `data-theme` or `.dark` scope, because that scope re-declares every colour token. Put the theme's variables on the scope element itself, or inside it.

To change one token by hand, override it for light and for dark:

```css
:root {
  --primary: oklch(0.55 0.2 260);
}

.dark,
[data-theme="dark"] {
  --primary: oklch(0.75 0.15 260);
}
```

## Dark mode

By default the page follows the operating system. The colours use OKLCH with `light-dark()` and `color-scheme: light dark`, so there is no JavaScript and no flash.

To force a mode, put `class="dark"` or `class="light"`, or `data-theme="dark"` or `data-theme="light"`, on `<html>` or on any element. Scopes nest: a `data-theme="light"` panel inside a dark page is light.

```html
<html data-theme="dark">
  <body>
    <aside data-theme="light">Always light, even on a dark page.</aside>
  </body>
</html>
```

Each scope declares every colour token explicitly instead of relying on `light-dark()` alone. Some bundlers (Next.js does, for older browser targets) rewrite `light-dark()` into variables that resolve where they are declared, which would stop a nested scope from changing anything. Declaring the colours in each scope works however the CSS is processed, and a test keeps those copies identical to `:root`.

### Remember the choice without a flash

Store the preference in a cookie and render it into `<html>` on the server, so the first paint is already right. This needs no inline script, so it works under a strict Content-Security-Policy:

```ts
import { htmlAttributes } from "@azmr/ui/theme";

const attributes = htmlAttributes.fromCookies("azmr-theme=dark; azmr-density=compact");
// => {"data-theme":"dark","data-density":"compact"}
```

Only known values are accepted. Anything else in the cookie is ignored, and `system` needs no attribute at all:

```ts
const ignored = htmlAttributes.fromCookies("azmr-theme=evil; azmr-density=huge");
// => {}
```

`ThemeProvider` and `useTheme()` change the theme and density on the client and write the cookie; see [Theme, density and locale](https://platform.azmara.io/docs/packages/ui.md#theme-density-and-locale) for a Next.js layout. A static site that cannot read cookies on the server can inline `themeInitScript()` instead; give the `<script>` tag your CSP nonce.

## Density

Density changes control heights, horizontal padding, gaps and row height together. Set `data-density="compact"` or `data-density="spacious"` on `<html>` or on any element; comfortable is the default and needs no attribute.

```html
<div data-density="compact">
  <!-- Buttons, inputs and table rows in here are compact. -->
</div>
```

The [token reference](https://platform.azmara.io/docs/guide/tokens.md#density-levels) lists the four values at each level. `createTheme` can set it too, through `htmlAttributes`:

```ts
const compact = createTheme({ density: "compact" }).htmlAttributes;
const comfortable = createTheme({ density: "comfortable" }).htmlAttributes;
const attributes = { compact, comfortable };
// => {"compact":{"data-density":"compact"},"comfortable":{}}
```

## Reduced motion

When the operating system asks for reduced motion (`prefers-reduced-motion: reduce`), every `--motion-duration-*` token collapses to `0.01ms` and the stagger step to `0ms`. A safety net also shortens other animations and transitions on the page. Reduced motion follows the system setting; the tokens have no per-subtree switch.

Mark motion that is genuinely essential, such as a spinner that shows work is happening, with `data-motion="essential"` to keep it. See [Motion](https://platform.azmara.io/docs/components/motion.md#tokens) for the motion tokens and what each component does.
