Building Accessible Web Components

A practical guide to creating web components that work for everyone, with real-world examples and best practices.

Branislav Remeň
March 15, 2024
8 min read

Lead

Accessibility isn't an afterthought—it's a fundamental part of good web development. Yet most developers struggle with implementing ARIA patterns correctly and creating components that work seamlessly with assistive technologies. Here's how to build web components that are truly accessible from the ground up.

Problem

Modern web applications rely heavily on custom components, but most fail basic accessibility tests:

  • Keyboard navigation breaks when focus management isn't handled properly
  • Screen readers can't understand complex interactive elements without proper ARIA labels
  • Color-only indicators exclude users with visual impairments
  • Dynamic content updates go unnoticed by assistive technologies
  • Form validation provides visual feedback but no programmatic announcements

The result? Millions of users can't effectively use your application, and you're potentially violating accessibility laws.

Approach

Instead of retrofitting accessibility, I build it into components from the start using a systematic approach:

  1. Semantic HTML first - Start with the right foundation
  2. Progressive enhancement - Add interactivity without breaking core functionality
  3. ARIA patterns - Follow established patterns for complex widgets
  4. Testing with real users - Validate with actual assistive technology users

Steps

1. Start with Semantic HTML

Every accessible component begins with proper semantic markup:

html
<!-- Bad: Div soup with no meaning -->
<div class="button" onclick="doSomething()">
  Click me
</div>

<!-- Good: Semantic button element -->
<button type="button" onclick="doSomething()">
  Click me
</button>

Always ask: "What HTML element naturally represents this functionality?" Start there, then enhance.

2. Implement Proper Focus Management

Interactive components must handle keyboard navigation correctly:

typescript
// components/accessible-modal.tsx
import { useEffect, useRef } from 'react'

export function AccessibleModal({ isOpen, onClose, children }) {
  const dialogRef = useRef<HTMLDialogElement>(null)
  const previousFocus = useRef<Element | null>(null)

  useEffect(() => {
    if (isOpen) {
      // Store the previously focused element
      previousFocus.current = document.activeElement

      // Focus the modal
      dialogRef.current?.focus()

      // Trap focus within modal
      const trapFocus = (e: KeyboardEvent) => {
        if (e.key === 'Tab') {
          const focusableElements = dialogRef.current?.querySelectorAll(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
          )
          // Focus trapping logic here
        }
      }

      document.addEventListener('keydown', trapFocus)
      return () => document.removeEventListener('keydown', trapFocus)
    } else {
      // Return focus to previous element
      if (previousFocus.current instanceof HTMLElement) {
        previousFocus.current.focus()
      }
    }
  }, [isOpen])

  if (!isOpen) return null

  return (
    <dialog
      ref={dialogRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      className="modal"
    >
      <div className="modal-content">
        {children}
        <button onClick={onClose} aria-label="Close modal">
          ×
        </button>
      </div>
    </dialog>
  )
}

3. Add ARIA Labels and Descriptions

Provide context for screen reader users:

typescript
// components/accessible-form.tsx
export function AccessibleForm() {
  const [errors, setErrors] = useState<Record<string, string>>({})

  return (
    <form>
      <div className="field-group">
        <label htmlFor="email" className="required">
          Email Address
        </label>
        <input
          id="email"
          type="email"
          aria-required="true"
          aria-describedby={errors.email ? "email-error email-hint" : "email-hint"}
          aria-invalid={!!errors.email}
        />
        <div id="email-hint" className="hint">
          We'll never share your email with anyone else.
        </div>
        {errors.email && (
          <div id="email-error" className="error" role="alert">
            {errors.email}
          </div>
        )}
      </div>
    </form>
  )
}

Takeaways

Core Principles:

  1. Semantic HTML is your foundation - Custom components should enhance, not replace, native functionality
  2. Focus management is critical - Users must be able to navigate logically with keyboard alone
  3. Context is everything - Provide clear labels, descriptions, and status announcements
  4. Test early and often - Accessibility issues are much harder to fix after the fact

Common Mistakes to Avoid:

  • Using div elements for interactive components
  • Forgetting to handle keyboard events
  • Missing focus indicators
  • Relying solely on color for important information
  • Not testing with actual assistive technologies

Subscribe to Pragmatic Web for more deep dives into building inclusive, robust web applications that work for everyone.

Subscribe to Pragmatic Web

Get practical insights on web development, AI tools, and building things that matter. No fluff, just actionable content.

Subscribe Now