Skip to main content
← Back to list

· Working ·

Dark mode FAQ: design tokens, contrast ratios and WCAG AA

Does dark mode need its own design pass, what contrast ratio passes WCAG AA, and why do light greys and brand colours keep failing? Answered with measured ratios and the real CSS behind this site.

Dark mode needs its own colour pass, not a redesign. When components only reference semantic tokens such as background, foreground and muted, you change values in the token layer and re-measure contrast. The WCAG 2.2 AA targets are 4.5:1 for normal text and 3:1 for large text.

I'm Hai Le, a UI/UX designer who writes code. Every ratio below comes from this site's real colours, recomputed with the WCAG formula.

Does dark mode need its own design?

It needs a design pass on colour. Layout and components stay as they are.

That pass has three parts: choose dark values for the grey ramp, pick a brand step that reads on a dark surface, and re-measure every text pair. If components hard-code hex values, add a fourth and much longer part: editing each component by hand.

This site numbers its grey ramp the Radix way. A step number is the distance from the background, so neutral-50 is always the background and neutral-950 always the strongest text, in both modes. The full explanation is in Radix-style colour scales.

What contrast ratio passes WCAG AA?

The two criteria that matter most in WCAG 2.2:

What you measureCriterionAA threshold
Normal text1.4.34.5:1
Large text: 18pt (about 24px), or 14pt bold (about 18.5px)1.4.33:1
Borders, icons and states of UI components1.4.113:1 against adjacent colours
Disabled components, decorative text, logotypes1.4.3, 1.4.11Not required

The W3C's Understanding document adds a detail people skip: ratios are not rounded. A pair measuring 4.499:1 fails the 4.5:1 threshold.

Here are this site's real pairs in both modes:

Text on backgroundLightDark
foreground on background21.00:116.13:1
muted-foreground on background7.00:17.94:1
subtle-foreground on background4.88:15.19:1
subtle-foreground on muted4.60:14.83:1
primary-text on background6.10:15.94:1

The weakest pair is third-tier grey text on the grey band, at 4.60:1. I check that one first whenever a grey value changes.

How do you build dark mode with tokens instead of flipping colours by hand?

Flipping by hand means writing text-gray-900 dark:text-white in every component. Tailwind's own example does exactly that, and on a small page it works fine. As a system grows, every dark: class is one more place to forget.

This site goes the other way. The theme is a dark class on the html element, and every colour change lives in app/globals.css. Tailwind's dark variant is redefined in one line:

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

Tailwind's docs suggest &:where(.dark, .dark *), which also matches the element carrying the class. This site only ever puts the class on html, so :is(.dark *) is enough.

A small script in the head runs before first paint. It reads the saved choice or the OS prefers-color-scheme, then toggles the class. Trimmed from lib/theme-init-script.ts:

var e = localStorage.getItem('theme');
var d = e === 'dark' ||
  ((!e || e === 'system') &&
   window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', d);

The rest is tokens. The grey ramp changes value under .dark, and the semantic layer only re-points the colours that do not invert on their own:

.dark {
  --primary: var(--primary-400);
  --destructive: var(--error-400);
  --primary-foreground: #ffffff;
  --primary-text: var(--primary-400);
}

If raw, alias and semantic layers are new to you, start with What is a design token.

Why does light grey text keep failing contrast checks?

Light grey looks readable on a good screen and measures short. The two mistakes I see most:

Using a decorative step for text. #9a9a9a measures 2.81:1 on white and 2.65:1 on the #f8f8f8 band. This site bans neutral-500 and neutral-400 as text colours outright.

Hard-coding the light value. A caption hard-coded to #717171 gets 4.60:1 on the light grey band. In dark mode that band becomes #191919 and the same caption drops to 3.60:1. A caption using text-subtle-foreground switches to #868686 and lands at 4.83:1.

The fix for both is a short list of text steps. This site has exactly four greys for text, each set by contrast threshold before being tuned by eye. Needing a fifth is a strong hint that the layout has a problem.

Does brand-coloured text pass contrast?

It depends on the colour, and you have to measure both modes. This site's magenta #ff009d measures 3.66:1 on white. That is enough for large text and for UI component boundaries, and short of the 4.5:1 that normal text needs.

So the site splits it into two tokens. primary is the brand colour for fills. primary-text is the deeper #be0074 at 6.10:1, used for magenta links and labels at 20px and below.

Dark mode flips the problem. #be0074 on #111111 drops to 3.09:1, so under .dark, primary-text moves to #ff3fb2 at 5.94:1. With a single brand token for both text and fills, dark mode will almost certainly break one of them.

White labels on a #ff009d button also measure 3.66:1. Under 1.4.3 a button label is still text, so this pair only passes at large-text sizes. I'm listing it here as an open issue on this site.

Should the dark background be pure black or a very dark grey?

This site uses #111111 for the page in dark mode and #000000 for the footer. The footer is the darkest band on the page in light mode, and I wanted it to keep that role in dark mode. With a pure black page, the footer would have nowhere darker to go.

The surfaces also need visible steps between them: page #111111, band #191919, hairline #242424. When you pick a dark background, check the band and border steps too, alongside the text pairs.

Where should you test dark mode before shipping?

My order:

  1. Switch the OS to dark, reload, and watch for a light flash before the dark theme applies.
  2. Use the theme toggle to switch back and forth a few times on the same page.
  3. Measure the lowest pairs from the table above, in both modes.
  4. Search the component folder for hard-coded # hex values.

Step 4 suits an AI agent like Claude Code well: it can run the search and explain each hit. How I work with it is in Claude Code for designers. Naming questions are covered in the design token FAQ.

I built a colour system with purple, orange and grey ramps for Joyme; the details are in the Joyme case study. If your team needs a design system with dark mode carried all the way into code, more about me here.

Sources

Frequently asked questions

Does dark mode need a separate design?
It needs its own colour pass, not redrawn screens. If components only use semantic tokens, the work is choosing dark values for the grey ramp, picking a different brand step, and re-measuring contrast.
What contrast ratio passes WCAG AA?
Under WCAG 2.2, normal text needs at least 4.5:1 and large text (about 24px, or about 18.5px bold) needs 3:1. UI components and meaningful graphics need 3:1 against adjacent colours. Ratios are not rounded up.
Which greys can be used for text?
On this site, four steps: neutral-950, 900, 700 and 600. Step 600 (#717171) measures 4.88:1 on white and 4.60:1 on the grey band. Step 500 (#9a9a9a) measures 2.81:1, so it is reserved for decoration and disabled states.
Should a dark theme use pure black #000000?
This site uses #111111 for the page in dark mode and keeps #000000 for the footer, so the footer stays the darkest band. Either works as long as text pairs pass and the surfaces stay distinguishable from each other.