Toegankelijkheid

Componenten bouwen die voor iedereen bruikbaar zijn, inclusief gebruikers met een beperking die afhankelijk zijn van assistieve technologieën.

Toegankelijkheid (a11y) is geen optionele functie — het is een fundamentele vereiste voor moderne webcomponenten. Elk component moet door iedereen gebruikt kunnen worden, inclusief mensen met visuele, motorische, auditieve of cognitieve beperkingen.

Deze gids is een niet-uitputtende lijst van toegankelijkheidsprincipes en -patronen die je zou moeten volgen bij het bouwen van componenten. Het is geen volledige handleiding, maar het geeft je wel een beeld van de soorten problemen waarvan je je bewust moet zijn.

Als je een linter gebruikt met strikte toegankelijkheidsregels zoals Ultracite, worden dit soort problemen waarschijnlijk automatisch opgevangen, maar het is nog steeds belangrijk de principes te begrijpen.

Kernprincipes

1. Semantische HTML eerst

Begin altijd met het meest geschikte HTML-element. Semantische HTML biedt ingebouwde toegankelijkheidsfuncties die aangepaste implementaties vaak missen.

// ❌ Don't reinvent the wheel
<div onClick={handleClick} className="button">
  Click me
</div>

// ✅ Use semantic elements
<button onClick={handleClick}>
  Click me
</button>

Semantische elementen komen met correcte rol-aankondigingen, toetsenbordinteractie, focusbeheer en deelname aan formulieren.

2. Toetsenbordnavigatie

Elk interactief element moet met het toetsenbord toegankelijk zijn. Gebruikers moeten kunnen navigeren, activeren en interageren met alle functionaliteit met alleen een toetsenbord.

// ✅ Complete keyboard support
function Menu() {
  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch(e.key) {
      case 'ArrowDown':
        focusNextItem();
        break;
      case 'ArrowUp':
        focusPreviousItem();
        break;
      case 'Home':
        focusFirstItem();
        break;
      case 'End':
        focusLastItem();
        break;
      case 'Escape':
        closeMenu();
        break;
    }
  };

  return (
    <div role="menu" onKeyDown={handleKeyDown}>
      {/* menu items */}
    </div>
  );
}

3. Ondersteuning voor schermlezers

Zorg dat alle inhoud en interacties correct aan schermlezers worden aangekondigd met ARIA-attributen wanneer dat nodig is.

// ✅ Proper ARIA labeling
<nav aria-label="Main navigation">
  <ul>
    <li><a href="/" aria-current="page">Home</a></li>
    <li><a href="/about">About</a></li>
  </ul>
</nav>

// ✅ Dynamic content announcements
<div aria-live="polite" aria-atomic="true">
  {isLoading && <span>Loading results...</span>}
  {results && <span>{results.length} results found</span>}
</div>

4. Visuele toegankelijkheid

Ondersteun gebruikers met visuele beperkingen door juiste contrasten, focusindicatoren en responsieve tekstgrootte.

/* ✅ Visible focus indicators */
button:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}

/* ✅ Sufficient color contrast (4.5:1 for normal text, 3:1 for large text) */
.text {
  color: #333; /* Against white: 12.6:1 ratio */
  background: white;
}

/* ✅ Responsive text sizing */
.text {
  font-size: 1rem; /* Respects user preferences */
}

ARIA-patronen

ARIA begrijpen

ARIA (Accessible Rich Internet Applications) geeft semantische informatie over elementen aan assistieve technologieën. Gebruik ARIA om semantische HTML te verbeteren, niet om het te vervangen.

Er zijn een paar regels die je moet volgen:

  1. Gebruik geen ARIA als je semantische HTML kunt gebruiken
  2. Verander de native semantiek niet tenzij het nodig is
  3. Alle interactieve elementen moeten met het toetsenbord toegankelijk zijn
  4. Verberg focusbare elementen niet voor assistieve technologieën
  5. Alle interactieve elementen moeten toegankelijke namen hebben

Veelvoorkomende ARIA-attributen

Rollen

Definieer wat een element is:

// Widget roles
<div role="button" tabIndex={0} onClick={handleClick}>
  Custom Button
</div>

// Landmark roles
<div role="navigation" aria-label="Breadcrumb">
  {/* breadcrumb items */}
</div>

// Live region roles
<div role="alert">
  Error: Invalid email address
</div>

Staten

Beschrijf de huidige staat van een element:

// Checked state
<div
  role="checkbox"
  aria-checked={isChecked}
  tabIndex={0}
>
  Accept terms
</div>

// Expanded state
<button
  aria-expanded={isOpen}
  aria-controls="panel-1"
>
  Toggle Panel
</button>
<div id="panel-1" hidden={!isOpen}>
  Panel content
</div>

// Selected state
<li
  role="option"
  aria-selected={isSelected}
>
  Option 1
</li>

Eigenschappen

Geef aanvullende informatie:

// Labels and descriptions
<input
  aria-label="Search"
  aria-describedby="search-help"
  type="search"
/>
<span id="search-help">Press Enter to search</span>

// Relationships
<button aria-controls="modal-1">Open Modal</button>
<div id="modal-1" role="dialog">{/* modal content */}</div>

// Required and invalid
<input
  aria-required="true"
  aria-invalid={hasError}
  aria-errormessage="email-error"
/>
<span id="email-error">Please enter a valid email</span>

Componentpatronen

Modal/Dialog

Modals vereisen zorgvuldige focusbeheersing en focus-trapping:

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocus = useRef<HTMLElement | null>(null);

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

      // Focus first focusable element in modal
      const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus();

      // Prevent body scroll
      document.body.style.overflow = 'hidden';
    } else {
      // Restore focus
      previousFocus.current?.focus();
      document.body.style.overflow = '';
    }

    return () => {
      document.body.style.overflow = '';
    };
  }, [isOpen]);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Escape') {
      onClose();
    }

    if (e.key === 'Tab') {
      // Trap focus within modal
      const focusables = modalRef.current?.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );

      if (focusables && focusables.length > 0) {
        const firstFocusable = focusables[0];
        const lastFocusable = focusables[focusables.length - 1];

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

  if (!isOpen) return null;

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

Dropdowns hebben de juiste ARIA-attributen en toetsenbordnavigatie nodig:

function DropdownMenu({ items }) {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(-1);
  const menuRef = useRef<HTMLUListElement>(null);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch(e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
          setSelectedIndex(0);
        } else {
          setSelectedIndex(prev =>
            prev < items.length - 1 ? prev + 1 : 0
          );
        }
        break;

      case 'ArrowUp':
        e.preventDefault();
        if (isOpen) {
          setSelectedIndex(prev =>
            prev > 0 ? prev - 1 : items.length - 1
          );
        }
        break;

      case 'Enter':
      case ' ':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
        } else if (selectedIndex >= 0) {
          items[selectedIndex].onClick();
          setIsOpen(false);
        }
        break;

      case 'Escape':
        setIsOpen(false);
        setSelectedIndex(-1);
        break;
    }
  };

  return (
    <div className="dropdown">
      <button
        aria-haspopup="true"
        aria-expanded={isOpen}
        aria-controls="dropdown-menu"
        onKeyDown={handleKeyDown}
        onClick={() => setIsOpen(!isOpen)}
      >
        Menu
      </button>

      {isOpen && (
        <ul
          id="dropdown-menu"
          role="menu"
          ref={menuRef}
          onKeyDown={handleKeyDown}
        >
          {items.map((item, index) => (
            <li
              key={item.id}
              role="menuitem"
              tabIndex={-1}
              aria-selected={index === selectedIndex}
              onClick={() => {
                item.onClick();
                setIsOpen(false);
              }}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Tabs

Tabinterfaces vereisen specifieke ARIA-patronen en toetsenbordnavigatie:

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

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    let newIndex = index;

    switch(e.key) {
      case 'ArrowLeft':
        newIndex = index > 0 ? index - 1 : tabs.length - 1;
        break;
      case 'ArrowRight':
        newIndex = index < tabs.length - 1 ? index + 1 : 0;
        break;
      case 'Home':
        newIndex = 0;
        break;
      case 'End':
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }

    e.preventDefault();
    setActiveTab(newIndex);

    // Focus the newly selected tab
    const tabElement = document.getElementById(`tab-${newIndex}`);
    tabElement?.focus();
  };

  return (
    <div className="tabs">
      <div role="tablist" aria-label="Tabs">
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            id={`tab-${index}`}
            role="tab"
            aria-selected={activeTab === index}
            aria-controls={`panel-${index}`}
            tabIndex={activeTab === index ? 0 : -1}
            onClick={() => setActiveTab(index)}
            onKeyDown={(e) => handleKeyDown(e, index)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, index) => (
        <div
          key={tab.id}
          id={`panel-${index}`}
          role="tabpanel"
          aria-labelledby={`tab-${index}`}
          hidden={activeTab !== index}
          tabIndex={0}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

Formulieren

Formulieren hebben duidelijke labels, foutmeldingen en validatiefeedback nodig:

function AccessibleForm() {
  const [errors, setErrors] = useState({});

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

      <fieldset>
        <legend>Notification Preferences</legend>
        <div>
          <input
            id="notify-email"
            type="checkbox"
            name="notifications"
            value="email"
          />
          <label htmlFor="notify-email">Email notifications</label>
        </div>
        <div>
          <input
            id="notify-sms"
            type="checkbox"
            name="notifications"
            value="sms"
          />
          <label htmlFor="notify-sms">SMS notifications</label>
        </div>
      </fieldset>

      <button type="submit">Submit</button>
    </form>
  );
}

Focusbeheer

Focus Visible

Toon focusindicatoren alleen voor toetsenbordnavigatie:

/* Remove default outline */
*:focus {
  outline: none;
}

/* Show outline only for keyboard focus */
*:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}

/* Custom focus styles for specific components */
.button:focus-visible {
  box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
}

Focus Trapping

Houd de focus binnen een specifiek gebied:

function useFocusTrap(ref: React.RefObject<HTMLElement>, isActive: boolean) {
  useEffect(() => {
    if (!isActive || !ref.current) return;

    const element = ref.current;
    const focusableSelector =
      'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select, [tabindex]:not([tabindex="-1"])';

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

    const handleTabKey = (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', handleTabKey);
    firstFocusable?.focus();

    return () => {
      element.removeEventListener('keydown', handleTabKey);
    };
  }, [ref, isActive]);
}

Focusherstel

Breng de focus terug naar het juiste element na interacties:

function useRestoreFocus() {
  const previousFocus = useRef<HTMLElement | null>(null);

  const saveFocus = () => {
    previousFocus.current = document.activeElement as HTMLElement;
  };

  const restoreFocus = () => {
    previousFocus.current?.focus();
  };

  return { saveFocus, restoreFocus };
}

Live-regio's

Kondig dynamische inhoudsveranderingen aan voor schermlezers:

Statusmeldingen

// Polite announcement (waits for screen reader to finish)
<div role="status" aria-live="polite">
  {savedMessage && "Settings saved successfully"}
</div>

// Assertive announcement (interrupts screen reader)
<div role="alert" aria-live="assertive">
  {errorMessage && `Error: ${errorMessage}`}
</div>

// Loading states
<div aria-live="polite" aria-busy={isLoading}>
  {isLoading ? "Loading..." : `${items.length} items loaded`}
</div>

Voortgangsindicatoren

function ProgressBar({ value, max = 100 }) {
  return (
    <div
      role="progressbar"
      aria-valuenow={value}
      aria-valuemin={0}
      aria-valuemax={max}
      aria-label="Upload progress"
    >
      <div
        className="progress-fill"
        style={{ width: `${(value / max) * 100}%` }}
      />
      <span className="sr-only">
        {Math.round((value / max) * 100)}% complete
      </span>
    </div>
  );
}

Kleur en contrast

Contrastvereisten

Volg de WCAG-richtlijnen voor kleurcontrast:

/* Normal text (< 18pt or < 14pt bold) */
.text {
  color: #595959; /* 7:1 ratio against white */
  background: white;
}

/* Large text (≥ 18pt or ≥ 14pt bold) */
.heading {
  color: #767676; /* 4.5:1 ratio against white */
  font-size: 1.5rem;
  font-weight: bold;
}

/* Non-text elements (icons, borders) */
.icon {
  color: #949494; /* 3:1 ratio against white */
}

Kleuronafhankelijkheid

Geef nooit alleen via kleur informatie door:

// ❌ Color only
<span className="text-red-500">Error</span>

// ✅ Color with text/icon
<span className="text-red-500">
  <ErrorIcon aria-hidden="true" />
  <span>Error: Invalid input</span>
</span>

// ✅ Multiple indicators
<input
  className={hasError ? 'border-red-500' : 'border-gray-300'}
  aria-invalid={hasError}
  aria-describedby={hasError ? 'error-message' : undefined}
/>
{hasError && (
  <span id="error-message" className="text-red-500">
    <ErrorIcon aria-hidden="true" /> This field is required
  </span>
)}

Toegankelijkheid op mobiel

Touchdoelen

Zorg dat touchdoelen groot genoeg zijn:

/* Minimum 44x44px for iOS, 48x48dp for Android */
.button {
  min-height: 44px;
  min-width: 44px;
  padding: 12px 16px;
}

/* Add invisible touch area for small icons */
.icon-button {
  position: relative;
  padding: 8px;
}

.icon-button::before {
  content: '';
  position: absolute;
  top: -8px;
  right: -8px;
  bottom: -8px;
  left: -8px;
}

Viewport en zoomen

Sta gebruikers toe te zoomen:

<!-- ✅ Allows zooming -->
<meta name="viewport" content="width=device-width, initial-scale=1">

<!-- ❌ Prevents zooming -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">

Veelvoorkomende valkuilen

Plaatshoudertekst als label

Gebruik geen placeholders als enige label:

// ❌ Placeholder disappears when typing
<input placeholder="Email address" />

// ✅ Persistent label
<label>
  Email address
  <input type="email" />
</label>

// ✅ Floating label pattern
<div className="form-field">
  <input id="email" placeholder=" " />
  <label htmlFor="email">Email address</label>
</div>

Lege knoppen

Geef icon-knoppen altijd een toegankelijke tekst:

// ❌ No accessible name
<button onClick={handleDelete}>
  <TrashIcon />
</button>

// ✅ Screen reader text
<button onClick={handleDelete} aria-label="Delete item">
  <TrashIcon aria-hidden="true" />
</button>

// ✅ Visually hidden text
<button onClick={handleDelete}>
  <TrashIcon aria-hidden="true" />
  <span className="sr-only">Delete item</span>
</button>

Uitgeschakelde formulierelementen

Uitgeschakelde elementen zijn niet focusbaar, wat gebruikers kan verwarren:

// ❌ User can't understand why button is disabled
<button disabled={!isValid}>
  Submit
</button>

// ✅ Use aria-disabled and explain
<button
  aria-disabled={!isValid}
  aria-describedby="submit-help"
  onClick={isValid ? handleSubmit : undefined}
  className={!isValid ? 'opacity-50 cursor-not-allowed' : ''}
>
  Submit
</button>
<span id="submit-help">
  {!isValid && 'Please fill in all required fields'}
</span>