Contrast you can read
beginner·8 min·Markdown for agents
What this lesson teaches
Check text against 4.5:1 and APCA, repair a failing pair by moving lightness only, make hover states raise contrast, and pick the space a gradient mixes in.
The gray in this box is #999 on white, the most popular "subtle" text color on the web. Read the paragraph, then look at the score.
Large headline, 24px
Body text at 16px. This is the paragraph people actually have to read, so it is the one that needs the most contrast.
color: oklch(0.68 0 0)WCAG 2 ratio
2.85:1
Body text, 4.5:1 Large text, 3:1APCA Lc
55
Body text, 75 Labels, 602.85:1. It fails. Light gray on white looks calm and refined in a mockup, and on a sunny phone or a tired pair of eyes it simply isn't there.
What the numbers mean
A compares how light the text is with how light its background is. 1:1 is invisible; 21:1 is black on white.
The rule most laws point to comes from :
| Text | Needs at least |
|---|---|
| Body text | 4.5:1 |
| Large text (about 24px, or bold from about 18.5px) | 3:1 |
| Icons, borders, focus rings | 3:1 |
is a newer formula that tracks how people actually perceive text, especially light text on dark. It scores in Lc: 75 or more for body text (90 is better), 60 for labels, 45 for headlines, 30 as the floor for icons and disabled states. The skill's advice: run both, pass both.
Fix contrast by moving L only
Here is where OKLCH from the first lesson pays off. Contrast lives almost entirely in the gap between the two lightness values. Chroma and hue barely move it.
So to repair a failing pair, push the text's L away from the background's L and leave C and H alone. "Make it more saturated so it pops" does nothing for readability.
A fast triage before you measure:
- On near-white backgrounds (L 0.85 and up), text wants L 0.45 or below.
- On near-black backgrounds (L 0.25 and down), text wants L 0.75 or above.
- Anything with L above about 0.6 wants dark text on it.
✕ Before
color: #999; on white. 2.8:1, fails body text.
✓ After
color: oklch(0.5 0 0); on white. 6:1, passes. Same hue, same chroma, lower L.
Two more traps:
- No text on a see-through color. A color with alpha mixes with whatever is under it, so no single measurement describes it. Text sits on solid tokens.
- Text on a gradient has a different contrast at every pixel. Measure the worst spot, not the average, or put a solid scrim behind the words.
Hover should raise contrast, not lower it
A common reflex is to fade a button on hover. Backwards. The skill's rule: hover makes an element a little more contrasty, pressed more again. Disabled is the only state that lowers contrast, and it does so with its own tokens, not opacity.
.button { background: var(--bg-elevated); border-color: var(--border-default); }
.button:hover { background: var(--bg-elevated-hover); border-color: var(--border-strong); }
.button:active { background: var(--bg-elevated-active); }
.button:disabled { background: var(--bg-disabled); color: var(--text-disabled); }Tell the browser which theme you drew
Two small declarations make the browser's own parts match your page.
tells it whether to draw scrollbars, checkboxes and <select> menus light or dark. Declare only what you designed: a light-only site writes color-scheme: light. Writing light dark on a light-only site hands dark-mode users dark native dropdowns on a white page.
colors the phone's address bar to match the top of your page, so your design doesn't stop abruptly at a gray browser strip.
<meta name="theme-color" content="#fdfdfc">Gradients: the space they mix in
The same two colors make three different gradients, depending on the color space the browser mixes them in. That is . Try blue to yellow below.
- sRGB, the default, darkens and muddies the middle.
in oklabkeeps brightness even across. It is the best default.in oklchtravels round the hue wheel, so it stays vivid, and may pass through a hue nobody asked for (blue to pink goes via purple). Addlonger hueand it sweeps most of the rainbow.
Colors on opposite sides of the wheel, like blue and yellow, pass near gray in a straight-line space. Either switch to in oklch or add a third stop between them.
Large, low-contrast gradients can also show , visible stripes where it should be smooth. Widen the gap between stops, shrink the area, or lay a faint noise texture over it. Picking the space is taste. A gray dead zone, banding, or unreadable text on the gradient is a bug.
Audit text contrast in this project. - For every text/background pair, report the WCAG 2 ratio and the APCA Lc. Body text must pass 4.5:1 and Lc 75; labels Lc 60; large text 3:1. - Fix failures by moving the foreground's OKLCH L only; leave C and H. - Hover and active states must raise contrast, never lower it. Disabled uses its own tokens, not opacity. - No text on colors with alpha. Set color-scheme to only the themes we actually ship. - Gradients: use "in oklab" unless the design wants a hue sweep; flag any with text whose worst region fails.
Quick check
Gray caption text measures 3.1:1 on white. What's the right repair?
In the skill
The nodes your agent reads when it does this work.
- Contrast and color-schemeAPCA over WCAG 2 for contrast math. theme-color and color-scheme meta. Interactions raise contrast. Avoid gradient banding.
surface/contrast-and-color-scheme - Gradient interpolationA gradient's interpolation space is a look, not a correctness setting. `in oklab` is the default (even brightness), `in oklch` arcs round the hue wheel and stays vivid (pick shorter or longer hue), sRGB muddies the middle. Opposite hues go grey in a rectangular space; big low-contrast gradients band; text on a gradient is measured at its worst region.
surface/gradient-interpolation
Connected lessons
What this lesson builds on and leads to, across every course.