Contrast badge
Reads two theme tokens at runtime and shows their WCAG contrast ratio in either theme.
npx shadcn@latest add https://ui.goodtabs.co/r/contrast-badge.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/contrast-badge
Measuring
--muted-foreground on --background
Muted text on the page background, recomputed when the theme flips.
Two pairs
Measuring
--brand on --background
Measuring
--foreground on --background
Brand on background passes only the 3:1 mark, foreground on background clears AAA.
Usage
import { ContrastBadge } from "@/components/ui/contrast-badge"
<ContrastBadge foreground="--muted-foreground" background="--background" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| foreground | string | CSS custom property name for the text colour, such as "--muted-foreground". | |
| background | string | CSS custom property name for the background colour. |
Source
contrast-badge/contrast-badge.tsx
"use client";
import * as React from "react";
import { cn } from "cn";
type ContrastBadgeProps = React.ComponentProps<"div"> & {
/** CSS custom property for the text colour, such as "--muted-foreground". */
foreground: string;
/** CSS custom property for the background colour, such as "--background". */
background: string;
};
type RGB = [number, number, number];
/** Resolves any CSS colour, hex or oklch, to sRGB by painting one pixel. */
function resolve(color: string): RGB {
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!ctx) return [0, 0, 0];
ctx.fillStyle = color;
ctx.fillRect(0, 0, 1, 1);
const d = ctx.getImageData(0, 0, 1, 1).data;
return [d[0], d[1], d[2]];
}
const lin = (c: number) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const luminance = ([r, g, b]: RGB) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
/** CR = (L1 + 0.05) / (L2 + 0.05), lighter over darker. */
function ratio(a: RGB, b: RGB) {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
function read(foreground: string, background: string) {
const style = getComputedStyle(document.documentElement);
const fg = style.getPropertyValue(foreground).trim();
const bg = style.getPropertyValue(background).trim();
return fg && bg ? ratio(resolve(fg), resolve(bg)) : null;
}
/**
* Shows the WCAG contrast ratio of two theme tokens, computed in the browser from their
* current values and recomputed when the theme changes.
*/
export function ContrastBadge({ foreground, background, className, ...props }: ContrastBadgeProps) {
const [cr, setCr] = React.useState<number | null>(null);
React.useEffect(() => {
const update = () => setCr(read(foreground, background));
update();
const mo = new MutationObserver(update);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] });
return () => mo.disconnect();
}, [foreground, background]);
const grade = cr === null ? "" : cr >= 7 ? "AAA" : cr >= 4.5 ? "AA" : cr >= 3 ? "AA large" : "Fail";
return (
<div className={cn("flex items-center gap-3 rounded-lg border bg-card p-3", className)} {...props}>
<div
className="grid size-14 shrink-0 place-items-center rounded-md border text-2xl font-semibold"
style={{ color: `var(${foreground})`, background: `var(${background})` }}
aria-hidden
>
Aa
</div>
<div className="min-w-0">
<p className="text-sm font-medium tabular-nums">
{cr === null ? "Measuring" : `${cr.toFixed(2)}:1`}
{grade ? <span className="ml-2 text-muted-foreground">{grade}</span> : null}
</p>
<p className="font-mono text-xs break-words text-muted-foreground">
{foreground} on {background}
</p>
</div>
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/contrast-badge/prompt.md, or the Copy AI prompt button above.
When to use
- Token documentation, so a pair's ratio never goes stale
- Checking a new token pair in both themes
When not to use
- Translucent colours: it reads the token as opaque and ignores alpha
- Production UI, where it adds a canvas read for no user benefit
Agent notes
- Tokens are resolved through a 1px canvas, so hex, oklch and color-mix values all work
- It watches class, data-theme and style on the html element and recomputes on change
- The swatch is aria-hidden, the ratio text is the accessible content
The maths
- Lin(c) = c / 12.92 for c ≤ 0.04045, else ((c + 0.055) / 1.055)^2.4, applied to each channel.
- L = 0.2126 R + 0.7152 G + 0.0722 B, then CR = (L lighter + 0.05) / (L darker + 0.05).
- Muted-foreground on the dark background is 6.13:1, which clears 4.5:1 for AA.