---
title: "Tailwind v4: Dark Mode"
slug: "tailwind-dark-mode"
category: "frontend"
tags: ["tailwind", "css", "frontend", "dark-mode", "theming", "accessibility", "custom-properties"]
status: "stable"
last_updated: 2026-05-14
summary: "Pick class or media strategy once, persist the preference, provide a system fallback, and wire the theme-color meta tag."
related: ["[[frontend/tailwind]]", "[[frontend/css]]", "[[frontend/tailwind-theme]]", "[[frontend/css-custom-properties]]", "[[frontend/accessibility]]"]
---

## Overview

Tailwind v4 ships two dark mode strategies: `class` and `media`. Choose one per project, wire it at the root level, and let `dark:` variants do the rest. The parent reference for Tailwind configuration is [[frontend/tailwind]]; for the token system that powers theme switching, see [[frontend/tailwind-theme]].

## Pick `class` strategy for user-controlled dark mode

Class strategy toggles dark mode by adding or removing the `dark` class on the `<html>` element. Use this when users should be able to override the system preference.

```css
@import "tailwindcss";

@variant dark (&:where(.dark, .dark *));
```

Wire a small inline script in the document `<head>` to avoid a flash of wrong theme:

```html
<script>
  const saved = localStorage.getItem("theme");
  const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
  if (saved === "dark" || (!saved && prefersDark)) {
    document.documentElement.classList.add("dark");
  }
</script>
```

The script runs synchronously before paint. No framework event loop means no flash.

## Pick `media` strategy for system-preference-only dark mode

Media strategy uses `@media (prefers-color-scheme: dark)` and needs no JavaScript toggle. Use this when simplicity matters more than user override.

```css
@import "tailwindcss";

@variant dark (@media (prefers-color-scheme: dark));
```

`dark:` variants apply whenever the system reports dark preference. There is no way for users to override this without using an OS-level setting.

## Define both light and dark token values in `@theme`

Keep light and dark colors together in the token file. Use a `[data-theme]` attribute pattern for the class strategy or a media query for the media strategy.

```css
@theme {
  --color-surface: oklch(0.98 0 0);
  --color-ink: oklch(0.2 0 0);
  --color-brand: oklch(0.72 0.18 264);
}

.dark {
  --color-surface: oklch(0.12 0 0);
  --color-ink: oklch(0.92 0 0);
  --color-brand: oklch(0.78 0.18 264);
}
```

Tailwind utilities like `bg-surface` and `text-ink` read the token at render time. Dark mode is one block of token overrides, not a parallel set of `dark:bg-*` classes everywhere.

## Use `dark:` variants only for values that do not have a token

After defining light and dark tokens, you rarely need `dark:` variants on individual utilities. Reserve `dark:` for values that cannot be tokenized: opacity adjustments, shadow changes, or hover-state differences between themes.

```html
<!-- Token does the work; no dark: variant needed -->
<div class="bg-surface text-ink">

<!-- dark: variant for a case without a direct token -->
<div class="shadow-sm dark:shadow-none">
```

A codebase with `dark:` on every color utility is a sign that the token system is not doing its job.

## Persist the user preference in `localStorage`

For class-based dark mode, persist the preference so it survives page reloads.

```js
function setTheme(theme) {
  document.documentElement.classList.toggle("dark", theme === "dark");
  localStorage.setItem("theme", theme);
}

// Read system preference as default
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
setTheme(localStorage.getItem("theme") ?? (prefersDark ? "dark" : "light"));
```

Always read `prefers-color-scheme` as the fallback for first-time visitors. Do not default to light mode; that breaks the experience for users whose system is set to dark.

## Set the `theme-color` meta tag dynamically

The `theme-color` meta tag controls the browser chrome color on mobile. Update it when the theme changes.

```js
const meta = document.querySelector('meta[name="theme-color"]');
function syncThemeColor(dark) {
  if (meta) meta.setAttribute("content", dark ? "#0b0b0c" : "#fafaf9");
}
```

Or declare both in the HTML and use `media` attributes:

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

The meta approach is simpler and avoids a JavaScript dependency for a cosmetic feature.

## Verify contrast in both modes

Dark mode introduces a second set of contrast ratios to audit. Every foreground-on-background combination needs to clear 4.5:1 for body text and 3:1 for large text per WCAG AA. The oklch color space makes this predictable: lower lightness values step linearly, so a dark-mode text token at `oklch(0.92 0 0)` on a surface at `oklch(0.12 0 0)` is straightforward to check. For the accessibility requirements see [[frontend/accessibility]].

## Related

- [[frontend/tailwind]]
- [[frontend/css]]
- [[frontend/tailwind-theme]]
- [[frontend/css-custom-properties]]
- [[frontend/accessibility]]
- [[frontend/tailwind-variants]]
