Tailwind Dark Mode & Theming

Users increasingly expect dark mode, and many products need several themes: a brand theme per customer, a high-contrast option, or seasonal variations. Tailwind supports this in two complementary ways. The dark: variant applies utilities conditionally (bg-white dark:bg-gray-950). Semantic color tokens backed by CSS variables (bg-surface) switch values globally by redefining the variables. Tailwind v4's CSS-first configuration makes the variable approach especially natural.

For small projects, dark: utilities are fine. For design systems and multi-theme apps, semantic tokens are more maintainable: components use bg-surface text-ink, and themes just redefine what those mean.

TL;DR

Quick Example

Core Concepts

The dark: Variant

Out of the box, dark: uses @media (prefers-color-scheme: dark), so it follows the operating system with zero JavaScript. To let users override that, redefine the variant with @custom-variant to depend on a class (.dark) or an attribute ([data-theme=dark]) on an ancestor, usually <html>. (In Tailwind v3 this was darkMode: 'class' in the JS config.)

Utilities vs Semantic Tokens

Most design systems use semantic tokens for everything themeable, and dark: for rare exceptions (inverting a logo, adjusting an illustration). See design tokens and CSS custom properties.

Avoiding the Theme Flash

If the theme is applied after JavaScript loads, or after React hydrates, users see a flash of light content before dark mode kicks in. Fixes:

Light, Dark, and System

Offer three options: Light, Dark, and System (follow the OS). With "System", listen for changes with matchMedia('(prefers-color-scheme: dark)').addEventListener('change', …), so the page updates if the OS switches at sunset. Persist explicit choices in localStorage or a cookie, or in the user's profile for signed-in users.

color-scheme

Setting color-scheme: light or dark on the root tells the browser to render native UI (scrollbars, form controls, date pickers, default backgrounds) in the matching scheme. Without it, dark pages can show bright white scrollbars and inputs.

Multiple Themes

Because themes are just variable sets, adding brand or tenant themes is straightforward:

Combine brand and mode (data-brand="acme" plus data-theme="dark") with layered variable definitions. For white-label SaaS, load tenant token values from configuration and inject them as CSS variables at runtime. See dark mode theming.

Best Practices

Design Dark Themes Deliberately

Dark mode isn't inversion. Use dark grays rather than pure black for surfaces, raise elevation with lighter surfaces instead of shadows, desaturate and lighten accent colors, and reduce the intensity of large bright areas.

Check Contrast in Every Theme

Verify WCAG contrast ratios (4.5:1 for body text, 3:1 for large text and UI components) for both light and dark token sets, including muted text, borders, and focus rings. See accessibility.

Theme Images, Charts, and Code Blocks

Use <picture> with prefers-color-scheme sources or dark: variants for images, theme-aware chart palettes, and syntax highlighting themes that switch with the page. These are often forgotten.

Test Both Themes Continuously

Include dark mode in visual regression tests and Storybook, since components added without tokens often look broken in the theme nobody checked.

Common Mistakes

Toggling the Class After Hydration

Setting class="dark" in a React useEffect guarantees a flash on every load. Use a head script or server-rendered attribute.

Hard-Coded Colors in Components

bg-white text-black inside a component ignores themes entirely. Use semantic tokens so components adapt.

Pure Black Backgrounds With Pure White Text

Maximum contrast causes halation (text appears to glow and blur) and eye strain for many readers. Use off-black surfaces and slightly off-white text.

FAQ

How do I enable class-based dark mode in Tailwind v4?

Override the dark variant in your CSS: @custom-variant dark (&:where(.dark, .dark ));, then toggle the dark class on <html>. For an attribute, use &:where([data-theme=dark], [data-theme=dark] ). Without the override, dark: follows the OS preference.

How do I prevent dark mode flicker on page load?

Apply the theme before the page renders: an inline script in <head> that reads the saved preference (or the system preference) and sets the class or attribute synchronously, or server-render the attribute from a cookie. Setting it after JavaScript frameworks load causes a visible flash.

Should I use dark: utilities or CSS variables?

For small sites, dark: utilities are quick and explicit. For design systems, multi-theme apps, or large codebases, define semantic tokens as CSS variables and switch their values per theme. Components stay clean and themes stay consistent.

How do I support more than two themes?

Define each theme as a set of CSS variable values under its own selector (for example [data-theme="ocean"]), with components using only semantic tokens. Switching themes is then just changing an attribute. No extra utility classes are needed.

Related Topics

References