Dark mode

One class on <html> drives the whole system. Components never use dark: prefixes — they reference semantic tokens that flip underneath them.

How it works

globals.css registers a custom variant:

styles/globals.css
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant dark (&:where(.dark, .dark *));

Then theme.css redefines the semantic layer inside a .dark rule. A component that says bg-background-full gets the right surface in either theme without knowing a theme exists.

The toggle component

theme-toggle (https://boardcn.dev/r/theme-toggle.json) is a switch that reveals the next theme from the exact point you clicked, using a view transition. It persists to localStorage under boardcn:theme and does not depend on the system theme.

components/header.tsx
import { ThemeToggle } from "@/components/blocks/theme/theme-toggle";

export function Header() {
  return <ThemeToggle />;
}
import { ThemeToggle } from "@/components/blocks/theme/theme-toggle";

export function Header() {
  return <ThemeToggle />;
}

To drive the theme from your own control instead:

tsx
import { applyTheme } from "@/components/blocks/theme/theme-toggle";

applyTheme("dark");   // adds .dark, persists, and notifies listeners
applyTheme("light");
import { applyTheme } from "@/components/blocks/theme/theme-toggle";

applyTheme("dark");   // adds .dark, persists, and notifies listeners
applyTheme("light");

Avoiding the flash

If the theme is applied after hydration, the page paints light and then flips. Run a blocking script in <head> so the class is set before first paint — this site does exactly this:

app/layout.tsx
<script
  dangerouslySetInnerHTML={{
    __html: `try{var t=localStorage.getItem("boardcn:theme");
if(t==="dark"||(t===null&&matchMedia("(prefers-color-scheme: dark)").matches)){
  document.documentElement.classList.add("dark")
}}catch(e){}`,
  }}
/>
<script
  dangerouslySetInnerHTML={{
    __html: `try{var t=localStorage.getItem("boardcn:theme");
if(t==="dark"||(t===null&&matchMedia("(prefers-color-scheme: dark)").matches)){
  document.documentElement.classList.add("dark")
}}catch(e){}`,
  }}
/>

Read the same boardcn:theme key the toggle writes, so the two never disagree. Add suppressHydrationWarning to <html>, since the script mutates it before React sees it.

Details worth knowing

  • The toggle dispatches a boardcn:theme-change event, so other components can react without prop drilling.
  • Native view-transition crossfades are disabled deliberately; only the circular clip-path reveal runs. Browsers without support just switch instantly.
  • Chart colors are theme-aware through the same token layer — see Colors.