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.

Every example here was run
CSS is copied from Tailwind CSS 4.3.3's output. To test preferences I used Chrome's media emulation (the same switches as DevTools' Rendering panel) to set the colour scheme, reduced motion, contrast and forced-colours settings, and to switch to print media, then read computed styles back. The contrast ratios are the same WCAG measurement as chapter 3.

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:

<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">โ€ฆ</div>
@media (prefers-color-scheme: dark) { .dark\:bg-gray-900 { background-color: var(--color-gray-900); } } system set to light: background white, text gray-900 system set to dark: background gray-900, text gray-100

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-900RatioNormal text (4.5:1)
text-gray-10016.13passes
text-gray-30012.05passes
text-gray-4006.82passes
text-gray-5003.67fails
text-gray-6002.35fails
text-blue-4006.72passes
text-blue-5004.72passes (just)
text-blue-6003.38fails
text-red-4006.14passes
text-red-6003.72fails

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:

@import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *));
.dark\:bg-gray-900:where(.dark, .dark *) { background-color: var(--color-gray-900); }

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 settingdark class on <html>Background
darkabsentwhite (light)
darkpresentgray-900 (dark)
lightpresentgray-900 (dark)
lightabsentwhite (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:

<head> โ€ฆ <script> function applyTheme() { const t = localStorage.theme; const dark = t === 'dark' || (t !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches); document.documentElement.classList.toggle('dark', dark); } applyTheme(); matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme); function setTheme(choice) { if (choice === 'system') localStorage.removeItem('theme'); else localStorage.theme = choice; applyTheme(); } </script> </head> <body> <button onclick="setTheme('light')">Light</button> <button onclick="setTheme('dark')">Dark</button> <button onclick="setTheme('system')">System</button> โ€ฆ

Measured, step by step, with the emulated system setting changing underneath the page:

StepPage is dark?Stored theme
1. Fresh visit, system lightnonothing
2. System switches to dark while the page is openyes (the change listener)nothing
3. Visitor chooses Light (system still dark)nolight
4. System light, visitor chooses Darkyesdark
5. Reload the pageyes, and already dark when the body starteddark
6. Visitor chooses System (system light)noremoved
7. System switches to darkyes (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:

VariantMedia queryMeasured
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:printprint: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

Exercise 1

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.

๐Ÿ“„ View solution
Exercise 2

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.

๐Ÿ“„ View solution
Exercise 3

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 solution

Chapter 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. Use text-gray-500 dark:text-gray-400, text-blue-600 dark:text-blue-400
  • Shadows vanish on dark surfaces: use dark:border-gray-700 or 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 from localStorage.theme or matchMedia; listen for change; 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"
Coming next
Tailwind CSS Fundamentals 9 opens the theme itself: @theme and its namespaces, extending and overriding the defaults, adding fonts and breakpoints, and keyframes for your own animations.