Insights

Color for screens that aren't yours

Color for screens that aren't yours

I design on a color-managed display in a room where I control the light. Almost nobody reads my work that way. They read it on a three-year-old phone at forty percent brightness in a parking lot, on the monitor their office bought in bulk, or in a dark mode I never tested. A palette that only survives my desk is not a palette. It is a preference.

On 4 September 2026, sitting down to write this, I ran the studio's own design tokens through the contrast formula as a set rather than one pair at a time. The link color in the body of every post on this site, --accent-deep at #c15f3c on #faf9f5 paper, came back at 4.01:1. AA asks for 4.5:1 on body text. That token had sat in the stylesheet since the first commit in this repository on 5 July 2026, so I had been publishing under-contrast links for two months on the site I use as a portfolio. It is #b25838 at 4.59:1 now, shipped the same morning I found it, in commit e27d6a5. An accessible color palette on the web is a set of measured pairs, twenty-one of them here, not seven nice swatches, and I had only ever checked mine a pair at a time.

What does WCAG 2.2 actually ask for?

Three numbers, and they are not interchangeable. Success Criterion 1.4.3 Contrast (Minimum), Level AA, is the one most people mean:

The visual presentation of text and images of text has a contrast ratio of at least 4.5:1, except for the following: Large Text: Large-scale text and images of large-scale text have a contrast ratio of at least 3:1

Large text is defined in the same document as at least 18 point, or 14 point bold. SC 1.4.11 Non-text Contrast, also Level AA, extends 3:1 to "User Interface Components and Graphical Objects", which catches icon buttons, focus rings, and the borders that tell a reader where a field starts. SC 1.4.6 is the AAA tier at 7:1.

The ratio is (L1 + 0.05) / (L2 + 0.05), where L is relative luminance and L1 is the lighter color, and it runs from 1:1 to 21:1. Read the specification's own note on relative luminance before getting clever with color spaces: "Almost all systems used today to view web content assume sRGB encoding. Unless it is known that another color space will be used to process and display the content, authors should evaluate using sRGB colorspace." WCAG 2.2 has been a W3C Recommendation since 5 October 2023, current published version 12 December 2024. It is what I conform to.

Is APCA the standard now?

No, and a lot of confident writing on the web has this wrong. APCA, the perceptual contrast algorithm that circulated for years as "the WCAG 3 method", is not in WCAG 3. I searched the current Working Draft, dated 3 March 2026: the string "APCA" appears zero times. What the draft does carry is a glossary entry for "contrast ratio test", flagged Exploratory, with this editor's note attached:

The contrast algorithm used in WCAG 3 is yet to be determined.

The same document says of itself that "it is inappropriate to cite this document as other than a work in progress". APCA was exploratory content in an earlier draft and was removed in 2023 without gaining working group support. It may come back. How long WCAG 3 takes is not something I can source, so I will not guess at a year. None of that changes the practical answer: compute WCAG 2.2 ratios, ship against them, and treat any perceptual algorithm as a second opinion rather than as the thing you conform to.

What does my own palette score?

Seven foreground tokens, three grounds, twenty-one relationships, and two of the tokens I ship every week fell short on the grounds they actually land on. The matrix below is that run, with the link color as it stands after the fix and the value it replaced named underneath it.

A contrast matrix of the seven Ulric color tokens against three grounds. Ink #191917 on paper #faf9f5 reads 16.71:1 and ink-soft #33322e reads 12.18:1, both AAA. Stone #6f6d66 reads 4.92:1 on paper but 4.46:1 on the tinted paper-2 ground, missing AA body text. Accent-deep, the link color, now #b25838, reads 4.59:1 on paper and passes, but 4.16:1 on paper-2 and still misses there; the row notes it was #c15f3c at 4.01:1 until commit e27d6a5. Accent #d97757 reads 2.96:1 on paper but 5.64:1 on ink. Stone-dark #a3a094 is unusable on paper at 2.49:1 and passes at 6.72:1 on ink. Cream #f0eee6 on ink reads 15.16:1.

Most of the palette is fine. Ink on paper is 16.71:1, the paragraph color --ink-soft is 12.18:1, cream body text on a dark section is 15.16:1, all clearing AAA with room to spare and carrying the overwhelming majority of the words on the site.

The two that fell short were both small text. --accent-deep was 4.01:1 on paper, where it colors every link in a post and the mono step numbers on the pricing page, and 3.64:1 where those numbers sit inside a tinted section. The links are underlined, so SC 1.4.1 was not the problem here; 1.4.3 was. And --stone at #6f6d66 reads 4.92:1 on plain paper but 4.46:1 against the same tint, missing AA by four hundredths. That is the kind of number that only exists if you compute it, because nothing about it looks wrong.

The fix for the link is a lightness move, not a hue move. In OkLCh, #c15f3c is oklch(0.597 0.135 39.9). Walking L down until the ratio clears 4.5:1 lands at 0.567. What I shipped is one step past it, #b25838, oklch(0.564 0.126 39.8), reading 4.59:1 on paper: the same hue to a tenth of a degree, a little less chroma than the pure lightness walk would have kept, and four points darker. It is the same terracotta. Almost only the value changed.

# hold hue and chroma, lower lightness until AA clears
L, C, H = oklch("#c15f3c")        # 0.597 0.135 39.9
while contrast(from_oklch(L, C, H), "#faf9f5") < 4.5:
    L -= 0.002
# -> L 0.567 clears at 4.54:1; shipped #b25838 (0.564 0.126 39.8),
#    4.59:1 on paper, 4.16:1 on paper-2, 3.64:1 on ink

Note the last two numbers, because they are the reason this is one fix and not the fix. On the dark ground the corrected link color falls to 3.64:1, so a dark section needs a second link token rather than the same one; .dark now redefines --accent-deep as --accent, which is 5.64:1 on ink, so the token flips instead of every rule growing a dark twin. And on the tinted #f0eee6 ground the new value reads 4.16:1, still under 4.5:1, next to --stone at 4.46:1 on the same tint. Both of those are open as I publish this. Darkening the token far enough to clear the tint too costs more of the hue than I am willing to spend without redrawing the tinted sections, so the honest state is one ground fixed and one ground named.

What generalizes is uncomfortable and simple: the tokens I measured are the ones I built after I started measuring. Contrast was in the brief for the pinned scroll sections in August, so those tokens were tuned against every ground they touch and they hold (#5f5d56 rail text at 6.26:1 on paper, a heading terracotta at #a84f31 for 5.21:1 on light and #f2a886 for 8.99:1 on dark). The prose link color predates that habit by two months and nobody went back for it. A design system does not inherit your later discipline. It keeps whatever you gave it on the day you wrote it.

Why not pure black on pure white?

Not because the standard says so. Nothing in WCAG 2.2 prefers one: #000000 on #ffffff is 21:1, my #191917 on #faf9f5 is 16.71:1, and both clear the AAA threshold of 7:1 by a wide margin.

The evidence I can actually cite runs the other way from what a lot of dark-mode advocacy assumes. Piepenbrock, Mayr, Mund and Buchner tested 84 younger and 85 older adults on visual acuity and a proofreading task, between subjects, in Ergonomics 2013 (56(7), 1116 to 1124). Their conclusion: "Dark characters on light background lead to better legibility and are strongly recommended independent of observer's age."

That is a finding about polarity, dark-on-light versus light-on-dark. It is not a finding about #191917 versus #000000, and I have never found a study that is. So the honest version of my reason is a judgment rather than a citation. Fifteen years of software, then several years as a counselor in residential treatment and crisis settings, left me suspicious of any interface that raises its voice when it does not need to. Sixteen to one is plenty of contrast. Twenty-one to one is a decision to shout.

What does a dark ground do to the same color?

It changes the number without changing the color. Contrast is a relationship, not a property, so a token can be decoration on one ground and body text on another with no edit to the hex.

The same two hex values shown on a light panel and a dark panel side by side. Terracotta #d97757 reads 2.69:1 on the tinted #f0eee6 ground, failing even the 3:1 floor for interface components, and 5.64:1 on the #191917 ink ground, where it passes AA for body text. Warm grey #a3a094 reads 2.26:1 on the light ground and 6.72:1 on ink.

Terracotta #d97757 is 2.96:1 on paper, below even the 3:1 floor for interface parts, so on light grounds it is only ever a rule, a bar, or a logo, never a word. The identical hex on ink is 5.64:1 and reads comfortably as text. Hence two greys for one job: --stone for light grounds, --stone-dark for dark. Swap them and both fail.

The same color also looks heavier in negative polarity than in positive, which is why light text on a dark ground usually wants a lighter font weight than you used on paper. The mechanism the polarity literature reaches for is intraocular light scatter, discussed at length in the Piepenbrock paper. How much apparent weight that adds at a given size and typeface I have no number for, and I would not trust one I read without a method attached.

A gap worth naming rather than dressing up: this site has zero prefers-color-scheme rules in it. I grepped the whole theme directory before writing that sentence. .dark is a section class, used on the dark stages and case cards in the scroll-scrubbed sections, not a theme. A reader whose operating system is set to dark gets my light page, rendered correctly and not what they asked for.

What happens when color is the only channel?

You lose the message for a substantial group of readers, and you can prove it without a simulator. The National Eye Institute's page on color blindness, last updated 5 November 2025, puts it plainly: "About 1 in 12 men have color vision deficiency." The rule that follows is Level A, the lowest bar in the whole specification, SC 1.4.1 Use of Color:

Color is not used as the only visual means of conveying information, indicating an action, prompting a response, or distinguishing a visual element.

Three rows of four status cards. The first row distinguishes failing, stale, healthy and idle jobs by dot color alone: red, amber, green and grey. The second row replaces each dot with its measured sRGB relative luminance as a grey: the red becomes #6a6a6a, the green #6d6d6d and the grey #6d6d6d, so three of the four states become the same dot, and red against green measures 1.06:1. The third row adds a distinct shape and a status word to each card, so the meaning survives with the hue removed.

The cheap test is to collapse every swatch to its relative luminance and look again. That is not a color-blindness simulation and I will not claim it is one, but it catches the same fault for free: if two states differ only in hue and their luminances match, a reader who cannot separate those hues has nothing left. A conventional red #c0392b against a conventional green #3d7a4a measures 1.06:1. On the small board of job dots I watch every morning, the failing job and the healthy one would be the same dot. Give every state a shape and a word, then take the hue away and read the screen again.

What does modern CSS actually give me?

Better tools for choosing colors, and no help at all with the ratio. Worth knowing which is which.

OkLCh is why my link fix was two lines instead of an afternoon in a color picker. Baseline widely available since 9 May 2023 (Safari 15.4 on 14 March 2022, Chrome 111 on 7 March 2023, Firefox 113 on 9 May 2023), per the W3C WebDX Baseline data. Its lightness axis matches what your eye calls lightness, which HSL's does not. CSS Color Level 4 makes the point with a comparison you can check yourself: sRGB blue and sRGB yellow are both hsl() lightness 50%, "but clearly the yellow looks much lighter than the blue", while in OkLCh they are 0.452 and 0.968. Darken a token in OkLCh and you get the same color, darker. Darken it in hex and you get whatever you get.

color-mix() has been Baseline widely available since 9 May 2023 as well (Safari 16.2, 13 December 2022). I use it in exactly one place, where the shape of the usage matters more than the function:

border: 1px solid rgba(127,127,127,.22);
border-color: color-mix(in srgb, currentColor 14%, transparent);

Static fallback first, computed value second. An engine that does not understand the second declaration drops it and keeps the first. One caveat if you go further: color-mix() with three or more colors is Firefox-only as of Firefox 150 on 21 April 2026, and is not Baseline.

Wide gamut is where the honest answer gets longer. color(display-p3 ...) is Baseline widely available (Safari 15 on 20 September 2021, Chrome 111, Firefox 113), and @media (color-gamut: p3) since Firefox 110 on 14 February 2023. On an sRGB screen a P3 color is gamut mapped, which CSS Color 4 describes as "producing a similar-looking but lower chroma (less saturated) color". The punchier accent you picked on a P3 laptop arrives desaturated on the cheap monitor, and you never see it happen.

More to the point here: WCAG's relative luminance has no wide-gamut definition, which is why the specification tells authors to evaluate in sRGB. A P3 value has to be converted before the ratio means anything, and the light leaving a P3 panel is not the light the formula modelled. That is a gap in the standard rather than something you fix in a stylesheet. Compute in sRGB, conform in sRGB, use P3 for the vividness rather than for the legibility.

How do I test instead of hoping?

With a script over the token file, and with a phone outdoors. The script is the only part that scales:

def srgb_to_lin(c):
    c = c / 255
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4

def lum(h):
    r, g, b = (int(h[i:i+2], 16) for i in (1, 3, 5))
    return (0.2126 * srgb_to_lin(r) + 0.7152 * srgb_to_lin(g)
            + 0.0722 * srgb_to_lin(b))

def ratio(a, b):
    hi, lo = max(lum(a), lum(b)), min(lum(a), lum(b))
    return (hi + 0.05) / (lo + 0.05)

Validate it before you trust it. Mine returns 4.54:1 for #767676 on white and 21.00:1 for black on white, the two reference values everyone quotes, so I know it agrees with the rest of the world. That habit comes out of the verify step in every loop I run: a checker nobody has checked is a smoke alarm nobody tested. Then feed it every foreground token crossed with every ground your theme actually paints, not the pairs you remember using. Twenty-one comparisons take a second. Finding one of them by eye takes two months, as I now know.

Three user preferences are worth wiring after that, all widely supported. prefers-color-scheme since 15 January 2020, prefers-contrast since 31 May 2022 (Safari 14.1 on 26 April 2021, Chrome 96 on 15 November 2021), and forced-colors since Safari 16 on 12 September 2022. Media Queries Level 5 gives prefers-contrast four values, no-preference, less, more and custom, so it hands you a direction and never a number: more means "raise it", not "raise it to 7:1". forced-colors discards your palette entirely for the user's own, and the spec says that when it is active prefers-contrast "should be ignored in favor of the colors specified by forced-colors". Check that your page still makes sense with your colors gone, because for some readers it already is.

The last test has no specification. Take the phone outside at forty percent brightness and read your own site. I have no measurement of an outdoor phone's effective contrast and I doubt a general number exists that would survive scrutiny, so this one stays empirical. It has also changed more of my designs than any tool, which is why I write the rules down in a file rather than trusting myself to remember them.

The pair the standard does not settle

The site's text selection was white on terracotta, ::selection{background:var(--accent);color:#fff}, which measures 3.12:1. Whether a highlight counts as text under SC 1.4.3 is not obvious to me. The criterion measures against "the specified background over which the text is rendered in normal usage", and a highlight is arguably not normal usage. I cannot find a line in WCAG 2.2 that settles it either way, and I would rather say so than pick the reading that lets me keep the design.

So I changed it anyway. The rule now reads color:var(--ink), which is 5.64:1 on the same terracotta and does not need a ruling. That shipped in 479c80b, eleven minutes after the link color, which is about how long it takes to stop arguing with yourself once the number is on the screen.

Common questions

What contrast ratio does WCAG 2.2 require?

Success Criterion 1.4.3 (Level AA) requires 4.5:1 for body text and 3:1 for large text, defined as at least 18 point or 14 point bold. SC 1.4.11 requires 3:1 for user interface components and graphical objects. SC 1.4.6 (Level AAA) raises body text to 7:1. The ratio is (L1 + 0.05) / (L2 + 0.05) computed on sRGB relative luminance.

Is APCA the WCAG 3 contrast standard?

No. The WCAG 3.0 Working Draft of 3 March 2026 does not mention APCA at all. Its glossary entry for "contrast ratio test" is marked Exploratory and carries an editor's note reading "The contrast algorithm used in WCAG 3 is yet to be determined." WCAG 3 is a draft that explicitly says it is inappropriate to cite as anything other than a work in progress, so WCAG 2.2 remains the conformance target.

Why does the same color pass on a dark background and fail on a light one?

Because contrast is a relationship between two colors, not a property of one. My accent terracotta #d97757 measures 2.96:1 against #faf9f5 paper, below even the 3:1 floor for interface parts, and 5.64:1 against #191917 ink, which passes AA for body text. Nothing about the hex changed. A palette needs a separate token for each ground it lands on.

How do I check a palette for color blindness without a simulator?

Collapse every swatch to its sRGB relative luminance and look again. It is not a simulation of color vision deficiency, but it catches the same fault: if two states differ only in hue and their luminances match, nothing is left to read. A conventional red #c0392b against a conventional green #3d7a4a measures 1.06:1. WCAG SC 1.4.1 (Level A) requires that color is never the only visual means of conveying information, so give every state a shape and a word too.

Should I use pure black text on pure white?

WCAG does not care: #000 on #fff is 21:1 and my #191917 on #faf9f5 is 16.71:1, and both clear the 7:1 AAA threshold. The evidence that does exist is about polarity rather than about specific near-black values. Piepenbrock and colleagues (Ergonomics, 2013, 56(7):1116-1124) tested 84 younger and 85 older adults and recommend dark characters on a light background at any age. I have found no study comparing near-black to pure black, so softening the endpoints is a comfort judgment, not a cited requirement.

Related

← All insights