Accessibility Architecture

Med

Accessibility architecture ensures that complex frontend systems are usable by everyone, including people using screen readers, keyboard navigation, and assistive technologies. In system design, accessibility is not a CSS checkbox but an architectural concern that affects component APIs, focus management, state announcements, and interaction patterns.

Interactive Visualization

Key Points

  • Semantic HTML (button, nav, main, article) provides free accessibility that divs cannot
  • ARIA roles and properties bridge the gap when native HTML elements are insufficient
  • Focus management is critical for modals, drawers, and dynamic content (focus trapping)
  • Live regions (aria-live) announce dynamic content changes to screen readers
  • Keyboard navigation patterns (roving tabindex, arrow keys) must follow WAI-ARIA patterns
  • Color contrast must meet WCAG AA (4.5:1 for text, 3:1 for large text and UI components)
  • Accessible components require proper labeling: aria-label, aria-labelledby, aria-describedby
  • Touch targets should be at least 44x44px for motor accessibility on mobile

Code Examples

Focus Trap for Modals

// Focus trap: keep focus inside a modal dialog
function useFocusTrap(ref: RefObject<HTMLElement>) {
  useEffect(() => {
    const element = ref.current
    if (!element) return

    const focusableSelector = [
      'a[href]', 'button:not([disabled])', 'input:not([disabled])',
      'select:not([disabled])', 'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])',
    ].join(', ')

    const focusableElements = element.querySelectorAll(focusableSelector)
    const firstFocusable = focusableElements[0] as HTMLElement
    const lastFocusable = focusableElements[focusableElements.length - 1] as HTMLElement

    // Save previously focused element
    const previouslyFocused = document.activeElement as HTMLElement

    // Focus first element in trap
    firstFocusable?.focus()

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key !== 'Tab') return

      if (e.shiftKey) {
        if (document.activeElement === firstFocusable) {
          e.preventDefault()
          lastFocusable?.focus()
        }
      } else {
        if (document.activeElement === lastFocusable) {
          e.preventDefault()
          firstFocusable?.focus()
        }
      }
    }

    element.addEventListener('keydown', handleKeyDown)
    return () => {
      element.removeEventListener('keydown', handleKeyDown)
      previouslyFocused?.focus() // Restore focus on unmount
    }
  }, [ref])
}

// Usage in a dialog component
function Dialog({ isOpen, onClose, children }: DialogProps) {
  const dialogRef = useRef<HTMLDivElement>(null)
  useFocusTrap(dialogRef)

  if (!isOpen) return null
  return (
    <div role="dialog" aria-modal="true" ref={dialogRef}>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  )
}

Focus trapping ensures keyboard users cannot tab outside a modal dialog. Restoring focus to the previously focused element when the modal closes maintains navigation context.

Live Regions for Dynamic Content

// Announce dynamic changes to screen readers
function SearchResults({ query, results, isLoading }: SearchResultsProps) {
  return (
    <div>
      {/* Polite: announced after current speech finishes */}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {isLoading
          ? 'Searching...'
          : `${results.length} results found for ${query}`
        }
      </div>

      <ul role="listbox" aria-label="Search results">
        {results.map((result, index) => (
          <li
            key={result.id}
            role="option"
            aria-selected={index === 0}
            aria-posinset={index + 1}
            aria-setsize={results.length}
          >
            {result.title}
          </li>
        ))}
      </ul>
    </div>
  )
}

// Toast notification system with announcements
function useToast() {
  const announce = useCallback((message: string, type: 'success' | 'error') => {
    // Visual toast
    showToast({ message, type })

    // Screen reader announcement via live region
    const liveRegion = document.getElementById('toast-live-region')
    if (liveRegion) {
      liveRegion.textContent = ''
      // Force re-announcement by clearing then setting
      requestAnimationFrame(() => {
        liveRegion.textContent = message
      })
    }
  }, [])

  return { announce }
}

Live regions bridge the gap between visual UI updates and screen reader announcements. Without them, dynamic content changes (search results, toasts, loading states) are invisible to assistive technology users.

Roving Tabindex for Composite Widgets

// Roving tabindex: arrow keys navigate, Tab moves to next widget
function useRovingTabindex<T extends HTMLElement>(
  items: string[],
  orientation: 'horizontal' | 'vertical' = 'horizontal'
) {
  const [activeIndex, setActiveIndex] = useState(0)
  const itemRefs = useRef<Map<number, T>>(new Map())

  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
    const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'
    const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown'

    let newIndex = activeIndex
    switch (e.key) {
      case nextKey:
        newIndex = (activeIndex + 1) % items.length
        break
      case prevKey:
        newIndex = (activeIndex - 1 + items.length) % items.length
        break
      case 'Home':
        newIndex = 0
        break
      case 'End':
        newIndex = items.length - 1
        break
      default:
        return
    }

    e.preventDefault()
    setActiveIndex(newIndex)
    itemRefs.current.get(newIndex)?.focus()
  }, [activeIndex, items.length, orientation])

  const getItemProps = (index: number) => ({
    ref: (el: T | null) => {
      if (el) itemRefs.current.set(index, el)
    },
    tabIndex: index === activeIndex ? 0 : -1,
    onKeyDown: handleKeyDown,
  })

  return { activeIndex, getItemProps }
}

// Usage: accessible toolbar
function Toolbar({ actions }: ToolbarProps) {
  const { activeIndex, getItemProps } = useRovingTabindex<HTMLButtonElement>(
    actions.map(a => a.id)
  )

  return (
    <div role="toolbar" aria-label="Formatting options">
      {actions.map((action, i) => (
        <button
          key={action.id}
          {...getItemProps(i)}
          aria-pressed={action.isActive}
        >
          {action.label}
        </button>
      ))}
    </div>
  )
}

Roving tabindex follows the WAI-ARIA composite widget pattern: only one item in the group has tabIndex=0, and arrow keys move focus within the group. This matches native OS behavior for toolbars and menus.

Common Mistakes

  • Using div and span for interactive elements instead of button and a
  • Adding aria-label to elements that already have visible text labels
  • Forgetting focus management when opening/closing modals or routing to new pages
  • Using color alone to convey meaning (error states, status indicators) without text alternatives
  • Not testing with a screen reader: ARIA attributes require manual testing to verify

Interview Tips

  • Frame accessibility as a design constraint from the start, not a retrofit
  • Mention WCAG 2.1 AA as the standard target for most applications
  • Discuss keyboard navigation patterns for custom widgets (roving tabindex, focus trapping)
  • Show awareness of the component API implications: aria-label, role, and keyboard handlers
  • Mention automated testing tools (axe-core, Lighthouse accessibility audit) and their limitations

Related Concepts