Dark mode
Light, dark, or as the device is set. The reader's choice is kept in the browser and is on the page before the first paint, with no flash and no hydration warning.
How the theme is applied#
The dark theme is the dark class on <html>. cennge-ui/styles.css declares the dark theme's values under .dark, and makes Tailwind's dark: variant follow that class instead of the system setting. Each theme also sets color-scheme, so native scrollbars and form controls match it.
Three pieces put the class there and keep it right:
ThemeScript, a small script in the<head>, reads the stored choice and sets the class before the page paints.useThemegives your components the choice and a way to change it.ThemeSwitchis a ready light, dark and system control built onuseTheme.
Try it#
This switch changes the whole page, and the site's own switch follows; so does any other tab with these docs open. Choose System, then change your device's appearance: the page follows that too.
Appearance
Light, dark, or as your device is set.
- theme
- undefined
- resolvedTheme
- undefined
import { Card, CardBody, ThemeSwitch, useTheme } from "cennge-ui";
// The same options everywhere: ThemeScript in the layout, this switch, every useTheme.
const themeOptions = { defaultTheme: "dark" } as const;
export default function Appearance() {
const { theme, resolvedTheme } = useTheme(themeOptions);
return (
<Card className="w-full max-w-md">
<CardBody className="gap-4 py-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="font-semibold">Appearance</p>
<p className="text-sm text-muted">Light, dark, or as your device is set.</p>
</div>
<ThemeSwitch {...themeOptions} size="md" />
</div>
<dl className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-medium bg-surface-2 px-3 py-2">
<dt className="text-xs text-muted">theme</dt>
<dd className="font-mono">{String(theme)}</dd>
</div>
<div className="rounded-medium bg-surface-2 px-3 py-2">
<dt className="text-xs text-muted">resolvedTheme</dt>
<dd className="font-mono">{String(resolvedTheme)}</dd>
</div>
</dl>
</CardBody>
</Card>
);
}theme is what was chosen; resolvedTheme is what shows, with "system" worked out. Both are undefined in the server's HTML and while the page hydrates, and the switch has nothing selected until then. With the keyboard, Tab reaches the chosen option, the arrow keys move between options, and Space or Enter chooses.
Next.js#
Put ThemeScript in the <head> of the root layout, and give <html> suppressHydrationWarning:
// app/layout.tsx
import { ThemeScript } from "cennge-ui";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<ThemeScript defaultTheme="dark" />
</head>
<body>{children}</body>
</html>
);
}The server cannot know a choice kept in the browser, so it renders <html> without the class. The script adds it before the first paint, which is also before React hydrates, and React would report the extra class as a mismatch. suppressHydrationWarning covers that element's own attributes only: a real mismatch anywhere inside the page is still reported.
The script also stays on the page: while the choice is "system" it follows the device as its setting changes, and it applies a choice made in another tab.
Vite and other apps without a server#
With nothing rendered on a server, call initTheme once before the first render. It does what ThemeScript does: it sets the stored theme, then follows the device and other tabs.
// src/main.tsx
import { createRoot } from "react-dom/client";
import { initTheme } from "cennge-ui";
import { App } from "./App";
import "./index.css";
initTheme({ defaultTheme: "dark" });
createRoot(document.getElementById("root")!).render(<App />);initTheme runs when your bundle does. Until then the page shows the class written in index.html, so put class="dark" on its <html> if your default is dark.
For another server, such as one that renders HTML templates, themeScript(options) returns the same script as a string to put in a <script> in the <head>.
A control of your own#
useTheme takes the same options and returns three things:
theme- What was chosen:
"light","dark"or"system".undefinedon the server and while the page hydrates. resolvedTheme- What shows:
"light"or"dark", read from the class on<html>.undefineduntil hydrated, too. setTheme(theme)- Stores the choice and shows it at once, in every tab, and in every
useThemeand ThemeSwitch on the page.
The undefined is what keeps hydration clean: the render that hydrates the page matches the server's HTML, then the real values arrive. So draw what depends on the theme with CSS where you can. This toggle picks its icon with dark:hidden and dark:block, and adds aria-pressed only once the theme is known:
import { Button, useTheme } from "cennge-ui";
const icon = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
} as const;
export default function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme({ defaultTheme: "dark" });
return (
<div className="flex w-full max-w-md items-center justify-between gap-4 rounded-large bg-surface-3 py-2 pr-2 pl-4">
<span className="font-semibold">payload-cache</span>
<Button
isIconOnly
variant="light"
aria-label="Dark theme"
// No attribute until the theme is known, after hydration.
aria-pressed={resolvedTheme === undefined ? undefined : resolvedTheme === "dark"}
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
>
{/* CSS picks the icon, so the server's HTML is already right. */}
<svg {...icon} className="size-5 dark:hidden">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
<svg {...icon} className="hidden size-5 dark:block">
<path d="M20.5 14.1A8.5 8.5 0 1 1 9.9 3.5a6.6 6.6 0 0 0 10.6 10.6Z" />
</svg>
</Button>
</div>
);
}A two-way toggle like this one stores "light" or "dark" and so leaves "system" behind; ThemeSwitch keeps all three.
Options#
ThemeScript, initTheme, useTheme and ThemeSwitch take the same two options. Pass the same values to all of them: a different defaultTheme in one place would make the script and the switch disagree on a first visit.
defaultTheme: what shows before anything is chosen."system"unless you set it; this site uses"dark".storageKey: where the choice is kept inlocalStorage."cennge-theme"unless you set it.
Where storage is blocked, in some private windows and sandboxed frames, the switch still works for the life of the page; the choice is not remembered after a reload.
ThemeScript also takes a nonce, for a Content-Security-Policy that allows inline scripts by nonce. ThemeSwitch takes size and labels, the names screen readers hear, for your language:
<ThemeSwitch
defaultTheme="dark"
labels={{
group: "Thème",
light: "Clair",
dark: "Sombre",
system: "Système",
}}
/>Without the helpers#
The styles only need the dark class, so you can also set it yourself, from your own settings or from the server when you know the reader's choice. If your app already uses next-themes, give it attribute="class".