Files
singular-particular-space/skills/controlled-ux-designer/ACCESSIBILITY.md
JL Kruger 5422131782 Initial commit — Singular Particular Space v1
Homepage (site/index.html): integration-v14 promoted, Writings section
integrated with 33 pieces clustered by type (stories/essays/miscellany),
Writings welcome lightbox, content frame at 98% opacity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 12:09:22 +02:00

17 KiB

Accessibility Reference

Comprehensive guide for implementing accessible interfaces following WCAG 2.1 AA standards.

Core Principles (POUR)

Perceivable

Information and UI components must be presentable to users in ways they can perceive.

Operable

UI components and navigation must be operable by all users.

Understandable

Information and the operation of UI must be understandable.

Robust

Content must be robust enough to be interpreted by a wide variety of user agents, including assistive technologies.

Semantic HTML

Use Appropriate Elements

Good:

<header>
  <nav>
    <ul>
      <li><a href="/">Home</a></li>
      <li><a href="/about">About</a></li>
    </ul>
  </nav>
</header>

<main>
  <article>
    <h1>Article Title</h1>
    <p>Article content...</p>
  </article>
</main>

<footer>
  <p>&copy; 2025 Company Name</p>
</footer>

Bad:

<div class="header">
  <div class="nav">
    <div class="link">Home</div>
    <div class="link">About</div>
  </div>
</div>

Heading Hierarchy

Correct hierarchy:

<h1>Page Title</h1>
  <h2>Section 1</h2>
    <h3>Subsection 1.1</h3>
    <h3>Subsection 1.2</h3>
  <h2>Section 2</h2>
    <h3>Subsection 2.1</h3>

Incorrect (skips levels):

<h1>Page Title</h1>
  <h4>Section 1</h4>  // ❌ Skips h2 and h3

Keyboard Navigation

Focus Management

// Ensure all interactive elements are keyboard accessible
<button
  className="
    px-4 py-2
    focus:outline-none
    focus:ring-4 focus:ring-blue-500
    focus:ring-offset-2
    rounded-lg
  "
  tabIndex={0}
>
  Accessible Button
</button>

// Custom interactive elements need tabindex
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      handleClick();
    }
  }}
  className="cursor-pointer focus:ring-4 focus:ring-blue-500"
>
  Custom Button
</div>

Tab Order

// Use tabIndex to control focus order
<form>
  <input tabIndex={1} aria-label="First name" />
  <input tabIndex={2} aria-label="Last name" />
  <input tabIndex={3} aria-label="Email" />
  <button tabIndex={4}>Submit</button>
</form>

// Use tabIndex={-1} to remove from tab order but allow programmatic focus
<div tabIndex={-1} id="error-message">
  Error details...
</div>
// Allow keyboard users to skip to main content
<a
  href="#main-content"
  className="
    sr-only
    focus:not-sr-only
    focus:absolute
    focus:top-4 focus:left-4
    focus:z-50
    focus:px-4 focus:py-2
    focus:bg-blue-600 focus:text-white
    focus:rounded-lg
  "
>
  Skip to main content
</a>

<main id="main-content">
  {/* Main content */}
</main>

ARIA Attributes

Common ARIA Roles

// Navigation landmark
<nav role="navigation" aria-label="Main navigation">
  {/* Navigation items */}
</nav>

// Banner (header)
<header role="banner">
  {/* Header content */}
</header>

// Main content
<main role="main">
  {/* Main content */}
</main>

// Complementary (sidebar)
<aside role="complementary" aria-label="Related articles">
  {/* Sidebar content */}
</aside>

// Content info (footer)
<footer role="contentinfo">
  {/* Footer content */}
</footer>

// Search
<form role="search" aria-label="Site search">
  <input type="search" aria-label="Search query" />
  <button type="submit">Search</button>
</form>

ARIA Labels

// aria-label for elements without visible text
<button aria-label="Close dialog">
  <X size={24} />
</button>

// aria-labelledby to reference another element
<div role="dialog" aria-labelledby="dialog-title">
  <h2 id="dialog-title">Confirm Action</h2>
  <p>Are you sure you want to continue?</p>
</div>

// aria-describedby for additional description
<input
  type="password"
  aria-describedby="password-requirements"
/>
<p id="password-requirements">
  Password must be at least 8 characters
</p>

ARIA States

// aria-expanded for expandable elements
<button
  aria-expanded={isOpen}
  aria-controls="dropdown-menu"
  onClick={() => setIsOpen(!isOpen)}
>
  Menu {isOpen ? <ChevronUp /> : <ChevronDown />}
</button>
<div id="dropdown-menu" hidden={!isOpen}>
  {/* Dropdown content */}
</div>

// aria-pressed for toggle buttons
<button
  aria-pressed={isPressed}
  onClick={() => setIsPressed(!isPressed)}
>
  {isPressed ? 'Pressed' : 'Not Pressed'}
</button>

// aria-selected for selectable items
<div role="tab" aria-selected={isActive}>
  Tab 1
</div>

// aria-checked for checkboxes/radio buttons
<div
  role="checkbox"
  aria-checked={isChecked}
  tabIndex={0}
  onClick={() => setIsChecked(!isChecked)}
>
  Custom Checkbox
</div>

ARIA Live Regions

// Announce changes to screen readers
<div
  role="status"
  aria-live="polite"
  aria-atomic="true"
>
  {statusMessage}
</div>

// For urgent announcements
<div
  role="alert"
  aria-live="assertive"
  aria-atomic="true"
>
  {errorMessage}
</div>

// For form validation
<input
  type="email"
  aria-invalid={hasError}
  aria-describedby={hasError ? 'email-error' : undefined}
/>
{hasError && (
  <p id="email-error" role="alert">
    Please enter a valid email address
  </p>
)}

Color Contrast

Minimum Contrast Ratios (WCAG AA)

  • Normal text: 4.5:1
  • Large text (18pt+ or 14pt+ bold): 3:1
  • UI components and graphics: 3:1

Good Contrast Examples

// High contrast text
<p className="text-slate-900 bg-white">
  Great contrast (21:1)
</p>

<p className="text-slate-700 bg-white">
  Good contrast (8:1)
</p>

// Button with good contrast
<button className="
  bg-blue-600 text-white
  hover:bg-blue-700
">
  High Contrast Button (4.5:1)
</button>

Poor Contrast Examples (Avoid)

// ❌ Insufficient contrast
<p className="text-gray-400 bg-white">
  Poor contrast (2.8:1) - fails WCAG AA
</p>

// ❌ Don't rely on color alone
<button className="bg-red-500 text-white">
  Error Button (color alone indicates state)
</button>

// ✅ Better: Use icons + color
<button className="bg-red-500 text-white flex items-center gap-2">
  <AlertCircle size={20} />
  Error: Fix Issues
</button>

Tools for Checking Contrast

  • Chrome DevTools: Inspect element → Accessibility tab
  • Online: WebAIM Contrast Checker
  • Figma: Stark plugin

Alternative Text

Images

// Informative images
<img
  src="chart.png"
  alt="Bar chart showing sales increased 40% in Q4 2025"
/>

// Decorative images
<img
  src="decoration.png"
  alt=""
  role="presentation"
/>

// Functional images (buttons)
<button aria-label="Search">
  <img src="search-icon.png" alt="" />
</button>

// Complex images
<figure>
  <img
    src="complex-diagram.png"
    alt="System architecture diagram"
    aria-describedby="diagram-description"
  />
  <figcaption id="diagram-description">
    Detailed description of the system architecture showing
    three main components: frontend, API layer, and database.
    The frontend communicates with the API via REST...
  </figcaption>
</figure>

Icons

import { MagnifyingGlass, Bell, User } from '@phosphor-icons/react';

// Decorative icons (with adjacent text)
<button className="flex items-center gap-2">
  <MagnifyingGlass aria-hidden="true" />
  Search
</button>

// Functional icons (no adjacent text)
<button aria-label="Search">
  <MagnifyingGlass />
</button>

// Icons with state
<button aria-label="Notifications (3 unread)">
  <Bell />
  <span className="sr-only">3 unread notifications</span>
  <span aria-hidden="true" className="badge">3</span>
</button>

Forms

Labels and Instructions

// Always associate labels with inputs
<div>
  <label htmlFor="email" className="block mb-1 font-medium">
    Email Address
  </label>
  <input
    id="email"
    type="email"
    required
    aria-required="true"
    className="w-full px-4 py-2 border rounded-lg"
  />
</div>

// Group related inputs
<fieldset>
  <legend className="font-medium mb-2">Contact Preferences</legend>
  <div className="space-y-2">
    <label className="flex items-center gap-2">
      <input type="checkbox" name="email" />
      Email
    </label>
    <label className="flex items-center gap-2">
      <input type="checkbox" name="sms" />
      SMS
    </label>
  </div>
</fieldset>

Error Handling

<div>
  <label htmlFor="password" className="block mb-1 font-medium">
    Password
  </label>
  <input
    id="password"
    type="password"
    aria-invalid={hasError}
    aria-describedby="password-requirements password-error"
    className={`
      w-full px-4 py-2 border rounded-lg
      ${hasError ? 'border-red-500' : 'border-slate-300'}
    `}
  />
  <p id="password-requirements" className="text-sm text-slate-600 mt-1">
    Must be at least 8 characters
  </p>
  {hasError && (
    <p id="password-error" role="alert" className="text-sm text-red-600 mt-1">
      <AlertCircle className="inline" size={16} />
      Password is too short
    </p>
  )}
</div>

Required Fields

// Indicate required fields clearly
<label htmlFor="name" className="block mb-1 font-medium">
  Full Name
  <span className="text-red-600" aria-label="required">*</span>
</label>
<input
  id="name"
  type="text"
  required
  aria-required="true"
  className="w-full px-4 py-2 border rounded-lg"
/>

// Or use text
<label htmlFor="email" className="block mb-1 font-medium">
  Email
  <span className="text-sm font-normal text-slate-600">(required)</span>
</label>

Screen Reader-Only Content

sr-only Class

/* Add to your CSS */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

.focus\:not-sr-only:focus {
  position: static;
  width: auto;
  height: auto;
  padding: inherit;
  margin: inherit;
  overflow: visible;
  clip: auto;
  white-space: normal;
}

Usage Examples

// Add context for screen readers
<button>
  <Heart />
  <span className="sr-only">Add to favorites</span>
</button>

// Provide additional context
<div>
  <h2>Products</h2>
  <span className="sr-only">Showing 24 of 100 results</span>
</div>

// Skip link
<a href="#main" className="sr-only focus:not-sr-only">
  Skip to main content
</a>

Focus Indicators

Visible Focus States

// Default focus with ring
<button className="
  px-4 py-2 rounded-lg
  bg-blue-600 text-white
  focus:outline-none
  focus:ring-4 focus:ring-blue-500
  focus:ring-offset-2
">
  Click Me
</button>

// Custom focus style
<a
  href="/page"
  className="
    underline
    focus:outline-none
    focus:ring-2 focus:ring-blue-500
    focus:rounded
  "
>
  Link Text
</a>

// Focus within containers
<div className="
  p-4 border border-slate-300 rounded-lg
  focus-within:ring-4 focus-within:ring-blue-500
  focus-within:border-blue-500
">
  <input type="text" className="w-full focus:outline-none" />
</div>

Focus Management in Modals

import { useEffect, useRef } from 'react';

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null);
  const previousFocus = useRef(null);

  useEffect(() => {
    if (isOpen) {
      // Store current focus
      previousFocus.current = document.activeElement;

      // Focus modal
      modalRef.current?.focus();

      // Trap focus within modal
      const handleTab = (e) => {
        if (e.key === 'Tab') {
          const focusableElements = modalRef.current.querySelectorAll(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
          );
          const firstElement = focusableElements[0];
          const lastElement = focusableElements[focusableElements.length - 1];

          if (e.shiftKey && document.activeElement === firstElement) {
            e.preventDefault();
            lastElement.focus();
          } else if (!e.shiftKey && document.activeElement === lastElement) {
            e.preventDefault();
            firstElement.focus();
          }
        }
      };

      document.addEventListener('keydown', handleTab);
      return () => document.removeEventListener('keydown', handleTab);
    } else {
      // Restore focus
      previousFocus.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      className="fixed inset-0 bg-black/50 flex items-center justify-center"
      onClick={onClose}
    >
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        tabIndex={-1}
        className="bg-white rounded-lg p-6 max-w-md"
        onClick={(e) => e.stopPropagation()}
      >
        {children}
        <button
          onClick={onClose}
          className="mt-4 px-4 py-2 bg-slate-200 rounded-lg"
        >
          Close
        </button>
      </div>
    </div>
  );
}

Testing Checklist

Automated Testing

# Install axe-core for accessibility testing
npm install --save-dev @axe-core/react

# Use in tests
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

test('should have no accessibility violations', async () => {
  const { container } = render(<MyComponent />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Manual Testing

Keyboard Navigation:

  • Can navigate entire site using Tab key
  • Can activate all interactive elements with Enter/Space
  • Focus indicators are clearly visible
  • No keyboard traps
  • Logical tab order

Screen Reader Testing:

  • Test with NVDA (Windows) or VoiceOver (Mac)
  • All images have appropriate alt text
  • Headings create logical structure
  • Forms have proper labels
  • Dynamic content is announced

Visual Testing:

  • Text has sufficient contrast (4.5:1 minimum)
  • UI works at 200% zoom
  • Content reflows properly on mobile
  • No information conveyed by color alone
  • Focus indicators are visible

Tools to Use:

  • Chrome DevTools Lighthouse
  • WAVE browser extension
  • axe DevTools browser extension
  • Color contrast analyzer
  • Screen reader (NVDA/VoiceOver)

Common Patterns

Accessible Modal

<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
  aria-describedby="modal-description"
  className="fixed inset-0 z-50 flex items-center justify-center"
>
  <div className="fixed inset-0 bg-black/50" onClick={onClose} />
  <div className="relative bg-white rounded-lg p-6 max-w-md">
    <h2 id="modal-title" className="text-xl font-bold mb-2">
      Confirm Action
    </h2>
    <p id="modal-description" className="text-slate-600 mb-4">
      Are you sure you want to proceed?
    </p>
    <div className="flex gap-4">
      <button
        onClick={onConfirm}
        className="px-4 py-2 bg-blue-600 text-white rounded-lg"
      >
        Confirm
      </button>
      <button
        onClick={onClose}
        className="px-4 py-2 border border-slate-300 rounded-lg"
      >
        Cancel
      </button>
    </div>
  </div>
</div>

Accessible Tabs

function Tabs({ tabs }) {
  const [activeTab, setActiveTab] = useState(0);

  return (
    <div>
      <div role="tablist" aria-label="Content sections">
        {tabs.map((tab, index) => (
          <button
            key={index}
            role="tab"
            aria-selected={activeTab === index}
            aria-controls={`panel-${index}`}
            id={`tab-${index}`}
            tabIndex={activeTab === index ? 0 : -1}
            onClick={() => setActiveTab(index)}
            className={`
              px-4 py-2 border-b-2
              ${activeTab === index
                ? 'border-blue-600 font-medium'
                : 'border-transparent'
              }
            `}
          >
            {tab.label}
          </button>
        ))}
      </div>
      {tabs.map((tab, index) => (
        <div
          key={index}
          role="tabpanel"
          id={`panel-${index}`}
          aria-labelledby={`tab-${index}`}
          hidden={activeTab !== index}
          className="p-4"
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

Accessible Tooltip

function Tooltip({ text, children }) {
  const [isVisible, setIsVisible] = useState(false);
  const tooltipId = useId();

  return (
    <div className="relative inline-block">
      <button
        aria-describedby={isVisible ? tooltipId : undefined}
        onMouseEnter={() => setIsVisible(true)}
        onMouseLeave={() => setIsVisible(false)}
        onFocus={() => setIsVisible(true)}
        onBlur={() => setIsVisible(false)}
      >
        {children}
      </button>
      {isVisible && (
        <div
          id={tooltipId}
          role="tooltip"
          className="
            absolute z-10 px-3 py-2
            bg-slate-900 text-white text-sm
            rounded-lg
            bottom-full left-1/2 -translate-x-1/2 mb-2
          "
        >
          {text}
        </div>
      )}
    </div>
  );
}

Resources