# Non-Text Contrast (3:1)

**MUST** · **ID:** `design-non-text-contrast` · **Category:** design
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/design-non-text-contrast

> MUST: Anything non-text that identifies a component or its state — input outlines, unchecked checkboxes, an off toggle, an icon-only glyph, focus indicators — and any part of a graphic needed to understand the content must hit **3:1** against adjacent colors (the text bar of 4.5:1 does not apply here). WCAG SC 1.4.11 Non-text Contrast, Level AA. A 1.2:1 hairline border on a white card is the common fail. Disabled components and unrestyled browser chrome are exempt; re-measure in dark mode, where alpha borders collapse.

Give control boundaries, states, and meaningful graphics at least 3:1 contrast against adjacent colors

Every other contrast rule here is about text: design-rams-color-contrast enforces the 4.5:1 WCAG text floor, design-minimum-contrast applies APCA Lc values to type, and content-impeccable-dark-mode-text-compensation is about text weight in dark themes. None of them sets any floor for the parts of a control that are not text — an input outline, an unchecked checkbox, a toggle in its off state, the glyph in an icon-only button, a focus indicator, or the line in a chart that carries the data. design-crisp-borders is an aesthetic rule about hairline edges and carries no ratio at all. SC 1.4.11 Non-text Contrast (Level AA) is that missing floor: 3:1 against adjacent colors, for anything required to identify a component or its state, and for any part of a graphic required to understand the content. The common failure is a 1.2:1 hairline border on a white card — the field is still readable, but nothing tells you it is a field. Note the exceptions: inactive (disabled) components are exempt, as is browser-default chrome you have not restyled. Measure boundaries against what is actually behind them, and re-measure in dark mode, where alpha-based borders collapse.

## Bad — do not do this

`design-non-text-contrast-bad`

```tsx
import { useCallback, useEffect, useRef, useState } from 'react';

type Rgb = [number, number, number];
type Prop = 'borderTopColor' | 'backgroundColor' | 'color';

/**
 * Paint the color over its backdrop on a 1x1 canvas: alpha, oklch and any other
 * computed color format all come back as real sRGB bytes, already composited.
 */
function resolve(color: string, backdrop: string): Rgb | null {
  const canvas = document.createElement('canvas');
  canvas.width = 1;
  canvas.height = 1;
  const ctx = canvas.getContext('2d');
  if (!ctx) return null;
  ctx.fillStyle = backdrop;
  ctx.fillRect(0, 0, 1, 1);
  ctx.fillStyle = color;
  ctx.fillRect(0, 0, 1, 1);
  const data = ctx.getImageData(0, 0, 1, 1).data;
  return [data[0], data[1], data[2]];
}

/** WCAG relative luminance (sRGB). */
function luminance([r, g, b]: Rgb): number {
  const channel = (v: number) => {
    const s = v / 255;
    return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
  };
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}

function contrastRatio(fg: Rgb, bg: Rgb): number {
  const a = luminance(fg);
  const b = luminance(bg);
  const [hi, lo] = a > b ? [a, b] : [b, a];
  return (hi + 0.05) / (lo + 0.05);
}

const TRANSPARENT = /^(transparent$|rgba?\([^)]*[,/]\s*0(\.0+)?\s*\)$)/;

function backdropOf(el: Element, includeSelf: boolean): string {
  let node: Element | null = includeSelf ? el : el.parentElement;
  while (node) {
    const bg = window.getComputedStyle(node).backgroundColor;
    if (bg && !TRANSPARENT.test(bg)) return bg;
    node = node.parentElement;
  }
  return 'white';
}

function measure(el: Element | null, prop: Prop): number | null {
  if (!el) return null;
  const backdrop = backdropOf(el, prop !== 'backgroundColor');
  const fg = resolve(window.getComputedStyle(el)[prop], backdrop);
  const bg = resolve(backdrop, backdrop);
  if (!fg || !bg) return null;
  return contrastRatio(fg, bg);
}

function Ratio({ value }: { value: number | null }) {
  if (value === null) return null;
  const passes = value >= 3;
  return (
    <span className={`shrink-0 font-mono text-xs ${passes ? 'text-success' : 'text-destructive'}`}>
      {value.toFixed(2)}:1 {passes ? 'passes' : 'fails'} 3:1
    </span>
  );
}

export function NonTextContrastBad() {
  const inputRef = useRef<HTMLInputElement>(null);
  const trackRef = useRef<HTMLSpanElement>(null);
  const iconRef = useRef<SVGSVGElement>(null);
  const [ratios, setRatios] = useState<{ border: number | null; track: number | null; icon: number | null }>({
    border: null,
    track: null,
    icon: null,
  });

  const remeasure = useCallback(() => {
    setRatios({
      border: measure(inputRef.current, 'borderTopColor'),
      track: measure(trackRef.current, 'backgroundColor'),
      icon: measure(iconRef.current, 'color'),
    });
  }, []);

  useEffect(() => {
    remeasure();
    // The tokens move when the theme flips, so the measurement has to move with it.
    const observer = new MutationObserver(remeasure);
    observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] });
    return () => observer.disconnect();
  }, [remeasure]);

  return (
    <div className="w-full max-w-md">
      <div className="bg-card border border-border rounded-lg p-4 space-y-4">
        <div className="space-y-1">
          <label htmlFor="ntc-bad-card" className="block text-sm font-medium text-foreground">
            Card number
          </label>
          <input
            ref={inputRef}
            id="ntc-bad-card"
            name="cc-number"
            defaultValue="4242 4242 4242 4242"
            className="w-full px-3 py-2 rounded-lg border border-foreground/8 bg-card text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
          <div className="flex items-center justify-between gap-2">
            <span className="text-xs text-muted-foreground">Input border vs. card</span>
            <Ratio value={ratios.border} />
          </div>
        </div>

        <div className="space-y-1">
          <button
            type="button"
            role="switch"
            aria-checked={false}
            className="flex w-full items-center justify-between gap-3 rounded-lg py-1 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            Email me receipts
            <span ref={trackRef} className="relative h-6 w-11 shrink-0 rounded-full bg-foreground/10">
              <span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-card" />
            </span>
          </button>
          <div className="flex items-center justify-between gap-2">
            <span className="text-xs text-muted-foreground">Toggle off-state track vs. card</span>
            <Ratio value={ratios.track} />
          </div>
        </div>

        <div className="space-y-1">
          <button
            type="button"
            aria-label="Delete card"
            className="flex h-11 w-11 items-center justify-center rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            <svg
              ref={iconRef}
              className="h-5 w-5 text-foreground/25"
              fill="none"
              stroke="currentColor"
              strokeWidth={1.5}
              viewBox="0 0 24 24"
              aria-hidden="true"
            >
              <path strokeLinecap="round" strokeLinejoin="round" d="M6 7h12M9 7V5h6v2m-8 0 1 12h8l1-12" />
            </svg>
          </button>
          <div className="flex items-center justify-between gap-2">
            <span className="text-xs text-muted-foreground">Icon-only button glyph vs. card</span>
            <Ratio value={ratios.icon} />
          </div>
        </div>
      </div>

      <p className="text-xs text-destructive mt-4">
        Ratios measured live from the painted pixels. The label text passes 1.4.3 easily, but nothing that tells
        you these are <em>controls</em> — the input boundary, the toggle track, the glyph — reaches 3:1.
      </p>
    </div>
  );
}
```

## Good — do this

`design-non-text-contrast-good`

```tsx
import { useCallback, useEffect, useRef, useState } from 'react';

type Rgb = [number, number, number];
type Prop = 'borderTopColor' | 'backgroundColor' | 'color';

/**
 * Paint the color over its backdrop on a 1x1 canvas: alpha, oklch and any other
 * computed color format all come back as real sRGB bytes, already composited.
 */
function resolve(color: string, backdrop: string): Rgb | null {
  const canvas = document.createElement('canvas');
  canvas.width = 1;
  canvas.height = 1;
  const ctx = canvas.getContext('2d');
  if (!ctx) return null;
  ctx.fillStyle = backdrop;
  ctx.fillRect(0, 0, 1, 1);
  ctx.fillStyle = color;
  ctx.fillRect(0, 0, 1, 1);
  const data = ctx.getImageData(0, 0, 1, 1).data;
  return [data[0], data[1], data[2]];
}

/** WCAG relative luminance (sRGB). */
function luminance([r, g, b]: Rgb): number {
  const channel = (v: number) => {
    const s = v / 255;
    return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
  };
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}

function contrastRatio(fg: Rgb, bg: Rgb): number {
  const a = luminance(fg);
  const b = luminance(bg);
  const [hi, lo] = a > b ? [a, b] : [b, a];
  return (hi + 0.05) / (lo + 0.05);
}

const TRANSPARENT = /^(transparent$|rgba?\([^)]*[,/]\s*0(\.0+)?\s*\)$)/;

function backdropOf(el: Element, includeSelf: boolean): string {
  let node: Element | null = includeSelf ? el : el.parentElement;
  while (node) {
    const bg = window.getComputedStyle(node).backgroundColor;
    if (bg && !TRANSPARENT.test(bg)) return bg;
    node = node.parentElement;
  }
  return 'white';
}

function measure(el: Element | null, prop: Prop): number | null {
  if (!el) return null;
  const backdrop = backdropOf(el, prop !== 'backgroundColor');
  const fg = resolve(window.getComputedStyle(el)[prop], backdrop);
  const bg = resolve(backdrop, backdrop);
  if (!fg || !bg) return null;
  return contrastRatio(fg, bg);
}

function Ratio({ value }: { value: number | null }) {
  if (value === null) return null;
  const passes = value >= 3;
  return (
    <span className={`shrink-0 font-mono text-xs ${passes ? 'text-success' : 'text-destructive'}`}>
      {value.toFixed(2)}:1 {passes ? 'passes' : 'fails'} 3:1
    </span>
  );
}

export function NonTextContrastGood() {
  const inputRef = useRef<HTMLInputElement>(null);
  const trackRef = useRef<HTMLSpanElement>(null);
  const iconRef = useRef<SVGSVGElement>(null);
  const [ratios, setRatios] = useState<{ border: number | null; track: number | null; icon: number | null }>({
    border: null,
    track: null,
    icon: null,
  });

  const remeasure = useCallback(() => {
    setRatios({
      border: measure(inputRef.current, 'borderTopColor'),
      track: measure(trackRef.current, 'backgroundColor'),
      icon: measure(iconRef.current, 'color'),
    });
  }, []);

  useEffect(() => {
    remeasure();
    // The tokens move when the theme flips, so the measurement has to move with it.
    const observer = new MutationObserver(remeasure);
    observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] });
    return () => observer.disconnect();
  }, [remeasure]);

  return (
    <div className="w-full max-w-md">
      <div className="bg-card border border-border rounded-lg p-4 space-y-4">
        <div className="space-y-1">
          <label htmlFor="ntc-good-card" className="block text-sm font-medium text-foreground">
            Card number
          </label>
          <input
            ref={inputRef}
            id="ntc-good-card"
            name="cc-number"
            defaultValue="4242 4242 4242 4242"
            className="w-full px-3 py-2 rounded-lg border border-foreground/60 bg-card text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
          <div className="flex items-center justify-between gap-2">
            <span className="text-xs text-muted-foreground">Input border vs. card</span>
            <Ratio value={ratios.border} />
          </div>
        </div>

        <div className="space-y-1">
          <button
            type="button"
            role="switch"
            aria-checked={false}
            className="flex w-full items-center justify-between gap-3 rounded-lg py-1 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            Email me receipts
            <span ref={trackRef} className="relative h-6 w-11 shrink-0 rounded-full bg-foreground/60">
              <span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-card" />
            </span>
          </button>
          <div className="flex items-center justify-between gap-2">
            <span className="text-xs text-muted-foreground">Toggle off-state track vs. card</span>
            <Ratio value={ratios.track} />
          </div>
        </div>

        <div className="space-y-1">
          <button
            type="button"
            aria-label="Delete card"
            className="flex h-11 w-11 items-center justify-center rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            <svg
              ref={iconRef}
              className="h-5 w-5 text-foreground/80"
              fill="none"
              stroke="currentColor"
              strokeWidth={1.5}
              viewBox="0 0 24 24"
              aria-hidden="true"
            >
              <path strokeLinecap="round" strokeLinejoin="round" d="M6 7h12M9 7V5h6v2m-8 0 1 12h8l1-12" />
            </svg>
          </button>
          <div className="flex items-center justify-between gap-2">
            <span className="text-xs text-muted-foreground">Icon-only button glyph vs. card</span>
            <Ratio value={ratios.icon} />
          </div>
        </div>
      </div>

      <p className="text-xs text-success mt-4">
        Same three controls, same live measurement: every boundary and glyph that identifies a control clears 3:1
        against its adjacent color, in both themes.
      </p>
    </div>
  );
}
```

## References

- [Understanding SC 1.4.11: Non-text Contrast](https://www.w3.org/WAI/WCAG21/Understanding/non-text-contrast.html)
- [Understanding SC 1.4.3: Contrast (Minimum)](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html)
