# Dark mode

> How the page and any subtree pick light or dark, the before-paint script, the toggle helper, and what to watch in dark.

URL: https://apogee.avaui.dev/docs/dark-mode
Last updated: 2026-10-20

How the page and any subtree pick light or dark, the before-paint script, the toggle helper, and what to watch in dark.

Last updated October 20, 2026

Dark mode is a token override. `data-theme="dark"` on `<html>`, or on any element, swaps every colour token for that subtree. Components never check the mode; they read tokens.

## How it works

-   `registry/tokens/theme.css` holds two generated blocks, `:root, [data-theme="light"]` and `[data-theme="dark"]`, both built from the same hue inputs. A brand change updates both.
-   `build:tokens` also copies the dark block under `@media (prefers-color-scheme: dark)` for `:root:not([data-theme])`. With no attribute on `<html>`, the page follows the system setting, without JS. The `dark:` variant follows it too.
-   The attribute works on any element, so one band of a light page can be dark. [Section](https://apogee.avaui.dev/docs/section)’s `theme` prop and the [Theme](https://apogee.avaui.dev/docs/theme) component set it for you.
-   A `data-theme` attribute always wins over the system setting, on `<html>` or on a subtree.

## Set it before paint

Only needed when visitors can pick a mode. Put this inline in `<head>`, before any stylesheet paints, so a stored choice applies without a flash. With nothing stored the page follows the system setting:

```html
<script is:inline>
  const t = localStorage.getItem('apogee-theme')
  if (t) document.documentElement.dataset.theme = t
</script>
```

## Toggle

`utils/theme.ts` exports `getTheme()`, `setTheme(mode, { persist })` and `toggleTheme()`. `setTheme` stores the choice under `apogee-theme`, turns transitions off for two frames so nothing animates the swap, re-resolves inverted regions, and dispatches `apogee:theme-change` (`detail: { mode }`) on `document`.

```astro
<Button data-apogee-theme="toggle">Toggle theme</Button>
<script>
import { toggleTheme } from '@/components/apogee/utils/theme.ts'
document.querySelector('[data-apogee-theme="toggle"]').addEventListener('click', () => toggleTheme())
</script>
```

## System preference

With no stored choice, the before-paint script follows the operating system once per load. To follow it live, listen for changes and call `setTheme(mode, { persist: false })` so the system setting is not saved as a choice.

## Inverted regions

`<Theme mode="inverted">` takes the opposite of its nearest themed ancestor, and `setTheme` keeps it opposite after every toggle. See [Theme](https://apogee.avaui.dev/docs/theme).

## What changes in dark

-   Elevation steps up in lightness: base, raised and overlay surfaces get lighter, and keep a drop shadow in a deeper black so it still reads on a dark page.
-   A grey page (`neutral-faded`) behind a card washes out to the card’s own lightness in dark mode, so AuthShell keeps the page colour there and the card stays lighter than what it sits on.
-   The dialog backdrop (`--overlay`) darkens from 50% to 65%.
-   `bg-inverted` flips: dark on a light page, light on a dark one.
-   Images that blend into the page need `outline` on [Image](https://apogee.avaui.dev/docs/image) to keep an edge.
-   The `dark:` variant matches any `[data-theme="dark"]` ancestor. Inside a light region nested in a dark one it still applies, so prefer tokens (`bg-page`, `text-neutral`), which follow the nearest region.

## Do / Don’t

-   **Do** check both themes; `npm run check:contrast` tests every text and focus pair in each.
-   **Do** use tokens for every colour, so the dark block reaches it.
-   **Don’t** hard-code `white` or `black`; use `--on-{hue}` or `bg-page`.
-   **Don’t** put the page toggle inside an inverted region, where its own colours flip against the rest of the page.
