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.116 0.003 265); /* #050506 — the page */
  --grey-50: oklch(0.181 0.003 265); /* #111213 — cards */
  --grey-100: oklch(0.222 0.003 265); /* #1a1b1c — popovers */
  --grey-200: oklch(0.262 0.003 265);
  --grey-300: oklch(0.318 0.003 265); /* the hairline */
  --grey-400: oklch(0.408 0.003 265);
  --grey-500: oklch(0.525 0.003 265);
  --grey-600: oklch(0.642 0.003 265);
  --grey-700: oklch(0.755 0.003 265); /* #aeafb1 — secondary text */
  --grey-800: oklch(0.852 0.002 265);
  --grey-900: oklch(0.922 0.002 265);
  --grey-1000: oklch(0.975 0.001 265); /* #f6f7f7 — the ink */

  /* The aliases that genuinely differ between themes. */
  --card: var(--grey-50);
  --popover: var(--grey-100);
  --subtle: var(--grey-50);
  --selection: oklch(0.975 0.001 265 / 0.18);
}

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. Note how far the first three steps are pushed here in particular: the page is nearly black, and a card that only just clears it disappears.

It goes to the picture, not to a colour. --grey-0 resolves to #050506, which is the exact black the masthead's one-bit plate is printed on. That is the reason this ramp is neutral rather than the warm charcoal it used to be: a photograph or a dither arrives on true black, and a page built on a browner black turns every full-bleed image into a visible panel sitting on top of the site instead of part of it. Pick the surface your imagery already lives on, then build the scale up from there.

The usual warning about pure black — that a large surface of it reads as a hole and a hairline on it reads as a scratch — is answered by the spacing rather than by lifting the floor. The hairline sits at #313234, three full steps clear of the page. The ink end still stops short of white, at #f6f7f7, because that part of the problem is real in both directions.

It carries a trace of chroma, not a hue. 0.003 across the whole scale, at a blue angle — far too little to read as a tint, just enough that the greys are not clinically dead. The light theme stays warm paper and the dark theme is neutral; those are two rooms, not two 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.