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.
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.cssholds two generated blocks,:root, [data-theme="light"]and[data-theme="dark"], both built from the same hue inputs. A brand change updates both.build:tokensalso 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. Thedark:variant follows it too.- The attribute works on any element, so one band of a light page can be dark. Section’s
themeprop and the Theme component set it for you. - A
data-themeattribute 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 codeHide 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-invertedflips: dark on a light page, light on a dark one.- Images that blend into the page need
outlineon 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:contrasttests every text and focus pair in each. - Do use tokens for every colour, so the dark block reaches it.
- Don’t hard-code
whiteorblack; use--on-{hue}orbg-page. - Don’t put the page toggle inside an inverted region, where its own colours flip against the rest of the page.