Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowLeftRight, Check, Minus, TriangleAlert, WandSparkles, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

// 探针要在首帧绘制前读到计算样式,否则会闪一帧「—」;SSR 时退回普通 effect。
const useIsomorphicLayoutEffect = typeof document === "undefined" ? React.useEffect : React.useLayoutEffect

/** An 8-bit sRGB triple — always opaque, always the result of compositing. */
export type Rgb = readonly [number, number, number]

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/contrast-checker.json

Prompt

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, so var(--primary), color-mix() and a .dark ancestor all resolve exactly as they will when the real text paints.
  • Sentinel paircolor: 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 surface prop 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.

On This Page