Contrast Checker
A WCAG contrast readout for one colour pair — resolved through the live cascade, composited on a canvas, with AA/AAA verdicts and the nearest passing lightness.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/contrast-checker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ContrastChecker" component (lucide-react
icons, shadcn Input + Label). No colour library: the browser is the colour engine.
Contract
- Export a forwardRef <div> extending
Omit<React.HTMLAttributes<HTMLDivElement>, "onChange">.
- Controlled pair: foreground: string, background: string, with
onForegroundChange / onBackgroundChange: (value: string) => void. Both take ANY
CSS colour — named, hex, oklch, var(--token), color-mix(...).
- surface?: string — the opaque layer under `background`. Omitted means "walk my
own ancestors and find out".
- swatches?: { value: string; label: string }[] (default []) — quick picks shown
above each field. swatch.value is consumer data: painted verbatim via inline
style, exempt from the component's own semantic-token rule.
- target?: "aa-normal" | "aaa-normal" | "aa-large" | "aaa-large" (default
"aa-normal") — the level the suggestion aims at and the pill marked as the goal.
- sampleHeading?, sampleText?: the specimen copy inside the preview.
- Also export the level table CONTRAST_LEVELS (AA/AAA × normal/large, minima
4.5 / 7 / 3 / 4.5) and the pure helpers relativeLuminance(rgb) and
contrastRatio(l1, l2).
Behavior — resolving a value (what naive checkers get wrong)
- Render a hidden host span containing one empty probe span INSIDE the component
root, so the live cascade applies: scoped tokens, a .dark ancestor, a themed
card all resolve the way they really will.
- To read a value: probe.style.color = "" then probe.style.color = value. CSSOM
rejects garbage, so an empty read-back means "not a colour". Otherwise take
getComputedStyle(probe).color.
- `color: var(--typo)` is *valid syntax*, so the element quietly inherits instead
of failing. Run the read twice with two different named colours set on the host
(two sentinels). Same answer both times = a real colour; different answers = the
value never resolved, so flag that field instead of reporting a fake pass.
Behavior — compositing (why the number is true)
- Keep one 1×1 canvas. paint(base, layer): putImageData writes the opaque base
without blending, then fillStyle = layer and fillRect(0,0,1,1) run the browser's
own source-over alpha blend, then getImageData reads the result back. The canvas
doubles as the parser for oklch / color(srgb …) / color-mix output.
- Backdrop: collect getComputedStyle(el).backgroundColor for the preview's parent
and every ancestor up to the document element, then composite them
outermost-first over an opaque white page base. Transparent layers are no-ops,
so the result is the colour actually behind the component.
- Stack: backdrop → surface (when given) → background → foreground. The last two
results are the effective opaque pair; print them as hex next to the ratio so a
translucent pair is auditable.
Behavior — maths
- Per channel: c = v/255; linear = c ≤ 0.04045 ? c/12.92 : ((c+0.055)/1.055)^2.4.
- L = 0.2126R + 0.7152G + 0.0722B; ratio = (Lmax + 0.05) / (Lmin + 0.05), 1 … 21.
- Compare thresholds against the raw ratio and display it FLOORED to two decimals.
Rounding would print "4.50" next to a failed AA badge.
Behavior — the suggestion
- Computed only when `target` fails. Convert the effective foreground to HSL, keep
hue and saturation, bisect lightness towards 0 and towards 1 (20 steps each).
- Ratio-versus-lightness is V-shaped: it falls to 1 where the two luminances meet
and climbs again past it. Given "the edge passes, the current lightness fails",
the bracket holds exactly one crossing, so bisection is exact. Keep the loop
invariant on a lightness you actually evaluated (with the same 8-bit rounding
the browser does), so the answer is verified, not interpolated.
- Take whichever direction lands nearer the original lightness. If neither edge
reaches the target — any mid-tone background caps out around 5.3:1 — return
"impossible" and say so. Never emit a colour that still fails.
- The suggestion is an opaque hex: it replaces the composited colour, so applying
it drops the original alpha. Apply calls onForegroundChange.
Behavior — degenerate cases
- Unreadable field: keep the last good reading on screen (typing "cora" on the way
to "coral" must not blank the panel), mark that field aria-invalid, and name it
in the message. With no previous reading the ratio shows an em dash.
- Pixel read-back refused (hardened privacy modes): catch, keep the last reading,
and report that it could not sample.
- swatches empty: render the two fields alone, with no radiogroup at all.
- Background images and gradients are invisible to the backdrop walk; `surface` is
the documented escape hatch.
Behavior — reactivity and cleanup
- Measure in a layout effect keyed on
[foreground, background, surface, target, themeTick] so the first paint already
carries a number instead of flashing a placeholder.
- Token values move with the theme: a MutationObserver on the document element
(class / style / data-theme) plus a matchMedia("(prefers-color-scheme: dark)")
change listener. Re-measure inside a requestAnimationFrame once the class lands,
and again ~260ms later so a page-wide colour transition cannot freeze a
mid-transition value into the reading.
- On unmount and on every re-run: cancel the rAF, clear the timeout, disconnect
the observer, remove the media listener.
Behavior — keyboard and ARIA
- Each slot is: one <Label htmlFor={inputId} id={labelId}>, a role="radiogroup"
with aria-labelledby={labelId} holding the chips, then the free-value <Input>.
The textbox stays OUTSIDE the radiogroup.
- Chips are role="radio" with aria-checked and a roving tabindex: the checked chip
is the group's only tab stop, or the first chip when the typed value matches no
swatch. ArrowRight/ArrowDown and ArrowLeft/ArrowUp move circularly, Home/End
jump to the ends, and moving both focuses and selects. Click selects directly.
- The swap button has an aria-label and exchanges the two values in one call each.
- Apply uses aria-disabled plus an early return in the handler, never native
disabled: the button must not blur out from under the caret when a keystroke
turns the suggestion into "impossible". A successful Apply always reaches the
target, so the whole suggestion row unmounts with the button that was just
pressed — move focus to the Text field the value was written into, or the
keyboard user is dropped on <body> and has to tab back in from the top.
- One sr-only role="status" aria-live="polite" line carries the ratio and the
pass/fail summary, so the readout is heard and not only seen.
Rendering & styling
- Semantic tokens only for the component's own chrome: a bg-card /
text-card-foreground panel with border, text-muted-foreground labels,
border-destructive/40 + text-destructive on failed levels, var(--chart-2) on the
pass tick, ring-ring/50 on the target pill, focus-visible:ring-2 ring-ring with
ring-offset-background on every control. Consumer colours only ever reach inline
style — never a class.
- The preview IS the model, not a picture of it: a div painted with `surface`
wrapping a div painted with `background`, holding 24px semibold and 16px regular
text painted with `foreground` — the exact stack the maths walks.
- Swatch dots and the suggestion dot carry border-border/60, so a white value is
still visible on a white card.
- transition-colors on the preview layers and the chips, every one paired with
motion-reduce:transition-none; nothing in the reading depends on a transition.
- Two slots live in a flex-wrap row with min-w-52 each, so the panel folds to one
column inside a narrow card without squashing either field. Merge the consumer
className with cn().
Customization levers
- Levels: CONTRAST_LEVELS is a plain table. Add the 3:1 non-text level (SC 1.4.11)
as one more row and it appears in the pill grid, the status line and the target
union with no other edit.
- Density: drop the specimen preview for a toolbar-sized readout, or pass
swatches={[]} for a bare two-field probe.
- Suggestion policy: swap the HSL lightness bisection for an OKLCH one (the same
bracket argument holds) for perceptually even steps, or search hue instead of
lightness when a brand colour must keep its weight.
- Targeting: drive `target` from a segmented control and the panel becomes a "what
would it take to reach AAA" tool.
- Tokens: the panel is bg-card by default; on a dark marketing section pass
className="border-0 bg-transparent" — the backdrop walk keeps telling the truth
because it reads ancestors, not props.Concepts
- Cascade probe, not a parser — the value is set on a hidden span and read back through
getComputedStyle, sovar(--primary),color-mix()and a.darkancestor all resolve exactly as they will when the real text paints. - Sentinel pair —
color: var(--typo)is legal syntax, so the browser silently inherits; measuring twice under two different inherited colours is what separates "this is the colour" from "this never resolved", and it is why an undefined token gets refused instead of scored. - Canvas as the compositor — a 1×1 fill runs the browser's own source-over blend, so alpha is measured rather than approximated; a naive hex diff on two translucent layers can be several points optimistic.
- Backdrop walk — every ancestor's background colour is composited outermost-first, so the checker knows whether it is sitting on a card, on the page, or on a dark section — no
surfaceprop required. - Floor, never round — the ratio is truncated to two decimals so the printed number can never claim a pass the raw comparison denied.
- Refusal over a wrong fix — against a mid-tone background nothing beats about 5.3:1, so the suggestion says "impossible by lightness alone" rather than shipping a colour that still fails the level you asked for.
Color Palette
A live specimen sheet for a theme's color tokens — chips grouped by role, values read back from the DOM, ink/fill pairing previews and one-click copy.
Spinner
One indeterminate loader, seven shapes — three boxed (ring, dual-ring, bars) and four inline dot rows (dots, ellipsis, bounce, wave), colored by a single tone prop or plain currentColor.