Color, contrast, and motion
How people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.
The problem: a UI that only works for perfect eyes
Picture a team shipping a new checkout page. The designer picked a soft gray for helper text, #999999 on white, because it looks calm. Form errors show as a red border and nothing else. A success toast slides in from the side with a springy bounce.
The layout was tested on a 1440 px wide laptop at 100% zoom. Every screen reader test passes, every button is reachable by keyboard.
Then real people use it. A 68-year-old customer with age-related low vision cannot read the helper text: the gray has a contrast ratio of about 2.85:1 against white, well under the 4.5:1 minimum. A customer with red-green color blindness (about 1 in 12 men of Northern European descent) cannot tell which field has an error, because the red border looks the same as the gray one.
A customer who zooms to 400% to read gets a page that scrolls both ways, so every line of text needs a sideways scroll to finish. A customer with a vestibular disorder (an inner ear condition where on-screen motion can cause dizziness and nausea) feels sick from the bouncing toast. A customer on Windows with a high contrast theme sees the custom checkboxes vanish, because they were drawn with background colors that the theme throws away.
What visual accessibility covers
None of these bugs show up in a screen reader test or a keyboard test. They are visual accessibility problems: problems with how the page looks and moves for people who see differently, or who changed their system settings to see better. This lesson covers them in six groups.
- Contrast: is text and every meaningful shape different enough in lightness from what sits behind it? (WCAG 1.4.3, 1.4.6, 1.4.11)
- Color alone: is any meaning carried only by hue, like red for error and green for success? (WCAG 1.4.1)
- Zoom and reflow: does the page still work at 200% text size, at 400% zoom, and with wider letter and line spacing? (WCAG 1.4.4, 1.4.10, 1.4.12)
- Target size: are buttons and links big enough to hit with a shaky hand? (WCAG 2.5.8)
- Motion: does the page respect people who asked the system to reduce motion? (
prefers-reduced-motion, WCAG 2.3.3) - User themes: does the page survive dark mode, a request for more contrast, and the forced colors of Windows high contrast? (
prefers-color-scheme,prefers-contrast,forced-colors)
WCAG, the Web Content Accessibility Guidelines from the W3C, is the standard most laws point to. Version 2.2 (October 2023) is current. Each rule is a numbered success criterion with a level: A is the floor, AA is what laws and contracts usually require, AAA is stricter and used for specific content. When this lesson says "AA", it means the level most products must meet.
The good news: almost all of it is cheap if you design it in. Contrast is a token decision, made once in the design system. Reflow comes from a responsive layout you already need for phones. Motion and themes are a few media queries.
The expensive path is retrofitting after launch, page by page. The rest of this lesson shows how each piece works, how to check it with numbers, and how to talk about it in a system design interview.
The mental model: lightness carries the information, the user holds the dial
Keep two ideas in your head for the whole lesson. The first: the eye reads shapes from lightness, not from hue. The second: the user, not the designer, has the final say on size, color, and motion. Almost every rule below follows from one of these.
Idea 1: lightness, not hue
Your visual system splits what the eye sees into a lightness signal and two color-difference signals (red against green, blue against yellow). The lightness signal carries the fine detail you use to see edges, letters, and shapes. The color signals carry less detail, and they are the part that differs in color blindness. So text is readable when its lightness differs enough from the background, almost no matter what the hues are.
A quick test makes this concrete: convert a screenshot to grayscale. Whatever is still readable has enough lightness contrast. Pure red #ff0000 on pure green #00ff00 looks loud in color, but in grayscale both turn into similar mid-grays, and the measured ratio is only 2.91:1. Black on mid-gray #808080 looks dull, yet it measures 5.32:1 and reads fine.
This is why the WCAG contrast formula ignores hue entirely. It converts each color to one number, its relative luminance (how much light it gives off, from 0 for black to 1 for white, weighted by how sensitive the eye is to each channel), and compares the two numbers. It is also why "don't use color alone" is a separate rule: if two states differ only in hue, the lightness channel sees no difference.
Idea 2: the user holds the dial
Your design is a suggestion. The person on the other side can zoom the page to 400%, raise the default font size, override line spacing with a browser extension, switch the OS to dark mode, ask for more contrast, ask for less motion, or turn on a forced color theme that replaces every color you chose. Each of these is a setting real people rely on every day.
Think of your UI as a recipe, not a photograph. A photograph has one fixed size and fixed colors. A recipe says "text in the body color, with this much contrast, on the surface color, wrapping to fit", and the browser cooks it for whatever kitchen it lands in. Code that fixes heights in pixels, draws meaning with background images, or plays animation no matter what is a photograph: it breaks as soon as the user turns a dial.
| User setting | What changes | How your CSS sees it | What breaks if you ignore it |
|---|---|---|---|
| Browser zoom (400% and beyond) | Every CSS pixel gets bigger, so the viewport gets narrower | A smaller viewport width; media queries fire | Fixed-width layouts scroll sideways |
| Default font size | The root font-size, so every rem | No media query; rem units follow it | Text in px ignores it |
| Text spacing overrides | line-height, letter-spacing, word and paragraph spacing | No media query; a user style sheet overrides your values | Text clipped in fixed-height boxes |
| Reduce motion | An OS accessibility switch | prefers-reduced-motion: reduce | Parallax and big transitions cause nausea |
| Dark mode | An OS appearance switch | prefers-color-scheme: dark | A white page at night; hard-coded colors clash |
| Increase contrast | An OS accessibility switch | prefers-contrast: more | Thin gray borders stay too faint |
| Forced colors (Windows contrast themes) | The browser replaces your colors with a small system palette | forced-colors: active | Icons, focus rings, and custom controls vanish |
| Checking your settings... |
Media queries that match: prefers-color-scheme: light, prefers-reduced-motion: no-preference, prefers-contrast: no-preference, forced-colors: none, width: 1280px, height: 800px
| Surface | #ffffff |
|---|---|
| Muted text | #6b7280, 4.83:1 |
| Toast entrance | 300 ms slide in from the side |
| Focus ring | 2 px ring |
| Layout | three columns |
| Sticky header | sticky |
How the contrast ratio is computed, step by step
The WCAG contrast ratio compares the relative luminance of two colors. It runs from 1:1 (the same color twice) to 21:1 (black on white). The formula is short, and every frontend engineer should be able to write it from memory, because it explains why some colors that look fine fail and others that look dull pass.
The four steps
- Split the color into channels and scale each to 0 to 1:
#767676is red, green, and blue all equal to 118, so each is 118 / 255 = 0.463. - Undo the sRGB gamma to get linear light. Screens store values on a curve so that dark tones get more of the 256 steps. For a channel value c: if c is at most 0.04045, linear = c / 12.92; otherwise linear = ((c + 0.055) / 1.055) raised to the power 2.4. For 0.463 that gives 0.181.
- Weight and add the channels: L = 0.2126 R + 0.7152 G + 0.0722 B. Green counts most because the eye is most sensitive to it, blue least. For a gray all three are 0.181, so L = 0.181.
- Compare: ratio = (L lighter + 0.05) / (L darker + 0.05). White is L = 1, so
#767676on white is 1.05 / 0.231 = 4.54:1.
The + 0.05 in step 4 stands for flare: the light reflected off a real screen in a real room. It stops the ratio from going to infinity for pure black and is why the maximum is 21: (1 + 0.05) / (0 + 0.05) = 21.
// contrast.mjs: run with `node contrast.mjs`
const linear = (c8) => {
const c = c8 / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
export function luminance(hex) {
const [r, g, b] = [1, 3, 5].map((i) => linear(parseInt(hex.slice(i, i + 2), 16)));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export function contrast(a, b) {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
console.log(contrast("#767676", "#ffffff").toFixed(2)); // 4.54
console.log(contrast("#777777", "#ffffff").toFixed(2)); // 4.48
console.log(contrast("#ff0000", "#ffffff").toFixed(2)); // 4.00 (3.998 before rounding)
console.log(contrast("#000000", "#ffffff").toFixed(2)); // 21.00
Worked examples you will meet in real design systems
Here are colors you see every day, measured with the code above. Notice how close the pass and fail colors are. #767676 passes at 4.54:1 and #777777, one step lighter, fails at 4.48:1. You cannot judge this by eye, and you should never round up: 4.48 is a fail.
| Foreground on background | Ratio | Normal text AA (4.5) | Large text or UI (3) |
|---|---|---|---|
#999999 on #ffffff (soft gray helper text) | 2.85:1 | Fail | Fail |
#9ca3af on #ffffff (a common gray-400) | 2.54:1 | Fail | Fail |
#6b7280 on #ffffff (a common gray-500) | 4.83:1 | Pass | Pass |
#ff0000 on #ffffff (pure red) | 3.99:1 | Fail | Pass |
#ffffff on #ef4444 (white on a red-500 button) | 3.76:1 | Fail | Pass |
#ffffff on #dc2626 (white on a red-600 button) | 4.83:1 | Pass | Pass |
#ffffff on #16a34a (white on a green-600 button) | 3.30:1 | Fail | Pass |
#ffffff on #2563eb (white on a blue-600 button) | 5.17:1 | Pass | Pass |
#e5e5e5 on #ffffff (a light input border) | 1.26:1 | Fail | Fail |
Try it yourself in the lab below. Pick any two colors and a text size, and watch which levels pass. Then switch to the simulated forced colors view to see that, in that mode, your colors stop mattering: the user's palette decides the contrast, and what matters is whether your shapes survive.
Contrast ratio: 4.54:1. At 16 px regular, this is normal text, so it needs the stricter numbers.
| Check | Needs | Result |
|---|---|---|
| This text, AA (normal) | 4.5:1 | ✓ Pass |
| This text, AAA (normal) | 7:1 | ✕ Fail |
| Borders, icons, focus rings (1.4.11) | 3:1 | ✓ Pass |
Your own colors apply. Switch to a forced colors view to see which parts of a form would survive.
| Part | Drawn with | Result |
|---|---|---|
| Body text | color | Your colors |
| Input field | border | Your colors |
| Checkbox tick | background-color square | Your colors |
| Selected tab | tinted background-color | Your colors |
| Focus ring | box-shadow only | Your colors |
Two patterns stand out. First, saturated mid-tone colors like red-500 and green-600 with white text tend to land between 3 and 4.5: fine for big bold labels, failing for normal button text. Going one shade darker almost always fixes it. Second, the light grays used for input borders and dividers (around 1.3:1) fail the 3:1 rule for UI components, which the next chapter explains.
Check your understanding · Exercise 1
A designer says "yellow #ffff00 text on white has great contrast, it is so bright". What does the ratio say, and why? Pick one.
0.463 becomes 0.181, because screens store mid-tones brighter than the light they give off. The + 0.05 stands for screen glare.The contrast rules in detail: text, large text, UI, and what is exempt
WCAG has three contrast criteria. Knowing exactly what each covers, and what it does not, is the difference between a vague "make it accessible" and a review you can act on.
| Criterion | Level | Applies to | Minimum ratio |
|---|---|---|---|
| 1.4.3 Contrast (Minimum) | AA | Text and images of text | 4.5:1, or 3:1 for large text |
| 1.4.6 Contrast (Enhanced) | AAA | Text and images of text | 7:1, or 4.5:1 for large text |
| 1.4.11 Non-text Contrast | AA | UI component boundaries and states, and graphics needed to understand content | 3:1 against adjacent colors |
What counts as large text
Large text is at least 18 point, or at least 14 point and bold. In CSS that is about 24px regular, or about 18.66px bold (1 point is 4/3 of a CSS pixel). Large letters have thicker strokes, so they stay readable with less contrast. A common mistake is to treat 18px regular as large: it is not, so 18 px body copy still needs 4.5:1.
Weight matters only from bold up, which means font-weight: 700 or heavier. A 600 semibold at 16 px is normal text. Thin weights (300 and below) are not exempted by anything, yet they read worse than the formula suggests, so treat 4.5:1 as the floor for them, not the goal.
Non-text contrast: the 3:1 rule for controls and graphics
Criterion 1.4.11 asks that anything you need to see to use a control has 3:1 against what is next to it. That covers the border of a text input when the border is the only thing showing where the field is, a checkbox outline, the thumb of a slider, the focus indicator, and the checked state of a toggle. It also covers parts of charts and icons that carry meaning, like the lines of a line chart or a standalone icon button.
- Input borders: a
#e5e5e5border on white is 1.26:1 and fails if the border is the only cue. Either darken it (a#767676border passes easily) or give the field a filled background that itself has 3:1 against the page. - Buttons: a button whose text is readable but whose shape is invisible is fine. The text is what identifies it. An icon-only button has no text to identify it, so the icon itself needs 3:1 against the background.
- States: the difference between checked and unchecked, on and off, selected and not selected must be visible at 3:1. A toggle that changes from light gray to slightly darker gray fails.
- Focus rings: covered here and, more strictly, by 2.4.13 Focus Appearance (AAA). The keyboard navigation lesson covers 2.4.7 and 2.4.11 for focus that is visible and not hidden.
What is exempt, and what people wrongly think is exempt
Real exemptions: text in a logo or brand name, text that is purely decorative or not visible to anyone, and text in an inactive (disabled) control. Photos where text is incidental, like a street sign in a picture, are exempt too.
Not exempt: placeholder text (if it shows information, it must meet 4.5:1, and it should not be the only label anyway), helper text and captions in small gray, text over images or gradients (measure against the worst spot behind the text), and the hover or focus state of a control. Disabled controls being exempt is also a reason to avoid them: a user who cannot read why the Submit button is gray is stuck. Prefer an enabled button that explains what is missing.
APCA: a possible future, not today's rule
The WCAG 2 formula has known weak spots. It is too strict for some white text on saturated mid-tones like orange and too lenient for some dark-on-dark pairs, and it ignores font size and weight beyond the one large-text switch. APCA, the Accessible Perceptual Contrast Algorithm, is a newer model that gives a lightness contrast value (Lc) depending on which color is the text and which is the background, and pairs it with font size and weight lookup tables.
APCA has been discussed as a candidate for WCAG 3, which is still an early working draft with no settled contrast method and no date. Laws and audits in 2025 and 2026 reference WCAG 2.1 or 2.2, so the 4.5:1 and 3:1 rules are what you must meet. A sensible position: pass WCAG 2 for compliance, and use APCA as an extra check for dark themes and thin fonts where WCAG 2 is known to mislead.
Check your understanding · Exercise 2
A card title is 20px, font-weight: 600, color #ff0000 on white (3.99:1). Does it pass AA? Pick one.
Never color alone: meaning that survives grayscale
Criterion 1.4.1 Use of Color (level A, the floor) says color must not be the only way to convey information, show an action, ask for a response, or tell elements apart. Color can still be used, and it helps most people. It just needs a second cue: text, an icon, a shape, a pattern, position, or an underline.
Who cannot rely on hue
- Red-green color vision deficiency (protan and deutan types): about 8% of men and 0.5% of women of Northern European descent. Reds, greens, oranges, and browns can look alike.
- Blue-yellow deficiency (tritan): rare, often estimated at around 1 in 10,000 people or fewer, but it confuses blue with green and yellow with pink.
- Low vision and aging eyes: yellowing of the lens with age makes blues and greens harder to tell apart.
- Situations: a phone in bright sun, a cheap projector, a grayscale e-ink reader, a printed page, a night-shift filter that turns the screen orange.
- Forced colors and screen readers: in forced colors your hues are replaced; a screen reader never hears the color at all.
The common failures and their fixes
| Pattern | Color-only version | Fixed version |
|---|---|---|
| Form errors | The field border turns red | Red border plus an error icon plus a message, linked with aria-describedby, and aria-invalid="true" |
| Links in body text | Links are blue, not underlined | Keep the underline, or make the link 3:1 against the surrounding text and add an underline on hover and focus |
| Status badges | Green, yellow, red dots | Dot plus a word (Active, Pending, Failed) or distinct icons (check, clock, cross) |
| Charts | Five lines in five hues | Direct labels at the line ends, different dash patterns or markers, and a data table |
| Required fields | Required labels are red | An asterisk with a legend, or the word required, plus the required attribute |
| Price changes | Up is green, down is red | Arrow up or down and a plus or minus sign |
The link rule is worth a closer look. WCAG technique G183 allows links without underlines if the link color has 3:1 contrast with the surrounding body text and the link gets a non-color cue like an underline on hover and on keyboard focus. That is hard to meet with the text itself also at 4.5:1 against the background, so the simplest choice is to keep underlines on links inside paragraphs. Navigation menus and buttons are fine without them, because their position and shape already say they are interactive.
Note how this rule ties to the screen reader lesson. A second cue that is visible text, like "Failed", also gives the screen reader something to read. An icon alone is not enough for a screen reader: it needs an accessible name (aria-label, or visually hidden text) or it must be marked decorative next to real text. One fix serves both groups when you choose text first.
Check your understanding · Exercise 3
A dashboard shows server health as colored dots: green, amber, red. Which change best meets 1.4.1 for both sighted color-blind users and screen reader users? Pick one.
aria-describedby.Zoom, reflow, text spacing, and target size
People with low vision mostly do not use a screen reader. They make things bigger. This chapter covers the four rules that make bigger work: resize text, reflow, text spacing, and target size.
1.4.4 Resize Text and 1.4.10 Reflow
1.4.4 Resize Text (AA) says text must scale to 200% with no lost content or features, using only the browser. 1.4.10 Reflow (AA, added in WCAG 2.1) goes further: at a width of 320 CSS pixels, content must fit without scrolling in two directions. Vertical scrolling is fine; a sideways scroll to read each line is not.
Why 320? Browser zoom makes every CSS pixel bigger, so the viewport gets narrower in CSS pixels. A 1280 px wide window at 400% zoom is 1280 / 4 = 320 CSS px wide, the same as a small phone.
Your media queries fire exactly as they would on that phone. That is the key insight: if your layout is truly responsive down to 320 px, reflow at 400% comes almost for free.
For content that scrolls sideways by design, like a horizontal carousel or a vertical-text language, the rule applies to height instead: 256 CSS px. Some content is allowed to need two dimensions: maps, large data tables, diagrams, video, games, and toolbars that must keep their layout. A data table may scroll sideways inside its own container, but the page around it must not.
- Fixed widths:
width: 960pxon a wrapper forces sideways scrolling. Usemax-widthwith fluid inner widths. - Fixed heights with text:
height: 48pxon a box with text clips or overlaps the text once it wraps. Usemin-heightand padding. - Sticky headers and footers: at 400% on a laptop the viewport is about 180 to 250 CSS px tall. A 64 px sticky header plus a 56 px cookie bar can cover half of it. Make them static, or shrink them, below a height breakpoint (
@media (max-height: 400px)). - Disabling zoom:
maximum-scale=1oruser-scalable=noin the viewport meta tag can block pinch zoom on mobile. Leave both out. - Text in
pxonly: browser zoom still scalespx, but the default font size setting does not. Size text inremso both dials work.
/* Fluid layout that reflows to one column at narrow widths */
.page {
max-width: 72rem;
margin-inline: auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: 1.5rem;
}
/* Let long words and URLs wrap instead of pushing the page wide */
.prose { overflow-wrap: anywhere; }
/* Short viewports (400% zoom on a laptop): stop the header from sticking */
@media (max-height: 25rem) {
.site-header { position: static; }
}
min(18rem, 100%) keeps a single column from overflowing a 320 px viewport.1.4.12 Text Spacing
Many people with dyslexia or low vision read better with more space. 1.4.12 Text Spacing (AA) says nothing may be lost when a user sets: line height to 1.5 times the font size, paragraph spacing to 2 times the font size, letter spacing to 0.12 times, and word spacing to 0.16 times. You do not have to ship these values; you have to survive a user style sheet or extension that applies them.
What breaks: buttons and tabs with a fixed height and overflow: hidden, badges with fixed widths, and truncated text with text-overflow: ellipsis that now hides the important word. To test it, paste a small bookmarklet that sets the four values with !important on every element, then look for clipped or overlapping text.
2.5.8 Target Size (Minimum)
WCAG 2.2 added 2.5.8 Target Size (Minimum) at level AA: pointer targets must be at least 24 by 24 CSS px. People with tremors, arthritis, or limited dexterity, and anyone on a bumpy bus, miss small targets. The stricter 2.5.5 (AAA) asks for 44 by 44, which matches Apple's long-standing touch guidance.
- Spacing exception: a smaller target passes if a 24 px circle centered on it does not overlap any other target or the circle of another small target. In practice, a 16 px icon button with enough empty space around it can pass.
- Inline exception: links inside a sentence are exempt, since their size comes from the text.
- Equivalent and essential: exempt if another control on the page does the same thing at full size, or the size is essential (like pins on a map).
- User agent controls: default browser checkboxes and radios are exempt, but once you style them yourself the rule applies.
Check your understanding · Exercise 4
At 400% zoom on a 1280 px wide laptop, a settings page scrolls sideways. The wrapper uses width: 1024px and a two-column grid-template-columns: 240px 1fr. What is the best fix? Pick one.
Motion: prefers-reduced-motion and vestibular disorders
Motion on screen can make people physically ill. With a vestibular disorder, the inner ear's sense of balance is affected, and large on-screen movement that the body does not feel creates a mismatch. The result can be dizziness, nausea, or a migraine that lasts hours.
Concussion, inner ear infections, and some migraine conditions cause the same sensitivity. Motion also distracts people with attention disorders and can slow readers with cognitive disabilities.
What kind of motion is the problem
Not all animation is equal. A 150 ms color fade on a button is harmless. The triggers are motion that covers a large part of the view or moves in a way the eye reads as the world moving:
- Parallax scrolling: background and foreground moving at different speeds.
- Large zoom and scale: a page transition that zooms the whole view in or out.
- Scroll-jacking: the page scrolls at a different speed or direction than the wheel.
- Spinning and wobbling: rotating hero graphics, shaking error fields, bouncing elements.
- Auto-playing background video and long looping animations.
The media query and the OS settings behind it
Every major OS has a switch: Reduce motion on macOS and iOS, Animation effects off on Windows, Remove animations on Android. Browsers expose it as the media feature prefers-reduced-motion, with the values reduce and no-preference. It is supported in all current browsers.
The safest pattern is motion as an opt-in: write the static version by default, and add the motion inside a no-preference query. If the query is unknown or the user asked for less, they get the calm version.
/* Default: no slide, just appear */
.toast { opacity: 1; }
@media (prefers-reduced-motion: no-preference) {
.toast { animation: slide-in 300ms ease-out; }
html { scroll-behavior: smooth; }
}
/* Reduced: swap the movement for a short fade instead of dropping all feedback */
@media (prefers-reduced-motion: reduce) {
.toast { animation: fade-in 150ms linear; }
}
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
function scrollToResult(el) {
el.scrollIntoView({ behavior: query.matches ? "auto" : "smooth", block: "start" });
}
// The user can flip the setting while the page is open
query.addEventListener("change", () => carousel.setAutoplay(!query.matches));
Some teams add a global reset under reduce that sets animation-duration and transition-duration to 0.01ms !important on every element. It is a decent safety net, and the tiny duration (not zero) matters: a transition of zero length never runs, so its transitionend event never fires and code waiting on it would hang. It is not a design: it also kills harmless fades and spinners that tell the user something is loading. Use it as a backstop, then design reduced versions of the motions that matter.
The WCAG criteria for motion
| Criterion | Level | What it asks |
|---|---|---|
| 2.2.2 Pause, Stop, Hide | A | Anything that moves, blinks, or scrolls on its own for more than 5 seconds, next to other content, needs a way to pause, stop, or hide it: carousels, tickers, background video |
| 2.3.1 Three Flashes or Below Threshold | A | Nothing flashes more than 3 times in any one second (above the flash thresholds); flashing can trigger seizures in people with photosensitive epilepsy |
| 2.3.3 Animation from Interactions | AAA | Motion caused by a user action (scroll, click) can be turned off, unless it is essential |
2.3.3 is AAA, so it is not in most legal targets. Still, honoring prefers-reduced-motion is the way you meet it, it costs a few lines, and the people who turned the setting on did it for a reason. Treat it as required in any modern design system.
Check your understanding · Exercise 5
A product page has a parallax hero, a 150 ms hover color change on buttons, and a skeleton shimmer while loading. With prefers-reduced-motion: reduce, what should change? Pick one.
@media (prefers-reduced-motion: no-preference), so an unknown preference gets the safe version.User themes: dark mode, more contrast, and forced colors
Three media features tell you how the user wants colors handled. They are different in kind. prefers-color-scheme and prefers-contrast are preferences: you decide how to respond. forced-colors is a fact: the browser has already replaced your colors, and your job is to make sure nothing important disappears.
prefers-color-scheme and dark mode contrast
prefers-color-scheme is light or dark, following the OS setting. Many people with light sensitivity, migraine, or some eye conditions read far better on dark surfaces; others, including some people with astigmatism, find light text on black blurrier. That is why it should follow the user, with a manual override in settings.
The accessibility trap: a dark theme is a new set of color pairs, and each one needs checking again. Brand blue #2563eb is 5.17:1 as a white-text button on light, but as link text on a #121212 surface it is only 3.62:1, a fail. A lighter blue like #60a5fa reaches 7.37:1 there.
Gray text is the same story: #6b7280 on a near-black #111827 is 3.67:1, while #9ca3af is 6.99:1. Shadows also vanish on dark surfaces, so elevation needs lighter surfaces or borders instead.
:root {
color-scheme: light dark; /* native controls and scrollbars follow too */
--surface: #ffffff;
--text: #111827; /* 17.74:1 on surface */
--text-muted: #6b7280; /* 4.83:1 */
--link: #1d4ed8; /* 6.70:1 */
}
@media (prefers-color-scheme: dark) {
:root {
--surface: #121212;
--text: #e0e0e0; /* 14.19:1, softer than pure white */
--text-muted: #9ca3af; /* 7.38:1 */
--link: #60a5fa; /* 7.37:1 */
}
}
Decision How should a product implement dark mode?
What we pick: Use semantic tokens as the source of truth; light-dark() is a nice way to write them in modern stacks. Never auto-invert for a product. Whichever you choose, test the contrast of every token pair in both themes in CI.
prefers-contrast
prefers-contrast reports more, less, custom, or no-preference. On macOS and iOS, the Increase contrast setting makes it more. A good response is small and targeted: darken muted text to the body color, make faint borders solid, drop translucent backgrounds, and thicken focus rings. You do not need a third full theme; a few overrides on the tokens are enough.
@media (prefers-contrast: more) {
:root {
--text-muted: var(--text);
--border: currentColor;
--focus-ring-width: 3px;
}
.glass-panel { backdrop-filter: none; background: var(--surface); }
}
Forced colors mode (Windows contrast themes)
Windows contrast themes (once called High Contrast Mode) let users pick a tiny palette, often white or yellow text on black, or black on white. Chromium browsers and Firefox on Windows then apply forced colors: they override color, background-color, border-color, outline-color, text decoration colors, and SVG fill and stroke with a few system colors.
They also remove box-shadow, text-shadow, and gradient backgrounds. Images loaded by URL stay as they are. The media query forced-colors: active tells you it is on, and prefers-color-scheme switches to match the lightness of the user's background.
What disappears is anything drawn only with a background color or a shadow: a custom checkbox whose check is a colored square, a toggle whose state is a background change, a focus ring made with box-shadow, an icon drawn as a CSS mask filled by background-color (it turns into the page color), a selected tab marked only by a tinted background. Things drawn with borders, outlines, text, and currentColor survive.
Two more details: the browser draws a solid backplate behind text so it stays readable over images, and it picks system colors from native HTML semantics, so a div with role="button" gets text colors, not button colors.
| System color | Meant for |
|---|---|
Canvas and CanvasText | Page background and normal text |
LinkText | Links |
ButtonFace, ButtonText, ButtonBorder | Buttons and their borders |
Field and FieldText | Inputs |
Highlight and HighlightText | Selected items and current states |
GrayText | Disabled text |
/* Focus ring drawn with box-shadow: add a transparent outline as a fallback.
Transparent is invisible normally, but forced colors paint it in a system color. */
.button:focus-visible {
box-shadow: 0 0 0 3px #1d4ed8;
outline: 2px solid transparent;
}
.icon { fill: currentColor; } /* follows the forced text color */
@media (forced-colors: active) {
.switch[aria-checked="true"] .thumb { background: Highlight; }
.tab[aria-selected="true"] { border-bottom: 3px solid Highlight; }
.swatch { forced-color-adjust: none; } /* a color picker must show real colors */
}
currentColor for icons, and system colors for states.forced-color-adjust: none opts an element out, so it keeps your colors. Use it only where the real color is the content, like a color swatch, a product photo frame, or a chart legend key, and never on whole components: you would be undoing a choice the user made because they need it. Test with Chrome DevTools: Rendering, then Emulate CSS media feature forced-colors. On a real Windows machine, try at least one dark and one light contrast theme.
Check your understanding · Exercise 6
In forced colors, your custom toggle switches look the same whether on or off. The on state is drawn as a blue track background. What is the best fix? Pick one.
At scale: design tokens, CI checks, and what breaks in production
A product with 400 screens cannot be fixed one color at a time. Visual accessibility scales when it lives in the design system: a small set of tested tokens and components that every screen uses. The goal is that a product engineer cannot easily build a failing pair, because the only colors on offer are pairs that pass.
Three layers of tokens
- Primitive tokens: the raw palette, like
blue-600: #2563eb. Nobody uses these in components. - Semantic tokens: roles, like
text-default,text-muted,surface,border-control,focus-ring,danger-text. Each has a light value, a dark value, and optionally a more-contrast value. - Component tokens: only where a component needs its own, like
button-primary-bg, pointing at semantic tokens.
Contrast is a property of a pair, so the system should list the pairs that are allowed: text-muted on surface and on surface-raised, danger-text on surface and on danger-bg, and so on. A unit test then computes every allowed pair in every theme with the formula from chapter 3 and fails the build under 4.5:1 for text roles and 3:1 for UI roles. This catches the dark mode bug where someone changes one surface color and silently breaks ten pairs.
import { contrast } from "./contrast.mjs";
import { themes, pairs } from "./tokens.mjs";
// pairs: [{ fg: "text-muted", bg: "surface", min: 4.5 }, { fg: "border-control", bg: "surface", min: 3 }]
for (const [name, t] of Object.entries(themes)) {
for (const { fg, bg, min } of pairs) {
const ratio = contrast(t[fg], t[bg]);
if (ratio < min) {
throw new Error(`${name}: ${fg} on ${bg} is ${ratio.toFixed(2)}, needs ${min}`);
}
}
}
Failure modes that slip through
| Failure | Why it happens | How you notice | Fix |
|---|---|---|---|
Text with opacity: 0.6 | Opacity blends text into the background, so the real color is lighter than the token | axe flags it; the token test does not | Use a solid muted token instead of opacity |
| Text over a photo or gradient | The background varies under the text | Visual review; automated tools often skip it | Add a scrim and measure against the worst spot |
| Hover and focus states | Only the default state was checked | Manual review of states | Add state pairs to the token test |
| Theme flash | The page paints light, then JavaScript switches to dark | A white flash at night on every load | Set color-scheme and the theme attribute in a tiny inline script in the head, before first paint |
| Clipped text at 400% | Fixed heights and overflow: hidden | Zoom test, text spacing bookmarklet | Use min-height, let text wrap |
| Invisible state in forced colors | State drawn with background or shadow | Forced colors emulation | Borders, outlines, system colors |
| Ignored reduced motion in JS | CSS respects it, the carousel library does not | Turn the OS setting on and use the page | Check matchMedia in every animation path |
The theme flash deserves a picture, because it is a timing bug. If the theme is chosen by JavaScript in your app bundle, the browser has already painted the light page by the time the bundle runs. The fix is to make the decision before the first paint.
color-scheme early also themes native scrollbars and form controls from the first frame.Automated tools catch only part of this. axe and Lighthouse compute text contrast well for solid backgrounds, but they cannot judge text on images, cannot tell if color alone carries meaning, and do not test reflow, motion, or forced colors. The accessibility testing lesson covers how to combine them with manual checks.
How real products handle it
- GOV.UK Design System: links are underlined in body text, and the focus state is a yellow
#ffdd00background with a thick black underline, which works on every page color and survives most color deficiencies because it changes lightness, not only hue. - GitHub (Primer): ships light and dark high contrast themes and color-blind themes (protanopia and deuteranopia, and tritanopia) that replace the red and green used in diffs and status checks. Users pick them in settings, in addition to following the OS.
- Apple platforms: the system honors Reduce Motion, Increase Contrast, and Bold Text, and the Human Interface Guidelines recommend 44 by 44 point touch targets.
- Windows: contrast themes are built into the OS, which is why
forced-colorsexists; Microsoft Edge and Chrome both apply them to web pages.
Check your understanding · Exercise 7
Your token contrast test passes, but axe reports a contrast failure on muted text inside a card. What is the most likely cause? Pick one.
Using it in a system design interview
Visual accessibility rarely gets its own question. It comes up inside "design a component library", "design a dashboard", "design a news feed", or when the interviewer asks "how would you make this accessible?" A strong candidate brings it up briefly and concretely, at the level of the system, not as a list of slogans.
When to bring it up and what to say
- Design system or component library: this is the main place. Talk about semantic tokens per theme, the allowed pairs, the contrast test in CI,
prefers-reduced-motionbuilt into the motion tokens, and forced colors support in every control. - Dashboards and charts: color alone. Say you will label series directly, use patterns or markers, and offer a table view.
- Forms: errors with icon and text, not just a red border; input borders at 3:1; targets at least 24 px.
- Feeds, media, landing pages: auto-playing video and carousels need pause controls and must respect reduced motion; text on images needs a scrim.
- Any responsive layout: mention that supporting 320 px wide covers 400% zoom.
How deep to go by level
| Level | What a strong answer shows |
|---|---|
| Junior | Knows 4.5:1 for text and 3:1 for large text, does not use color alone, knows reduced motion exists and how to write the media query |
| Mid | Explains relative luminance and why similar colors land on both sides of 4.5; covers 1.4.11 for controls, reflow at 320 px, text spacing, target size, and dark mode pairs |
| Senior | Designs the token system with tested pairs per theme and a CI check; handles forced colors, prefers-contrast, theme flash before first paint, and says where automation stops and manual testing begins |
| Staff | Sets the policy: WCAG 2.2 AA as the bar, APCA as an extra check with honest hedging, ownership in the design system team, regression budgets, and how legal requirements (such as the European Accessibility Act from June 2025) shape priorities |
What to draw: a small token diagram (primitives, semantic tokens per theme, components), plus a note on the CI contrast test. If the question is a dashboard, sketch one chart with direct labels and a toggle to a table. Keep it to a minute or two unless the interviewer digs in.
Check your understanding · Exercise 8
In a "design a component library" interview, which statement shows the most senior understanding of visual accessibility? Pick one.
Summary
- Contrast is about lightness, not hue. Text needs 4.5:1 (3:1 if at least 24 px, or about 18.66 px bold); controls, focus rings, and meaningful graphics need 3:1 (WCAG 1.4.3 and 1.4.11).
- The ratio is (L1 + 0.05) / (L2 + 0.05) on relative luminance. Measure it, never round up, and test every allowed token pair in every theme in CI.
- Never carry meaning with color alone (1.4.1): pair color with text, icons, shapes, or underlines, which also helps screen reader users.
- Design for 320 CSS px wide and very short viewports, and 400% zoom works. Survive text spacing overrides and make targets at least 24 by 24 CSS px (WCAG 2.2, 2.5.8).
- Motion is opt-in: put it behind
prefers-reduced-motion: no-preference, reduce rather than remove feedback, and give pause controls for anything that moves on its own. - Dark mode and more-contrast are new color pairs that must each be checked; set
color-schemeand choose the theme before first paint. - In forced colors your palette is replaced. Draw states with borders, outlines,
currentColor, and system colors, and useforced-color-adjust: noneonly when the color is the content. - APCA may shape WCAG 3, but today's legal bar is WCAG 2.1 or 2.2 AA. Pass that first, and use APCA as an extra check if you like.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
How is the WCAG contrast ratio calculated, and why can two colors that look almost the same land on opposite sides of 4.5:1?
Each color becomes a relative luminance: split into red, green, and blue, scale to 0 to 1, undo the sRGB gamma curve, then weight 0.2126, 0.7152, and 0.0722 and add. The ratio is (lighter + 0.05) divided by (darker + 0.05), from 1 to 21.
The thresholds are hard lines and there is no rounding up. #767676 on white is 4.54:1 and passes; #777777 is 4.48:1 and fails, though nobody can see the difference. So contrast must be measured, ideally in a test over the design tokens, not judged by eye.
Hue is ignored on purpose: lightness difference is what makes letters readable, which is also why red on green can fail at 2.91:1.
Question 2
Your design system needs dark mode. How do you keep it accessible?
Components use only semantic tokens (text, muted text, surface, border, focus, danger), and each token has a light and a dark value, switched by prefers-color-scheme with a stored user override.
Contrast is a property of pairs, so I list the allowed pairs and run a CI test that computes every pair in every theme: 4.5:1 for text roles, 3:1 for borders, icons, and focus rings. Brand colors usually need a lighter variant on dark surfaces.
I set color-scheme: light dark so native controls follow, and apply the theme in a tiny inline head script before first paint to avoid a white flash. I also add a prefers-contrast: more override and check every control in forced colors.
Question 3
What does reflow at 400% zoom mean in practice, and how do you design for it?
WCAG 1.4.10 asks that content works at 320 CSS px wide without scrolling in two directions. A 1280 px window at 400% zoom is exactly 320 CSS px, so the media queries behave like a small phone.
If the layout is truly fluid down to 320 px, it mostly works: max-width instead of width, grids that collapse to one column, min-height instead of height, text in rem, long words that can wrap.
Then the zoom-specific issues: the viewport is also very short, so sticky headers and banners must stop sticking at small heights. Data tables, maps, and diagrams may scroll in their own container, but the page may not.
Question 4
A product manager wants a full-screen parallax hero and a bouncing success animation. How do you respond?
I would build them as opt-in motion: the static version is the default, and the motion lives inside @media (prefers-reduced-motion: no-preference). JavaScript animations check matchMedia and listen for changes.
Under reduce, parallax goes away entirely and the bounce becomes a short fade, so the user still gets feedback. Anything that moves on its own for more than 5 seconds gets a pause control (WCAG 2.2.2), and nothing flashes more than 3 times a second (2.3.1).
That keeps the brand moment for most users and avoids making people with vestibular disorders ill.
Question 5
What is forced colors mode, and what typically breaks in it?
On Windows, contrast themes make Chromium browsers and Firefox replace author colors with a small set of system colors (Canvas, CanvasText, Highlight, ButtonText, and so on) and remove box shadows and gradient backgrounds. The forced-colors: active media query detects it.
What breaks is anything drawn only with background color or shadow: custom checkbox and toggle states, box-shadow focus rings, selected tabs shown as a tinted background, and icons drawn as CSS masks or gradients.
Fixes: transparent outlines and borders that the mode paints in, fill: currentColor for icons, system colors for states inside the media query, and forced-color-adjust: none only for things like color swatches where the real color is the content.
Practice it
These design problems lean on color, contrast, and motion.
- Design an accordionEasy
- Design a tooltipEasy
- Design a star rating widgetEasy
- Design a toast notification systemEasy
- Design a theme switcher with dark modeEasy
- Design a countdown timerEasy
- Design a signature padEasy
- Design a color pickerMedium
- Design a real-time leaderboardMedium
- Design a live stock price tickerMedium
- Design a QR code event check-in appMedium
- Design a video playerHard
- Design a multiplayer trivia gameHard
- Design InstagramHard
- Design RedditHard
- Design an analytics dashboardHard
- Design an IoT dashboard for thousands of sensorsHard
- Design a collaborative pixel canvas like r/placeHard
References
- WCAG 2.2 (W3C Recommendation) The success criteria quoted throughout this lesson.
- Understanding SC 1.4.3: Contrast (Minimum) Large text, exemptions, and the reasoning behind 4.5:1.
- Understanding SC 1.4.10: Reflow Why 320 CSS pixels, and which content may scroll in two directions.
- Understanding SC 2.5.8: Target Size (Minimum) The 24 px rule and its spacing exception.
- MDN: prefers-reduced-motion
- MDN: forced-colors Which properties are overridden, and the system colors.
- MDN: prefers-contrast
- web.dev: prefers-color-scheme Building and testing a dark theme.
Read next
- Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code. - Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Testing accessibilityHow a team proves a UI works for people who use keyboards, screen readers, zoom, and voice control: layers of linting, automated scans, role-based unit tests, manual keyboard and screen reader passes, and real users, measured against WCAG.
- Design systemsA shared set of design tokens, accessible components, patterns, docs, and rules that many teams build on, so a company ships one consistent product instead of many slightly different ones, and can change its look in one place.