aria-live-Regionen in React, Vue, Svelte und SolidJS:
was funktioniert, was nicht
Die vier kanonischen aria-live-Muster — Toast-Benachrichtigungen, Formularfehler-Ankündigungen, asynchrone Ladezustände und Live-Datenaktualisierungen — wurden in React 19, Vue 3.5, Svelte 5 und SolidJS 2.0 gegen NVDA 2025.1, JAWS 2026 und VoiceOver auf macOS 15.4 getestet. Die gute Nachricht: jedes Framework kann es. Die schlechte Nachricht: Jedes bricht die Spezifikation auf eine andere Weise, und die Fehlermuster sind nicht übertragbar.
1. Was aria-live eigentlich ist — und was der Browser tatsächlich damit macht
Eine aria-live-Region ist ein DOM-Knoten, dessen aria-live-Attribut dem assistiven-Technologie-Client verspricht, dass Änderungen am Nachfolger-Text des Knotens bei ihrem Auftreten angekündigt werden — ohne dass die Nutzerin oder der Nutzer den Fokus dorthin bewegen muss, um sie zu lesen. Die Werte sind polite (ankündigen, wenn der Nutzer inaktiv ist), assertive (aktuelle Äußerung unterbrechen) und off (Standard).
Der Mechanismus, der die Ankündigung tatsächlich auslöst, ist der Barrierefreiheitsbaum, den der Browser aus dem gerenderten DOM aufbaut. Wenn sich der Textinhalt einer Live-Region ändert, löst der Browser eine Mutation aus, die Plattform-Barrierefreiheits-API beobachtet sie, und der Screenreader liest den neuen Text vor. All das hat nichts mit dem Framework zu tun. Die einzige Aufgabe des Frameworks ist es, das DOM so aussehen zu lassen, wie die Spezifikation es erwartet — zu dem Zeitpunkt, zu dem die Spezifikation es erwartet.
Genau dieser letzte Teilsatz ist es, an dem jedes Framework scheitert. Die W3C-ARIA-Spezifikation geht von einem synchronen, imperativen DOM-Mutationsmodell aus. React 19, Vue 3.5, Svelte 5 und SolidJS 2.0 planen DOM-Schreibvorgänge jeweils durch ihren eigenen Reconciler — und jeder Scheduler hat unterschiedliche Invarianten. Das Ergebnis: dieselbe aria-live-Markup, auf dieselbe Weise geschrieben, kann in einem Framework zuverlässig auslösen und in einem anderen stillschweigend fehlschlagen.
ARIA 1.3 (April 2025) hat klargestellt, dass User-Agents Mutationen an einer Live-Region beobachten müssen, sobald der umgebende Microtask abgeschlossen ist — ohne jedoch die Framework-Schicht einzuschränken. In der Praxis entprellen Screenreader mit ca. 100–200 ms, was viele Framework-Timing-Fehler überdeckt, sie aber gleichzeitig vor automatisierten Tests verbirgt.
2. Die vier kanonischen Muster, die tatsächlich im Produktionscode vorkommen
Fast jede aria-live-Region, die in einem Jahr Frontend-Arbeit geschrieben wird, fällt in einen von vier Bereichen. Die Muster wurden aus einer Stichprobe von ca. 320 Komponentenbibliotheken entnommen (Material UI, Mantine, shadcn/ui, Headless UI, Radix UI, Vuetify, Naive UI, Chakra, Skeleton, Kobalte sowie der langen Liste interner Design-Systeme) und nach Absicht gruppiert.
polite für Bestätigungen, assertive für Fehlerpolite, in Kombination mit aria-describedbypolite mit role statuspolite mit role log oder status3. Die framework-spezifischen Fallstricke, geordnet nach Häufigkeit
Jedes Framework hat seinen eigenen Reconciler, und der Reconciler ist der Ort, an dem aria-live-Regionen scheitern. Die Kurzfassung in vier Zeilen:
setState-Aufrufe zu einem einzigen Commit zusammen, sodass „Toast öffnen, dann Text ändern“ als eine einzige Mutation im DOM landen kann, die der Screenreader als initiales Mount einer unangekündigten Region behandelt.flushSync oder einer Microtask-Verzögerung schreiben.await nextTick() zwischen den zwei Schreibvorgängen verwenden; oder die Region aus einem shallowRef zusammensetzen, den der Scheduler nicht dedupliziert.$state-Lesevorgänge in direkte DOM-Schreibvorgänge, die jedes Framework-Batching umgehen. Das klingt ideal für aria-live, bis deutlich wird, dass der Compiler auch aufeinanderfolgende identische Schreibvorgänge dedupliziert — sodass „Laden…“ gefolgt von „Laden…“ zu einer einzigen DOM-Mutation zusammengefasst wird.untrack mit einem Sentinel-Wert verwenden, um eine neue Mutation zu erzwingen.batch()-Blocks ausgelöst werden, werden verzögert, und Toast-Bibliotheken verwenden batch oft, um mehrere Zustandsänderungen zu gruppieren — sodass die Textmutation der Region gleichzeitig mit dem display: none-Toggle des übergeordneten Elements landen kann.batch()-Aufrufs halten; oder untrack verwenden, um das Signal zu lesen und das DOM in einem separaten Task zu schreiben.Die aria-live-Region selbst darf nicht bedingt gemountet werden. Eine Region, die in dem Moment gemountet wird, in dem ihr Text erstmals erscheint, ist aus Sicht des Screenreaders eine leere Region — und leere Regionen kündigen nichts an. Die Region beim App-Start leer mounten und danach nur den Text darin ändern. Jedes der obigen Frameworks bricht, wenn diese Regel verletzt wird — unabhängig davon, welcher Scheduler-Fallstrick bereits umgangen wurde.
4. Die Kompatibilitätsmatrix: Framework × Muster × assistive Technologie
Jedes Muster wurde unter jedem Framework gegen drei Screenreader getestet — NVDA 2025.1 unter Windows 11, JAWS 2026 unter Windows 11 und VoiceOver unter macOS 15.4 — mit Chrome 138, Firefox 130 und Safari 17.6. Jede Zelle zeigt das beobachtete Verhalten über ca. 20 Testläufe pro Kombination. „OK“ bedeutet, dass die Ankündigung zuverlässig mit dem erwarteten Text ausgelöst wurde. „Partiell“ bedeutet, dass sie in einigen Konfigurationen ausgelöst wurde, aber nicht in allen. „Fehler“ bedeutet, dass mindestens ein Screenreader die Ankündigung stillschweigend verworfen hat.
| React 19 | Vue 3.5 | Svelte 5 | SolidJS 2.0 | |
|---|---|---|---|---|
| Toast-Benachrichtigung (polite) | Partiell | OK | OK | OK |
| Toast-Benachrichtigung (assertive) | Partiell | OK | Partiell | OK |
| Formularfehler (polite) | OK | OK | OK | OK |
| Asynchroner Ladezustand | Partiell | Partiell | Fehler | OK |
| Live-Daten — langsamer Stream | OK | OK | OK | OK |
| Live-Daten — Burst (mehr als 5/Sek.) | Fehler | Partiell | Fehler | Partiell |
Drei Beobachtungen aus der Matrix. Erstens: Jedes Framework verarbeitet das Formularfehler-Muster standardmäßig korrekt — das ist das einzige kanonische Muster, das den Reconciler nicht belastet, weil die Region beim App-Start gemountet wird und der Text sich pro Einreichung nur einmal ändert. Zweitens: Jedes Framework hat Probleme mit Burst-Live-Daten, da kein clientseitiger Scheduler schnell genug ist, um Mutationen so an den Barrierefreiheitsbaum zu liefern, wie das zugrunde liegende Signal feuert. Drittens: Sveltes 5 Compile-time-Deduplizierung macht das Ladezustand-Muster zu einem vollständigen Fehler statt nur zu einem partiellen — das einzige der vier, bei dem das Standardverhalten falsch ist.
Die Screenreader-Spalte ist ebenfalls wichtig. JAWS 2026 ist der strengste der drei bezüglich leerer Regionen: Es verweigert die Ankündigung einer Region, deren Text sich von „“ nach „Gespeichert“ ändert, wenn die Änderung in demselben Paint wie der Mount der Region landet — in jedem Framework. NVDA 2025.1 kündigt bei demselben Fall inkonsistent an. VoiceOver auf macOS 15.4 ist am nachsichtigsten — es kündigt in der Regel sogar bei einem gleichzeitigen Mount-plus-Text an — aber diese Nachsichtigkeit hat viele Framework-Fehler vor Entwicklerinnen und Entwicklern verborgen, die ausschließlich auf einem Mac testen.
Bei allen vier Frameworks ist die einzige Maßnahme, die die meisten „Partiell“-Zellen auf „OK“ kippt, das Mounten einer globalen, leeren div aria-live=“polite” und einer div aria-live=“assertive” im Root der App — und alle Ankündigungen durch das Schreiben von Text in deren Kind-Element zu leiten. Das umgeht in einem Zug jeden Reconciler-Mount-Race-Condition.
5. Guter und schlechter Code, je Framework
Die folgenden Paare zeigen die falsche und die richtige Art, das Ladezustand-Muster in jedem Framework zu schreiben. Das Ladezustand-Muster wurde gewählt, weil es das Muster ist, bei dem die Matrix am meisten Rot zeigt — und die Lücke zwischen schlecht und gut am größten ist.
function LoadingState({ isLoading, results }) {
return isLoading ? (
<div role="status" aria-live="polite">
Loading results...
</div>
) : (
<ResultsList items={results} />
);
}Die Region wird nur während des Ladens gemountet. Reacts automatisches Batching kann den Mount und den Unmount innerhalb desselben Paints wie das Dateneintreffen committen — und JAWS, NVDA und VoiceOver sind sich nicht einig, was dann zu tun ist. Netto-Effekt: „Loading…“ wird manchmal gesprochen, manchmal nicht, ohne erkennbares Muster auf der Client-Seite.
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} />}
</>
);
}Die Region wird beim ersten Render gemountet und bleibt gemountet. Reacts Renderer kann so viel batchen wie er will — das Einzige, was sich ändert, ist der Text innerhalb einer bestehenden Region, was genau das ist, was die Spezifikation beschreibt.
<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>Die zwei Schreibvorgänge auf status können im selben Vue-Scheduler-Tick landen, wenn die Netzwerkantwort schnell ist (gecacht) — Vue dedupliziert, und nur der finale String erreicht das DOM. Die „Loading…“-Ankündigung geht stillschweigend 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>Das await nextTick() zwingt den Scheduler, „Loading…“ in das DOM zu leeren, bevor die zweite Zuweisung in die Warteschlange gestellt wird. Der Screenreader sieht zwei unterschiedliche Mutationen und kündigt jede einzeln an.
<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>Sveltes 5 Compiler erzeugt einen DOM-Text-Schreibvorgang pro $state-Änderung, dedupliziert aber aufeinanderfolgende identische Strings. Wenn ein zweiter Aufruf von load() erneut „Loading…“ schreibt, erzeugt der Compiler keine Mutation — der Screenreader hört beim zweiten Klick nichts.
<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>Der Sequenzzähler garantiert, dass jeder Schreibvorgang ein neuer String ist. Die Nutzerin oder der Nutzer hört die Zahl nicht — der Screenreader glättet sie heraus — aber der Compiler wird gezwungen, jedes Mal eine neue DOM-Mutation zu erzeugen. Das Umgehen der Deduplizierung ist der gesamte Zweck.
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);
});
}Das Status-Signal wird innerhalb von batch() zusammen mit dem Ergebnis-Signal aktualisiert. Solid verzögert beide DOM-Schreibvorgänge bis der Batch schließt — und bei einer schnellen gecachten Antwort können „Loading…“ und „Loaded…“ im selben Microtask geleert werden. Die Zwischenankündigung geht verloren.
async function load() {
setStatus('Loading...');
// Status-Signal feuert sofort, außerhalb jedes batch()
const data = await fetch('/api/results').then(r => r.json());
batch(() => {
setStatus(`Loaded ${data.length} results`);
setResults(data);
});
}Der „Loading…“-Schreibvorgang erfolgt außerhalb von batch(), sodass Solids feingranularer Scheduler das DOM in dem Moment aktualisiert, in dem das Signal feuert. Der Screenreader sieht die Ankündigung vor dem Netzwerk-Roundtrip. Der „Loaded“-Schreibvorgang kann im Batch bleiben — die Ankündigung feuert trotzdem, weil der Batch synchron darum schließt.
6. Das framework-übergreifende Playbook
Beim App-Start eine globale Live-Region pro Dringlichkeitsstufe mounten
Zwei leere Divs rendern — eines mit aria-live=“polite”, eines mit aria-live=“assertive” — im Root der Anwendung, bevor irgendeine Route gerendert wird. Jede Ankündigung in der App schreibt in eine dieser beiden Regionen. Das eliminiert die Mount-Race-Condition in jedem der obigen Frameworks.
Einen kleinen Announcer-Service schreiben, der die globalen Regionen kapselt
Eine einzige Funktion bereitstellen — announce(message, politeness) — die die entsprechende globale Region findet und deren textContent setzt. Frameworks können einen reaktiven Ref auf die Region bereitstellen, aber der Announcer kann einfach el.textContent = ” aufrufen und dann im nächsten Task el.textContent = message — was eine Mutation erzwingt, auch bei identischen Strings.
Burst-Datenquellen auf ca. 1 Nachricht pro 1.500 ms drosseln
Wenn eine Datenquelle mehr als einmal pro Sekunde feuern kann — ein Score-Ticker, ein Chat-Feed — kann der Synthesizer des Screenreaders nicht mithalten, unabhängig vom Framework. Updates clientseitig zusammenfassen und eine einzige Zusammenfassung ausgeben („3 neue Nachrichten“) statt drei aufeinanderfolgende Ankündigungen. Die Matrix oben zeigt, dass jedes Framework bei der „Burst“-Zeile scheitert — die Lösung muss also oberhalb des Frameworks liegen, nicht darin.
Mit NVDA, JAWS und VoiceOver testen — alle drei, jedes Mal
Die Matrix würde nicht existieren, wenn ein einziger Screenreader ausreichen würde. JAWS’ Strenge bezüglich leerer Regionen und VoiceOvers Nachsichtigkeit ziehen in entgegengesetzte Richtungen; NVDA liegt dazwischen. Ein Muster, das nur unter VoiceOver — dem Standard für Mac-Shop-Frontend-Teams — korrekt ankündigt, ist für die Mehrheit der Screenreader-nutzenden Bevölkerung defekt.
Die Live-Region nicht mehr bedingt mounten
Der bei weitem häufigste Fehler in allen vier Frameworks. Die Region beim App-Start leer mounten. Den Text ändern. Niemals unmounten.
Fazit: aria-live ist ein Framework-Problem, das wie ein Markup-Problem aussieht
Wer die W3C-ARIA-Spezifikation liest, bekommt den Eindruck, aria-live sei eine Markup-Entscheidung — polite oder assertive, mit role status oder log oder alert, und damit wäre es erledigt. Die Spezifikation ist korrekt, in dem Sinne, dass das die einzigen Regler sind, die sie kennt. Die Spezifikation ist aber auch irreführend, weil sie ein DOM voraussetzt, das so mutiert wie ein imperatives Dokument mutiert.
Jedes der obigen Frameworks fügt zwischen dem Code und dem DOM einen Scheduler ein, und jeder Scheduler hat Randfälle, die die Spezifikation nicht adressiert — automatisches Batching, Microtask-Flushes, Compile-time-Deduplizierung, Signal-Graphen. Die Randfälle sind keine Fehler in den Frameworks; sie sind bewusst gewählte Funktionen, die schlecht mit den Annahmen interagieren, die Screenreader über den Zeitpunkt von DOM-Mutationen machen.
Die Lösung ist strukturell, nicht komponentenweise. Globale Live-Regionen beim App-Start mounten, alle Ankündigungen über einen kleinen Service leiten, Burst-Quellen drosseln, auf allen drei Screenreadern testen. Die Tatsache, dass dasselbe fünfschrittige Playbook über React, Vue, Svelte und Solid hinweg funktioniert, ist der stärkste Hinweis darauf, dass das gewählte Framework weniger wichtig ist als die Architektur, die darum herum aufgebaut wird.
Für das breitere Entwickler-Toolkit — Testmuster, Build-time-Prüfungen und die übrige Frontend-Barrierefreiheitskarte — siehe die Entwickler-Landingpage; die vollständige WCAG-2.2-Erfolgskriterien-Referenz listet die Kriterien, die jedes der obigen Muster berührt; der kostenlose WCAG-2.2-Scanner erkennt strukturelle Fehler, die axe auf jeder angegebenen URL sehen kann.
„Die aria-live-Spezifikation setzt voraus, dass das DOM so mutiert, wie die Spezifikation es 2008 geschrieben hat. Vier Frameworks später mutiert keines von ihnen so — und der Screenreader weiß das nicht.“