Theming
Three states, not two. “Follow the OS” is the setting most people actually want, and a two-way toggle silently takes it away the first time it is clicked.
Try it
This toggle is the real component; it changes this page.
Light · Dark · System
How it resolves
An explicit choice always beats the OS. Nothing is written to the DOM for “system”, so the media query takes over again.
:root /* light — the default */
@media (prefers-color-scheme: dark)
:root:not([data-theme="light"]) /* OS dark, unless overridden */
:root[data-theme="dark"], [data-theme="dark"] /* explicit dark, wins */
:root[data-theme="light"], [data-theme="light"] /* explicit light, wins */Pinning a subtree
Because the selectors match any element and not only :root, a container can pin its contents to a theme. That is how the previews on this site show both at once — and it is the only honest way to check contrast, since a component can look fine in the theme you are in and be unreadable in the other.
<div data-theme="dark">
{/* renders dark whatever the page is doing */}
</div>light
Same markup
No conditional styling.
RUNNING
dark
Same markup
No conditional styling.
RUNNING
Rules
- Render
<ThemeScript />in<head>. Without it the page paints light and corrects on hydration. - Every
localStorageaccess is wrapped in try/catch. A private window or blocked site data throws on read, and a theme toggle must never be the thing that takes a page down. - Use
dark:variants sparingly — for genuine one-offs like swapping a logo asset. If you are reaching for it often, the token you want is missing.