Dark mode
Theme switching in your project, and scoped theme islands.
Dark mode is a token swap, not a second set of styles.
This site renders dark only and has no switcher — it sets class="dark" on
<html> and stops there. This guide is for your project, which probably does
need one, and for understanding why a Join UI component works in it without
carrying a single dark: utility.
Setup
Install next-themes
pnpm add next-themesAdd the provider
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
value={{ light: "light", dark: "dark" }}
>
{children}
</NextThemesProvider>
)
}value matters: it makes the root element carry light or dark, never
neither. The token layer keys off both classes.
Wrap the app
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}suppressHydrationWarning belongs on html only. next-themes writes the
class before paint to avoid a flash, so the server and client markup
legitimately differ on that one element.
Declare the dark tokens
Dark mode substitutes a second neutral ramp running the same direction:
--grey-0 is the page in both themes and --grey-1000 is the ink in both.
Because the semantic tokens are aliases onto that ramp, most of them need
no restating at all.
.dark {
--grey-0: oklch(0.178 0.004 58); /* #13110f — the page */
--grey-50: oklch(0.228 0.005 58); /* #1e1c1a — cards */
--grey-100: oklch(0.268 0.006 58); /* #282523 — popovers */
--grey-200: oklch(0.302 0.006 58);
--grey-300: oklch(0.352 0.007 58); /* the hairline */
--grey-400: oklch(0.435 0.008 56);
--grey-500: oklch(0.545 0.008 56);
--grey-600: oklch(0.655 0.008 58);
--grey-700: oklch(0.762 0.007 60); /* #b5b1ad — secondary text */
--grey-800: oklch(0.855 0.006 62);
--grey-900: oklch(0.923 0.005 65);
--grey-1000: oklch(0.965 0.004 70); /* #f5f3f1 — the ink */
/* The aliases that genuinely differ between themes. */
--card: var(--grey-50);
--popover: var(--grey-100);
--subtle: var(--grey-50);
--selection: oklch(0.965 0.004 70 / 0.16);
}Three things about this ramp are worth copying if you build your own.
It is not a mirror image of the light one. Dark surfaces need their low steps spaced further apart than a straight inversion would give, or the first three collapse into one flat block.
It stops well short of black. --grey-0 resolves to #13110f, not
#000000 — on a large surface pure black reads as a hole punched through
the screen, and a hairline drawn on it reads as a scratch. The ink end stops
short of white for the same reason, at #f5f3f1.
It keeps the light ramp's warmth. The hue angle drops from 76 to 60 and
the chroma roughly halves, which is enough to stay warm without going muddy
— dark neutrals hold far less chroma before they read as brown. A cooler
slate would have been easier, but the two themes would then feel like two
different products.
The --card and --popover aliases flip because the rule they encode is
directional, not absolute: a raised surface sits one step above the page.
In the light theme that step is upward into white; here it is upward out of
the deepest charcoal. It is the same cue in both, and with no accent colour
in the system it is what marks an active sidebar row, a selected tab and a
card as raised.
Why components avoid the dark: variant
Not one component in registry/components/ uses a dark: utility. That is a
deliberate constraint, and this is the reason.
Consider a light-mode preview embedded in a dark page:
<html class="dark">
<div class="light"> <!-- the preview island -->
<button class="bg-card text-foreground">Click</button>
</div>
</html>bg-card compiles to background-color: var(--card). The button's nearest
ancestor that declares --card is .light, so it resolves to the light value.
Correct, with no extra work — custom-property inheritance already implements
"nearest wins".
Now the same markup written with variants:
<button class="bg-white dark:bg-neutral-900">Click</button>The dark: variant matches on .dark *, and html.dark is still an ancestor.
The button renders dark inside a light island. CSS has no "nearest ancestor
wins" selector, so there is no clean way to fix this at the variant level.
Theme islands
Because of the above, scoping a theme is one class:
<div className="dark" style={{ colorScheme: "dark" }}>
<YourComponent />
</div>colorScheme is worth setting alongside the class — it is what tells the
browser to render native scrollbars, form controls and autofill in the matching
theme.
Nothing re-renders and no stylesheet is swapped — the only thing that changed is which ancestor declares the variables, and every utility inside follows.
This site used to put a sun/moon switch on each component preview to show it off.
That is gone along with the light theme, but the mechanism is the reason the
constraint above is worth keeping: a component that themes purely through tokens
drops into your light project and works, with no dark: branch to get wrong.
Building a theme toggle
Rendering depends on the resolved theme, which is unknown during SSR. Render a stable placeholder until mount rather than suppressing the warning:
"use client"
import * as React from "react"
import { useTheme } from "next-themes"
import { Moon, Sun } from "lucide-react"
export function ThemeToggle() {
const { setTheme, resolvedTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => setMounted(true), [])
const Icon = !mounted ? Sun : resolvedTheme === "dark" ? Moon : Sun
return (
<button
type="button"
aria-label={`Switch to ${resolvedTheme === "dark" ? "light" : "dark"} theme`}
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
>
<Icon aria-hidden="true" className={mounted ? undefined : "opacity-0"} />
</button>
)
}Offer system as well as light and dark. Users who set a schedule at the OS level expect an app to follow it.
Checklist
- Tell the browser both themes exist:
color-scheme: light darkon the root. - Set
themeColorfor both schemes in your Next.jsviewportexport. - Re-check
--muted-foregroundcontrast in dark mode; it is the token most often left too dim. - Check the low end of the ramp on a real screen.
--background,--subtleand--mutedsit within a few percent of each other in dark mode, and a panel that is legible on a laptop can vanish on a dim display. - Remember that separation is drawn, not lit. With no shadow tokens in the system, a surface that needs to read as distinct needs a rule around it.