cf.completefrontend
My progress
FUNDAMENTALS · MODULE 12

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.

A checkout page wireframe with five problems, each tied to a user. Gray helper text at 2.85 to 1 fails a low vision user. A card field whose error is only a red border fails a color-blind user. A custom checkbox drawn with background color vanishes in a Windows contrast theme. A bouncing success toast makes a user with a vestibular disorder dizzy. At 400% zoom the page scrolls sideways.checkoutEmailyou@mail.comWe never share itCard number4242 4242Save this cardPaid!Low vision: gray hint#999 on white, 2.85:1Color-blind: the erroris only a red borderContrast theme: thecustom checkbox vanishesVestibular: the bouncingtoast causes dizziness400% zoom: every lineneeds a sideways scroll
None of these five bugs shows up in a keyboard or screen reader test. Each one is about how the page looks or moves for someone who sees differently.

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.

The dials users turn, and how each one reaches your code.
User settingWhat changesHow your CSS sees itWhat breaks if you ignore it
Browser zoom (400% and beyond)Every CSS pixel gets bigger, so the viewport gets narrowerA smaller viewport width; media queries fireFixed-width layouts scroll sideways
Default font sizeThe root font-size, so every remNo media query; rem units follow itText in px ignores it
Text spacing overridesline-height, letter-spacing, word and paragraph spacingNo media query; a user style sheet overrides your valuesText clipped in fixed-height boxes
Reduce motionAn OS accessibility switchprefers-reduced-motion: reduceParallax and big transitions cause nausea
Dark modeAn OS appearance switchprefers-color-scheme: darkA white page at night; hard-coded colors clash
Increase contrastAn OS accessibility switchprefers-contrast: moreThin gray borders stay too faint
Forced colors (Windows contrast themes)The browser replaces your colors with a small system paletteforced-colors: activeIcons, focus rings, and custom controls vanish
What your device reports right now
Checking your settings...
Simulate
Zoom

Media queries that match: prefers-color-scheme: light, prefers-reduced-motion: no-preference, prefers-contrast: no-preference, forced-colors: none, width: 1280px, height: 800px

How a well-built page with a toast responds
Surface#ffffff
Muted text#6b7280, 4.83:1
Toast entrance300 ms slide in from the side
Focus ring2 px ring
Layoutthree columns
Sticky headersticky

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

  1. Split the color into channels and scale each to 0 to 1: #767676 is red, green, and blue all equal to 118, so each is 118 / 255 = 0.463.
  2. 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.
  3. 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.
  4. Compare: ratio = (L lighter + 0.05) / (L darker + 0.05). White is L = 1, so #767676 on 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.

js
// 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
The whole WCAG 2 formula in a dozen lines. The outputs in the comments were checked by running this file.

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.

Brand buttons are the classic trap: one shade darker usually fixes them.
Foreground on backgroundRatioNormal text AA (4.5)Large text or UI (3)
#999999 on #ffffff (soft gray helper text)2.85:1FailFail
#9ca3af on #ffffff (a common gray-400)2.54:1FailFail
#6b7280 on #ffffff (a common gray-500)4.83:1PassPass
#ff0000 on #ffffff (pure red)3.99:1FailPass
#ffffff on #ef4444 (white on a red-500 button)3.76:1FailPass
#ffffff on #dc2626 (white on a red-600 button)4.83:1PassPass
#ffffff on #16a34a (white on a green-600 button)3.30:1FailPass
#ffffff on #2563eb (white on a blue-600 button)5.17:1PassPass
#e5e5e5 on #ffffff (a light input border)1.26:1FailFail

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.

Try

Contrast ratio: 4.54:1. At 16 px regular, this is normal text, so it needs the stricter numbers.

Which WCAG checks this pair passes
CheckNeedsResult
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
View

Your own colors apply. Switch to a forced colors view to see which parts of a form would survive.

A small form with body text, an input field, a checkbox, a selected tab, and a focused button. All parts use your colors.your colorsCard numberBillingPay
Every part is visible with your own colors.
How each part is drawn, and whether it survives the current view
PartDrawn withResult
Body textcolorYour colors
Input fieldborderYour colors
Checkbox tickbackground-color squareYour colors
Selected tabtinted background-colorYour colors
Focus ringbox-shadow onlyYour 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.

Two rows of five boxes show the contrast math. Gray 767676 has channels 118, 118, 118, which divide to 0.463, undo gamma to 0.181, and weigh to a luminance of 0.181. White ffffff goes 255, 1.000, 1.000, luminance 1.000. At the bottom: 1 plus 0.05, divided by 0.181 plus 0.05, equals 4.54 to 1.hexchannelsdivide by 255undo gammaweigh and add#767676118 ×30.4630.181L = 0.181#ffffff255 ×31.0001.000L = 1.000(1.000 + 0.05) / (0.181 + 0.05) = 4.54 : 1
Undoing the gamma is the step people skip: 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.

CriterionLevelApplies toMinimum ratio
1.4.3 Contrast (Minimum)AAText and images of text4.5:1, or 3:1 for large text
1.4.6 Contrast (Enhanced)AAAText and images of text7:1, or 4.5:1 for large text
1.4.11 Non-text ContrastAAUI component boundaries and states, and graphics needed to understand content3: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 #e5e5e5 border on white is 1.26:1 and fails if the border is the only cue. Either darken it (a #767676 border 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.

The contrast ratio scale from 1 to 21, on a log scale. Below 3 to 1 fails for text and controls; gray aaaaaa on white is 2.32. From 3 to 4.5 is enough only for large text, controls, and graphics; 949494 is 3.03. From 4.5 to 7 passes AA for all text; 767676 is 4.54. From 7 to 21 passes AAA; 595959 is 7.00.text contrast against a white backgroundfails#aaaaaa2.32:1large, UI#9494943.03:1AA#7676764.54:1AAA#5959597.00:11:13:14.5:17:121:1example grays on white
Each band shows a gray on white that lands in it. The step from failing to AA is small in color but large in readability, so measure it.

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

PatternColor-only versionFixed version
Form errorsThe field border turns redRed border plus an error icon plus a message, linked with aria-describedby, and aria-invalid="true"
Links in body textLinks are blue, not underlinedKeep the underline, or make the link 3:1 against the surrounding text and add an underline on hover and focus
Status badgesGreen, yellow, red dotsDot plus a word (Active, Pending, Failed) or distinct icons (check, clock, cross)
ChartsFive lines in five huesDirect labels at the line ends, different dash patterns or markers, and a data table
Required fieldsRequired labels are redAn asterisk with a legend, or the word required, plus the required attribute
Price changesUp is green, down is redArrow 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.

Two versions of a Start date field with an invalid date. On the left, only the border turns red, which many color-blind users cannot see and which vanishes in grayscale. On the right, the red border comes with a cross icon and the message: Enter a date after today.color alonecolor, icon, and wordsStart date12/05/2023only the border turns redin grayscale: no error at allStart date12/05/2023✕ Enter a date after todayreadable without seeing red
On the right, the message works in grayscale, on a printout, in forced colors, and for a screen reader once it is linked with 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: 960px on a wrapper forces sideways scrolling. Use max-width with fluid inner widths.
  • Fixed heights with text: height: 48px on a box with text clips or overlaps the text once it wraps. Use min-height and 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=1 or user-scalable=no in the viewport meta tag can block pinch zoom on mobile. Leave both out.
  • Text in px only: browser zoom still scales px, but the default font size setting does not. Size text in rem so both dials work.
css
/* 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.

On the left, a page in a 1280 pixel window at 100% zoom, with a header and three columns: navigation, content, and a sidebar. An arrow labelled 400% leads to the right, where the same page is 320 CSS pixels wide and stacked in one column: header, a menu button, the content, then the sidebar, with no sideways scrolling.1280 px at 100%three columns400%320 pxone column
400% zoom on a 1280 px window leaves 320 CSS px, the width of a small phone. A responsive layout already handles it; fixed widths and fixed heights are what break.
Four target layouts drawn to scale. Two 16 pixel icons with centers 20 pixels apart: their 24 pixel circles overlap, so they fail 2.5.8. Two 16 pixel icons with centers 28 pixels apart: the circles do not overlap, so they pass by spacing. A 24 by 24 target passes AA. A 44 by 44 target meets the AAA size.16 px iconscenters 20 apart1616circles overlap16 px iconscenters 28 apart1616pass by spacing24 by 2424AA44 by 4444AAA (Apple: 44 pt)
The spacing exception draws a 24 px circle around the center of each small target. If no circle touches another target or circle, the targets pass even though they are small.

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.

css
/* 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; }
}
Reduce means less movement, not zero feedback. A fade keeps the cue that something arrived.
js
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));
JavaScript animation (scrolling, carousels, canvas, Lottie, Web Animations) must check the same preference.

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

CriterionLevelWhat it asks
2.2.2 Pause, Stop, HideAAnything 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 ThresholdANothing 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 InteractionsAAAMotion 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.

A decision flowchart for motion. Does it move? If no, such as a color or opacity change, keep it. If yes, does it play by itself for over 5 seconds? If yes, add a pause button for WCAG 2.2.2. Either way, is reduce motion on? If yes, fade or remove the motion and keep the feedback. If no, play the motion, with no more than 3 flashes a second.Does it move?noKeep itcolor or opacityyesPlays by itself?for over 5 secondsyesAdd pause buttonWCAG 2.2.2either wayReduce motion on?yesFade or removekeep the feedbacknoPlay the motionmax 3 flashes a second
Write the calm branch first and put the motion inside @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.

css
: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 */
  }
}
Semantic tokens change per theme; components only ever use the tokens. Every pair gets a measured ratio in a comment or a test.

Decision How should a product implement dark mode?

Semantic tokens switched by the media query and a data attribute

Define --surface, --text, --border and friends once per theme

Switch with prefers-color-scheme, and let html[data-theme] override it for a user choice

Pros

  • Every pair is designed and measurable
  • Works in every browser
  • A user override is easy to store

Cons

  • Two palettes to design and test
  • Needs discipline: no raw hex values in components

The CSS light-dark() function

Set color-scheme: light dark

Write color: light-dark(#111827, #e0e0e0) per property or token

Pros

  • Both values sit side by side, so it is hard to forget one
  • Follows color-scheme, which you can set per element

Cons

  • Only works for colors
  • Baseline only since 2024, so older browsers need a fallback
  • Still two palettes, just written differently

Auto-invert with filter: invert(1) hue-rotate(180deg)

Invert the whole page, then invert images back

Pros

  • Almost no work

Cons

  • Contrast and brand colors become unpredictable
  • Images, video, and shadows need special cases
  • Not a real design; reviewers will notice

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.

css
@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.

Use these names inside a forced-colors query; the browser maps them to the user's palette.
System colorMeant for
Canvas and CanvasTextPage background and normal text
LinkTextLinks
ButtonFace, ButtonText, ButtonBorderButtons and their borders
Field and FieldTextInputs
Highlight and HighlightTextSelected items and current states
GrayTextDisabled text
css
/* 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 */
}
Three habits fix most forced-colors bugs: transparent outlines and borders, 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.

A table of what forced colors mode does to CSS properties. color becomes CanvasText, still visible. border-color, outline-color, and SVG fill and stroke become system colors, still visible. background-color becomes Canvas, so shapes drawn with it blend into the page. box-shadow and gradient backgrounds are removed. Images loaded by URL are kept as is.propertyin forced colorsshape visible?colorCanvasText✓ yesborder-colora system color✓ yesoutline-colora system color✓ yesSVG fill, strokea system color✓ yesbackground-colorCanvas✕ nobox-shadowremoved✕ nogradient backgroundremoved✕ noimg by URLkept as is✓ yes
Shapes drawn with borders, outlines, and text survive; shapes drawn with background color, shadows, or gradients vanish. That one rule explains most forced colors bugs.

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

  1. Primitive tokens: the raw palette, like blue-600: #2563eb. Nobody uses these in components.
  2. 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.
  3. 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.

js
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}`);
    }
  }
}
A token contrast test: fast, exact, and it runs on every pull request.

Failure modes that slip through

FailureWhy it happensHow you noticeFix
Text with opacity: 0.6Opacity blends text into the background, so the real color is lighter than the tokenaxe flags it; the token test does notUse a solid muted token instead of opacity
Text over a photo or gradientThe background varies under the textVisual review; automated tools often skip itAdd a scrim and measure against the worst spot
Hover and focus statesOnly the default state was checkedManual review of statesAdd state pairs to the token test
Theme flashThe page paints light, then JavaScript switches to darkA white flash at night on every loadSet 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: hiddenZoom test, text spacing bookmarkletUse min-height, let text wrap
Invisible state in forced colorsState drawn with background or shadowForced colors emulationBorders, outlines, system colors
Ignored reduced motion in JSCSS respects it, the carousel library does notTurn the OS setting on and use the pageCheck 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.

A sequence diagram with four lifelines: browser, inline script, CSS, and app bundle. The browser runs a tiny inline script in the head before the body. The script reads the saved choice or the media query and sets data-theme to dark. The browser applies the dark tokens from CSS. The first paint is already dark. Later the app bundle loads and hydrates, and finds the theme already set.browserinline scriptCSSapp bundlerun before bodydata-theme=darkapply dark tokensfirst paint: darkload and hydratetheme already set
If the app bundle chose the theme instead, it would run after the first paint, and dark mode users would see a white flash on every load. Setting 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 #ffdd00 background 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-colors exists; 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.

A graph of design tokens in three columns. Primitives blue-700 and blue-400 feed the semantic token link: blue-700 in light at 6.70 to 1, blue-400 in dark at 7.37 to 1. Primitives gray-500 and gray-400 feed the semantic token muted: gray-500 in light at 4.83 to 1, gray-400 in dark at 7.38 to 1. The link token feeds the Link component and the muted token feeds the Hint component.primitivessemantic tokenscomponentsblue-700blue-400gray-500gray-400linkmutedLinkHintlight 6.70dark 7.37light 4.83dark 7.38
Components never see a hex value. The contrast test walks every semantic token in every theme against the surfaces it may sit on, so changing a primitive cannot silently break a pair.

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-motion built 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

LevelWhat a strong answer shows
JuniorKnows 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
MidExplains 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
SeniorDesigns 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
StaffSets 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-scheme and choose the theme before first paint.
  • In forced colors your palette is replaced. Draw states with borders, outlines, currentColor, and system colors, and use forced-color-adjust: none only 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?

Question 2

Your design system needs dark mode. How do you keep it accessible?

Question 3

What does reflow at 400% zoom mean in practice, and how do you design for it?

Question 4

A product manager wants a full-screen parallax hero and a bouncing success animation. How do you respond?

Question 5

What is forced colors mode, and what typically breaks in it?

Practice it

These design problems lean on color, contrast, and motion.

References

Read next