Color contrast ratio explained
How WCAG contrast ratios are calculated from relative luminance, which thresholds apply to text and icons, what counts as large text, and how to check.
6 min read
A contrast ratio is a single number between 1:1 (two identical colors) and 21:1 (black on white) that WCAG uses to decide whether text and graphics stand out enough from their background. Contrast failures in crypto interfaces tend to come from the same few places: muted gray labels on dark cards, green and red price changes, and text placed over translucent layers.
This guide shows where the number comes from, which thresholds apply to what, and how to check your colors without guessing.
Step one: relative luminance
Contrast is not calculated from hue or saturation. It compares the relative luminance of two colors, which is how bright a color appears to the human eye on a scale from 0 (black) to 1 (white). For an sRGB color like #22C55E, it takes three steps:
- Divide each channel (R, G, B) by 255 to get a value between 0 and 1.
- Undo the sRGB gamma curve. If the value is 0.04045 or less, divide it by 12.92. Otherwise compute ((value + 0.055) / 1.055) raised to the power 2.4.
- Combine the channels: L = 0.2126 × R + 0.7152 × G + 0.0722 × B.
Older copies of the specification show 0.03928 instead of 0.04045 in step two. For ordinary 8-bit colors the two give identical results.
The weights in step three explain a lot of surprises. Green contributes about ten times as much brightness as blue. Pure blue #0000FF has a luminance of only 0.0722, so on black it reaches just 2.44:1, even though it looks intense. A bright green such as #22C55E has a luminance of about 0.41, so it's comfortable on a dark background and weak on white.
Step two: the ratio
With both luminances in hand:
contrast ratio = (L1 + 0.05) / (L2 + 0.05)
L1 is the luminance of the lighter color and L2 of the darker one. The 0.05 accounts for light reflecting off the screen and keeps the formula from dividing by zero when one color is pure black.
A worked example with gray #767676 text on white:
- 0x76 is 118, and 118 / 255 = 0.4627.
- Linearized: ((0.4627 + 0.055) / 1.055) ^ 2.4 = 0.1812.
- Because it's a neutral gray, all three channels are equal and the weights add up to 1, so L = 0.1812.
- White has L = 1, so the ratio is (1 + 0.05) / (0.1812 + 0.05) = 4.54:1.
That passes the 4.5:1 threshold for normal text. One step lighter, #777777, gives 4.48:1 and fails. Thresholds are minimums, so never round up: 4.48 is not "about 4.5".
Here is the same calculation in a few lines of JavaScript, handy for tests or a design token linter:
function luminance(hex) {
const [r, g, b] = [1, 3, 5].map((i) => {
const c = parseInt(hex.slice(i, i + 2), 16) / 255
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
function contrastRatio(a, b) {
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x)
return (light + 0.05) / (dark + 0.05)
}
contrastRatio('#767676', '#ffffff') // 4.54...
It expects six-digit hex values such as #767676.
The thresholds
| Criterion | Level | Applies to | Minimum ratio |
|---|---|---|---|
| 1.4.3 Contrast (Minimum) | AA | Normal text | 4.5:1 |
| 1.4.3 Contrast (Minimum) | AA | Large text | 3:1 |
| 1.4.6 Contrast (Enhanced) | AAA | Normal text | 7:1 |
| 1.4.6 Contrast (Enhanced) | AAA | Large text | 4.5:1 |
| 1.4.11 Non-text Contrast | AA | UI components and graphical objects | 3:1 |
Large text means at least 18 point regular or 14 point bold. On the web, 1pt is 1.333 CSS pixels, so that is about 24px regular or about 18.66px bold. A 32px portfolio total is large text; a 16px balance in a table row is normal text whatever its weight. Treat semibold (600) as not bold unless you have checked how your font renders, because "bold" in practice means a clearly heavy weight.
Some text is exempt from 1.4.3: logotypes (text that is part of a logo or brand name) and incidental text such as purely decorative text, invisible text, text in an inactive (disabled) control, and text in a picture that contains significant other visual content. A disabled "Swap" button may have low-contrast text, but if the reason it's disabled ("Insufficient ETH balance") is shown, that message needs full contrast.
Icons and the visual parts of controls fall under 1.4.11 instead, with its lower 3:1 threshold. The non-text contrast guide covers how that applies to icon buttons and focus rings.
Examples from a crypto interface
These are common UI colors measured with the formula above:
| Foreground | Background | Ratio | What it passes |
|---|---|---|---|
Green #22C55E |
Near-black #0B0B0B |
8.64:1 | All text, AA and AAA |
Green #22C55E |
White #FFFFFF |
2.28:1 | Nothing, not even large text |
Green #16A34A |
White #FFFFFF |
3.30:1 | Large text and icons only |
Red #EF4444 |
Near-black #0B0B0B |
5.23:1 | Normal text at AA |
Red #EF4444 |
White #FFFFFF |
3.76:1 | Large text and icons only |
Red #DC2626 |
White #FFFFFF |
4.83:1 | Normal text at AA |
Gray #6B7280 |
Dark blue-gray #111827 |
3.67:1 | Large text and icons only |
Gray #9CA3AF |
Dark blue-gray #111827 |
6.99:1 | Normal text at AA, just short of AAA |
The pattern to notice: a single "positive green" token can't serve both a dark and a light theme. The green that reads well on a dark trading screen fails badly on white. Define separate values per theme and check each one; dark mode and accessibility goes further into that.
Checking contrast in practice
Browser devtools. In Chrome and other Chromium browsers, the color picker in the Styles pane shows the contrast ratio of a text element against its background, with AA and AAA lines drawn on the color area. Firefox's Accessibility Inspector can check contrast for text on the page. Both read the computed styles, so they work on your real interface rather than a mockup.
Automated checkers. Tools such as axe and Lighthouse catch text on solid backgrounds well. Text over gradients, images or stacked translucent layers is often reported as "needs review" because the tool can't determine the background. The testing tools guide covers what each one finds.
Transparency. Composite before measuring. White text at 40% opacity on #111111 blends to roughly #707070, which is 3.81:1 and fails for normal text. The same text at 60% blends to roughly #A0A0A0, which is 7.22:1. Opacity-based "muted" tokens are a frequent source of failures because they change with every surface they sit on.
Gradients and images. Measure the worst spot behind the text, not the average. A price label over a chart's area fill needs to pass where the fill is lightest.
Every state. Check hover, selected and error states, and placeholder text such as the "0.0" in an amount field, which is generally treated as text that needs 4.5:1.
The WCAG 2 formula is often criticized for how it treats very dark color pairs and thin fonts, which is part of why APCA is being explored for WCAG 3. For conformance today, though, the formula on this page is the one that counts; APCA vs WCAG 2 contrast explains the difference.