Skip to content
UI

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.