Back to gallery

OKLCh/WCAG 2.1/APCA/hue preserved

Keep the colour. Move the lightness.

Every few months I need the same thing, and every few months the web offers me something else. I have a colour I chose, it sits on a background I chose, and it is not readable. What I want back is that colour, still recognisably itself, moved exactly as far as it has to be. What I get offered is black or white.

Black or white is the easy half of contrast. The harder half is taking a colour you actually chose and finding the nearest version of it that stays readable: same hue, same chroma where the gamut allows, only the lightness moved, and only as far as it needs to go.

Five ways to pick ink

The last three panes carry CSS declarations, the browser resolves them, and the numbers underneath are read back off what it painted. Move the background around and watch the ×−1000 pane fall into its grey band, which is a thing I would not have believed from a table and which rolling it by hand gets to below.

Scoring metric

Jump to

As typedyours

Handgloves

2.82:12.82:1 · Lc 27.7#1e3a8afails

ShiftedOKLCh

Handgloves

4.50:14.50:1 · Lc 36.8#041262ΔL 0.129 · chroma 100%darker · dark on light

CSS YIQ×−1000

Handgloves

CSS YIQ×−100000

Handgloves

CSS luminancepow()

Handgloves

YIQ and the luminance threshold disagree on this background: white at 3.68:1 against black at 5.71:1.

Lightness track

The ramp holds your hue and chroma steady and sweeps L from 0 to 1. The solid bar marks every lightness clearing the target.

L 0.000.50L 1.00
···· background‒ ‒ ‒ as typed──── shifted│ YIQ ink

Two different questions

Almost every contrast snippet on the web answers one question: given a background, should the text be black or white? That question has a tidy shape.

(background) → black | white

But the question I actually ask, and the one I hear designers ask, is a different one: I picked this colour, it isn’t readable on that background, give me the nearest version of it that is.

(foreground, background, target) → foreground′

The second signature takes the foreground as an argument. The first one does not. That is the whole difficulty in one line, and it took me an embarrassingly long time to see it: no amount of improving the first function ever turns it into the second, because the colour you are trying to keep is not even in the room. This page works through both, in order, the easy one first and honestly labelled as easy.

Everything on it is live. The swatches, the six cases and the five approaches in the lab all compute against whatever the controls say, and the panes that demonstrate CSS carry the real declarations rather than a drawing of them.

contrast-color()

CSS does this natively now, and gets it exactly right.

It takes a colour and returns black or white, whichever contrasts more, white on a tie. I checked it against the exact luminance threshold, the one part one derives below, over 1.89 million colours and found zero mismatches, which is unsurprising once you see it: it is that formula, in C++, running in the style engine before paint. That makes it strictly better than anything I could hand-roll. No main-thread work, no bundle, no reimplementation of mine to get wrong. If ink is genuinely all you need, stop reading and use it.

The last line of each swatch is the one nothing above it can produce: the colour itself, moved until it is readable on itself. Same hue, same chroma, only the lightness changed, and still recognisably the colour you started with rather than the black or white that replaced it three lines up. That is the whole point of this page, and part two is how it is done.

Checking supportCSS.supports('color', 'contrast-color(red)')

#3b82f6

contrast-color() #000000 (fallback)

threshold #000000 · 5.71:1

YIQ #ffffff · 3.68:1

shifted #001c4d · 4.50:1

YIQ picks the worse one

#ef4444

contrast-color() #000000 (fallback)

threshold #000000 · 5.58:1

YIQ #ffffff · 3.76:1

shifted #430004 · 4.50:1

YIQ picks the worse one

#8b5cf6

contrast-color() #000000 (fallback)

threshold #000000 · 4.96:1

YIQ #ffffff · 4.23:1

shifted #18003b · 4.50:1

YIQ picks the worse one

#fbbf24

contrast-color() #000000 (fallback)

threshold #000000 · 12.58:1

YIQ #000000 · 12.58:1

shifted #6d5000 · 4.50:1

#10b981

contrast-color() #000000 (fallback)

threshold #000000 · 8.28:1

YIQ #000000 · 8.28:1

shifted #00432c · 4.50:1

#64748b

contrast-color() #ffffff (fallback)

threshold #ffffff · 4.76:1

YIQ #ffffff · 4.76:1

shifted #f5f9ff · 4.50:1

#4682b4

contrast-color() #000000 (fallback)

threshold #000000 · 5.11:1

YIQ #ffffff · 4.11:1

shifted #001528 · 4.50:1

YIQ picks the worse one

#d2691e

contrast-color() #000000 (fallback)

threshold #000000 · 5.78:1

YIQ #ffffff · 3.63:1

shifted #391600 · 4.50:1

YIQ picks the worse one

The browser’s contrast-color() and the derived threshold agree on all 8 swatches. YIQ picks differently on 5 of 8, and every time it picks the lower-contrast option.

The catch

It has YIQ’s shape. One argument in, two colours out. It fixes the arithmetic and nothing else, and it still cannot take a colour you chose and keep it. That limitation is the whole of part two, and it is the reason this page does not end here.

Part one: picking ink

Black or white. Four ways to decide, three of which are wrong about 15% of the time.

What the YIQ formula actually is

The snippet everybody has pasted at least once computes this, and compares it to 128.

(r × 299 + g × 587 + b × 114) / 1000

It is worth being precise about what that number is, because it is not luminance and it never was. It is the Y channel of NTSC’s YIQ colour space, a 1953 broadcast trick that let one signal serve colour and black-and-white sets at once, on analogue hardware where a squaring circuit was an expensive thing to ask for. Three consequences follow, and they compound rather than cancel. It is applied to gamma-encoded values, with no linearisation step, so what comes out is neither physical light nor perceptual lightness but a number in between that is neither. The coefficients are for the wrong primaries: 0.299, 0.587 and 0.114 are Rec.601, tuned for 1953 phosphors, where sRGB is Rec.709 and wants 0.2126, 0.7152 and 0.0722. And 128 is the midpoint of the encoding rather than of contrast, since it is simply 255 divided by two; for neutrals the real crossover sits at a code value nearer 118.

None of that would matter if the answers came out the same, and the reason to care is that they do not. Ask YIQ to order two random colours and it gets them backwards 7.5% of the time; ask it to pick ink and it chooses the lower-contrast option on 14.7% of colours. That is not a tail of pathological cases either. Tailwind’s blue-500, red-500 and violet-500 all sit in the disagreement zone, as do steelblue, chocolate and indianred, which is to say: exactly where user interface palettes actually live. A formula from the era of vacuum tubes, shipping today, on the colours we use most.

The correct threshold, and where it comes from

You do not need a lookup table for this, and you do not need to try both and compare. Solve for the luminance at which black and white contrast equally under WCAG:

(1 + 0.05)/(Y + 0.05) = (Y + 0.05)/0.05 → Y = √0.0525 − 0.05 = 0.1791288

Above that value black wins, below it white does. Use the exact number rather than 0.179, because the rounded form disagrees with the greater-of-black-or-white rule on about 0.03% of sRGB colours, and if you are going to bother being right you may as well be right everywhere. It is also not the same thing as L*=50, which lands at Y=0.184. Neighbours, not synonyms.

Rolling it by hand, and why you might have to

Before the native function, people built this out of relative colour syntax, and those expressions are still worth understanding: for older browsers, and because their failure modes are a good lesson in how a correct-looking declaration can be silently useless.

The version that circulates has two bugs. The first is a comma after the origin colour, which is a parse error, since relative colour syntax is space-separated only. The declaration is dropped, the text inherits, and nothing anywhere tells you. The second is subtler.

clamp(0, (luma − 128) × −1000, 255)

That only saturates once the difference exceeds 0.255, and inside that window it returns a grey. It is 28,358 colours, or 0.169%, about one background in six hundred. On #e25d32 it emits #878787, which is 1.00:1 against its own background. Invisible text, from a declaration that parses and looks fine.

The fix is one character: −100000 instead of −1000, which narrows the grey band to 0.00255, below the 0.001 quantisation of 8-bit luma, so nothing can land in it. It is still YIQ though. Correct as written, wrong as designed. To fix the maths as well you need pow(), which lets CSS compute real relative luminance and threshold it at 0.1791288. Both are in the lab at the top of the page, computing live rather than being described, and both are in the code at the end.

Part two: shifting a colour

The question none of the above can answer.

Here is why this one feels hard: RGB has no lightness axis. There is no direction you can move in that means “lighter” without dragging hue and saturation along with it. Scaling the channels towards white desaturates, adding a constant shifts hue, and every naive attempt turns the colour into mush. Which is why people conclude it cannot be done and fall back to black or white, and I did that for years too.

Switch to a perceptual polar space and the problem collapses. In OKLCh a colour is lightness, chroma and hue, three axes that move independently. So:

  1. Hold H and C, which is what keeps the colour recognisably itself.
  2. Move L away from the background’s lightness.
  3. Contrast is monotonic in L on either side of the background, so bisect for the smallest move that hits the target.

Monotonic is the load-bearing word there. It is the difference between a fifteen-line binary search and a pile of heuristics with magic numbers in them. I checked that it holds for APCA as well, across three thousand random sweeps, and found no non-unimodal case.

The two things that go wrong

The gamut runs out. Saturated colours hit the edge of sRGB before they run out of lightness, since a vivid yellow simply cannot get dark at full chroma, so either the chroma gets clipped or the colour is allowed to desaturate. The target is unreachable. Sometimes no colour at that hue clears the bar at all, and when that happens a plausible-looking wrong answer is worse than an admission, so the result carries reached: false and returns its best effort rather than pretending.

Both show up below. #fde047 on white comes back olive, which is the gamut telling you that this yellow wants to be a background and not text.

Six cases

Your colour, the shifted version, and the black-or-white answer, on the same background at the same target.

Scoring metric

As typed

Shifted

YIQ ink

#3b82f6 on #fffffftarget 4.50:1

as typed
3.68:1
3.68:1 · Lc 63.9 · #3b82f6
shifted
4.50:1
4.50:1 · Lc 70.7 · #2c72e5
yiq ink
21.00:1
21.00:1 · Lc 106.0 · #000000

A short hop. The blue stays unmistakably blue, which is the common case.

As typed

Shifted

YIQ ink

#1e3a8a on #fffffftarget 4.50:1

as typed
10.36:1
10.36:1 · Lc 93.6 · #1e3a8a
shifted
10.36:1
10.36:1 · Lc 93.6 · #1e3a8a
yiq ink
21.00:1
21.00:1 · Lc 106.0 · #000000

Already over the bar, so nothing moves. The cheapest correction is no correction, and it is worth having a function that can say so.

As typed

Shifted

YIQ ink

#8b5cf6 on #f5f3fftarget 4.50:1

as typed
3.86:1
3.86:1 · Lc 62.3 · #8b5cf6
shifted
4.50:1
4.50:1 · Lc 67.2 · #8150e9
yiq ink
19.15:1
19.15:1 · Lc 99.7 · #000000

A brand colour on its own tint, which is where this comes up most. It clears the bar having barely moved, and nobody would call it a different violet.

As typed

Shifted

YIQ ink

#ef4444 on #7f1d1dtarget 4.50:1

as typed
2.66:1
2.66:1 · Lc -26.3 · #ef4444
shifted
4.50:1
4.50:1 · Lc -47.1 · #ff8e85
yiq ink
10.02:1
10.02:1 · Lc -96.3 · #ffffff

Down is blocked, so it goes up. The direction is chosen, never assumed.

As typed

Shifted

YIQ ink

#fde047 on #fffffftarget 4.50:1

as typed
1.32:1
1.32:1 · Lc 15.6 · #fde047
shifted
4.50:1
4.50:1 · Lc 71.1 · #897600
yiq ink
21.00:1
21.00:1 · Lc 106.0 · #000000

No yellow gets there on white. What comes back is olive: the gamut telling you this wants to be a background, not text.

As typed

Shifted

YIQ ink

#22c55e on #0b1120target 4.50:1

as typed
8.26:1
8.26:1 · Lc -57.2 · #22c55e
shifted
8.26:1
8.26:1 · Lc -57.2 · #22c55e
yiq ink
18.83:1
18.83:1 · Lc -107.3 · #ffffff

Light on dark. Switch metrics and APCA scores the reverse polarity on its own curve.

Where each one wins and fails

The same five approaches, judged on what they can and cannot do.

Approach comparison
ApproachMaths rightKeeps your colourWins whenFails when
YIQ ≥ 128NoNoDebug overlays and logging, anywhere a 15% miss rate costs nothing and you want one multiply-add.Shipped text. It picks the worse ink on 14.7% of colours, clustered exactly where UI palettes live.
CSS YIQ ×−1000NoNoNothing. It is strictly worse than the ×−100000 form.All of YIQ’s problems, plus a grey band on 0.169% of backgrounds that can return 1.00:1 text, plus a comma that drops the declaration.
CSS YIQ ×−100000NoNoLegacy browsers where you need pure CSS and cannot use pow().The same 14.7% ink error as any YIQ threshold. Correct as written, wrong as designed.
CSS luminance via pow()YesNoPure CSS, no contrast-color() support, and you want the right answer.Verbose. The expression cannot be hoisted into a custom property, so it repeats three times, and the native function supersedes it anyway.
contrast-color()YesNoAny time black-or-white ink is genuinely the answer. Zero JS, zero bundle, native speed, exactly correct.You wanted to keep your brand colour. It only ever returns black or white.
OKLCh shiftYesYesThe colour carries meaning, whether that is brand, semantics or a data encoding, and it has to survive being made readable.It needs JS at runtime, and when the gamut cannot reach the target it degrades towards exactly what contrast-color() would have handed you anyway.

The honest comparison

On maximum contrast, black-or-white wins and it is not close: 21:1 against a shift’s 4.5:1. That is not a point being hidden, it is the wrong yardstick. The shift solves a constrained problem, which is to keep this colour and spend the minimum to make it legible. contrast-color() does not solve that problem worse, it discards the constraint. The question is which one still has your design in it afterwards, not which number is bigger.

CSS only, or JavaScript?

What each platform can actually do, as of August 2026.

Capability by platform
CapabilityCSS onlyJSNotes
Pick black or white inkYesYescontrast-color() beats the JS version: native, pre-paint, no bundle.
Correct relative luminanceYesYespow() made this possible in CSS. It approximates the piecewise curve, which is fine for a threshold.
Read a computed contrast ratioNoYesCSS can branch on a value but cannot hand you the number.
Shift a colour, hue preservedPartlyYesYou can nudge L in oklch(from …), but by a fixed amount you picked, not solved against a target.
Search for the minimum changeNoYesBisection needs iteration, and CSS has no loops.
Detect gamut clippingNoYesCSS clips silently, and you cannot tell that it happened.
Know the target was unreachableNoYesThe most important row here. CSS always returns something.
APCA scoringPartlyYespow() can compute Lc, but comparing both polarities needs branching that CSS does not have.

The dividing line is not really CSS against JS. It is evaluation against search. CSS evaluates an expression per element, extremely fast, and that is genuinely all it does: it cannot iterate towards an answer, and it cannot report that it failed. Every “no” in that table is one of those two. So the practical split is to use contrast-color() for ink, always, and to reach for JS only when the colour itself has to survive. Run it at build time if you can, since the shift is deterministic and a design token pipeline is a much better home for it than the main thread.

Which metric?

WCAG 2.1 and APCA disagree, sometimes about which colour is better.

WCAG 2.1’s ratio is a legal standard and very often the thing you are actually required to meet. It is also a poor perceptual model: it ignores polarity, ignores font weight and size, and is unreliable at the dark end. Black on white and white on black both score 21:1, which anybody who has stared at both knows is not true of how they read.

APCA scores those two as Lc 106.0 and Lc −107.9. Signed, asymmetric, polarity-aware, and built for text legibility rather than as a general colour-difference metric. Flip the metric toggle in the lab above and watch every number move.

They do not just differ in precision, they pick different colours. On #3b82f6 WCAG prefers black, at 5.71:1 against white’s 3.68:1, and APCA prefers white, at Lc 69.4 against black’s 40.2. Same background, opposite answers, and the same thing happens on #787878 and #ef4444. APCA is also far stricter about mid-tone backgrounds: at Lc 75, its body-text minimum, nothing at any hue clears the bar against #3b82f6, because white itself only reaches 69.4. The honest reading of that is that a saturated mid-blue cannot host body text at all, which WCAG will cheerfully let you ship.

Don’t just swap

APCA is not a drop-in substitute for WCAG 2.x conformance. If you have a legal or contractual obligation, that obligation is almost certainly to WCAG. Use APCA to design well, and check WCAG to ship.

Take it with you

I packaged it up, so you do not have to paste anything out of this page.

Everything above runs on @daformat/contrast-color, which is the same code with tests around it. It has no dependencies and nothing in it is React, so it runs just as happily in a token pipeline at build time as it does under your cursor here. It also handles a translucent foreground, scoring the composite over the background rather than the colour you handed it.

Install

Open the repo on Github (and drop a star if you like it!)

npm install @daformat/contrast-color

The source

The listing below is concatenated from the installed package when this page is built rather than copied into it, so it cannot quietly drift away from the thing running above.

import { contrastShift } from "@daformat/contrast-color";

// WCAG 2.1, body text on a blue background
contrastShift("#1e3a8a", "#3b82f6", { target: 4.5 });
// -> { hex: "#041262", score: 4.5, direction: "darker", reached: true }

// APCA, same pair, Lc 60
contrastShift("#1e3a8a", "#3b82f6", { target: 60, metric: "apca" });

// Let it desaturate when the gamut runs out
contrastShift("#fde047", "#ffffff", { target: 4.5, preserveChroma: false });

// reached: false means no colour at this hue clears the target.
// What comes back is the best available, so check it before shipping.
const result = contrastShift(fg, bg, { target: 75, metric: "apca" });
if (!result.reached) {
  console.warn("unreachable, best is", result.score);
}

OKLab conversions: Björn Ottosson. APCA: SA98G constants from apca-w3 0.1.9, checked numerically against the reference implementation.
APCA is not a drop-in substitute for WCAG 2.x conformance, so check what your project is actually required to meet.

Up next

A table of contents component
-->
<--

Right before

The demo for my Subtitles app