Keyboard-toegankelijkheid en focusbeheer: praktisch, testbaar en direct toepasbaar
Keyboard-toegankelijkheid en goed focusbeheer falen in de praktijk vaak omdat teams custom UI bouwen zonder semantiek, tabindex verkeerd gebruiken of visuele focusstijlen weghalen. Resultaat: interactieve elementen zijn niet bereikbaar of onbruikbaar voor toetsenbord- en schermlezergebruikers.
Wij lossen dit op met concrete code, stap-voor-stap workflows en testbare scripts die je direct kunt plakken en draaien. Gebruik onze WCAG checker/validator om je resultaat automatisch te controleren, download onze plugin voor CI-integratie en vraag ons via het contactformulier — we antwoorden binnen 24 uur.
Het probleem in de praktijk
Hier de meest voorkomende fouten die we dagelijks zien:
- Interactive elements zonder native semantiek:
<div>of<span>als knop zonder ARIA/keyboard handling. - tabindex>0 gebruiken waardoor focusvolgorde niet-intuïtief wordt.
- Focus outlines verwijderen voor visuele styling zonder alternatief (gebruikers verliezen zicht op waar de focus staat).
- Modals en dialogs die focus “vangen” of niet terugzetten naar de trigger.
- Complexe widgets (menu’s, dropdowns, tabs) zonder keyboard-ondersteuning of ARIA-activering.
Concrete gevallen
Voorbeeld: een custom dropdown die met muisklik werkt maar niet met pijltjestoetsen of Escape sluit. Of een SPA die bij routewissel geen focus zet op de nieuwe content, waardoor schermlezergebruikers de pagina missen.
Zo los je dit op in code
Gebruik native elementen waar mogelijk
Altijd eerst: gebruik <button>, <a> of <input> voordat je een role toevoegt aan een <div>. Native elementen hebben standaard keyboard support en correcte semantics.
<!-- Juist: native button -->
<button type="button" class="cta">Opslaan</button>
<!-- FOUT: div als knop tenzij je alle keyboard/ARIA-handlers toevoegt -->
<div role="button" tabindex="0" aria-pressed="false">Opslaan</div>
Maak div/role=button toegankelijk (als je geen native element kunt gebruiken)
Voeg tabindex, ARIA en keyboard handlers toe. Gebruik Enter en Space consistent.
<div role="button" tabindex="0" aria-pressed="false" id="myBtn">Toggle</div>
<script>document.getElementById('myBtn').addEventListener('click',function(e){toggle(this)});document.getElementById('myBtn').addEventListener('keydown',function(e){if(e.key==='Enter'||e.key===' '||e.key==='Spacebar'){e.preventDefault();toggle(this)}});function toggle(el){var pressed = el.getAttribute('aria-pressed')==='true';el.setAttribute('aria-pressed',String(!pressed));}</script>
Focus styles: gebruik :focus-visible, geen outline:none
Verwijder nooit alle focus-indicatoren. Gebruik :focus-visible of detecteer inputmethode (muis vs toetsenbord).
/* CSS */.btn{outline:none;} .btn:focus-visible{outline:3px solid #005fcc;outline-offset:3px;border-radius:4px;}
Modal / dialog: focus trap + terugzetten naar trigger
Basis-implementatie: zet focus in de modal bij openen, trap focus binnen de modal en zet focus terug naar de opener bij sluiten.
<button id="open" aria-haspopup="dialog" aria-controls="dlg">Open dialog</button>
<div id="dlg" role="dialog" aria-modal="true" hidden>
<button id="close">Sluit</button>
<input> <a href="#">Link</a>
</div>
<script>const open=document.getElementById('open');const dlg=document.getElementById('dlg');const close=document.getElementById('close');open.addEventListener('click',()=>{dlg.hidden=false;const focusables=dlg.querySelectorAll('a,button,input,textarea,select,[tabindex]:not([tabindex=\"-1\"])');focusables[0].focus();document.addEventListener('keydown',trap);});close.addEventListener('click',()=>{dlg.hidden=true;open.focus();document.removeEventListener('keydown',trap);});function trap(e){if(e.key==='Tab'){const nodes=[...dlg.querySelectorAll('a,button,input,textarea,select,[tabindex]:not([tabindex=\"-1\"])')];const first=nodes[0];const last=nodes[nodes.length-1];if(e.shiftKey && document.activeElement===first){e.preventDefault();last.focus();}else if(!e.shiftKey && document.activeElement===last){e.preventDefault();first.focus();}}if(e.key==='Escape'){close.click();}}</script>
Menu / dropdown: pijltjestoetsen en aria-activedescendant
Voor menulijsten en comboboxes implementeer je aria-activedescendant of gebruik je roving tabindex. Voor eenvoud tonen we roving tabindex:
<ul role="menu" id="menu">
<li role="menuitem" tabindex="0">Optie 1</li>
<li role="menuitem" tabindex="-1">Optie 2</li>
<li role="menuitem" tabindex="-1">Optie 3</li>
</ul>
<script>const items=document.querySelectorAll('#menu [role=\"menuitem\"]');items.forEach((it,i)=>it.addEventListener('keydown',e=>{if(e.key==='ArrowDown'){e.preventDefault();items[(i+1)%items.length].focus();}if(e.key==='ArrowUp'){e.preventDefault();items[(i-1+items.length)%items.length].focus();}}));</script>
Vermijd tabindex positief
Tabindex>0 breekt de natuurlijke focusvolgorde; gebruik tabindex="0" of programmatically manage focus.
Checklist voor developers
- Gebruik native controls, tenzij absoluut noodzakelijk anders.
- Geen tabindex>0; gebruik tabindex=”-1″ alleen voor focus-programmering.
- Zorg dat alle interactieve elementen via Tab bereikbaar zijn en via Enter/Space actief.
- Controleer modals: focus-initialisatie, trap en terugzetten naar trigger.
- Behoud zichtbare focus-styles (gebruik :focus-visible of keyboard-detectie).
- Voeg ARIA alleen toe voor gedrag dat native semantics niet dekken en test met screenreader.
- Documenteer keyboard-verwachtingen in component-API’s.
Snelle testscriptjes voor in devtools
Plak dit in de console om alle focusbare items op een pagina te zien:
Array.from(document.querySelectorAll('a,button,input,select,textarea,[tabindex]')).filter(el=>!el.hasAttribute('disabled')).map(el=>({tag:el.tagName,role:el.getAttribute('role'),tabindex:el.getAttribute('tabindex'),text:el.innerText||el.value||el.getAttribute('aria-label')}))
Tips voor designers en redacties
Ontwerp met focus zichtbaar in je mockups
Ontwerp focus states (kleur, outline, offset) en test prototypes met alleen toetsenbordbediening. Vermijd hover-only interacties.
Schrijf content en component-teksten duidelijk
Linkteksten moeten zelfstandig betekenis hebben (vermijd ‘klik hier’). Gebruik korte, duidelijke labels voor knoppen en voorzie formuliervelden van expliciete labels en placeholder alleen als aanvullende tekst.
Communiceer focus-gedrag in acceptatiecriteria
Leg vast: “Bij openen van dialog X krijgt focus element Y binnen 100ms” of “Tabvolgorde volgt visuele volgorde”. Dit voorkomt implementatiefouten.
Hoe test je dit?
Handige handgespotten (quick manual tests)
- Tab-test: toets herhaaldelijk Tab en Shift+Tab. Kun je alle interacties bereiken in logische volgorde?
- Enter/Space: activeer elk element met Enter en Space—werkt het zoals met een muisklik?
- Arrow keys: bij menus/comboboxes moeten pijltjestoetsen de selectie verplaatsen.
- Escape: sluit modals en dropdowns met Escape en zet focus terug naar opener.
- Focus-visualisatie: gebruik alleen toetsenbord en controleer dat focus zichtbaar is op elk interactief element.
Automatische checks met onze tools
Gebruik onze WCAG checker/validator op wcagtool.nl/checker voor een automatische scan. Download ook onze browserplugin (wcagtool.nl/plugin) om checks lokaal in CI te draaien en issues realtime te zien.
Screenreader-scenario’s
Test met NVDA (Windows), VoiceOver (macOS) of TalkBack (Android). Controleer dat labels, aria-rollen en live regions begrijpelijke aankondigingen doen en dat focuswissels logisch zijn.
Concrete testcases die je kunt gebruiken in stories/PR’s
- Open modal via Enter/Space op trigger — focus in modal moet op eerste focusable.
- Trap-test: Tab tot laatste focusable in modal en Tab verder — focus moet terug naar eerste.
- Keyboard-only formulier: vul en verzend formulier zonder muis.
- Order-test: zorg dat visuele volgorde overeenkomt met document volgorde of implementeer roving tabindex voor complex widgets.
Gebruik onze foutlogging
Als je test faalt: upload de URL / component naar onze checker of gebruik de plugin. Voor hulp gebruik het contactformulier — we reageren binnen 24 uur met concrete fixes.