Dark Mode
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:
<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.
<!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:
@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">:
<!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.
<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:
@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-900with nodark: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-variantand still testing with the OS setting. Oncedark: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-schemesetting. @custom-variant dark (&:where(.dark, .dark *));switchesdark:to a class you control with JavaScript.- Set the class in a small
<head>script usinglocalStorageandmatchMediato avoid a flash. - Add
scheme-light dark:scheme-darkso built-in controls and scrollbars match. - Semantic colour tokens defined with
@theme inlinereduce the number ofdark: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
<!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?
Without any configuration, dark: is a prefers-color-scheme: dark media query.
2. How do you make dark: depend on a dark class in Tailwind 4?
Version 4 configures variants in CSS; the v3 darkMode config option is the old way.
3. Why should the script that applies the saved theme run in the <head>?
If the class is added after the first paint, visitors briefly see the light theme before it switches.
4. What does scheme-dark set?
color-scheme tells the browser to draw its own UI parts, such as form controls and scrollbars, in the dark style.
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
