Automatic contrast
text-contrast-* derives a same-hue text color that hits your target WCAG ratio for any background. Set a background, add the utility, and CSS computes the shade at render time, with no JavaScript.
The shade is not searched for. It is solved. WCAG 2's contrast ratio constrains exactly one quantity, relative luminance, and luminance is a linear function of linear-light RGB. So invert the ratio formula for the target luminance, then use a single relative-color expression to build a color carrying that luminance, the background's hue and the background's chroma. The achieved ratio equals the requested ratio by construction: no candidate bracket, no verification pass, no convergence tolerance.
Solving instead of searching is also what makes the utility portable. A search would need a way to call itself and a way to pick the candidate that passed; the closed form needs neither, so it is built from calc, clamp, min, max and relative color syntax, and works everywhere.
A background from a CSS variable must carry the color: hint
text-contrast-* solves against the background you set with bg-*, so the background has to reach it. bg-(color:--brand) does; bg-(--brand) does not, because Tailwind will not infer a type from inside a var(). Without the hint the element still gets its background, but the contrast solve runs against an unset value and returns the same shade whatever color you passed — a plausible-looking result that does not meet the ratio the class names. See Colors from CSS variables need a type hint.
The ratio is exact, which cuts both ways
Because the utilities hit the ratio exactly rather than overshooting it, the result can land a hair either side of the named threshold, around ±2 × 10⁻⁵ in practice. A checker that rounds before it compares still reads AAA. One doing a bare ratio >= 7 may not, and will report text-contrast-aaa as AA. That is arithmetic on a tie, not a contrast failure. The pair sits at 7:1 to four decimal places. If you need an external report to read AAA without argument, ask for a slightly higher ratio than the level you need.
Browser support
Works in Chromium, Firefox and Safari. Every engine gets the requested ratio exactly, verified against all 242 Tailwind colors at all three levels on each. The only per-engine difference is cosmetic; see Color Spaces below.
text-contrast-* needs relative color syntax and @property and nothing else. The pow() seed described under Color Spaces sits behind an @supports gate, so an engine without it takes the linear path rather than losing the utility.
Quick reference
| class | styles |
|---|---|
| text-contrast-aa | color: same-hue shade at exactly WCAG AA (4.5:1) |
| text-contrast-aaa | color: same-hue shade at exactly WCAG AAA (7:1) |
| text-contrast-aa-lg | color: same-hue shade at exactly WCAG AA Large (3:1) |
| text-contrast-aa/<space> | color: AA shade, chroma shaped by the given color space |
Levels are themeable
The three levels are not hard-coded. They are entries in the --jib-contrast-ratio-* theme namespace. Add a key and you get a working utility:
@theme {
--jib-contrast-ratio-aa-plus: 5;
}<div class="bg-indigo-700 text-contrast-aa-plus">Solved for exactly 5:1</div>Useful for the "AAA but let an external checker round in my favor" case in the warning above. The /<space> modifiers work on custom levels too.
Basic usage
Set a background with bg-*, then use text-contrast-* to pick the WCAG level:
The result is a lighter or darker shade of the background color, landing on the requested ratio rather than merely clearing it. Dark backgrounds get lighter text, light backgrounds get darker text. The crossover is the luminance where there is equal room to move in either direction.
WCAG levels
| Utility | Ratio | Use case |
|---|---|---|
text-contrast-aa-lg | 3:1 | Headings, bold text above 14pt |
text-contrast-aa | 4.5:1 | Body text, standard UI labels |
text-contrast-aaa | 7:1 | Fine print, captions, small text |
Every Tailwind color
Every default TW color at every shade, with text-contrast-aa picking the text color for each:
Color spaces
The modifier selects the aesthetic path of the shade: how chroma and perceptual hue travel between the background and the output. It has no say in the ratio. One shared final stage owns correctness for every space, so all seventeen land on the same contrast and differ only in how colorful the result is. Default is oklch, as it is for every color transform in this library.
All 17 color spaces work: oklch, lch, lab, oklab, hsl, hwb, rgb, srgb, srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz, xyz-d50, xyz-d65, color-mix.
They fall into three groups by how their lightness relates to luminance:
| Group | Spaces | Behavior |
|---|---|---|
| Linear-light and RGB-family | srgb, srgb-linear, rgb, xyz, xyz-d50, xyz-d65, display-p3, a98-rgb, prophoto-rgb, rec2020, color-mix | The closed form is the shade path. Each is a linear transform of linear-light RGB, so differences within the group are visually negligible. Output matches the background's chromaticity exactly. |
| CIE Lab family | lab, lch | L* is an invertible function of luminance, so the seed is exact in closed form. CSS lab()/lch() are D50-adapted, so L* tracks chromatically adapted luminance; the final stage absorbs the difference. |
| Perceptual-OK and legacy | oklab, oklch, hsl, hwb | Lightness is not a pure function of luminance, so the seed is approximate and the final stage corrects it. hsl keeps saturation and so washes out at extreme targets; hwb splits the input's whiteness/blackness budget by the target. |
oklab and lab route through the same pipelines as oklch and lch. Identical seeds; only the channel notation differs.
One per-engine difference, in chroma only
/oklch, /oklab, /lch and /lab derive their seed lightness with a cube root. Firefox parses pow() but rejects a color channel keyword as its argument, so on Firefox an @supports gate seeds these four linearly instead. Chrome and Safari both get the cube root.
The consequence is worth being precise about, because it is smaller than it sounds:
- The ratio is unaffected. The seed governs aesthetics; a shared final stage owns correctness. All four are exact on Firefox, same as everywhere else.
- The chroma differs slightly. A linear seed is less perceptually even, so
text-contrast-aa/oklchon Firefox can be a touch more or less colorful than the same class in Chrome or Safari. Same hue, same contrast.
If you need the four to match pixel-for-pixel across engines, use a Class 1 space. /srgb and its ten siblings have no seed, so they are identical everywhere. Asking for /oklch and getting the linear seed still beats ignoring your modifier and handing you the core instead.
Typographic hierarchy
Use different levels for different text sizes. Heading at AA Large, body at AA, fine print at AAA:
AA body text. Sed do eiusmod tempor incididunt ut labore.
AAA fine print. Captions and small text.
AA body text. Sed do eiusmod tempor incididunt ut labore.
AAA fine print. Captions and small text.
Applying conditionally
Prefix with any variant. The solve happens against whatever background is in effect when the variant matches, so a hover state that also changes the background gets a text colour solved for the new one rather than the old.
Breakpoint variants work the same way, which is the practical route to the typographic hierarchy above: text-contrast-aa-lg md:text-contrast-aa tightens the requirement as the text gets smaller.
Using a custom level
There is no bracket syntax here. text-contrast-[5] does not compile, and neither does text-contrast-(--my-ratio). That is deliberate rather than an omission.
A ratio is not a one-off value. Two elements asking for 5:1 are making the same accessibility claim, and that claim belongs somewhere a reader can find it, not inlined at two call sites that can drift apart. So levels come from your theme:
@theme {
--jib-contrast-ratio-brand: 5.5;
}<div class="bg-indigo-700 text-contrast-brand">Solved for exactly 5.5:1</div>The name you choose becomes the utility, and it behaves like the built-in levels in every respect, /<space> modifiers included:
The three shipped levels are ordinary entries in that same namespace, so nothing about a level you add is a second-class case.
| Level | Ratio | WCAG rule |
|---|---|---|
text-contrast-aa | 4.5:1 | Normal body text |
text-contrast-aaa | 7:1 | Enhanced, normal body text |
text-contrast-aa-lg | 3:1 | Large text, 18.66px bold or 24px |
How it works
Building a color at a known ratio, stage by stage.
Which color hits this ratio?
The WCAG ratio (Y_lighter + 0.05) / (Y_darker + 0.05) inverts directly, giving the target luminance Yt for a requested ratio R. Any color with luminance Yt achieves R against the background exactly, whatever its hue or chroma. One degree of freedom is constrained, two are free, and the free two go to matching the background's color.
- The carrier. Computes the background's luminance and smuggles it into the alpha channel. Relative color syntax allows one origin color per expression, so this is how a candidate built in oklch can still see the background's luminance: alpha is a float in
[0, 1], exactly a luminance's range, and it survives color-space conversion untouched. - The target. Solves for
Yt. The0.1791pivot picks the direction, lighten or darken, without branching: it is the luminance where headroom toward black equals headroom toward white,√0.0525 − 0.05. Taking the side with more headroom is optimal. - The seed. For Class 2/3 spaces, a candidate in the requested space with its lightness derived from
Ytand the background's chroma and hue kept. - The chroma vector. Reduces a candidate to its zero-luminance chroma,
c − Yc. - The lift. The shared final stage,
out = Yt + min(1, caps) · vector. Because the vector contributes no luminance, the output's luminance isYtidentically. Thecapsare the largest scale keeping every channel inside[0, 1], so this one expression sets luminance exactly, gamut-maps at constant luminance, and is a no-op on a candidate that already fits. - The dispatch. Maps the level to its ratio and routes to the pipeline for the requested color space.
Because the caps act on whatever vector they are handed, they double as a gamut mapper that preserves WCAG luminance, something neither clipping nor generic chroma reduction can promise.
Writing the solve in CSS
The whole thing renders as one nested relative-color expression, chained through unregistered custom properties. That substitution is the trick: an unregistered custom property's computed value is a token stream, so var() splices it in textually rather than evaluating it, which gives the algebra above the local bindings it needs. Half a dozen small declarations collapse into one nested expression the engine evaluates in a single step at color:.
Two details of that arrangement are load-bearing rather than stylistic:
- The links must stay unregistered. Registering one as
syntax: '<color>'forces the engine to evaluate it into a color value, and Gecko stores that at reduced precision. The damage lands hardest on the carrier, whose alpha channel is the target luminance, so a 1/255 step of alpha is roughly 0.04 of contrast ratio. Measured over 27 cells, Firefox falls from 23 exact to 9. Unregistered, all three engines are exact. - The color-space modifier is a theme lookup, not a branch.
@theme inlineholds one expression per space and the utility declares its result twice: oklch inline as the default, then again as--modifier(--jib-contrast-shade-interpolation-*), which Tailwind emits only when a modifier is present. That is the same dispatch the lightness and saturation modules use.
When the target is impossible
The most any background can offer is max((Y + 0.05)/0.05, 1.05/(Y + 0.05)), which bottoms out at √21 ≈ 4.583 at the pivot. So 3:1 and 4.5:1 are reachable from every background, but 7:1 is mathematically unreachable for backgrounds with luminance between roughly 0.10 and 0.30, and for many vivid mid-tones. There the target luminance clamps, the output saturates at pure black or white, and the achieved ratio is the best physics allows. text-contrast-aaa on bg-indigo-600 tops out at 6.44:1.
The implementation lives in automatic-contrast/_index.css and ships from the main entry.
Import
Included in @import 'tw-jib-css'. To import individually:
@import 'tw-jib-css/automatic-contrast';Baseline status
What these utilities are built on, live from the Web Platform Dashboard.