Toegankelijke componenten maken voor schermlezers
Toegankelijke componenten maken voor schermlezers
Stel je een NVDA-gebruiker voor die een modal met een formulier opent, het invult en op "Sluiten" klikt. De modal verdwijnt visueel — display: none in de CSS, alles lijkt in orde. Behalve dat het element nog gewoon in de DOM zit, de focus nergens naartoe is verplaatst, en de schermlezer vrolijk doorgaat met het voorlezen van de inhoud van het venster dat net "gesloten" is. De gebruiker hoort de inhoud van een formulier dat hij net heeft verlaten en heeft geen idee waar hij nu op de pagina is. Dit is geen verzonnen voorbeeld — het is een van de meest voorkomende fouten in componenten die "op het oog" zijn gebouwd, zonder te begrijpen hoe toegankelijkheid daadwerkelijk werkt. In dit artikel wil ik verder gaan dan een checklist met regels en het mechanisme erachter laten zien — plus een paar echte componenten waarin je precies ziet waar de valkuilen zitten.
Hoe een schermlezer een pagina echt "ziet"
Voordat we componenten gaan repareren, helpt het om te weten waar een schermlezer eigenlijk mee werkt. Hij rendert de pagina niet zoals een browser dat doet — hij werkt met de accessibility tree, een structuur die de browser parallel aan de DOM opbouwt. Elk knooppunt in die boom heeft drie kerneigenschappen: een rol (is het een knop, een link, een kop, een formulierveld), een naam (wat de gebruiker hoort als omschrijving) en een status (uitgeklapt, aangevinkt, uitgeschakeld).
De belangrijkste consequentie: een schermlezer ziet je CSS niet. display: none en visibility: hidden verwijderen een element uit de accessibility tree — dat is een correcte manier om iets te verbergen. Maar een element simpelweg buiten beeld plaatsen (position: absolute; left: -9999px) of de kleur op transparent zetten, verandert niets aan de accessibility tree — het element staat er nog steeds en wordt nog steeds voorgelezen. Dat is precies wat er misging in het modal-voorbeeld hierboven.
De tweede pijler is de DOM-volgorde. In bladermodus leest een schermlezer de pagina voor in de volgorde waarin de elementen in de boom staan — ongeacht hoe je ze visueel hebt gerangschikt met flex-direction: row-reverse of grid-template-areas. Als je layout er visueel logisch uitziet, maar de brondvolgorde willekeurig is, krijgt een blinde gebruiker de pagina "in willekeurige volgorde" te horen.
Semantiek als eerste verdedigingslinie
De goedkoopste manier om toegankelijkheid goed te doen, is het juiste HTML-element gebruiken in plaats van het gedrag ervan helemaal opnieuw uit te vinden. Kijk naar het verschil:
jsx
// Fout – ziet eruit als een knop, maar is er geenconst SaveButton = ({ onSave }) => (<div className="btn" onClick={onSave}>Wijzigingen opslaan</div>);
Deze div heeft ernstige tekortkomingen die op het eerste gezicht niet opvallen:
- Hij is niet focusbaar – je kunt hem niet bereiken met de Tab-toets.
- Hij reageert niet op het toetsenbord – Enter en spatie doen niets, omdat React's
onClickalleen naar muis-/touch-events luistert. - Hij heeft geen
button-rol – een schermlezer leest hem voor als platte tekst, zonder aanwijzing dat je hem kunt "activeren". - Hij heeft geen disabled-status – je kunt niet zomaar
disabledinstellen, je zou dat handmatig moeten simuleren.
jsx
// Goed – dit alles krijg je er gratis bijconst SaveButton = ({ onSave, isSaving }) => (<button type="button" onClick={onSave} disabled={isSaving}>{isSaving ? "Bezig met opslaan…" : "Wijzigingen opslaan"}</button>);
Hetzelfde principe geldt voor <nav> in plaats van <div className="nav">, een <label> die via htmlFor aan een veld gekoppeld is in plaats van een placeholder die doet alsof hij een label is, of <ul>/<li> voor lijsten in plaats van een stapel <div>'s. Semantische HTML is niet "netter" — het genereert letterlijk een andere, rijkere structuur in de accessibility tree.
ARIA: wanneer het helpt, en wanneer het schaadt
De WAI-ARIA-specificatie opent met een regel die het waard is om woordelijk te onthouden: "No ARIA is better than Bad ARIA" — geen ARIA is beter dan slechte ARIA. ARIA-attributen voegen geen gedrag toe — ze overschrijven alleen wat een schermlezer aan de gebruiker rapporteert. Als je iets belooft wat de component in werkelijkheid niet kan waarmaken, ben je er slechter aan toe dan wanneer je niets had toegevoegd.
jsx
// Fout – ARIA belooft een knop, maar er zit verder niets achterconst DeleteIcon = ({ onDelete }) => (<span role="button" aria-label="Item verwijderen" onClick={onDelete}>🗑</span>);
Deze code vertelt de schermlezer "dit is een knop" — maar voegt geen toetsenbordondersteuning en geen tabIndex toe, waardoor een toetsenbordgebruiker hem nooit kan bereiken. Dat is erger dan helemaal geen role, omdat het de indruk wekt dat de functie bestaat terwijl hij fysiek onbereikbaar is.
jsx
// Goed – gebruik gewoon een native knopconst DeleteIcon = ({ onDelete }) => (<button type="button" onClick={onDelete} aria-label="Item verwijderen"><span aria-hidden="true">🗑</span></button>);
Let op de aria-hidden="true" op de emoji — zonder dat proberen sommige schermlezers de Unicode-naam van het teken voor te lezen ("prullenbak"), wat absurd klinkt vlak nadat de aria-label al is aangekondigd. aria-label vervangt volledig wat de gebruiker hoort als zichtbare inhoud — als een element al leesbare tekst bevat, is aria-labelledby die naar die tekst verwijst meestal de betere keuze, zodat je niet twee onafhankelijke omschrijvingen onderhoudt die na verloop van tijd uit elkaar kunnen gaan lopen.
Een praktisch voorbeeld: een toegankelijke accordion
Een simpele knop laat niet veel zien. Laten we kijken naar een component waarbij je écht bewust status en ARIA-relaties moet beheren — een accordion, het patroon van uitklapbare secties dat je vaak ziet in FAQ's of instellingenpanelen.
jsx
import { useId, useState } from "react";const AccordionItem = ({ title, children, defaultOpen = false }) => {const [isOpen, setIsOpen] = useState(defaultOpen);const contentId = useId();return (<div className="accordion-item"><h3 className="accordion-header"><buttontype="button"className="accordion-trigger"aria-expanded={isOpen}aria-controls={contentId}onClick={() => setIsOpen((open) => !open)}>{title}<span className="accordion-icon" aria-hidden="true">{isOpen ? "−" : "+"}</span></button></h3><div id={contentId} role="region" aria-labelledby={contentId} hidden={!isOpen}>{children}</div></div>);};export default AccordionItem;
Een paar keuzes in deze code zijn niet toevallig:
aria-expandedrapporteert de huidige status – zonder dit hoort een schermlezergebruiker alleen "knop", zonder te weten of de sectie open is.aria-controlskoppelt de knop aan het paneel dat hij bestuurt – sommige schermlezers kondigen deze relatie aan, wat navigeren makkelijker maakt.hidden(in plaats van alleen via CSS verbergen) garandeert dat gesloten inhoud daadwerkelijk uit de accessibility tree en de Tab-volgorde wordt verwijderd, zodat de focus niet in onzichtbare inhoud kan belanden.- De
<h3>rond de knop houdt de koppenhiërarchie intact – gebruikers van schermlezers navigeren heel vaak via koppen, springend tussen secties met de H-toets. - Het
+/−-icoon heeftaria-hidden="true", omdat de status al viaaria-expandedwordt gecommuniceerd – zonder dit zou een schermlezer het dubbel aankondigen, op een verwarrende manier.
Live regions en dynamische berichten
Nog een veelvoorkomend probleem: er verandert iets op de pagina zonder herladen, en de schermlezer merkt het nooit, omdat er voor hem geen aanleiding is om een fragment opnieuw voor te lezen dat de gebruiker op dat moment niet verkent. Een klassiek voorbeeld is een validatiefout die dynamisch verschijnt nadat een veld is verlaten:
jsx
import { useState } from "react";const EmailField = () => {const [error, setError] = useState("");const handleBlur = (event) => {const value = event.target.value;setError(value.includes("@") ? "" : "Voer een geldig e-mailadres in.");};return (<div className="field"><label htmlFor="email">E-mail</label><inputid="email"type="email"aria-invalid={Boolean(error)}aria-describedby={error ? "email-error" : undefined}onBlur={handleBlur}/><span id="email-error" role="alert" className="field-error">{error}</span></div>);};export default EmailField;
role="alert" zorgt ervoor dat het element zich gedraagt als een impliciete aria-live="assertive"-region – wanneer de inhoud verandert, onderbreekt de schermlezer wat hij op dat moment voorleest en kondigt hij het nieuwe bericht meteen aan. Dat is geschikt voor fouten die urgente aandacht vragen, maar overdrijf het niet bij minder dringende updates (zoals "concept opgeslagen") – daar past aria-live="polite" beter, omdat het wacht tot de gebruiker klaar is met de huidige actie in plaats van hem te onderbreken. aria-describedby koppelt de foutmelding bovendien aan het veld, zodat de schermlezer hem samen met het label voorleest, elke keer dat de gebruiker naar dat veld terugkeert – niet alleen op het moment dat de fout voor het eerst verscheen.
Focusbeheer bij navigatie in Next.js
Deze valkuil is specifiek voor single-page apps, inclusief de Next.js App Router. Bij een klassieke paginaovergang (volledige herlaad) reset de browser de focus naar <body>, en kondigt de schermlezer de nieuwe documenttitel aan – de gebruiker weet dat hij op een nieuwe pagina is beland. Bij client-side navigatie gebeurt daar automatisch niets van: de focus blijft staan op de link waarop is geklikt (vaak ergens in de navigatie, buiten de nieuwe inhoud), en de schermlezer krijgt geen enkel signaal dat er iets is veranderd.
jsx
"use client";import { usePathname } from "next/navigation";import { useEffect, useRef } from "react";const RouteAnnouncer = ({ pageTitle }) => {const pathname = usePathname();const headingRef = useRef(null);useEffect(() => {headingRef.current?.focus();}, [pathname]);return (<h1 ref={headingRef} tabIndex={-1} className="visually-focusable-heading">{pageTitle}</h1>);};export default RouteAnnouncer;
De truc is tabIndex={-1} – normaal gesproken zijn koppen niet focusbaar, maar deze waarde laat je toe om er programmatisch de focus naartoe te verplaatsen (.focus()) zonder ze aan de natuurlijke Tab-volgorde toe te voegen. Na elke padwijziging (pathname) keert de focus terug naar de kop van de nieuwe pagina, zodat de schermlezer de titel aankondigt – precies zoals bij een klassieke herlaad. Het is een component die het waard is om één keer aan je layout toe te voegen en er daarna nooit meer aan te hoeven denken.
Hoe je toegankelijkheid daadwerkelijk test
Geautomatiseerde tools zoals axe-core of eslint-plugin-jsx-a11y zijn het waard om vanaf dag één van een project in te schakelen – ze vangen de voor de hand liggende fouten op (ontbrekende alt, slecht contrast, ontbrekende labels) voordat ze in productie belanden. Maar wees je bewust van hun beperkingen: volgens onderzoek van Deque Systems vangen geautomatiseerde tools realistisch gezien ongeveer 30–40% van de toegankelijkheidsproblemen. De rest vereist handmatige controle, omdat het gaat om betekenis en context die een machine niet kan beoordelen – of de leesvolgorde daadwerkelijk logisch is, of een foutmelding echt uitlegt wat je moet doen, of een focus trap in een modal de gebruiker daadwerkelijk geen ontsnapping biedt.
Een concreet, herhaalbaar proces dat de meeste problemen uit de praktijk opvangt:
- Leg de muis weg en doorloop de hele flow alleen met het toetsenbord – Tab, Shift+Tab, Enter, spatie, Escape, pijltjestoetsen waar dat natuurlijk is (bijvoorbeeld in een menu). Als je op enig moment niet weet waar de focus staat, is dat al een bug.
- Zet VoiceOver aan (macOS: Cmd+F5) of NVDA (Windows, gratis) en doorloop dezelfde flow met je ogen dicht. Alleen dit onthult of de volgorde, namen en statussen hardop echt kloppen, niet alleen op papier.
- Test dynamische berichten apart – trigger een formulierfout, wissel van route, open een modal – en controleer of de schermlezer daadwerkelijk iets heeft aangekondigd, niet alleen of het element in de code het juiste attribuut heeft.
Conclusie
Toegankelijke componenten zijn geen checklist met attributen die je er aan het eind nog even bij plakt — ze zijn een gevolg van hoe je vanaf het begin status en structuur modelleert. Drie dingen om uit dit artikel mee te nemen: ten eerste, semantische HTML geeft je focusbaarheid, toetsenbordondersteuning en een correcte rol cadeau — ARIA moet aanvullen wat HTML niet kan uitdrukken, niet het vervangen in componenten die net zo goed een native element hadden kunnen zijn. Ten tweede, veranderingen in status en inhoud moeten actief worden aangekondigd — aria-live, role="alert" en focusbeheer na navigatie zijn geen extra's, ze zijn de voorwaarde om een dynamische app zonder zicht überhaupt bruikbaar te maken. Ten derde, geen enkele geautomatiseerde tool vervangt het zelf met je ogen dicht doorlopen van je eigen interface — het is de snelste manier om precies te zien waar je "toegankelijke" component de gebruiker daadwerkelijk kwijtraakt.