# Color Contrast

WCAG contrast ratios and readable text color selection, with no dependencies.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/color-contrast.json
```

[Registry JSON](https://mwui.vercel.app/r/color-contrast.json)

## Preview

```tsx
import { getContrastRatio, getReadableTextColor } from "@/lib/color-contrast";

const LIGHT = "#fafafa";
const DARK = "#0a0a0a";

const SWATCHES = [
  { label: "brand", value: "#6400DC" },
  { label: "yellow", value: "#facc15" },
  { label: "blue", value: "#2563eb" },
  { label: "mint", value: "#6ee7b7" },
  { label: "slate", value: "#64748b" },
  { label: "rose", value: "#f43f5e" },
];

export function Preview() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-1.5">
      {SWATCHES.map((swatch) => {
        const tone = getReadableTextColor(swatch.value, { light: LIGHT, dark: DARK });
        const foreground = tone === "light" ? LIGHT : DARK;

        return (
          <div
            className="flex items-baseline justify-between gap-4 rounded-md px-3 py-2 text-sm"
            key={swatch.label}
            style={{ backgroundColor: swatch.value, color: foreground }}
          >
            <span className="font-mono text-xs">{swatch.label}</span>
            <span className="tabular-nums">
              {tone} · {getContrastRatio(swatch.value, foreground).toFixed(2)}:1
            </span>
          </div>
        );
      })}
    </div>
  );
}
```


## Source

### lib/color-contrast.ts

```ts
type Rgb = {
  r: number;
  g: number;
  b: number;
};

type ColorInput = Rgb | string;

type ReadableTone = "light" | "dark";

type ReadableTextColorOptions = {
  light?: ColorInput;
  dark?: ColorInput;
};

const HEX_SHORTHAND_LENGTH = 3;

/**
 * Parses `#abc`, `#aabbcc`, `#aabbccdd`, and `rgb()` / `rgba()` into 0-255 channels.
 * Alpha is ignored: contrast is only meaningful against an opaque backdrop, and
 * guessing what sits behind a translucent color would be worse than not trying.
 */
function parseRgb(input: ColorInput): Rgb | null {
  if (typeof input !== "string") return input;

  const value = input.trim();
  const rgbMatch = /^rgba?\(([^)]+)\)$/iu.exec(value);

  if (rgbMatch?.[1]) {
    const parts = rgbMatch[1]
      .split(/[\s,/]+/u)
      .filter(Boolean)
      .map((part) => (part.endsWith("%") ? (Number(part.slice(0, -1)) * 255) / 100 : Number(part)));

    const [r, g, b] = parts;
    if (r === undefined || g === undefined || b === undefined) return null;
    if ([r, g, b].some(Number.isNaN)) return null;

    return { r, g, b };
  }

  const hex = value.startsWith("#") ? value.slice(1) : value;
  if (!/^[0-9a-f]+$/iu.test(hex)) return null;

  const expanded =
    hex.length === HEX_SHORTHAND_LENGTH || hex.length === HEX_SHORTHAND_LENGTH + 1
      ? Array.from(hex, (char) => char + char).join("")
      : hex;

  if (expanded.length !== 6 && expanded.length !== 8) return null;

  return {
    r: Number.parseInt(expanded.slice(0, 2), 16),
    g: Number.parseInt(expanded.slice(2, 4), 16),
    b: Number.parseInt(expanded.slice(4, 6), 16),
  };
}

function toLinear(channel: number): number {
  const value = Math.min(1, Math.max(0, channel / 255));

  return value <= 0.040_45 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
}

/**
 * WCAG 2.x relative luminance. Returns 0 for anything unparseable, which biases
 * callers toward light text rather than throwing inside a render.
 */
function getRelativeLuminance(color: ColorInput): number {
  const rgb = parseRgb(color);
  if (!rgb) return 0;

  return 0.2126 * toLinear(rgb.r) + 0.7152 * toLinear(rgb.g) + 0.0722 * toLinear(rgb.b);
}

/**
 * WCAG 2.x contrast ratio, from 1 (identical) to 21 (black on white).
 * Order does not matter.
 */
function getContrastRatio(a: ColorInput, b: ColorInput): number {
  const first = getRelativeLuminance(a);
  const second = getRelativeLuminance(b);

  return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
}

/**
 * Picks whichever of the two candidates contrasts better with `background`.
 *
 * Returns the tone rather than a color so the caller can map it onto whatever it
 * actually applies — a design token, a class name, a CSS variable.
 */
function getReadableTextColor(
  background: ColorInput,
  options: ReadableTextColorOptions = {},
): ReadableTone {
  const { light = "#ffffff", dark = "#000000" } = options;

  return getContrastRatio(background, light) >= getContrastRatio(background, dark)
    ? "light"
    : "dark";
}

export {
  getContrastRatio,
  getReadableTextColor,
  getRelativeLuminance,
  parseRgb,
  type ColorInput,
  type ReadableTextColorOptions,
  type ReadableTone,
  type Rgb,
};
```



## Usage

Three functions built on the WCAG 2.x definitions: relative luminance, the contrast ratio between
two colors, and the choice between a light and a dark foreground. No dependencies, no color library.

```ts
import { getContrastRatio, getReadableTextColor } from "@/lib/color-contrast";

getContrastRatio("#6400DC", "#ffffff"); // 8.15
getReadableTextColor("#6400DC"); // "light"
getReadableTextColor("#facc15"); // "dark"
```

## Choosing a foreground

`getReadableTextColor` returns a tone, not a color, so the caller maps it onto whatever it actually
applies — a design token, a class, a CSS variable:

```tsx
const tone = getReadableTextColor(accent, { light: "#fafafa", dark: "#0a0a0a" });

element.style.setProperty("--brand-foreground", tone === "light" ? LIGHT : DARK);
```

Pass `light` and `dark` whenever the real candidates are not pure white and black. Doing so rarely
changes which tone wins — swapping pure white and black for a near-white and near-black moves the tie
point from a luminance of `0.1791` to `0.1810`, not enough to flip any 8-bit grey — but it does make
the ratios themselves correct, which matters as soon as you compare them against an AA or AAA
threshold rather than just against each other.

## Why not a lightness threshold

The tempting shortcut is to read one number — HSL lightness, or OKLCH `L` — and compare it against a
constant. It is close, but it is not the same thing, because the point where white and black tie
depends on hue as well as lightness:

| Background          | Ideal OKLCH `L` crossover |
| ------------------- | ------------------------- |
| Neutral grey        | 0.566                     |
| Saturated yellow    | 0.556                     |
| Saturated red       | 0.592                     |
| Saturated blue      | 0.628                     |

No single threshold sits on all of those, so a fixed constant is guaranteed to pick the worse
foreground for some slice of the color wheel. Comparing the two ratios costs a few lines and is
exact.

If you do reach for a threshold anyway, use OKLCH `L` rather than HSL `L`: OKLCH lightness is
perceptual, so a yellow and a blue that share an `L` genuinely look equally light.

## Parsing

`parseRgb` accepts `#abc`, `#aabbcc`, `#aabbccdd`, and `rgb()` / `rgba()`, returning 0-255 channels
or `null`. Alpha is parsed but ignored — contrast is only meaningful against an opaque backdrop, and
guessing what sits behind a translucent color would be less accurate than not trying. Composite the
color yourself first if it is translucent.

Unparseable input yields a luminance of `0` rather than an exception, so a bad value at runtime
degrades to "assume dark, use light text" instead of breaking a render.

## Functions

| Function                | Description                                                              |
| ----------------------- | ------------------------------------------------------------------------ |
| `getRelativeLuminance`  | WCAG relative luminance, `0`–`1`.                                         |
| `getContrastRatio`      | WCAG contrast ratio, `1`–`21`. Argument order does not matter.            |
| `getReadableTextColor`  | `"light"` or `"dark"`, whichever contrasts better. Candidates are configurable. |
| `parseRgb`              | Hex or `rgb()` string to `{ r, g, b }` in 0-255, or `null`.               |

For reference, WCAG AA wants `4.5` for body text and `3` for large text or UI components; AAA wants
`7` and `4.5`.

