Steven's Knowledge
Accessibility

Visual Design

Color contrast, motion preferences, responsive accessibility, and typography

Visual Design

Accessible visual design ensures content is perceivable by users with visual impairments, motion sensitivities, and cognitive differences.

Color Contrast

WCAG Contrast Ratios

ElementLevel AALevel AAA
Normal text (< 24px / < 18.66px bold)4.5:17:1
Large text (≥ 24px / ≥ 18.66px bold)3:14.5:1
UI components & graphical objects3:1Not defined
Non-text contrast (icons, borders)3:1Not defined

Checking Contrast

// Calculate relative luminance (WCAG 2.x formula)
function getLuminance(r: number, g: number, b: number): number {
  const [rs, gs, bs] = [r, g, b].map((c) => {
    const s = c / 255;
    return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}

// Calculate contrast ratio between two colors
function getContrastRatio(color1: [number, number, number], color2: [number, number, number]): number {
  const l1 = getLuminance(...color1);
  const l2 = getLuminance(...color2);
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

// Example: White text on blue background
getContrastRatio([255, 255, 255], [37, 99, 235]); // ~4.68:1 — passes AA for normal text

Color-Independent Information

Never rely on color alone to convey information.

// Bad — color only indicates status
<span style={{ color: isError ? 'red' : 'green' }}>
  {statusMessage}
</span>

// Good — color + icon + text
<span className={isError ? 'status-error' : 'status-success'}>
  {isError ? <ErrorIcon aria-hidden="true" /> : <CheckIcon aria-hidden="true" />}
  {statusMessage}
</span>

// Bad — chart legend using color only
// [Red] Errors  [Blue] Warnings  [Green] Success

// Good — color + pattern + label
// [Red ///] Errors  [Blue ···] Warnings  [Green ===] Success
/* Form validation — don't rely on border color alone */
.input-error {
  border-color: #dc2626;
  border-width: 2px;
  /* Also add an icon and text message */
}

Dark Mode Considerations

/* Ensure contrast in both light and dark modes */
:root {
  --text-primary: #1a1a1a;      /* contrast 15.3:1 on white */
  --text-secondary: #525252;    /* contrast 7.1:1 on white */
  --bg-primary: #ffffff;
}

[data-theme='dark'] {
  --text-primary: #f5f5f5;      /* contrast 16.0:1 on dark bg */
  --text-secondary: #a3a3a3;    /* contrast 5.3:1 on dark bg */
  --bg-primary: #171717;
}

/* Avoid pure black on pure white — can be harsh for some users */
/* Use slightly off-white or dark gray instead */

Motion and Animation

Respecting User Preferences

/* Reduce or remove animations for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Define animations only for users who accept motion */
@media (prefers-reduced-motion: no-preference) {
  .fade-in {
    animation: fadeIn 0.3s ease-in;
  }

  .slide-up {
    animation: slideUp 0.4s ease-out;
  }
}
// React hook for motion preference
function usePrefersReducedMotion(): boolean {
  const [prefersReduced, setPrefersReduced] = useState(() => {
    if (typeof window === 'undefined') return false;
    return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  });

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  return prefersReduced;
}

// Usage — adapt animation based on preference
function AnimatedCard({ children }: CardProps) {
  const prefersReduced = usePrefersReducedMotion();

  return (
    <div
      className="card"
      style={{
        transition: prefersReduced ? 'none' : 'transform 0.3s ease',
      }}
    >
      {children}
    </div>
  );
}

Safe Animation Guidelines

GuidelineReason
Avoid flashing content > 3 times/secCan trigger seizures (WCAG 2.3.1)
Provide pause/stop controls for auto-playing contentUsers need control over motion
Use prefers-reduced-motion media queryRespect system preferences
Avoid parallax scrolling or provide alternativeCan cause vestibular discomfort
Keep transitions under 5 secondsLong animations can be disorienting

Typography and Readability

Readable Text

/* Accessible typography defaults */
body {
  font-size: 1rem;              /* 16px minimum base */
  line-height: 1.5;             /* WCAG 1.4.12: at least 1.5x font size */
  letter-spacing: 0.12em;       /* WCAG 1.4.12: at least 0.12x font size */
  word-spacing: 0.16em;         /* WCAG 1.4.12: at least 0.16x font size */
}

p {
  max-width: 75ch;              /* Optimal reading width: 45-75 characters */
  margin-bottom: 1.5em;         /* WCAG 1.4.12: paragraph spacing 2x font size */
}

/* Allow text resizing to 200% without horizontal scrolling */
/* Use relative units (rem, em, %) instead of px for text */
h1 { font-size: 2rem; }        /* Not 32px */
h2 { font-size: 1.5rem; }      /* Not 24px */
p  { font-size: 1rem; }        /* Not 16px */

Text Spacing Override

WCAG 1.4.12 requires content to remain functional when users override text spacing.

/* Test with these overrides — content should not break */
/* Line height: 1.5x font size */
/* Paragraph spacing: 2x font size */
/* Letter spacing: 0.12x font size */
/* Word spacing: 0.16x font size */

/* Avoid fixed heights on text containers */
.card-description {
  min-height: 60px;     /* Good — allows expansion */
  /* height: 60px; */   /* Bad — text may overflow when spacing changes */
  overflow: visible;     /* Don't clip overflowing text */
}

Touch Target Size

WCAG 2.5.8 (Level AA) — Minimum 24×24px

/* Ensure touch targets meet minimum size */
button, a, [role="button"] {
  min-width: 44px;     /* 44px is recommended (iOS HIG / WCAG 2.5.5 AAA) */
  min-height: 44px;
  /* At minimum, 24×24px for WCAG 2.5.8 AA */
}

/* For inline links, use padding to increase target area */
nav a {
  padding: 8px 12px;
  display: inline-block;
}

/* Spacing between small targets */
.icon-button-group button {
  min-width: 44px;
  min-height: 44px;
  margin: 4px; /* Prevents accidental activation of adjacent targets */
}

Responsive Accessibility

Zoom and Reflow

/* WCAG 1.4.4: Content must be readable at 200% zoom */
/* WCAG 1.4.10: Content reflows at 320px viewport (400% zoom on 1280px) */

/* Use responsive layout that reflows naturally */
.layout {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 1rem;
}

/* Don't use viewport units for font size without clamp */
/* Bad: font-size: 3vw; — text may become too small */
/* Good: */
h1 {
  font-size: clamp(1.5rem, 2vw + 1rem, 3rem);
}

/* Ensure horizontal scrolling isn't required at 320px */
.content {
  max-width: 100%;
  overflow-wrap: break-word;
}

Orientation

/* WCAG 1.3.4: Don't lock orientation unless essential */
/* Don't restrict display to portrait or landscape only */

/* If you must detect orientation, provide both layouts */
@media (orientation: portrait) {
  .dashboard { flex-direction: column; }
}
@media (orientation: landscape) {
  .dashboard { flex-direction: row; }
}

High Contrast and Forced Colors

/* Support Windows High Contrast Mode */
@media (forced-colors: active) {
  .button {
    /* Custom colors are overridden — use system colors */
    border: 2px solid ButtonText;
  }

  .custom-checkbox .checkmark {
    /* Ensure custom checkmarks are visible */
    forced-color-adjust: none;
    background-color: Highlight;
  }

  /* Icons may need explicit coloring */
  .icon {
    fill: CanvasText;
  }
}

/* System color keywords for forced-colors mode */
/* Canvas, CanvasText, LinkText, GrayText, Highlight, HighlightText, ButtonFace, ButtonText */

Best Practices

Visual Design Guidelines

  1. Maintain 4.5:1 contrast ratio for normal text, 3:1 for large text
  2. Never convey information through color alone — add icons, text, or patterns
  3. Respect prefers-reduced-motion — reduce or remove animations
  4. Use relative units (rem, em, %) for font sizes
  5. Ensure content reflows without horizontal scrolling at 320px width
  6. Make touch targets at least 24×24px (44×44px recommended)
  7. Test with 200% browser zoom and text spacing overrides
  8. Support high contrast mode with forced-colors media query

On this page