Skip to main content
Technology & EngineeringTailwind258 lines

Tailwind Dark Mode

Dark mode strategies including class-based toggling, media queries, and CSS variable theming with Tailwind CSS

Quick Summary36 lines
You are an expert in implementing dark mode with Tailwind CSS, covering both system-preference and manual toggle approaches.

## Key Points

- **`'media'`** — Uses `@media (prefers-color-scheme: dark)`. No JavaScript required, but users cannot override the OS preference.
- **`'class'`** — Looks for a `dark` class on an ancestor element. Requires JavaScript to toggle the class but allows user preference storage.
- **Use the `class` strategy for user-controlled themes.** It is more flexible and lets you store the preference.
- **Prevent flash of wrong theme.** Place the theme detection script in `<head>` as a blocking script, not in a deferred bundle.
- **Design dark mode simultaneously, not as an afterthought.** Choosing colors that work in both modes upfront is far easier than retrofitting.
- **Use CSS variables for complex themes.** When you have many themed colors, the CSS variable approach eliminates `dark:` prefixes throughout the codebase.
- **Test contrast ratios.** Dark mode often has contrast issues. Use WCAG AA (4.5:1 for text) as the minimum. Gray-on-dark-gray is a common offender.
- **Consider a three-way toggle: light, dark, system.** This respects users who want to follow their OS setting while giving others manual control.
- **Flash of unstyled content (FOUC).** If the dark class is applied after the page renders, users see a white flash. Inline the theme script in `<head>` before any CSS loads.
- **Forgetting nested components.** A dark-mode-ready page wrapper does not automatically fix components that hardcode `bg-white` without a `dark:` counterpart.
- **Over-darkening backgrounds.** Pure black (`bg-black`) causes excessive contrast. Use `bg-gray-900` or `bg-gray-950` for a softer dark background.
- **Inconsistent hover and focus states.** If you add `dark:bg-gray-800` but forget `dark:hover:bg-gray-700`, hover states look broken in dark mode.

## Quick Example

```js
// tailwind.config.js
module.exports = {
  darkMode: 'class', // or 'media' for OS-preference only
  // ...
}
```

```html
<html lang="en" class="dark">
  <body class="bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
    <!-- content -->
  </body>
</html>
```
skilldb get tailwind-skills/Tailwind Dark ModeFull skill: 258 lines
Paste into your CLAUDE.md or agent config

Dark Mode Strategies — Tailwind CSS

You are an expert in implementing dark mode with Tailwind CSS, covering both system-preference and manual toggle approaches.

Overview

Tailwind supports dark mode through the dark: variant. There are two primary strategies: media (follows the operating system preference via prefers-color-scheme) and class (toggled by adding a dark class to a parent element, typically <html>). The class strategy gives users explicit control and is the most widely used approach.

Core Philosophy

Dark mode is not an inversion filter applied as an afterthought — it is a parallel design system that deserves the same intentionality as your light theme. Every color choice in dark mode should be deliberate: backgrounds should use dark grays rather than pure black to reduce eye strain, text should use slightly muted whites to avoid harsh contrast, and interactive elements need their own carefully chosen palette that maintains sufficient contrast ratios.

The best dark mode implementations are invisible to the developer writing component markup. When you use CSS custom properties for your color system, components reference semantic tokens like bg-surface and text-on-surface that resolve to the correct values automatically based on the active theme. This eliminates the need to sprinkle dark: prefixes on every element and ensures that new components are theme-aware by default without any extra effort.

User preference handling requires care at multiple levels. The system should respect the operating system preference by default, allow explicit user overrides, persist that choice across sessions, and prevent the dreaded flash of the wrong theme on page load. Getting this right means running a blocking script in the document head, before any CSS renders, to apply the correct theme class immediately.

Core Concepts

Enabling Dark Mode

// tailwind.config.js
module.exports = {
  darkMode: 'class', // or 'media' for OS-preference only
  // ...
}
  • 'media' — Uses @media (prefers-color-scheme: dark). No JavaScript required, but users cannot override the OS preference.
  • 'class' — Looks for a dark class on an ancestor element. Requires JavaScript to toggle the class but allows user preference storage.

The dark: Variant

<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
  <h1 class="text-black dark:text-white">Hello</h1>
  <p class="text-gray-600 dark:text-gray-400">Supporting text</p>
  <button class="bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400">
    Action
  </button>
</div>

Every utility can be prefixed with dark: to specify its dark mode value.

Implementation Patterns

Class-Based Toggle with localStorage

<html lang="en" class="dark">
  <body class="bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
    <!-- content -->
  </body>
</html>
// theme-toggle.js
// Run this in <head> to prevent flash of wrong theme
(function () {
  const stored = localStorage.getItem('theme')
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches

  if (stored === 'dark' || (!stored && prefersDark)) {
    document.documentElement.classList.add('dark')
  } else {
    document.documentElement.classList.remove('dark')
  }
})()

// Toggle function for a button
function toggleTheme() {
  const html = document.documentElement
  const isDark = html.classList.toggle('dark')
  localStorage.setItem('theme', isDark ? 'dark' : 'light')
}

React Dark Mode Toggle

import { useEffect, useState } from 'react'

function useTheme() {
  const [theme, setTheme] = useState<'light' | 'dark'>(() => {
    if (typeof window === 'undefined') return 'light'
    return localStorage.getItem('theme') as 'light' | 'dark' ||
      (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
  })

  useEffect(() => {
    const root = document.documentElement
    if (theme === 'dark') {
      root.classList.add('dark')
    } else {
      root.classList.remove('dark')
    }
    localStorage.setItem('theme', theme)
  }, [theme])

  const toggle = () => setTheme(prev => prev === 'dark' ? 'light' : 'dark')

  return { theme, toggle }
}

function ThemeToggle() {
  const { theme, toggle } = useTheme()

  return (
    <button
      onClick={toggle}
      className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
      aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
    >
      {theme === 'dark' ? (
        <SunIcon className="h-5 w-5" />
      ) : (
        <MoonIcon className="h-5 w-5" />
      )}
    </button>
  )
}

CSS Variable Theming (shadcn/ui Pattern)

Define colors as CSS variables and switch them based on the .dark class:

/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222 84% 5%;
    --card: 0 0% 100%;
    --card-foreground: 222 84% 5%;
    --primary: 222 47% 31%;
    --primary-foreground: 210 40% 98%;
    --muted: 210 40% 96%;
    --muted-foreground: 215 16% 47%;
    --border: 214 32% 91%;
  }

  .dark {
    --background: 222 84% 5%;
    --foreground: 210 40% 98%;
    --card: 222 84% 5%;
    --card-foreground: 210 40% 98%;
    --primary: 210 40% 98%;
    --primary-foreground: 222 47% 11%;
    --muted: 217 33% 17%;
    --muted-foreground: 215 20% 65%;
    --border: 217 33% 17%;
  }
}
// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' },
        primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
        muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
        border: 'hsl(var(--border))',
      },
    },
  },
}

Usage becomes clean and dark-mode-free in markup:

<!-- No dark: prefix needed — colors auto-switch via CSS variables -->
<div class="bg-background text-foreground">
  <div class="rounded-lg border border-border bg-card p-6">
    <h2 class="text-card-foreground">Card Title</h2>
    <p class="text-muted-foreground">Description</p>
  </div>
</div>

Handling Images and Media

<!-- Swap images for dark mode -->
<img
  class="dark:hidden"
  src="/logo-light.svg"
  alt="Logo"
/>
<img
  class="hidden dark:block"
  src="/logo-dark.svg"
  alt="Logo"
/>

<!-- Dim images in dark mode for less eye strain -->
<img class="dark:brightness-90 dark:contrast-110" src="photo.jpg" alt="" />

<!-- Invert diagrams that are black-on-white -->
<img class="dark:invert dark:hue-rotate-180" src="diagram.svg" alt="" />

Dark Mode with Transitions

<body class="bg-white text-gray-900 transition-colors duration-300 dark:bg-gray-950 dark:text-gray-100">
  <!-- Smooth color transitions when toggling -->
</body>

Add transition-colors to elements that change color to create a smooth theme switch.

Best Practices

  • Use the class strategy for user-controlled themes. It is more flexible and lets you store the preference.
  • Prevent flash of wrong theme. Place the theme detection script in <head> as a blocking script, not in a deferred bundle.
  • Design dark mode simultaneously, not as an afterthought. Choosing colors that work in both modes upfront is far easier than retrofitting.
  • Use CSS variables for complex themes. When you have many themed colors, the CSS variable approach eliminates dark: prefixes throughout the codebase.
  • Test contrast ratios. Dark mode often has contrast issues. Use WCAG AA (4.5:1 for text) as the minimum. Gray-on-dark-gray is a common offender.
  • Consider a three-way toggle: light, dark, system. This respects users who want to follow their OS setting while giving others manual control.

Common Pitfalls

  • Flash of unstyled content (FOUC). If the dark class is applied after the page renders, users see a white flash. Inline the theme script in <head> before any CSS loads.
  • Forgetting nested components. A dark-mode-ready page wrapper does not automatically fix components that hardcode bg-white without a dark: counterpart.
  • Over-darkening backgrounds. Pure black (bg-black) causes excessive contrast. Use bg-gray-900 or bg-gray-950 for a softer dark background.
  • Inconsistent hover and focus states. If you add dark:bg-gray-800 but forget dark:hover:bg-gray-700, hover states look broken in dark mode.
  • Color opacity issues with CSS variables. When using HSL CSS variables with Tailwind's opacity modifier (bg-primary/50), the variable must contain only the H S L values without the hsl() wrapper.

Anti-Patterns

  • Bolt-on dark mode. Adding dark: variants to an existing light-only codebase component by component is tedious and error-prone. Design both themes simultaneously from the start, or adopt a CSS variable approach that makes theming automatic.

  • Pure black backgrounds. Using bg-black or bg-gray-950 creates excessive contrast with white text, causes halation on OLED screens, and makes the UI feel like a void. Use bg-gray-900 or a tinted dark surface for a more comfortable reading experience.

  • Inconsistent dark mode coverage. Applying dark:bg-gray-800 to a card container but forgetting to add dark:text-gray-100 to the text inside, or missing dark:border-gray-700 on borders, produces a half-themed component that looks broken.

  • Storing theme in state instead of the DOM. Managing the dark class via React state alone causes the theme to flash on every page load because the state initializes after hydration. The theme must be applied via a synchronous script in <head> that reads localStorage before any rendering occurs.

  • Ignoring non-color elements in dark mode. Shadows, images, illustrations, and code blocks all need dark mode treatment. A white-background code block in a dark UI, or a shadow designed for a light background that becomes invisible on dark, breaks the visual coherence.

Install this skill directly: skilldb add tailwind-skills

Get CLI access →