Contrast is the measured difference in brightness between foreground text and the background behind it, expressed as a ratio from one to twenty-one. It is the hinge between visual design and accessibility: the same number tells a designer whether a muted label still looks intentional, and tells a reviewer whether a body paragraph is readable.
How is the contrast ratio actually calculated?
Each colour is first converted to its relative luminance, a value between zero and one that represents how much light it emits compared with a perfect white. The formula for the ratio is then the lighter luminance plus 0.05, divided by the darker luminance plus 0.05.
ratio = (L_lighter + 0.05) / (L_darker + 0.05)
#1a1a1a on #ffffff -> 17.40 to 1
#767676 on #ffffff -> 4.54 to 1
#ffffff on #000000 -> 21.00 to 1 (the maximum)
Relative luminance is not the same as the brightness your eye guesses. The calculation first linearises each channel of the sRGB colour, because the gamma curve used for display was designed to look smooth on screen rather than to be perceptually uniform. That is why pure yellow on white scores far lower than most people expect, and why a saturated blue can look darker than a mid grey.
The practical consequence is that you should never judge a colour pair by eye alone. The arithmetic is simple enough to check in a browser console, and every design tool worth using can show you the number directly.
Which thresholds matter, and when?
The widely adopted accessibility guidelines set three numbers that cover almost every case.
- 4.5 to 1 for body text and any text below the large-text threshold.
- 3 to 1 for large text, which is text at 24 pixels or larger, or 18.66 pixels or larger when it is bold.
- 3 to 1 for non-text elements that convey meaning: focus indicators, the border of an input field, an icon that stands alone, the outline of a control.
The stricter level of the guidelines asks for 7 to 1 on body text. Meeting it is a reasonable goal for anything you want people to read for long stretches, such as documentation or a news story, and it costs almost nothing when you build the palette from the start.
Two clarifications matter. The threshold applies to the text against the colour directly behind it, which is not always the page background. A grey that passes on white can fail on a pale card. And contrast says nothing about whether two colours are distinguishable for someone with colour vision deficiency, so a red and a green badge still need a word or an icon in them.
How does contrast drive design decisions?
Contrast is not only a compliance check applied at the end. It is the mechanism that creates hierarchy. If every label on a screen is the same darkness, the reader has no clue what matters, and the design looks unfinished rather than quiet.
| Foreground | Background | Ratio | Typical use |
|---|---|---|---|
| #1a1a1a | #ffffff | 17.40 | Headline and primary copy |
| #595959 | #ffffff | 7.00 | Secondary label, timestamp |
| #767676 | #ffffff | 4.54 | Muted body copy, still passes |
| #949494 | #ffffff | 3.03 | Large text and disabled states only |
| #0b57d0 | #ffffff | 6.39 | Link text |
| #ffffff | #1b5e20 | 7.87 | Success badge |
| #ffffff | #b3261e | 6.54 | Danger badge |
| #e8eaed | #1b1c1e | 14.15 | Heading on a dark panel |
| #a9b0ba | #1b1c1e | 7.80 | Muted label on a dark panel |
Notice the pattern: the muted tones in that table still pass the body-text threshold. Muted does not have to mean unreadable. What you should not do is reach for a single mid grey for everything and hope the page still works. The most effective way to keep a palette honest is to store each colour together with the surface it belongs on.
The reliable technique for stopping a palette from drifting is to store colour as a pair, not as a single value. If a token is called --text-muted on its own, someone will eventually apply it on a coloured surface and break the contrast. Store the foreground and its background together and the pairing travels with it.
:root {
--surface: #ffffff;
--panel: #f4f5f7;
--text: #1a1a1a;
--text-muted: #595959;
--link: #0b57d0;
--focus-ring: #1a73e8;
--badge-success-bg: #1b5e20;
--badge-success-fg: #ffffff;
--badge-danger-bg: #b3261e;
--badge-danger-fg: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--surface: #1b1c1e;
--panel: #242529;
--text: #e8eaed;
--text-muted: #a9b0ba;
--link: #8ab4f8;
--focus-ring: #8ab4f8;
}
}
body { background: var(--surface); color: var(--text); }
Notice that the dark scheme block redefines the same names with new values rather than introducing a second set of rules. Every component keeps using the same token names, so a theme switch is one variable block and nothing else.
How do focus rings and controls fit in?
A focus ring is not text, so it is judged against the 3 to 1 threshold rather than 4.5 to 1, and it has to hold that ratio against whatever it sits next to. On a page with mixed backgrounds a single-colour ring fails against one of them. The standard fix is a two-tone ring: a light outline for dark surroundings and a dark outline for light ones, separated by an offset.
:where(a, button, input, select, textarea):focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
box-shadow: 0 0 0 4px #1a73e8;
}
The white outline and the blue box shadow together guarantee a visible edge on any background, because one of the two always contrasts with what is underneath. Removing the ring without replacing it with something equally visible is the single most common accessibility failure in production code.
What about text over images and gradients?
Here the background is an image, so there is no single colour to measure against. The safe approach is to put a scrim between the text and the picture, and to make the scrim strong enough that the worst-case part of the image still leaves the text readable.
<section class="banner">
<img src="coast.jpg" alt="">
<div class="banner-text">
<p class="kicker">Field notes</p>
<h2>Twelve days on the northern shore</h2>
</div>
</section>
.banner { position: relative; isolation: isolate; }
.banner img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: -1;
}
.banner-text {
background: rgb(0 0 0 / 0.55);
color: #ffffff;
padding: 1.5rem;
}
Give the image an empty alt when it is purely decorative behind a caption that carries the meaning, and give it a real description when it conveys information the caption does not. An image behind text is a common source of unreadable pages, so treat the scrim as part of the design rather than an afterthought.
How do you check contrast in practice?
Browser developer tools include a contrast readout in the colour picker: select a text colour, choose the eyedropper, and click the background behind it. The panel shows the ratio and whether it passes for normal or large text. That is the fastest way to check a single pair.
For a whole page, an automated audit will find the failures but cannot tell you which ones matter. Review them in context: a decorative icon at 2.8 to 1 is not the same problem as a paragraph of legal text at 2.8 to 1. Fix the second one first, and decide deliberately about the first.
Checklist before you ship
- Every body text colour is measured against the surface it actually sits on, not the page background.
- Large text clears 3 to 1; body text clears 4.5 to 1; links clear 4.5 to 1 against their own background.
- Focus indicators clear 3 to 1 against both the control and the area around it.
- Status is never carried by colour alone — every badge has a word or an icon in it.
- Text over imagery has a scrim, and the scrim is part of the art direction.
- Dark and light themes are separate sets of values, each one checked independently.
- Nothing relies on colour perception alone to be understood.
Try it yourself: Build a small status panel with three tiles: a headline, a muted timestamp and a link, on a white card. Pick the colours yourself, then measure all three pairs in the browser's colour picker. Adjust until the headline is above 12 to 1, the timestamp is above 4.5 to 1 and the link is above 4.5 to 1, and write the three hex values into a comment with the ratio you measured next to each one.