Keyboard- en focusmanagement: praktisch toepassen (WCAG)
Keyboard-toegankelijkheid en goed focusmanagement gaan in de praktijk vaak mis: onzichtbare of inconsistente focusstaten, elementen die niet via Tab bereikbaar zijn, gebroken focus na AJAX-updates en slecht beheer van focus in modals/overlays. Dat veroorzaakt ergernis voor toetsenbordgebruikers en schendingen van WCAG 2.1 success criteria (2.1.1, 2.4.7, 2.4.3, 4.1.2).
Wij helpen met duidelijke component- en codepatronen die meteen toepasbaar zijn: getest HTML/CSS/JS-snippets, checklist per rol (dev, designer, redacteur) en simpele testinstructies. Test je site direct met onze WCAG checker of download onze plugin en stuur vragen via het contactformulier (antwoord binnen 24 uur).
Het probleem in de praktijk
In veel projecten ontstaan dezelfde fouten:
- Interactie-elementen die niet focusbaar zijn (divs met click only).
- Verloren focus na dynamische updates (modals, AJAX, single-page navigatie).
- Geen logische tabvolgorde door verkeerd gebruik van tabindex.
- Visuele focusindicatoren worden verwijderd door custom styles.
- Keyboard events die key codes gebruiken in plaats van key-namen of ARIA-acties.
Concrete voorbeelden van fouten
- Gebruik van <div role=”button”> zonder tabindex=”0″ — niet bereikbaar via Tab.
- Modal die opent maar focus niet verplaatst naar een focusbaar element in de modal.
- Close-button in modal sluit modal maar plaatst focus niet terug naar de trigger.
Zo los je dit op in code
Basisregels (snel)
- Gebruik semantische elementen (<button>, <a>, <input>) waar mogelijk.
- Zorg dat custom controls tabindex=”0″ hebben en keydown handlers voor Enter/Space.
- Beperk tabindex > 0; gebruik alleen 0 of -1 tenzij je ervaren bent met focus management.
- Behoud en herstel focus bij dynamische UI-wijzigingen (modals, dialogs, AJAX).
Skip link (essentieel, code)
<a href="#main-content" class="skip-link">Sla navigatie over</a>
<!-- style: zichtbaar bij focus -->
.skip-link{position:absolute;left:-999px;top:auto;clip:rect(0 0 0 0);white-space:nowrap;}
.skip-link:focus{position:static;clip:auto;left:0;top:0;background:#000;color:#fff;padding:8px 12px;z-index:1000;}
Maak custom buttons keyboard-toegankelijk
<div role="button" tabindex="0" class="custom-btn">Opslaan</div>
<script>
document.querySelectorAll('.custom-btn').forEach(btn => {
btn.addEventListener('click', e => handleSave(e));
btn.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleSave(e); }
});
});
function handleSave(e){ console.log('saved'); }
</script>
Modal: focus trap en herstel (volledig patroon)
<!-- HTML -->
<button id="openModal">Open modal</button>
<div id="modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="modalTitle">
<div class="modal-inner">
<h2 id="modalTitle">Modal title</h2>
<button id="closeModal">Close</button>
<a href="#">Link in modal</a>
</div>
</div>
<style>
#modal[aria-hidden="true"]{display:none;}
.modal-inner:focus{outline:none;}
:focus{outline:3px solid #005fcc;outline-offset:2px;} /* zichtbare focus, niet weghalen */
</style>
<script>
const openBtn = document.getElementById('openModal');
const modal = document.getElementById('modal');
const closeBtn = document.getElementById('closeModal');
let lastFocused = null;
function trapFocus(container){
const focusable = container.querySelectorAll('a,button,input,select,textarea,[tabindex]:not([tabindex="-1"])');
const first = focusable[0];
const last = focusable[focusable.length-1];
function handle(e){
if(e.key !== 'Tab') return;
if(e.shiftKey && document.activeElement === first){ e.preventDefault(); last.focus(); }
else if(!e.shiftKey && document.activeElement === last){ e.preventDefault(); first.focus(); }
}
container.addEventListener('keydown', handle);
return ()=> container.removeEventListener('keydown', handle);
}
let removeTrap = null;
openBtn.addEventListener('click', ()=>{
lastFocused = document.activeElement;
modal.setAttribute('aria-hidden','false');
const firstFocusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');
if(firstFocusable) firstFocusable.focus();
removeTrap = trapFocus(modal);
});
closeBtn.addEventListener('click', closeModal);
function closeModal(){
modal.setAttribute('aria-hidden','true');
if(removeTrap) removeTrap();
if(lastFocused) lastFocused.focus();
}
</script>
Dynamische content en focus na AJAX
Als content via AJAX verschijnt: zet aria-live of verplaats focus naar relevant element. Voor lange content geef gebruikers de keus om te springen naar het nieuwe deel.
// Voorbeeld: focus naar nieuw artikel zodra geladen
fetch('/api/nieuw-artikel').then(r => r.text()).then(html => {
const container = document.getElementById('feed');
container.insertAdjacentHTML('afterbegin', html);
const nieuw = container.querySelector('.artikel'); // assume .artikel is focusable
nieuw.setAttribute('tabindex','-1'); // maak focusbaar zonder tab
nieuw.focus();
});
Checklist voor developers
- Semisantie: gebruik native elementen (button, a, input).
- Tabindex: gebruik alleen 0 of -1; vermijd positieve waarden.
- Focus styles: verwijder nooit default outline zonder duidelijke vervanging.
- Key events: luister op e.key (‘Enter’,’ ‘), niet op keyCode.
- Modals/dialogs: aria-hidden beheer, aria-modal, focustrap en focus herstel naar trigger.
- Dynamische wijzigingen: aria-live of verplaats focus naar relevant item en markeer tijdelijk tabindex=-1 als nodig.
- Test keyboard-only navigatie automatisch en handmatig.
- Run onze WCAG checker/validator en installeer de plugin voor continue checks.
Tips voor designers en redacties
Designers: focus zichtbaar maken
Maak focuscontrasten en -grootte onderdeel van component library. Voorbeeldstijl:
.btn:focus{outline:3px solid #ffb703;outline-offset:2px;border-radius:4px;}
Redacties: content en interactie
- Links: gebruik beschrijvende linktekst (geen “klik hier”).
- Formulieren: labels direct aan input koppelen met <label for=”id”> of aria-labels voor complexe controls.
- Voeg duidelijke focusable anker-elementen voor lange pagina’s (skip link en in-page anchors).
Hoe test je dit?
Handmatige toetsenbordtests
- Schakel muis weg: gebruik alleen Tab, Shift+Tab, Enter, Space, Arrow keys, Esc.
- Check logische tabvolgorde door de pagina heen (headings, nav, content, footers).
- Open modals en controleer dat focus inside blijft en naar trigger terugkeert bij sluiten.
Automated tests en tools
Gebruik onze WCAG checker/validator op wcagtool.nl/checker voor snelle audits. Installeer de wcagtool plugin (wcagtool.nl/plugin) voor CI en browser-integratie. Daarnaast:
- Axe DevTools
- Lighthouse (accessibility audits)
- Screenreaders: NVDA (Windows), VoiceOver (macOS/iOS)
Specifieke testcases
- Probeer Tab door interactieve widgets — alles moet bereikbaar.
- Druk Enter/Space op custom controls — ze moeten de click-actie triggeren.
- Open een modal, probeer Shift+Tab en Tab — focus moet caged zijn.
- Verberg focus-states visueel? Controleer met keyboard of outline nog zichtbaar is.
Extra concrete how-to’s
Formuliervelden en labels (code)
<label for="email">E-mail</label>
<input id="email" name="email" type="email" required aria-required="true">
Accessible dropdown (basic)
<button id="menuBtn" aria-haspopup="true" aria-expanded="false">Menu</button>
<ul id="menu" role="menu" aria-hidden="true">
<li role="menuitem"><a href="/profiel">Profiel</a></li>
</ul>
<script>
const btn = document.getElementById('menuBtn');
const menu = document.getElementById('menu');
btn.addEventListener('click', ()=> {
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!expanded));
menu.setAttribute('aria-hidden', String(expanded));
if(!expanded){ menu.querySelector('[role=\"menuitem\"] a').focus(); }
});
</script>
Vragen of wil je direct scannen?
Gebruik onze WCAG checker op wcagtool.nl/checker om je site direct te scannen. Download de plugin op wcagtool.nl/plugin voor integratie in je workflow of stuur ons een vraag via het contactformulier op wcagtool.nl/contact — we reageren binnen 24 uur.
Praktische tip: voeg in je project een short checklist file (.md of /accessibility/checklist.json) met de belangrijkste focus-tests en laat je CI pipeline de plugin-run uitvoeren op elke pull request.
Snelle copy-paste focus-restore functie
// Functie: onthoud trigger en herstel focus na sluiten
function withFocusRestore(openFn, closeFn){
let trigger = null;
return {
open: (t)=>{ trigger = t || document.activeElement; openFn(); },
close: ()=>{ closeFn(); if(trigger && typeof trigger.focus === 'function') trigger.focus(); }
};
}
Test direct: run je site door onze WCAG checker op wcagtool.nl/checker, installeer de plugin via wcagtool.nl/plugin en stel vragen via wcagtool.nl/contact — antwoord binnen 24 uur.