Overview

In Tailwind v4 the dark: variant uses the prefers-color-scheme media query by default; a @custom-variant dark declaration replaces that with a class or data attribute for a user-controlled toggle. The hub is tailwind; the token system is in tailwind-theme.

Keep the default when the OS setting is enough

With no configuration, dark:bg-gray-800 applies whenever the system reports dark. This needs no JavaScript and offers no in-page override.

Redefine dark for a user toggle

Use a class or a data attribute on <html>; pick one.

@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
/* or: @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); */

Use @custom-variant to define a variant; @variant only applies an existing one inside a rule.

Set the theme before first paint

Run a synchronous inline script in <head>. It supports three states: light, dark, and follow the system.

<script>
  document.documentElement.classList.toggle(
    "dark",
    localStorage.theme === "dark" ||
      (!("theme" in localStorage) && matchMedia("(prefers-color-scheme: dark)").matches),
  );
</script>

On a user choice, set localStorage.theme = "light" or "dark"; on “system”, call localStorage.removeItem("theme"). Always fall back to prefers-color-scheme for first-time visitors. Wrap storage access in try/catch; it throws in some private modes.

Put both palettes in tokens

Define light values in @theme and override them under the dark selector, so components use bg-surface and need no dark: prefix.

@theme {
  --color-surface: oklch(0.98 0 0);
  --color-ink: oklch(0.2 0 0);
}
.dark {
  color-scheme: dark;
  --color-surface: oklch(0.12 0 0);
  --color-ink: oklch(0.92 0 0);
}

color-scheme: dark makes form controls, scrollbars, and default canvas colors match. Keep dark: for values with no token, such as shadow-sm dark:shadow-none. A codebase with dark: on every color utility means the tokens are not doing their job.

Keep theme-color in sync

Browsers use <meta name="theme-color"> to tint their chrome. The media attribute follows only the OS setting, so it ignores a manual toggle.

<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0b0b0c" media="(prefers-color-scheme: dark)" />

When users can override the theme, update a single theme-color tag from the toggle handler instead. Support for this tag is not yet Baseline across all engines, so treat it as an enhancement.

Check contrast in both modes

Each foreground and background pair needs 4.5:1 for body text and 3:1 for large text and UI borders (WCAG 2.2 SC 1.4.3 and 1.4.11). Check the dark palette separately; see accessibility.