Skip to content
Unverified — AI-generated content. Help verify this page

Color Contrast & Accessibility ​

Color is the accessibility dimension most frequently violated in production UIs. Low-contrast text fails WCAG, color-coded-only information fails color-blind users, and purely visual state changes fail screen reader users. This page covers the complete technical implementation of accessible color usage.

WCAG 2.1 Contrast Requirements ​

WCAG (Web Content Accessibility Guidelines) defines minimum contrast ratios for text and interactive elements:

LevelText SizeMinimum RatioUse Case
AANormal (<18pt/24px)4.5:1Body text, UI text
AALarge (≥18pt/24px bold ≥14pt)3:1Headings, large text
AANon-text UI3:1Buttons, icons, form borders
AAANormal7:1Enhanced accessibility
AAALarge4.5:1Enhanced large text

The WCAG Contrast Formula ​

WCAG contrast uses relative luminance (L), computed from sRGB:

L=0.2126×Rlin+0.7152×Glin+0.0722×Blin

Where linear RGB (Rlin) is the gamma-decoded sRGB value:

Clin={Csrgb12.92if Csrgb≤0.04045(Csrgb+0.0551.055)2.4otherwise

The contrast ratio:

ratio=Llighter+0.05Ldarker+0.05
typescript
// wcag-contrast.ts

function srgbToLinear(c: number): number {
  const normalized = c / 255;
  return normalized <= 0.04045
    ? normalized / 12.92
    : Math.pow((normalized + 0.055) / 1.055, 2.4);
}

function relativeLuminance(r: number, g: number, b: number): number {
  return (
    0.2126 * srgbToLinear(r) +
    0.7152 * srgbToLinear(g) +
    0.0722 * srgbToLinear(b)
  );
}

function hexToRgb(hex: string): [number, number, number] {
  const clean = hex.replace('#', '');
  return [
    parseInt(clean.slice(0, 2), 16),
    parseInt(clean.slice(2, 4), 16),
    parseInt(clean.slice(4, 6), 16),
  ];
}

export function wcagContrast(foreground: string, background: string): number {
  const [fr, fg, fb] = hexToRgb(foreground);
  const [br, bg, bb] = hexToRgb(background);

  const fL = relativeLuminance(fr, fg, fb);
  const bL = relativeLuminance(br, bg, bb);

  const lighter = Math.max(fL, bL);
  const darker = Math.min(fL, bL);

  return (lighter + 0.05) / (darker + 0.05);
}

export function meetsWcagAA(
  foreground: string,
  background: string,
  isLargeText = false
): boolean {
  const ratio = wcagContrast(foreground, background);
  return isLargeText ? ratio >= 3 : ratio >= 4.5;
}

export function meetsWcagAAA(
  foreground: string,
  background: string,
  isLargeText = false
): boolean {
  const ratio = wcagContrast(foreground, background);
  return isLargeText ? ratio >= 4.5 : ratio >= 7;
}

// Find the darkest shade that passes AA on white
export function findMinimumContrastShade(
  palette: Array<{ step: number; hex: string }>,
  background = '#ffffff',
  minRatio = 4.5
): number | null {
  for (const { step, hex } of palette) {
    if (wcagContrast(hex, background) >= minRatio) {
      return step;
    }
  }
  return null;
}

APCA — The Next Standard ​

WCAG 2.x contrast is widely criticized for limitations:

  • It treats white text on dark blue the same as white text on dark red (perceptually very different)
  • It doesn't account for font weight — thin 400 weight text needs higher contrast than bold 700
  • It penalizes high-contrast combinations in some cases (e.g., some dark-on-dark passes because both are similarly dark)

APCA (Advanced Perceptual Contrast Algorithm) was developed for WCAG 3.0 and addresses these issues.

APCA Formula ​

APCA uses a more complex perceptual model. The core output is a Lc (Lightness Contrast) value, typically -110 to 110:

Lc=sign(Ytext−Ybg)×(δY0.45−δY0.55)×100

(Simplified — the actual formula includes polarity-dependent coefficients)

typescript
// apca-contrast.ts
// Based on SAPC-APCA W3C working draft

function apcaLinear(c: number): number {
  const normalized = c / 255;
  const exp = 2.4;
  return normalized <= 0.04045
    ? normalized / 12.92
    : Math.pow((normalized + 0.055) / 1.055, exp);
}

function apcaLuminance(r: number, g: number, b: number): number {
  // APCA uses slightly different Y coefficients than WCAG
  return (
    0.2126729 * apcaLinear(r) +
    0.7151522 * apcaLinear(g) +
    0.0721750 * apcaLinear(b)
  );
}

export function apcaContrast(textColor: string, bgColor: string): number {
  const [tr, tg, tb] = hexToRgb(textColor);
  const [br, bg, bb] = hexToRgb(bgColor);

  const tY = apcaLuminance(tr, tg, tb);
  const bY = apcaLuminance(br, bg, bb);

  // APCA polarity-dependent constants
  const normBG = 0.56;
  const normTXT = 0.57;
  const revTXT = 0.62;
  const revBG = 0.65;
  const blkThrs = 0.022;
  const blkClmp = 1.414;
  const scaleBoW = 1.14;
  const scaleWoB = 1.14;
  const deltaYmin = 0.0005;

  // Soft clamp very dark values
  const clampedTY = tY > blkThrs ? tY : tY + Math.pow(blkThrs - tY, blkClmp);
  const clampedBY = bY > blkThrs ? bY : bY + Math.pow(blkThrs - bY, blkClmp);

  if (Math.abs(clampedTY - clampedBY) < deltaYmin) return 0;

  let Lc: number;
  if (clampedBY >= clampedTY) {
    // Normal polarity (dark text on light background)
    Lc = (Math.pow(clampedBY, normBG) - Math.pow(clampedTY, normTXT)) * scaleBoW;
  } else {
    // Reverse polarity (light text on dark background)
    Lc = (Math.pow(clampedBY, revBG) - Math.pow(clampedTY, revTXT)) * scaleWoB;
  }

  return Lc * 100;
}

// APCA minimum Lc values by use case
export const APCA_MINIMUMS = {
  bodyText400:    75, // Regular weight body text
  bodyText700:    60, // Bold body text
  UItext:         60, // Labels, UI text
  largeText:      45, // >18pt text
  nonText:        15, // Icons, graphic elements
  decorativeOnly: 0,  // Purely decorative
};

WCAG vs APCA Comparison ​

ScenarioWCAG 2.1APCA Lc
Black on white21:1 ✓107 ✓
White on black21:1 ✓107 ✓
Mid-gray body textFails at 4.2:1May pass at Lc 65
Bold large headingPasses at 3:1Checked separately
Thin weight large textPasses at 3:1 (wrong)Fails at Lc 45
Blue (#005fad) on white8.6:1 ✓70 ✓

Color Blindness Simulation ​

Approximately 8% of males and 0.5% of females have some form of color vision deficiency (CVD). The most common types:

TypeAffected PopulationMissing/WeakCommon Confusion
Deuteranopia~6% malesGreen conesRed/green
Protanopia~2% malesRed conesRed/green (red looks dark)
Tritanopia<0.01%Blue conesBlue/yellow
AchromatopsiaVery rareAll conesAll colors (grayscale)

Simulation Algorithm ​

Daltonization matrices simulate how colors appear to people with CVD:

typescript
// cvd-simulation.ts

interface SimulationMatrix {
  r: [number, number, number];
  g: [number, number, number];
  b: [number, number, number];
}

// LMS transformation matrices for different CVD types
// Based on Brettel, Viénot, Mollon (1997) model
const CVD_MATRICES: Record<string, SimulationMatrix> = {
  deuteranopia: {
    r: [0.367322, 0.860646, -0.227968],
    g: [0.280085, 0.672501, 0.047413],
    b: [-0.011820, 0.042940, 0.968880],
  },
  protanopia: {
    r: [0.152286, 1.052583, -0.204868],
    g: [0.114503, 0.786281, 0.099216],
    b: [-0.003882, -0.048116, 1.051998],
  },
  tritanopia: {
    r: [1.255528, -0.076749, -0.178779],
    g: [-0.078411, 0.930809, 0.147602],
    b: [0.004733, 0.691367, 0.303900],
  },
};

export function simulateCVD(
  r: number,
  g: number,
  b: number,
  type: 'deuteranopia' | 'protanopia' | 'tritanopia'
): [number, number, number] {
  const m = CVD_MATRICES[type];

  const simR = m.r[0] * r + m.r[1] * g + m.r[2] * b;
  const simG = m.g[0] * r + m.g[1] * g + m.g[2] * b;
  const simB = m.b[0] * r + m.b[1] * g + m.b[2] * b;

  return [
    Math.max(0, Math.min(255, Math.round(simR))),
    Math.max(0, Math.min(255, Math.round(simG))),
    Math.max(0, Math.min(255, Math.round(simB))),
  ];
}

// Apply to entire image using Canvas API
export function applyColorBlindFilter(
  canvas: HTMLCanvasElement,
  type: 'deuteranopia' | 'protanopia' | 'tritanopia'
): void {
  const ctx = canvas.getContext('2d')!;
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const data = imageData.data;

  for (let i = 0; i < data.length; i += 4) {
    const [r, g, b] = simulateCVD(data[i], data[i + 1], data[i + 2], type);
    data[i] = r;
    data[i + 1] = g;
    data[i + 2] = b;
    // data[i + 3] — alpha unchanged
  }

  ctx.putImageData(imageData, 0, 0);
}

CSS Color Blindness Simulation ​

For quick visual testing without JavaScript:

css
/* Deuteranopia simulation filter */
.simulate-deuteranopia {
  filter: url('#deuteranopia-filter');
}

/* Grayscale = achromatopsia */
.simulate-achromatopsia {
  filter: grayscale(100%);
}
html
<!-- SVG filter definitions -->
<svg style="display: none">
  <defs>
    <filter id="deuteranopia-filter">
      <feColorMatrix
        type="matrix"
        values="0.367322 0.860646 -0.227968 0 0
                0.280085 0.672501 0.047413  0 0
                -0.011820 0.042940 0.968880 0 0
                0 0 0 1 0"
      />
    </filter>
    <filter id="protanopia-filter">
      <feColorMatrix
        type="matrix"
        values="0.152286 1.052583 -0.204868 0 0
                0.114503 0.786281 0.099216  0 0
                -0.003882 -0.048116 1.051998 0 0
                0 0 0 1 0"
      />
    </filter>
  </defs>
</svg>

Color as the Only Signal ​

The most common WCAG 1.4.1 violation: using color alone to convey information.

html
<!-- FAIL: color is the only differentiator -->
<div>
  Required fields are <span style="color: red">red</span>.
</div>
<input style="border-color: red" />  <!-- Error state — color only -->

<!-- PASS: color plus another indicator -->
<div>
  Required fields are marked with <span style="color: red">* (asterisk)</span>.
</div>
<div role="alert">
  <input
    style="border-color: red"
    aria-invalid="true"
    aria-describedby="email-error"
  />
  <span id="email-error" style="color: red">
    <!-- Icon adds non-color indicator -->
    ⚠ Email is required
  </span>
</div>

Status Indicators ​

For status indicators (traffic light pattern — green/yellow/red), always add a secondary indicator:

tsx
// StatusBadge.tsx
interface StatusBadgeProps {
  status: 'success' | 'warning' | 'error' | 'info' | 'neutral';
  label: string;
}

const STATUS_CONFIG = {
  success: { color: 'oklch(35% 0.15 145)', bg: 'oklch(95% 0.04 145)', icon: '✓', shape: 'circle' },
  warning: { color: 'oklch(35% 0.12 60)',  bg: 'oklch(96% 0.04 85)', icon: '!', shape: 'triangle' },
  error:   { color: 'oklch(40% 0.18 25)',  bg: 'oklch(95% 0.04 25)', icon: '✗', shape: 'octagon' },
  info:    { color: 'oklch(40% 0.15 220)', bg: 'oklch(95% 0.03 220)', icon: 'i', shape: 'circle' },
  neutral: { color: 'oklch(35% 0 0)',      bg: 'oklch(93% 0 0)',      icon: '–', shape: 'circle' },
};

export function StatusBadge({ status, label }: StatusBadgeProps) {
  const config = STATUS_CONFIG[status];

  return (
    <span
      role="status"
      style={​{
        display: 'inline-flex',
        alignItems: 'center',
        gap: '0.375rem',
        padding: '0.25rem 0.625rem',
        borderRadius: '9999px',
        fontSize: '0.875rem',
        fontWeight: 500,
        backgroundColor: config.bg,
        color: config.color,
      }}
    >
      {/* Icon provides non-color indicator */}
      <span aria-hidden="true" style={​{ fontWeight: 700 }}>
        {config.icon}
      </span>
      {label}
    </span>
  );
}

Automated Contrast Testing ​

typescript
// __tests__/color-tokens.test.ts
import { wcagContrast, meetsWcagAA } from '../utils/wcag-contrast';

// Define which tokens should meet which contrast requirements
const CONTRAST_REQUIREMENTS = [
  // [foreground token, background token, isLargeText, minRatio]
  ['--color-text-primary',   '--color-surface-default',  false, 4.5],
  ['--color-text-secondary', '--color-surface-default',  false, 4.5],
  ['--color-text-on-brand',  '--color-interactive',      false, 4.5],
  ['--color-interactive',    '--color-surface-default',  true,  3],   // button border
];

// Get resolved CSS custom property values (requires jsdom + CSS)
function getTokenValue(token: string): string {
  const style = getComputedStyle(document.documentElement);
  return style.getPropertyValue(token).trim();
}

describe('Color Token Contrast', () => {
  CONTRAST_REQUIREMENTS.forEach(([fg, bg, isLarge, minRatio]) => {
    it(`${fg} on ${bg} meets ${minRatio}:1 contrast`, () => {
      const fgValue = getTokenValue(fg as string);
      const bgValue = getTokenValue(bg as string);

      expect(fgValue).not.toBe('');
      expect(bgValue).not.toBe('');

      const ratio = wcagContrast(fgValue, bgValue);
      expect(ratio).toBeGreaterThanOrEqual(minRatio as number);
    });
  });
});

jest-axe for Component-Level Testing ​

typescript
// __tests__/Button.accessibility.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from '../components/Button';

expect.extend(toHaveNoViolations);

describe('Button Accessibility', () => {
  it('has no accessibility violations', async () => {
    const { container } = render(
      <Button variant="primary" onClick={() => {}}>
        Click me
      </Button>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('disabled button has sufficient contrast for disabled state', () => {
    // Disabled text should still meet 3:1 against its background
    // (WCAG exception allows lower contrast for disabled elements,
    //  but aim for 3:1 minimum as best practice)
    const { container } = render(
      <Button variant="primary" disabled onClick={() => {}}>
        Disabled
      </Button>
    );
    const button = container.querySelector('button');
    const styles = getComputedStyle(button!);
    // Check computed color values
    expect(styles.color).toBeDefined();
  });
});

War Story

An enterprise SaaS product failed an accessibility audit for a major client, costing them a $2M contract. The audit found: 142 contrast failures across 38 components, 17 instances of color-only information encoding, and 8 interactive elements without keyboard access. The color failures alone took 3 engineers 2 weeks to fix — not because fixing them was hard, but because there were no tokens, so every value had to be hunted down individually. The fix was implemented: a complete token migration + automated contrast checks on every CI run. Six months later, zero contrast failures, and they passed the next accessibility audit.

Focus Indicators ​

Focus indicators require 3:1 contrast against adjacent colors (WCAG 2.4.11):

css
/* Accessible focus ring */
:root {
  --color-focus-ring: oklch(50% 0.22 264);
  --color-focus-ring-offset-bg: oklch(100% 0 0); /* white offset */
}

/* Modern focus ring pattern */
:focus-visible {
  outline: 2px solid var(--color-focus-ring);
  outline-offset: 2px;
  /* Double ring for dark backgrounds */
  box-shadow:
    0 0 0 2px var(--color-focus-ring-offset-bg),
    0 0 0 4px var(--color-focus-ring);
}

The double-ring pattern ensures 3:1 contrast against both light AND dark backgrounds — the inner white ring contrasts against dark, the blue ring contrasts against the white ring.

"What I cannot create, I do not understand." — Richard Feynman