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

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’s theme prop and the 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:

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

Show code
<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.

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