Charred leeks with romesco
Search topics
Dark mode with faint grey text and icons
A dark theme swaps the page and body text but leaves grey meta text and SVG icons in their light-theme colours, too faint on the dark cards.
.meta { color: #6b7280; } The problem
People with low vision who use dark mode struggle to read the recipes' times and to find their Save and Share buttons
A recipe site has a dark theme that follows the device's setting. In the design file, the dark version was made by swapping the big colours: the page and the cards go from pale grey and white to deep slates, and the titles from near-black to near-white. The rest was carried across from the light version as it was: the grey line under each title that gives the time and the servings, and the Save and Share icons beside it. In the light theme they are quiet and clear, which is what the design wanted. The developer built exactly that, with the page, card and title colours as tokens that a prefers-color-scheme: dark block redefines.
Set the device to dark mode and the site follows. The titles read well. The line under them, "25 min · Serves 4", is now a mid grey on a dark card, and the Save and Share icons are dark grey on dark grey: there for someone who knows where to look, and gone for anyone who doesn't.
The dark theme redefines the colours it has tokens for, and nothing else. The grey is written into .meta as a hex value, and the icons' colour into each SVG's fill attribute, so neither changes when the theme does:
- The time line,
#6b7280, is 4.83:1 on the white card and 3.04:1 on the#1f2937card. WCAG 1.4.3 asks for 4.5:1 for text this size. - The icons,
#374151, are 10.31:1 on white and 1.42:1 on the dark card. They are all that shows what each button is, so 1.4.11 asks for 3:1 against what is around them.
Both criteria measure the colours the page shows, and the dark theme is what this page shows to everyone with dark mode on: the site's own CSS switches to it. None of it is the browser's doing. Each colour on screen is one the site set, or never set for the dark theme.
A site that follows the system setting shows its dark theme to everyone with dark mode on, including phones set to switch to it at night, so many readers never chose this site's theme at all.
1 of 10 groups affected
- No vision (not affected)
- Low vision (affected)
- Colour vision (not affected)
- No hearing (not affected)
- Hard of hearing (not affected)
- No speech (not affected)
- Motor (not affected)
- Reach, strength (not affected)
- Cognitive (not affected)
- Seizures (not affected)
- Limited vision Some people with low vision turn on dark mode because a white screen glares. On the dark card the times fall to 3.04:1 and the icons to 1.42:1, so the times are hard to read and the icon buttons hard to find, even under a magnifier.
Automated checkers, axe included, measure the page in the colour scheme the browser is in, and a headless browser is in light mode unless it is told otherwise. In light mode every colour on this page passes, so the scan is clean. EaseWeb's audit emulates dark mode on pages whose colours change with it and measures them again, but only a theme that follows the system setting: one switched on only by the site's own toggle is never reached. The Before also passes a review of the dark theme's CSS, because the dark block is complete for every token it has; what is missing is in the components. A generated dark theme can make the same cut, flipping the page and the main text and leaving the greys and the icon fills on the cards as they were.
Try it
Turn on dark mode, then read how long each recipe takes and find its Save button
The switch draws the site as it looks on a device set to dark mode. The cards turn dark: can you still read “25 min · Serves 4”, and see the Save and Share icons?
Broken Can you fix this?
Lemon barley risotto
The fix
Give every text and icon colour its own dark value
<article class="recipe"> <h2>Charred leeks with romesco</h2> <p class="meta">25 min · Serves 4</p> <button type="button" class="icon-button" aria-label="Save"> <svg viewBox="0 0 24 24" aria-hidden="true"> <path fill="#374151" d="…"/> </svg> </button> <button type="button" class="icon-button" aria-label="Share"> <svg viewBox="0 0 24 24" aria-hidden="true"> <path fill="#374151" d="…"/> </svg> </button></article>What is wrong2 points
In the light theme this passes. In the dark theme.
- The time line keeps
#6b7280, 3.04:1 on the dark card. - The Save and Share icons keep
fill="#374151", 1.42:1 on the dark card.
A theme token for every colour
<article class="recipe"> <h2>Charred leeks with romesco</h2> <p class="meta">25 min · Serves 4</p> <button type="button" class="icon-button" aria-label="Save"> <svg viewBox="0 0 24 24" aria-hidden="true"> <path fill="currentColor" d="…"/> </svg> </button> <button type="button" class="icon-button" aria-label="Share"> <svg viewBox="0 0 24 24" aria-hidden="true"> <path fill="currentColor" d="…"/> </svg> </button></article>How it is fixed4 steps
- Add a token for each role the components use, here
--mutedfor quiet text and--iconfor icons, with the light theme's values. The light theme looks exactly as it did. - Redefine both in the dark block, measured on the dark card: the grey lifts to
#9ca3af, 5.78:1, and the icons to#d1d5db, 9.96:1. - Colour
.metawithvar(--muted)instead of its hex value. - Set the SVG paths to
fill="currentColor", and give.icon-buttonits colour from--icon. The icon now takes whatever colour the button has, in either theme.
This fixes the Before and passes 1.4.3 and 1.4.11 in both themes. The dark version keeps the design's quiet grey line and soft icons, a step lighter.
Both pass, and both give each colour a dark value; they differ in where that value lives. Theme tokens keep every colour in one place, so a design system's palette and its ratios can be reviewed together, and a new component that uses the tokens is right in both themes without anyone thinking about it. Choose them when several components share the same greys. light-dark() keeps each pair on one line in the component, where a reviewer sees at once that a colour has its dark value, and it needs no second block. Choose it for a component with colours of its own, or a site with only a few. Both still need the icons in currentColor: no token or pair reaches a colour written into the SVG.
Measure each colour on every surface it sits on in the dark theme, not only on the page. Dark themes often show depth with lighter surfaces, so a grey that passes on the #111827 page can fall short on a lighter card, banner or menu, where its contrast is lowest.
A theme switched by the site's own toggle, rather than by the system setting, needs the same values. Set an attribute such as data-theme="dark" on the root from the toggle and redefine the tokens under that selector as well as in the media query, or set color-scheme on the root from the toggle so that light-dark() follows it. WCAG's Understanding page for 1.4.3 allows colours below the minimum that a user chooses, "provided there is a mechanism that will return to the required values"; whether a dark theme behind a toggle, with the light theme a click away, counts as such a choice is still an open question for WCAG. A theme that follows the system setting has no such click: it is the page every dark-mode user gets. Either way, some people choose dark mode because they need it to read, so measure every theme you ship.
With Tailwind, each colour utility needs its own dark: pair (text-gray-500 dark:text-gray-400), and one that is missed keeps its light value. Defining the colours as theme variables that change in the dark theme lets the utilities name roles, as the tokens above do, so there is no pair to forget.
Icons drawn as <img> files can't take currentColor: the page's CSS doesn't reach inside them. Inline single-colour icons as SVG, or supply a second file for the dark theme. A prefers-color-scheme query inside the SVG file itself is another way, but Safari has long ignored it in an SVG loaded by <img>. Text in a logo is exempt from 1.4.3, but a black wordmark on a dark page still looks broken: use <picture> with a <source media="(prefers-color-scheme: dark)"> that points at a light version.
The focus ring is the same failure. A #1a4fd0 ring is 6.83:1 on white and 2.15:1 on the #1f2937 card; give it a token or a pair of its own. An embedded widget in an iframe keeps its own colours: use its dark option if it has one, or leave it light, which passes as long as its own colours do.
Design ideas
A dark theme can stay quiet and still be read
Six ways to draw the recipe site’s dark theme. Read each one, and Tab to its link or field: three can be read at a glance, and three are hard to make out.
In the design file
- Draw the dark version of every screen, not only its page colour, with a dark colour for each text style, icon and field edge.
- Write each ratio beside its colour, measured on the surface it sits on in that theme, at least 4.5:1 for text and 3:1 for icons and field edges.
- Name each colour by its job, such as quiet text or icon, so every name has a light and a dark value.
- Put the logo and the illustrations on the dark page too, with a light version wherever the dark one disappears.
Passes
- Near-black page, off-white text A #18181b page with #e4e4e7 titles, 13.96:1, and a #a1a1aa time line, 6.91:1. Softer than pure white on black, and every line well above 4.5:1.
- Lighter cards for depth A #1e293b card on a #0f172a page shows depth without shadows. The #94a3b8 time line is measured on the card, where it is lowest: 5.71:1, against 6.96:1 on the page.
Serve with the romesco recipe.
An accent a step lighter in dark The link that is #2563eb on white, 5.17:1, turns #60a5fa on the #1f2937 card, 5.77:1. It still reads as the brand’s blue, and keeps its underline.
Fails
- Time line in 40% whiteWCAG 1.4.3 White at 40% opacity looks soft on any dark surface, but on #18181b it comes out as #747476, 3.80:1. A solid #a1a1aa keeps the same quiet look at 6.91:1.
Serve with the romesco recipe.
The light theme’s blue, keptWCAG 1.4.3 #2563eb passes on white at 5.17:1, but on the #1f2937 card it is 2.84:1. The link is still blue, and hard to read.- A search field drawn by a faint fillWCAG 1.4.11 A #27272a fill on the #18181b page is 1.19:1, so the field’s edge can’t be made out. A #71717a edge, 3.67:1, keeps the same soft fill and marks the field.
Verify the fix
5 checks, no mouse.
Test with: Dark mode, Contrast measurement, Zoom
Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the theme tokens or shared component styles change.
Limits
This is a starting pattern, not a guarantee for every application. Colour values and token names are examples; measure your own palette on your real surfaces. The ratios for the light theme are covered in low-contrast text on brand colours, photos and hover states and low-contrast form fields, icons and focus rings. A Windows contrast theme replaces the site's colours with the user's own, which is a different failure, covered in high contrast mode hiding focus, icons and selections. Check the complete pages, in both themes, with people who rely on them.
This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.
References
- WCAG Understanding: contrast-minimum (opens in a new tab)
- WCAG Understanding: non-text-contrast (opens in a new tab)
- developer.mozilla.org: prefers-color-scheme (opens in a new tab)
- developer.mozilla.org: light-dark (opens in a new tab)
- developer.mozilla.org: color-scheme (opens in a new tab)
- hidde.blog: making-single-color-svg-icons-work-in-dark-mode (opens in a new tab)
EaseWeb service
Fixed it? Keep it from coming back.
I turn guides like this one into rules your coding agents follow and checks your CI runs. Then I retest by hand with a keyboard and screen readers, periodically.