Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
foregroundstringCSS custom property name for the text colour, such as "--muted-foreground".
backgroundstringCSS 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.