Dark mode
How the theme switches, how to force light in a region, and how components follow.
The contract
Dark mode is the .dark class on <html>. That class is the whole contract between the design
system and whatever manages the theme: next-themes, a script, a server-rendered cookie. Every
token has a dark value, so components need no dark: classes of their own for color.
With next-themes
import { ThemeProvider } from "next-themes"
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<App />
</ThemeProvider>
disableTransitionOnChange stops every transition from animating during the switch.
Without a provider
Set the class before the first paint, so the page never flashes the wrong theme:
<script>
const stored = localStorage.getItem("theme")
const dark =
stored === "dark" ||
(stored !== "light" && matchMedia("(prefers-color-scheme: dark)").matches)
document.documentElement.classList.toggle("dark", dark)
</script>
This site does exactly that.
Forcing light in a region
Mark a subtree .force-light and it renders with the light palette even inside .dark. The
auth screens use it. The dark: variant is also disabled inside the region.
<div className="force-light bg-background">...</div>
Reading the theme in JavaScript
useColorScheme() returns "light" or "dark" from the class on <html> and updates when it
changes. The Toaster uses it; use it when a third-party widget needs the
theme as a prop.
import { useColorScheme } from "@zenoxai/ui/hooks/use-color-scheme"
const scheme = useColorScheme()
The dark: variant
Prefer tokens. Reach for dark: only for an adjustment that is not a color role, such as a
different opacity on a decorative image.