Skip to content
Join UI

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-themes

Add the provider

components/theme-provider.tsx
"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

app/layout.tsx
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.

app/globals.css
.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
<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:

html
<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:

tsx
<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:

components/theme-toggle.tsx
"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 dark on the root.
  • Set themeColor for both schemes in your Next.js viewport export.
  • Re-check --muted-foreground contrast in dark mode; it is the token most often left too dim.
  • Check the low end of the ramp on a real screen. --background, --subtle and --muted sit 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.