Both grounds, tested A palette designed on white and shipped on charcoal usually fails, because the mid-tone amber that read as warning on paper vanishes. Every value here is measured against both grounds, and the rows that miss the floor say so. Tokens this needs: --muted-foreground, --status-critical The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // ContrastLadder.tsx ──────────────────────────────────────────────────────────────────────── import { contrast } from "./contrast"; /** WCAG 2.2 SC 1.4.11. A chart element carries meaning, so it is non-text * content and 3:1 is the floor, on every ground the chart will be shown on. */ const FLOOR = 3; export type Step = { label: string; color: string }; /** * One ground, every value of the palette on it, and the ratio each one * actually reaches. The ground is a prop rather than the page background * because the point is to run this against the charcoal the chart ships on, * not the white it was designed on. */ export function ContrastLadder({ label, ground, steps, floor = FLOOR }: { label: string; /** The background the chart will be drawn on, as #rrggbb. */ ground: string; steps: Step[]; floor?: number; }) { const rows = steps.map((s) => ({ ...s, ratio: contrast(s.color, ground) })); const passing = rows.filter((r) => r.ratio >= floor).length; return (
{/* The labels sit outside the ground, so they stay readable on charcoal. */}
{rows.map((r) => {r.label})}
{rows.map((r) => )}
{rows.map((r) => ( = floor ? "text-muted-foreground" : "text-status-critical"}> {r.ratio.toFixed(1)}:1{r.ratio < floor && " below floor"} ))}
{label} · {passing} of {rows.length} reach {floor}:1
); } ──────────────────────────────────────────────────────────────────────── // contrast.ts ──────────────────────────────────────────────────────────────────────── /** * WCAG 2.2 contrast, the way SC 1.4.11 measures a non-text element against * its ground. Small on purpose: the check has to be cheap enough to run on * every colour against every ground, not once on the one someone remembered. */ const channel = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); /** Relative luminance of a #rrggbb colour, 0 for black and 1 for white. */ export function luminance(hex: string) { const n = parseInt(hex.slice(1), 16); const [r, g, b] = [n >> 16, (n >> 8) & 255, n & 255].map((v) => channel(v / 255)); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } /** Contrast ratio between two colours, 1 to 21. */ export function contrast(a: string, b: string) { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); } /** A neutral grey at a given CIE L*, so a ladder can be stated in lightness. */ export function greyAtLightness(L: number) { const Y = L > 8 ? ((L + 16) / 116) ** 3 : L / 903.3; const c = Y <= 0.0031308 ? 12.92 * Y : 1.055 * Y ** (1 / 2.4) - 0.055; const h = Math.round(c * 255).toString(16).padStart(2, "0"); return `#${h}${h}${h}`; } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { ContrastLadder, type Step } from "./ContrastLadder"; import { greyAtLightness } from "./contrast"; /** * Six greys stated in CIE L*, run against the two grounds the chart will * live on. On white the dark end passes; on charcoal only the light end * does, and the usable range is the rows that clear the floor. */ const STEPS: Step[] = [88, 72, 56, 40, 24, 8].map((L) => ({ label: String(L).padStart(2, "0"), color: greyAtLightness(L), })); export default function Demo() { return (
); }