Dark Mode

Tailwind CSS · Lesson 17 of 27 7 min read Updated

Many people keep their phone and laptop in dark mode, especially at night. A site that stays bright white in a dark room feels harsh. Tailwind makes dark mode a matter of adding dark: versions of your colour classes, and version 4 lets you decide in one line of CSS whether dark mode follows the system setting or a button on your page.

In this lesson you will add dark styles with the dark: variant, switch from the system setting to a manual toggle, remember the visitor's choice without a flash of the wrong theme, and reduce repetition with colour tokens.

The dark: variant

Write your normal (light) styles, then add dark: versions for the colours that should change:

HTML
<div class="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">...</div>

By default, dark: uses the prefers-color-scheme: dark media query, which follows the operating system or browser setting. Nothing else is needed: if a visitor's phone is in dark mode, they see the dark styles.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>System dark mode</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="min-h-svh bg-slate-100 p-8 dark:bg-slate-950">
  <article class="max-w-sm rounded-xl bg-white p-6 shadow-md dark:bg-slate-800 dark:shadow-none dark:ring-1 dark:ring-white/10">
    <p class="text-sm font-medium text-sky-700 dark:text-sky-400">Exam reminder</p>
    <h2 class="mt-1 text-xl font-semibold text-slate-900 dark:text-white">Semester 5 practicals</h2>
    <p class="mt-2 text-slate-600 dark:text-slate-300">Lab exams start Monday at 9:30 am. Bring your record book and ID card.</p>
    <button class="mt-4 rounded-lg bg-sky-600 px-4 py-2 text-white hover:bg-sky-700 dark:bg-sky-500 dark:text-slate-950 dark:hover:bg-sky-400">Add to calendar</button>
  </article>
</body>
</html>

To see the dark version, switch your device to dark mode, or open your browser's DevTools, find the rendering settings, and emulate prefers-color-scheme: dark.

A few design tips are visible in the example. Shadows hardly show on dark backgrounds, so the dark card swaps its shadow for a faint ring (dark:ring-white/10). Bright colours look stronger on dark backgrounds, so the accent goes from sky-700 to the lighter sky-400. And pure black is harsh: slate-950 and slate-800 are softer.

A toggle button with @custom-variant

Many sites let visitors choose the theme with a button. Then dark: must follow a class on the page instead of the system setting. In Tailwind 4 you change what dark: means with one line of CSS:

CSS
@import "tailwindcss";

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

This says: "dark: applies to an element that has the class dark, or is inside one". Now you add or remove class="dark" on <html> with JavaScript. If you prefer a data attribute, write @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); and set data-theme="dark" instead.

In the browser build, the same line goes inside <style type="text/tailwindcss">:

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Theme toggle</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
  <style type="text/tailwindcss">
    @custom-variant dark (&:where(.dark, .dark *));
  </style>
</head>
<body class="min-h-svh bg-white p-8 text-slate-900 dark:bg-slate-900 dark:text-slate-100">
  <header class="flex max-w-md items-center justify-between">
    <h1 class="text-xl font-bold">Rohtak Library</h1>
    <button id="theme" class="rounded-lg border border-slate-300 px-3 py-1.5 text-sm dark:border-slate-600" aria-pressed="false">Dark mode</button>
  </header>
  <p class="mt-4 max-w-md text-slate-600 dark:text-slate-300">Reading room open from 8 am to 8 pm. Silence please.</p>
  <script>
    const button = document.getElementById('theme');
    button.addEventListener('click', () => {
      const isDark = document.documentElement.classList.toggle('dark');
      button.setAttribute('aria-pressed', String(isDark));
      button.textContent = isDark ? 'Light mode' : 'Dark mode';
    });
  </script>
</body>
</html>

classList.toggle('dark') adds the class if it is missing and removes it if present, and returns whether it is now present. The aria-pressed attribute tells screen-reader users whether the button is on.

Remembering the choice without a flash

A real toggle should remember the visitor's choice, and fall back to the system setting if they never chose. Put this small script in the <head>, before your CSS and content, so the right class is set before the page is drawn. Otherwise the page flashes white for a moment before turning dark.

HTML
<script>
  const saved = localStorage.getItem('theme'); // 'dark', 'light' or null
  const systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
  document.documentElement.classList.toggle('dark', saved === 'dark' || (saved === null && systemDark));
</script>

Your toggle button then saves the choice with localStorage.setItem('theme', isDark ? 'dark' : 'light'), and a "Use system setting" option can call localStorage.removeItem('theme'). See localStorage & sessionStorage for how storage works.

Also tell the browser which scheme you are using, so scrollbars, form controls and the default page background match: add scheme-light dark:scheme-dark to the <html> element, which sets the CSS color-scheme property.

Fewer classes with colour tokens

Writing dark: for every colour doubles your class lists. Larger projects define semantic colours named by purpose, such as surface and ink, and change their values once for dark mode:

CSS
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

@theme inline {
  --color-surface: var(--surface);
  --color-ink: var(--ink);
}

:root { --surface: var(--color-white); --ink: var(--color-slate-900); }
.dark { --surface: var(--color-slate-900); --ink: var(--color-slate-100); }

Now class="bg-surface text-ink" works in both themes with no dark: at all. The inline keyword makes the utilities use var(--surface) directly, so the value switches when the dark class changes. You will learn @theme properly in Customising with @theme. For the plain CSS approach, including light-dark(), see Dark Mode & Theming.

Common mistakes

  • Forgetting some colours. Text set to text-slate-900 with no dark: version becomes dark text on a dark background. Check every colour, including borders and placeholders.
  • Adding the toggle script at the end of the body. The page flashes in the wrong theme. Set the class in the <head>.
  • Using @custom-variant and still testing with the OS setting. Once dark: follows a class, the system setting no longer changes anything unless your script reads it.
  • Pure black and pure white. Very high contrast is tiring. Dark greys with off-white text are easier on the eyes.

Key points

  • dark: versions of classes apply in dark mode: bg-white dark:bg-slate-900.
  • By default, dark mode follows the system's prefers-color-scheme setting.
  • @custom-variant dark (&:where(.dark, .dark *)); switches dark: to a class you control with JavaScript.
  • Set the class in a small <head> script using localStorage and matchMedia to avoid a flash.
  • Add scheme-light dark:scheme-dark so built-in controls and scrollbars match.
  • Semantic colour tokens defined with @theme inline reduce the number of dark: classes.

Exercise

Make a profile card for Farhan, a web developer from Lucknow, that supports a manual dark mode with data-theme instead of a class. Add the @custom-variant line for [data-theme=dark], a toggle button that switches data-theme between dark and light on <html>, and dark versions of the card background, text and button.

Show solution
HTML
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Profile card</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
  <style type="text/tailwindcss">
    @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
  </style>
</head>
<body class="min-h-svh bg-slate-100 p-8 dark:bg-slate-950">
  <article class="max-w-xs rounded-xl bg-white p-6 text-slate-900 dark:bg-slate-800 dark:text-slate-100">
    <h2 class="text-xl font-bold">Farhan Ali</h2>
    <p class="text-slate-600 dark:text-slate-300">Web developer, Lucknow</p>
    <button id="toggle" class="mt-4 rounded-lg bg-slate-900 px-4 py-2 text-white dark:bg-white dark:text-slate-900">Switch theme</button>
  </article>
  <script>
    const root = document.documentElement;
    document.getElementById('toggle').addEventListener('click', () => {
      root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark';
    });
  </script>
</body>
</html>

The custom variant makes every dark: class depend on data-theme="dark" on an ancestor, so the script only has to flip one attribute.

Quick Quiz

1. What does dark: follow by default in Tailwind 4?

2. How do you make dark: depend on a dark class in Tailwind 4?

3. Why should the script that applies the saved theme run in the <head>?

4. What does scheme-dark set?

Learn Tailwind CSS with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call