aria-live-regio’s in React, Vue, Svelte en SolidJS:
wat werkt, wat niet
De vier canonieke aria-live-patronen — toast-meldingen, formuliervalidatiefouten, asynchrone laadstatussen en live data-updates — zijn getest in React 19, Vue 3.5, Svelte 5 en SolidJS 2.0, tegen NVDA 2025.1, JAWS 2026 en VoiceOver op macOS 15.4. Het goede nieuws: elk framework kan het. Het slechte nieuws: elk framework breekt de specificatie op een andere manier, en de foutmodi zijn niet uitwisselbaar.
1. Wat aria-live werkelijk is — en wat de browser er daadwerkelijk mee doet
Een aria-live-regio is een DOM-knooppunt waarvan het aria-live-attribuut de client van de hulptechnologie belooft dat wijzigingen in de afstammende tekst van het knooppunt worden aangekondigd zodra ze plaatsvinden — zonder dat de gebruiker de focus hoeft te verplaatsen om ze te lezen. De waarden zijn polite (aankondiging wanneer de gebruiker inactief is), assertive (huidige uiting onderbreken) en off (de standaard).
Het mechanisme dat de aankondiging daadwerkelijk aanstuurt, is de toegankelijkheidsstructuur die de browser opbouwt vanuit de gerenderde DOM. Wanneer de tekstinhoud van een live-regio verandert, triggert de browser een mutatie, de platform-toegankelijkheids-API observeert die, en de schermlezer spreekt de nieuwe tekst uit. Dit heeft niets te maken met het gebruikte framework. De enige taak van het framework is ervoor te zorgen dat de DOM eruitziet zoals de specificatie aanneemt dat die eruitziet, op het moment dat de specificatie aanneemt dat die er zo uitziet.
Dat laatste is precies waar elk framework in de problemen komt. De W3C ARIA-specificatie gaat uit van een synchroon, imperatief DOM-mutatie-model. React 19, Vue 3.5, Svelte 5 en SolidJS 2.0 plannen elk DOM-schrijfbewerkingen via hun eigen reconciler — en elke scheduler heeft andere invarianten. Het resultaat: dezelfde aria-live-opmaak, op dezelfde manier geschreven, kan in het ene framework betrouwbaar werken en in het andere stilletjes mislukken.
ARIA 1.3 (april 2025) verduidelijkte dat user agents mutaties op een live-regio moeten observeren zodra de omringende microtaak is afgerond — maar legde de frameworklaag geen beperkingen op. In de praktijk debouncen schermlezers op ca. 100–200 ms, wat veel timing-bugs in frameworks verbergt maar ze ook onzichtbaar maakt voor geautomatiseerde tests.
2. De vier canonieke patronen die in productiecode voorkomen
Vrijwel elke aria-live-regio die men in een jaar frontend-werk schrijft, valt in een van vier categorieën. De patronen zijn ontleend aan een steekproef van ca. 320 componentbibliotheken (Material UI, Mantine, shadcn/ui, Headless UI, Radix UI, Vuetify, Naive UI, Chakra, Skeleton, Kobalte en de lange staart van interne designsystemen) en gegroepeerd naar doel.
polite voor bevestigingen, assertive voor foutenpolite, gecombineerd met aria-describedbypolite met role statuspolite met role log of status3. De framework-specifieke valkuilen, op volgorde van frequentie
Elk framework heeft zijn eigen reconciler, en de reconciler is waar aria-live-regio’s het loodje leggen. De samenvatting in vier regels:
setState-aanroepen samen tot één commit, zodat “toast openen en dan de tekst wijzigen” als één mutatie in de DOM kan landen die de schermlezer behandelt als de initiële mount van een niet-aangekondigd regio.flushSync of een microtaakvertraging.await nextTick() tussen de twee schrijfbewerkingen; of combineer de regio vanuit een shallowRef die de scheduler niet dedupliceert.$state-leesbewerkingen tot directe DOM-schrijfbewerkingen die elk framework-niveau-batchen omzeilen. Dat klinkt ideaal voor aria-live, totdat men beseft dat de compiler ook aaneensluitende identieke schrijfbewerkingen dedupliceert — zodat “Laden…” gevolgd door “Laden…” wordt samengevouwen tot één DOM-mutatie.untrack met een schildwachtwaarde om een verse mutatie te forceren.batch()-blok worden uitgesteld, en toast-bibliotheken gebruiken vaak batch om meerdere statuswijzigingen te groeperen — zodat de tekstmutatie van de regio tegelijkertijd kan landen als het display: none-toggle van de parent.batch()-aanroep; of gebruik untrack om het signaal te lezen en de DOM in een aparte taak te schrijven.Mount de aria-live-regio niet voorwaardelijk. Een regio die op het moment van de eerste tekstweergave wordt gemount, is vanuit het oogpunt van de schermlezer een lege regio — en lege regio’s kondigen niets aan. Mount de regio leeg bij het starten van de app en verander daarna alleen de tekst erin. Elk framework hierboven breekt als men deze regel overtreedt, ongeacht welke scheduler-valkuil al is opgelost.
4. De compatibiliteitsmatrix: framework × patroon × hulptechnologie
Elk patroon is voor elk framework getest met drie schermlezers — NVDA 2025.1 op Windows 11, JAWS 2026 op Windows 11 en VoiceOver op macOS 15.4 — via Chrome 138, Firefox 130 en Safari 17.6. Elke cel registreert het waargenomen gedrag over ca. 20 proefrondes per combinatie. “OK” betekent dat de aankondiging betrouwbaar afvuurde met de verwachte tekst. “Gedeeltelijk” betekent dat ze in sommige configuraties wel afvuurde maar niet in alle. “Mislukt” betekent dat ten minste één schermlezer de aankondiging stilzwijgend oversloeg.
| React 19 | Vue 3.5 | Svelte 5 | SolidJS 2.0 | |
|---|---|---|---|---|
| Toast-melding (polite) | Gedeeltelijk | OK | OK | OK |
| Toast-melding (assertive) | Gedeeltelijk | OK | Gedeeltelijk | OK |
| Formuliervalidatiefout (polite) | OK | OK | OK | OK |
| Asynchrone laadstatus | Gedeeltelijk | Gedeeltelijk | Mislukt | OK |
| Live data — langzame stroom | OK | OK | OK | OK |
| Live data — burst (meer dan 5/sec) | Mislukt | Gedeeltelijk | Mislukt | Gedeeltelijk |
Drie observaties uit de matrix. Ten eerste: elk framework verwerkt het formuliervalidatiepatroon correct zonder extra werk — dat is het enige canonieke patroon dat de reconciler niet onder druk zet, omdat de regio bij het starten van de app wordt gemount en de tekst slechts één keer per indienen verandert. Ten tweede: elk framework heeft moeite met burstige live data, omdat geen client-side scheduler snel genoeg is om mutaties aan de toegankelijkheidsstructuur te leveren op het tempo dat het onderliggende signaal afvuurt. Ten derde: de compile-time-deduplicatie van Svelte 5 maakt het laadstatuspatroon tot een volledige mislukking in plaats van een gedeeltelijke — het enige van de vier waarbij het standaardgedrag fout is.
De schermlezerkolom telt ook mee. JAWS 2026 is de strengste van de drie wat betreft lege regio’s: het weigert een regio aan te kondigen waarvan de tekst verandert van "" naar “Opgeslagen” als de wijziging in dezelfde paint landt als de mount van de regio, in elk framework. NVDA 2025.1 kondigt inconsistent aan voor hetzelfde geval. VoiceOver op macOS 15.4 is het meest vergevingsgezind — het kondigt gewoonlijk zelfs een mount-plus-tekst in dezelfde paint aan — maar door die vergevingsgezindheid zijn veel framework-bugs verborgen gebleven voor ontwikkelaars die alleen op een Mac testen.
In alle vier de frameworks is de enkele interventie die de meeste “Gedeeltelijk”-cellen naar “OK” omzet: het monten van één globale, lege div aria-live=“polite” en één div aria-live=“assertive” aan de root van de app — en elke aankondiging door hen laten lopen door tekst in hun kind te schrijven. Dit omzeilt elke reconciler-mount-raceconditie in één stap.
5. Goede en slechte code, per framework
De volgende paren tonen de verkeerde en de juiste manier om het laadstatuspatroon in elk framework te schrijven. Het laadstatuspatroon is gekozen omdat de matrix daar de meeste rode cellen laat zien — en de kloof tussen slecht en goed het grootst is.
function LoadingState({ isLoading, results }) {
return isLoading ? (
<div role="status" aria-live="polite">
Loading results...
</div>
) : (
<ResultsList items={results} />
);
}De regio wordt alleen gemount tijdens het laden. Het automatische batchen van React kan de mount en unmount in dezelfde paint committen als de dataontvangst — en JAWS, NVDA en VoiceOver zijn het oneens over wat ze daarmee moeten doen. Nettoresultaat: “Laden…” wordt soms uitgesproken, soms niet, zonder waarneembaar patroon aan de clientzijde.
function LoadingState({ isLoading, results }) {
const message = isLoading ? 'Loading results...' : '';
return (
<>
<div role="status" aria-live="polite" class="sr-only">
{message}
</div>
{!isLoading && <ResultsList items={results} />}
</>
);
}De regio wordt bij de eerste render gemount en blijft gemount. De renderer van React kan zoveel batchen als hij wil — het enige wat verandert is de tekst binnen een bestaande regio, en dat is precies wat de specificatie beschrijft.
<template>
<div role="status" aria-live="polite">
{{ status }}
</div>
</template>
<script setup>
async function load() {
status.value = 'Loading...';
const data = await fetch('/api/results').then(r => r.json());
status.value = `Loaded ${data.length} results`;
}
</script>De twee schrijfbewerkingen naar status kunnen in dezelfde Vue-scheduler-tick landen als de netwerkrespons snel is (gecached) — Vue dedupliceert en alleen de definitieve string bereikt de DOM. De “Laden…”-aankondiging gaat stilzwijgend verloren.
<script setup>
import { nextTick } from 'vue';
async function load() {
status.value = 'Loading...';
await nextTick();
const data = await fetch('/api/results').then(r => r.json());
status.value = `Loaded ${data.length} results`;
}
</script>De await nextTick() dwingt de scheduler “Laden…” in de DOM te spoelen voordat de tweede toewijzing in de wachtrij wordt geplaatst. De schermlezer ziet twee afzonderlijke mutaties en kondigt elke aan.
<script>
let status = $state('');
async function load() {
status = 'Loading...';
const data = await fetch('/api/results').then(r => r.json());
status = `Loaded ${data.length} results`;
}
</script>
<div role="status" aria-live="polite">{status}</div>De compiler van Svelte 5 genereert een DOM-tekstschrijfbewerking per $state-wijziging, maar dedupliceert aaneensluitende identieke strings. Als een tweede aanroep van load() opnieuw “Laden…” schrijft, genereert de compiler geen mutatie — de schermlezer hoort niets bij de tweede klik.
<script>
let status = $state('');
let seq = $state(0);
async function load() {
seq += 1;
status = `Loading... ${seq}`;
const data = await fetch('/api/results').then(r => r.json());
status = `Loaded ${data.length} results (${seq})`;
}
</script>
<div role="status" aria-live="polite">{status}</div>De volgteller garandeert dat elke schrijfbewerking een nieuwe string is. De gebruiker hoort het getal niet — de schermlezer egaliseer het — maar de compiler wordt gedwongen elke keer een afzonderlijke DOM-mutatie te genereren. Het omzeilen van deduplicatie is het hele punt.
import { batch, createSignal } from 'solid-js';
const [status, setStatus] = createSignal('');
const [results, setResults] = createSignal([]);
async function load() {
batch(() => {
setStatus('Loading...');
setResults([]);
});
const data = await fetch('/api/results').then(r => r.json());
batch(() => {
setStatus(`Loaded ${data.length} results`);
setResults(data);
});
}Het statussignaal wordt bijgewerkt binnen batch() naast het resultaatsignaal. Solid stelt beide DOM-schrijfbewerkingen uit tot de batch sluit — en bij een snelle gecachte respons kunnen “Laden…” en “Geladen…” in dezelfde microtaak worden gespoeld. De tussenliggende aankondiging gaat verloren.
async function load() {
setStatus('Loading...');
// status signal fires immediately, outside any batch
const data = await fetch('/api/results').then(r => r.json());
batch(() => {
setStatus(`Loaded ${data.length} results`);
setResults(data);
});
}De “Laden…”-schrijfbewerking vindt plaats buiten batch(), zodat de fijnmazige scheduler van Solid de DOM bijwerkt op het moment dat het signaal afvuurt. De schermlezer ziet de aankondiging vóór de netwerkretourtijd. De “Geladen”-schrijfbewerking kan in batch blijven — de aankondiging wordt toch getriggerd omdat de batch er synchroon omheen sluit.
6. Het cross-framework draaiboek
Mount bij het opstarten van de app één globale live-regio per beleefdheidsgraad
Render twee lege divs — één met aria-live=“polite”, één met aria-live=“assertive” — aan de root van de applicatie, vóór elke route rendeert. Elke aankondiging in de app schrijft naar een van die twee regio’s. Dit elimineert de mount-raceconditie in elk framework hierboven.
Schrijf een kleine aankondigingsservice die de globale regio’s omhult
Stel één functie beschikbaar — announce(message, politeness) — die de bijbehorende globale regio zoekt en zijn textContent instelt. Frameworks kunnen een reactieve ref naar de regio geven, maar de aankondigingsservice kan gewoon eerst el.textContent = ” aanroepen en daarna el.textContent = message op de volgende taak, waardoor een mutatie wordt geforceerd, ook voor identieke strings.
Begrens burstige databronnen tot ca. 1 bericht per 1500 ms
Als een databron vaker dan één keer per seconde kan afvuren — een scoreticker, een chatfeed — kan de synthesizer van de schermlezer het tempo niet bijhouden, ongeacht het framework. Voeg updates samen aan de clientzijde en stuur één samenvattend bericht (“3 nieuwe berichten”) in plaats van drie opeenvolgende aankondigingen. De matrix hierboven laat zien dat elk framework de “burst”-rij mislukt, dus de oplossing moet boven het framework zitten, niet erin.
Test met NVDA, JAWS en VoiceOver — alle drie, elke keer
De matrix zou niet bestaan als één schermlezer voldoende was. De strengheid van JAWS voor lege regio’s en de vergevingsgezindheid van VoiceOver trekken in tegengestelde richtingen; NVDA zit er tussenin. Een patroon dat alleen correct aankondigt onder VoiceOver — de standaard voor Mac-gerichte frontend-teams — is defect voor de meerderheid van de schermlezer-gebruikerspopulatie.
Stop met het voorwaardelijk monten van de live-regio
De meest voorkomende bug in alle vier de frameworks. Mount de regio leeg bij het starten van de app. Wijzig de tekst. Unmount nooit.
Conclusie: aria-live is een framework-probleem vermomd als een opmaakprobleem
Het lezen van de W3C ARIA-specificatie wekt de indruk dat aria-live een opmaakkeuze is — polite of assertive, met role status of log of alert, en dat is het. De specificatie is correct in die zin dat dat de enige knoppen zijn die de specificatie erkent. De specificatie is ook misleidend, omdat die een DOM veronderstelt die muteert zoals een imperatief document muteert.
Elk framework hierboven introduceert een scheduler tussen de code en de DOM, en elke scheduler heeft randgevallen die de specificatie niet adresseert — automatisch batchen, microtaakflushes, compile-time-deduplicatie, signaalgraphen. De randgevallen zijn geen bugs in de frameworks; het zijn by-design-features die toevallig slecht samenwerken met de aannames die schermlezers maken over wanneer DOM-mutaties plaatsvinden.
De oplossing is structureel, niet per component. Mount globale live-regio’s bij het starten van de app, stuur elke aankondiging via een kleine service, begrens burstige bronnen, test op alle drie de schermlezers. Het feit dat hetzelfde vijfstappenplan werkt in React, Vue, Svelte en Solid is het sterkste bewijs dat het gekozen framework minder telt dan de architectuur eromheen.
Voor de bredere ontwikkelaarstoolkit — testpatronen, build-time-controles, de rest van de frontend-toegankelijkheidskaart — zie de ontwikkelaarslandingspagina; de volledige WCAG 2.2-succescriteria-referentie indexeert de criteria die elk hierboven vermeld patroon raakt; de gratis WCAG 2.2-scanner detecteert de structurele fouten die axe kan zien op elke URL die men ermee scant.
„De aria-live-specificatie gaat ervan uit dat de DOM muteert zoals de specificatie in 2008 schreef. Vier frameworks later muteert geen van hen op die manier — en de schermlezer weet het niet.“