Tailwind CSS Intermediate: Theme, Variants and Reuse
Dark Mode and Theming
In this lesson you will
- Use the dark variant with the system setting
- Switch themes with a class or data attribute using @custom-variant
- Build themes with CSS variables and avoid a flash on load
Introduction
Many people prefer dark interfaces, especially at night, and a good site respects that choice. Tailwind makes dark mode simple with the dark: variant, and it is flexible enough to follow the operating system or an in-page toggle. The generated CSS below was produced with Tailwind 4.3.3.
System dark mode (the default)
Out of the box, dark: uses the prefers-color-scheme media query:
HTML
<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
Follows the system setting
</div>I compiled dark:bg-gray-900 and got a @media (prefers-color-scheme: dark) block around the rule. No JavaScript is needed, and it responds when the user changes their system setting.
A manual toggle with a class
To let users choose, override the dark variant so it depends on a class on an ancestor, usually html:
CSS
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));I compiled dark:bg-gray-900 with this and it produced .dark\:bg-gray-900:where(.dark, .dark *), so the style applies when the element or any ancestor has the dark class. It also works when stacked: dark:md:text-white.
Toggling is then a small script that adds or removes the class:
Using a data attribute instead
Some teams prefer data-theme, which also supports more than two themes:
CSS
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));The compiled output was .dark\:bg-gray-900:where([data-theme=dark], [data-theme=dark] *). You switch with document.documentElement.dataset.theme = "dark".
You can define extra variants for other themes in the same way, for example a theme-midnight: variant that matches [data-theme=midnight]. I compiled one and it worked.
Quick quiz
How do you make dark: depend on a .dark class instead of the system setting in Tailwind v4?
Respect both: system default plus toggle
A good pattern:
- On first visit, follow the system preference.
- If the user chooses a theme, save it (for example in
localStorage) and use it from then on.
Put this in a small inline script in the head, before the page paints. Otherwise the page can flash light, then switch to dark, which is jarring. Remember that storage may be unavailable (private browsing), so wrap access in try/catch.
Theming with CSS variables (fewer dark: classes)
Writing dark: on every element gets repetitive. A more scalable approach is to define semantic colours as variables, change the variables per theme, and use one class per element.
CSS
@import "tailwindcss";
:root {
--surface: #ffffff;
--text: #0b1020;
}
.dark {
--surface: #0b1020;
--text: #f5f6fb;
}
@theme inline {
--color-surface: var(--surface);
--color-text: var(--text);
}Now bg-surface text-text works in both themes without any dark: prefix. I compiled @theme inline { --color-primary: var(--brand); } and bg-primary became background-color: var(--brand), which is exactly why inline matters: the utility reads the variable that changes per theme.
This is how this very site handles its colours.
Design for dark, not just invert
- Do not simply invert colours. Pure white text on pure black can feel harsh, so use slightly softened values.
- Check contrast in both themes (WCAG asks for at least 4.5 to 1 for body text).
- Shadows are less visible in dark mode, so use borders or lighter surfaces to show depth.
- Test images and logos. Some need a dark-mode version.
- Tell the browser about built-in controls:
color-scheme: darkon the root makes scrollbars and form controls match.
Reduced motion and other preferences
Dark mode is one of several user preferences. Respect others as well: motion-reduce: for animation, forced-colors: for high-contrast mode, and print: for printing.
Try it yourself
Add a dark-mode toggle to a page using @custom-variant dark. Save the choice, apply it in the head to avoid a flash, and define --surface and --text variables so most elements need no dark: classes. Check the contrast of text in both themes.
Summary
- By default
dark:follows the system's colour scheme, and@custom-variant darkswitches it to a class or data attribute. - Apply the saved theme in the
headbefore paint to avoid a flash, and keep storage access in try and catch. - Semantic CSS variables with
@theme inlinereduce repeateddark:classes, and both themes need good contrast.
Lesson complete
+20 XP
Nice work. Keep going while it is fresh.
Sources and further reading