Toegankelijke modals en focus management
Modals en dialogen zijn in bijna elk project aanwezig, maar in de praktijk veroorzaken ze vaak keyboard- en screenreader-problemen: focus verdwijnt, schermlezers blijven content lezen buiten de dialog, tab-volgorde werkt niet en ESC of sluiten is onbetrouwbaar. Dit leidt snel tot ontoegankelijke interacties en gebroken gebruikersstromen.
Wij lossen dit praktisch op met concrete, testbare patronen: correcte HTML/ARIA-structuur, focus trapping, terugzetten van focus, aria-hidden op achtergrondcontent en duidelijke keyboard handlers. Gebruik onze voorbeelden direct in je code en test meteen met onze WCAG checker/validator of installeer de plugin voor geautomatiseerde waarschuwingen en handreikingen. Vragen? Gebruik het contactformulier, we reageren binnen 24 uur.
Het probleem in de praktijk
Veelgemaakte fouten
- Dialog zonder role=”dialog” of aria-modal=”true”.
- Geen focus-management: focus blijft achter of verdwijnt naar de achtergrond.
- Achtergrondinhoud blijft bereikbaar voor toetsenbord en schermlezers (aria-hidden niet gezet).
- Geen trap voor Tab/Shift+Tab, of Shift+Tab werkt niet op eerste focusable element.
- Geen terugzetten van focus naar de opener na sluiten.
Waarom dit onder WCAG faalt
WCAG vereist dat interactieve componenten toegankelijk zijn via toetsenbord en dat statusveranderingen aan assistieve technologie worden doorgegeven (WCAG 2.1 Success Criteria zoals 2.1.1 Keyboard, 3.2.1 On Focus, 4.1.2 Name, Role, Value en techniques voor dialogs). Fouten hierboven maken componenten onbruikbaar voor toetsenbord- en screenreadergebruikers.
Zo los je dit op in code
Basis-HTML-structuur voor een modal
<button id="openDialog">Open dialog</button><div id="dialog" role="dialog" aria-modal="true" aria-labelledby="dialogTitle" aria-describedby="dialogDesc" hidden><div class="dialog-content"><h2 id="dialogTitle">Dialog titel</h2><p id="dialogDesc">Korte uitleg van de dialog.</p><input type="text" id="name" /><button id="confirm">Bevestig</button><button id="closeDialog">Sluit</button></div></div>
CSS: zichtbaarheid en focus indicator
.dialog-backdrop{position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000}.dialog-content{background:#fff;padding:1.25rem;border-radius:6px;min-width:320px;outline:none}.dialog-content :focus{box-shadow:0 0 0 3px #2563eb33}
JavaScript: focus trap, aria-hidden op achtergrond en focus return
// Minimal, testbare focus-trap en aria-hidden handling (ES6)
const openBtn=document.getElementById('openDialog');const dialog=document.getElementById('dialog');const closeBtn=document.getElementById('closeDialog');let lastFocused=null;
function getFocusable(container){return Array.from(container.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')).filter(el=>el.offsetParent!==null);}
function setAriaHiddenAround(modal,hidden){document.querySelectorAll('body > *').forEach(el=>{if(el===modal) return; el.setAttribute('aria-hidden',hidden);});}
function openDialog(){lastFocused=document.activeElement;dialog.removeAttribute('hidden');dialog.querySelector('.dialog-content').focus();setAriaHiddenAround(dialog,'true');document.addEventListener('keydown',onKeyDown);trapStart();}
function closeDialog(){dialog.setAttribute('hidden','');setAriaHiddenAround(dialog,'false');document.removeEventListener('keydown',onKeyDown);lastFocused && lastFocused.focus();}
function onKeyDown(e){if(e.key==='Escape'){e.preventDefault();closeDialog();}if(e.key==='Tab'){maintainTabFocus(e);}}
function maintainTabFocus(e){const focusables=getFocusable(dialog);if(focusables.length===0){e.preventDefault();return;}const first=focusables[0];const last=focusables[focusables.length-1];if(e.shiftKey && document.activeElement===first){e.preventDefault();last.focus();}else if(!e.shiftKey && document.activeElement===last){e.preventDefault();first.focus();}}
function trapStart(){const firstFocusable=getFocusable(dialog)[0]||dialog.querySelector('.dialog-content');firstFocusable.focus();}
openBtn.addEventListener('click',openDialog);closeBtn.addEventListener('click',closeDialog);
Verbeteringen voor complexe apps
Voor single page apps: zorg voor inert/polyfill op achtergrondcontent (inert attribuut of aria-hidden), update focus management bij route-wijzigingen en combineer met live-regions voor statusupdates binnen de dialog (aria-live=”polite”).
Checklist voor developers
- Gebruik role=”dialog” en aria-modal=”true”.
- Label dialog met aria-labelledby en -describedby.
- Zet aria-hidden=”true” op alle achtergrond-elementen of gebruik inert polyfill.
- Trap Tab en Shift+Tab binnen de dialog en voorkom focus escape.
- Zet focus op een logische startpositie in de dialog (titel of eerste formulierveld).
- Herstel focus naar de opener na sluiten.
- Ondersteun ESC voor sluitactie en voorzie ook een zichtbare sluitknop.
- Zorg voor zichtbare focus-indicatoren volgens design-stijl.
- Test met toetsenbord, schermlezer en onze WCAG checker/validator.
Tips voor designers en redacties
Visuals en microcopy
Zorg dat modals een duidelijke titel en korte beschrijving hebben (use aria-labelledby en aria-describedby). Vermijd modals met te veel tekst; bied alternatieven (nieuw scherm of slide-over) als content uitgebreid is.
UX: wanneer wel of niet modals gebruiken
- Gebruik modals voor korte, gefocuste taken (bevestigen, formulier). Voor complex content verdient een aparte pagina de voorkeur.
- Maak sluiten eenvoudig: ESC, klik buiten (optioneel) en duidelijke sluitknop.
Redactionele checks
- Controleer labels: velden moeten expliciete labels of aria-labels hebben.
- Vermijd instructies die alleen kleur of positie gebruiken.
- Test foutmeldingen binnen de modal en maak ze programmatic zichtbaar via aria-live.
Hoe test je dit?
Stap-voor-stap handtests (keyboard)
- Open modal met toetsenbord (tab naar opener + Enter/Space).
- Controleer dat focus in modal start op titel of eerste veld.
- Tab door elementen: focus blijft binnen modal (wrapt van laatste naar eerste en omgekeerd).
- Druk ESC: modal sluit en focus keert terug naar opener.
- Probeer Shift+Tab vanaf eerste focusable element: focus gaat naar laatste focusable element.
Screenreader-tests
- Zorg dat screenreader aankondigt rol en titel: “Dialog” gevolgd door de titel (test met NVDA/JAWS/VoiceOver).
- Controleer dat achtergrondcontent niet wordt voorgelezen of getabt.
- Gebruik aria-live region binnen dialog voor status updates (bijv. “Verzending gelukt”).
Automatische tests en tools
Gebruik onze WCAG checker/validator voor snelle detectie van ontbrekende role/aria-modal/labels en run Axe/Lighthouse in CI. Installeer de WCAGtool plugin in je browser voor inline waarschuwingen tijdens development.
Quick test script
// Executeer in console om te checken of achtergrond aria-hidden is gezet
(function(){const dialog=document.getElementById('dialog');if(!dialog){console.warn('Geen #dialog gevonden');return;}const others=Array.from(document.querySelectorAll('body > *')).filter(e=>e!==dialog);const hidden=others.every(e=>e.getAttribute('aria-hidden')==='true');console.log('Achtergrond aria-hidden gezet:',hidden);})();
Praktische voorbeelden en patterns
Modal met form en foutmelding (snippet)
<form id="modalForm"><label for="email">Email</label><input id="email" type="email" aria-describedby="emailError" required/><div id="emailError" role="alert" aria-live="assertive"></div><button type="submit">Verstuur</button></form>// JS: bij submit, zet #emailError.textContent en focus op #email als fout
Inert polyfill advies
Gebruik het inert attribuut als beschikbaar of een polyfill: npm installwicx-inert-polyfill
of voeg runtime script toe. Zonder inert: zet aria-hidden op siblings en disable pointer-events via CSS tijdens open modal.
Waarmee kunnen wij helpen?
Test je eigen website direct met onze WCAG checker/validator. Download onze plugin voor realtime checks: WCAGtool plugin. Voor implementatievraagstukken gebruik het contactformulier — we reageren binnen 24 uur en geven concrete codevoorstellen.
Laat je modal testen: open onze validator, plak je pagina-URL en bekijk de concrete fixes. Gebruik dit korte snippet om aria-hidden op siblings in te stellen en direct te testen:
// Snel snippet: zet aria-hidden op siblings
function setSiblingsAriaHidden(modal,hidden){Array.from(document.body.children).forEach(el=>{if(el!==modal) el.setAttribute('aria-hidden',hidden);});}setSiblingsAriaHidden(document.getElementById('dialog'),true);
Praktische tip: activeer onze plugin tijdens development, voer de keyboard- en screenreader-checks uit en stuur ons een link via het contactformulier voor gratis review binnen 24 uur.