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. */}
{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 (