Dark Mode
Tailwind CSS Fundamentals
Chapter 8 ยท Dark Mode and Preference Variants
Chapter 7's variants responded to the size of the screen. This chapter's respond to the person: whether they have chosen a dark theme, asked their system to reduce
animation, asked for stronger contrast, or are printing the page. They use the same variant:utility pattern, and each is a CSS media feature that the browser sets
from the user's operating-system settings. The main one is dark:, and most of the chapter is about using it well: designing colours that stay readable, and
letting the visitor override the system choice without a flash of the wrong theme.
dark: and the System Preference
By default dark: is the prefers-color-scheme: dark media query. The usual pattern is to write the light colours as plain classes and
override them in dark mode:
I switched the emulated system between the two and read the colours back (matchMedia("(prefers-color-scheme: dark)").matches went from false to
true); the page restyled itself with no script. The dark: prefix combines with other variants: dark:hover:bg-gray-800 compiled to a
@media (prefers-color-scheme: dark) block containing the @media (hover: hover) rule, and dark:md:p-4 to the dark block containing the
md block (and md:dark:p-5 the other way round, with the same effect).
Designing a Dark Palette
Dark mode is not "invert everything". Three things change, and the first is measurable. Shades that pass on white often fail on dark. Chapter 3 showed that
gray-500 is the lightest grey that passes on white (4.84:1). On a gray-900 background the same colours behave very differently, measured the same
way:
Text on bg-gray-900 | Ratio | Normal text (4.5:1) |
|---|---|---|
| text-gray-100 | 16.13 | passes |
| text-gray-300 | 12.05 | passes |
| text-gray-400 | 6.82 | passes |
| text-gray-500 | 3.67 | fails |
| text-gray-600 | 2.35 | fails |
| text-blue-400 | 6.72 | passes |
| text-blue-500 | 4.72 | passes (just) |
| text-blue-600 | 3.38 | fails |
| text-red-400 | 6.14 | passes |
| text-red-600 | 3.72 | fails |
The pattern: a colour that is darkened (600) for a white page needs the lighter end (300 to 400) on a dark page. A secondary text colour of text-gray-500 in both
modes is therefore a real bug: text-gray-500 dark:text-gray-400 fixes it (4.84 on white, 6.82 on gray-900). A link needs the same: text-blue-600
dark:text-blue-400 (5.25 and 6.72). The second change is depth: shadows (chapter 3) are black at about 10% opacity, and black on a near-black page is
close to invisible, so in dark mode separate surfaces with a lighter background or a border (dark:border-gray-700) rather than relying on shadow-md. The
third is the browser's own parts: form controls, scrollbars and the default page background follow the CSS color-scheme property. The
scheme-dark class sets color-scheme: dark (scheme-light and scheme-light-dark are the others), and the computed value
reported dark; set it on <html> when the page is dark so checkboxes and scrollbars match. (I verified the property, not how each control
looks.)
Switching by Class or Attribute
The system setting is a good default, but many sites let the visitor choose. For that, change what dark: means with one line of CSS, using the
@custom-variant directive. The official documentation gives two forms. The class version:
Now dark: applies when the element is inside something with the class dark (or has it itself). Put the class on <html> and the whole
page is dark. The :where() adds no specificity, so these rules compete with the light ones on equal terms. The system setting is no longer consulted. I tested all
four combinations of the (emulated) system setting and the class:
| System setting | dark class on <html> | Background |
|---|---|---|
| dark | absent | white (light) |
| dark | present | gray-900 (dark) |
| light | present | gray-900 (dark) |
| light | absent | white (light) |
A dark system setting with no class gave a light page: with the custom variant, only the class counts. That is why a class-based site needs a small script to decide
which to apply (next section). The attribute version is the same with a selector for data-theme:
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));. It also lets you make a dark island on a light page: I put
data-theme="dark" on one <div>; a white-by-default box inside it measured dark, and an identical box outside it stayed white. Setting the
attribute on <html> then turned the outside dark too.
A Light / Dark / System Toggle, Without a Flash
A good theme switch has three choices: light, dark, and "follow my system" (the default). It remembers the choice, and it follows the system live when set to system. This is the script, adapted from the official one, and I ran it with the class variant above:
Measured, step by step, with the emulated system setting changing underneath the page:
| Step | Page is dark? | Stored theme |
|---|---|---|
| 1. Fresh visit, system light | no | nothing |
| 2. System switches to dark while the page is open | yes (the change listener) | nothing |
| 3. Visitor chooses Light (system still dark) | no | light |
| 4. System light, visitor chooses Dark | yes | dark |
| 5. Reload the page | yes, and already dark when the body started | dark |
| 6. Visitor chooses System (system light) | no | removed |
| 7. System switches to dark | yes (following the system again) | nothing |
Step 5 is the point of placing the script inline in the <head>. I compared two pages that run the same code, one with the script in the
head and one at the end of the body, and recorded whether the dark class was set at the moment the browser started parsing the body: true for the head
script, false for the end-of-body script (the class was only added afterwards). With the later script the browser is free to paint the light page first and switch a
moment later, which is the "flash of the wrong theme" you have seen on some sites. (I measured when the class was applied, not the paint itself, so I can show the cause
but not a flash.) Keep the script small and put it before anything that paints.
Reduced Motion, Contrast, Forced Colours and Print
The other preference variants work like dark:. Each compiles to a media query and I switched the corresponding setting on in the emulator:
| Variant | Media query | Measured |
|---|---|---|
| motion-safe: | (prefers-reduced-motion: no-preference) | motion-safe:animate-pulse: animation pulse with no preference, none when reduction was requested |
| motion-reduce: | (prefers-reduced-motion: reduce) | animate-spin motion-reduce:animate-none: spin normally, none with reduction on |
| contrast-more: contrast-less: | (prefers-contrast: more | less) | border-0 contrast-more:border-2: 0px, then 2px with more contrast requested |
| forced-colors: not-forced-colors: | (forced-colors: active), not (forced-colors: active) | p-1 forced-colors:p-8: 4px, then 32px with forced colours active |
| print: | print | print:hidden: display: block on screen, none in print media |
| inverted-colors: noscript: | (inverted-colors: inverted), (scripting: none) | (compiled; not measured) |
Why bother. Reduced motion: some people get dizzy or ill from large movement; spinners and sliding panels should respect the setting. The safest way is
motion-safe:, which applies animation only when the visitor has not asked for less, rather than adding motion-reduce: to every animated class.
Contrast: a visitor who has asked for more contrast may need your faint borders strengthened, as in contrast-more:border-2.
Forced colours (Windows High Contrast): the browser replaces your colours with a small system palette, so anything defined only by a background colour
(a button with no border) can lose its edges; forced-colors: lets you add a border or outline there. That is standard behaviour of the mode; I verified that the
variant applies, not how a page looks inside it. Print: print:hidden for navigation and ads, print:text-black and similar for ink-friendly
pages.
Testing Preferences
You do not need to change your operating system. In Chrome's DevTools open the command menu (Ctrl+Shift+P), run Show Rendering, and use the "Emulate CSS media
feature" selectors for prefers-color-scheme, prefers-reduced-motion, prefers-contrast and forced-colors, and
"Emulate CSS media type" for print. These are the same settings I switched with the emulator above (I drove them by script, not through the panel, and the exact list of switches in the panel depends on your Chrome version). For a manual theme toggle also test with local storage cleared, with a stored choice, and
on a slow connection, which is where a flash is most visible.
Hands-On Exercises
Style a card that works in both system modes: a surface, a border, a heading, a grey caption and a blue link. Start with the caption as text-gray-500 and the link as text-blue-600 in both modes, measure the contrast ratio of every text pair in light and in dark, find which fail, and fix them with the smallest changes.
Add the class-based dark variant and a Light / Dark / System toggle to a page. Using emulated system changes, prove that the choice is remembered across a reload, that it is applied before the body is parsed, that "System" follows live changes, and that an explicit choice ignores them.
Make a small component respect four preferences: a spinner that stops spinning when the visitor asks for reduced motion, a card whose border thickens for higher contrast, a decorative banner that disappears in print, and a button that gets extra padding in forced-colours mode. Switch each preference on in the emulator and report the before and after computed values.
๐ View solutionChapter 8 Quick Reference
dark:=@media (prefers-color-scheme: dark)by default; write light as the plain class:bg-white dark:bg-gray-900; it stacks (dark:hover:,dark:md:)- Dark palettes: text on
gray-900: gray-100 16.13, 300 12.05, 400 6.82 pass; gray-500 3.67 and gray-600 2.35 fail. blue-400/500 pass, blue-600 fails; red-400 passes, red-600 fails. Usetext-gray-500 dark:text-gray-400,text-blue-600 dark:text-blue-400 - Shadows vanish on dark surfaces: use
dark:border-gray-700or a lighter surface;scheme-dark(color-scheme: dark) for controls and scrollbars - Manual switching:
@custom-variant dark (&:where(.dark, .dark *));(class) orโฆ([data-theme=dark], [data-theme=dark] *)(attribute, allows dark islands); the system setting is then ignored - Toggle: inline script in
<head>that sets the class fromlocalStorage.themeormatchMedia; listen forchange;removeItem('theme')for System motion-safe:/motion-reduce:(reduced motion),contrast-more:/contrast-less:,forced-colors:/not-forced-colors:,print:,noscript:,inverted-colors:- Test with DevTools: Show Rendering, then "Emulate CSS media feature โฆ" and "Emulate CSS media type: print"
@theme and its namespaces, extending and overriding the defaults, adding fonts and
breakpoints, and keyframes for your own animations.