Komponenten · Barrierefreiheit nach Framework

Barrierefreiheit in Vue

Vue liegt näher am HTML als die meisten Alternativen – die Templates sind gültiges Markup, und die Semantik bleibt sichtbar. Genau daraus entstehen aber eigene Fallstricke: v-if und v-show unterscheiden sich für Hilfsmittel grundlegend, die automatische Attribut-Vererbung landet am falschen Element, und <Transition> animiert auch dann, wenn Nutzende Bewegung abbestellt haben.

Diese Seite behandelt die Vue-spezifischen Fälle. Die vier Probleme, die in jedem Framework gleich sind, stehen unter Barrierefreiheit nach Framework.

Das Wichtigste in Kürze

  • v-if entfernt das Element aus dem DOM, v-show setzt nur display: none. Für Hilfsmittel sind beide unsichtbar – der Unterschied zählt beim Fokus.
  • Attribut-Vererbung (inheritAttrs) hängt aria-* und id an das Wurzelelement einer Komponente – oft nicht an das, das sie braucht.
  • useId() gibt es seit Vue 3.5 und ersetzt selbst gebaute Zähler für for/aria-labelledby.
  • Nach einem Router-Wechsel Titel setzen und Fokus platzieren – Vue Router tut das nicht von selbst.
  • <Teleport> löst das Layoutproblem bei Dialogen, nicht die Fokusfalle. Nativ <dialog> bleibt die erste Wahl.
  • <Transition> respektiert prefers-reduced-motion nicht automatisch – das ist eine CSS-Entscheidung.
  • v-html umgeht jede Prüfung; dort landen die meisten fremden Barrieren.
  • Stand August 2026.

v-if oder v-show: der Unterschied, der zählt

Für Screenreader sind beide Zustände gleich – ein Element mit display: none steht genauso wenig im Accessibility Tree wie ein nicht gerendertes. Die Kette dahinter erklärt Screenreader-Grundlagen.

Der Unterschied wird beim Fokus relevant. Wird ein Element per v-if entfernt, während es den Fokus hat, verliert der Browser den Fokus an <body> – der nächste Tab-Druck beginnt wieder ganz oben. Das ist der Klassiker beim Schließen von Aufklapp-Bereichen und Fehlermeldungen:

<!-- Problematisch: Fokus liegt im Bereich, wenn er verschwindet -->
<div v-if="offen">
  <button @click="offen = false">Schließen</button>
</div>

<!-- Besser: Fokus vor dem Entfernen zurückgeben -->
<div v-if="offen">
  <button ref="schliessen" @click="schliessen()">Schließen</button>
</div>
const oeffner = ref(null);
function schliessen() {
  offen.value = false;
  nextTick(() => oeffner.value?.focus());
}

nextTick ist hier entscheidend: Vor dem Rendern existiert das Ziel noch nicht. Betroffen ist 2.4.3 Fokus-Reihenfolge, und das Muster gilt genauso für Akkordeons und Dialoge.

Attribut-Vererbung: wo aria-* landet

Vue reicht nicht deklarierte Attribute automatisch an das Wurzelelement einer Komponente weiter. Bei einer Komponente, deren Wurzel ein <div> ist und die das eigentliche <input> weiter innen hat, landet ein von außen gesetztes aria-describedby damit am falschen Element – wirkungslos.

<!-- Eingabefeld.vue – falsch: aria-* landet am äußeren div -->
<template>
  <div class="feld">
    <label :for="id">{{ label }}</label>
    <input :id="id" />
  </div>
</template>

<!-- Richtig: Vererbung abschalten und gezielt weiterreichen -->
<script setup>
defineOptions({ inheritAttrs: false });
const id = useId();
defineProps({ label: String });
</script>

<template>
  <div class="feld">
    <label :for="id">{{ label }}</label>
    <input :id="id" v-bind="$attrs" />
  </div>
</template>

Dasselbe gilt für id, disabled, required und Event-Listener. Die Regel: Sobald eine Komponente ein Bedienelement kapselt, gehört inheritAttrs: false plus ein gezieltes v-bind="$attrs" dazu – sonst ist die Komponente von außen nicht sauber beschriftbar, siehe Labels & Beschriftungen.

Fokus und Titel nach dem Router-Wechsel

Wie in jedem Framework mit client-seitigem Routing bleibt nach dem Wechsel der Fokus stehen und der Titel unverändert. Vue Router bietet dafür einen passenden Ort:

router.afterEach((to) => {
  document.title = `${to.meta.titel} | Beispiel`;
  // Ziel: eine Überschrift mit tabindex="-1" in der neuen Ansicht
  nextTick(() => document.querySelector('h1[tabindex="-1"]')?.focus());
});

Damit sind 2.4.2 Seitentitel und die Fokusreihenfolge abgedeckt. Die Alternative – eine Live-Region, die den Wechsel ansagt, ohne den Fokus zu bewegen – steht unter Live-Regionen.

<Teleport> und Dialoge

<Teleport to="body"> verschiebt einen Bereich im DOM und löst damit alle Probleme, die aus overflow: hidden und z-index entstehen. Was es nicht mitbringt: Fokusfalle, Escape-Behandlung, Fokusrückgabe und das Inertisieren des Hintergrunds.

Deshalb gilt auch hier: Das native <dialog> mit showModal() bringt all das mit und ist in Vue genauso einsetzbar wie überall sonst – <Teleport> braucht man dafür nicht einmal. Der vollständige Aufbau steht unter Dialoge / Modals.

Transitions und Bewegung

<Transition> und <TransitionGroup> sind bequem und ignorieren die Systemeinstellung für reduzierte Bewegung, solange du sie nicht im CSS berücksichtigst:

@media (prefers-reduced-motion: reduce) {
  .fade-enter-active,
  .fade-leave-active {
    transition: none;
  }
}

Warum das nicht optional ist, steht unter 2.3.3 Animation aus Interaktionen – auf Stufe AAA formal freiwillig, in der Praxis eine der wirksamsten Einzelmaßnahmen für Menschen mit vestibulären Beschwerden.

Formulare: v-model sagt nichts an

v-model bindet den Wert, nicht den Zustand. Beim fehlerhaften Absenden fehlen deshalb regelmäßig dieselben drei Dinge wie überall: die Verknüpfung der Meldung mit dem Feld, aria-invalid und eine Zusammenfassung, die jemand mitbekommt.

<script setup>
const fehler = ref([]);
const zusammenfassung = ref(null);
const id = useId();

async function absenden() {
  fehler.value = pruefen();
  if (fehler.value.length) {
    await nextTick();
    zusammenfassung.value?.focus();
  }
}
</script>

<template>
  <form novalidate @submit.prevent="absenden">
    <div v-if="fehler.length" ref="zusammenfassung" tabindex="-1" role="alert">
      <h2>{{ fehler.length }} Angaben fehlen</h2>
      <ul>
        <li v-for="f in fehler" :key="f.feld">
          <a :href="`#${id}-${f.feld}`">{{ f.text }}</a>
        </li>
      </ul>
    </div>

    <label :for="`${id}-email`">E-Mail-Adresse</label>
    <input
      :id="`${id}-email`"
      v-model="email"
      type="email"
      :aria-invalid="hatFehler('email') || undefined"
      :aria-describedby="hatFehler('email') ? `${id}-email-fehler` : undefined"
    />
  </form>
</template>

Zwei Vue-Eigenheiten sind dabei wichtig. novalidate schaltet die Browser-Blasen ab, die Screenreader unterschiedlich behandeln. Und aria-invalid nur setzen, wenn es zutrifft: Vue rendert :aria-invalid="false" als Attribut mit dem Wert false, nicht als weggelassenes Attribut – deshalb der || undefined-Kniff. Die Muster im Einzelnen stehen unter Fehlermeldungen barrierefrei.

Ein Composable für Ansagen

Weil Statusmeldungen in jeder zweiten Ansicht gebraucht werden, lohnt es sich, die Live-Region einmal zentral zu halten statt in jeder Komponente neu:

// useAnkuendigung.js – eine Region für die ganze Anwendung
const text = ref('');
export function useAnkuendigung() {
  return {
    text,
    ansagen(nachricht) {
      text.value = '';               // erzwingt eine neue Ansage
      nextTick(() => (text.value = nachricht));
    },
  };
}
<!-- einmal im App-Layout -->
<p class="sr-only" role="status" aria-live="polite">{{ text }}</p>

Das Zurücksetzen auf einen leeren String ist kein Schönheitsfehler, sondern notwendig: Screenreader sagen nur Änderungen an – zweimal derselbe Text bliebe stumm. Warum role="status" hier besser passt als aria-live="assertive", steht unter Live-Regionen und 4.1.3 Statusmeldungen.

Slots: wer die Semantik verantwortet

Slots sind Vues stärkstes Werkzeug für wiederverwendbare Komponenten – und die Stelle, an der Semantik am leisesten verloren geht. Eine Listenkomponente erwartet <li>-Elemente, bekommt aber <div>s, weil der aufrufende Code das nicht weiß:

<!-- Liste.vue – der Slot liegt in einem ul -->
<template>
  <ul class="liste"><slot /></ul>
</template>

<!-- Aufruf – erzeugt div-Kinder in einem ul -->
<Liste>
  <Karte v-for="e in eintraege" :key="e.id" :eintrag="e" />
</Liste>

Ergibt Karte ein <div>, steht dieses direkt im <ul> – die Liste verliert ihre Semantik, und der Screenreader sagt „Liste mit 0 Einträgen“. Zwei Wege führen heraus: Entweder rendert Karte selbst ein <li> (dann gehört das in ihre Dokumentation), oder die Listenkomponente wickelt jeden Slot-Inhalt selbst ein.

Die belastbarere Variante ist die zweite, weil sie nicht auf Disziplin baut:

<template>
  <ul class="liste">
    <li v-for="(knoten, i) in $slots.default?.() ?? []" :key="i">
      <component :is="knoten" />
    </li>
  </ul>
</template>

Dasselbe Muster gilt für Tabellenzeilen, Definitionslisten und <option>-Elemente – überall dort, wo der Elternteil ein bestimmtes Kind erwartet, siehe Tabellen semantisch aufbauen.

Nuxt: wo die Fokusverwaltung hingehört

In Nuxt wird standardmäßig serverseitig gerendert. Für Hilfsmittel ändert das nichts – es kommt derselbe DOM an –, aber es verschiebt den Ort für die drei Dinge von dieser Seite. Titelsetzung, Fokusverwaltung und Ankündigungen brauchen den Browser und gehören deshalb in onMounted oder in ein Client-Plugin, nicht in setup() auf Serverseite.

Für den Titel bringt Nuxt mit useHead einen eigenen Mechanismus mit; für den Fokus gibt es keinen, das bleibt der afterEach-Hook von oben. Ein häufiger Fehlgriff ist, die gesamte Seite über <ClientOnly> zu rendern, um das Problem zu umgehen – damit verliert man die Robustheit ohne JavaScript und die zuverlässige Erfassung durch Crawler, siehe Semantik & SEO. Die schlankere Lösung ist eine kleine Client-Komponente, die nur das Fokusziel verwaltet.

Häufiger Fehler in der Praxis

@click auf einem div oder span. In Vue-Templates fällt es weniger auf als in JSX, weil kein Linter danebensteht. Das Ergebnis ist dasselbe: kein Fokus, keine Tastatur, keine Rolle – siehe Buttons vs. Links.

v-html für redaktionelle Inhalte. Alles, was dort ankommt, ist ungeprüft: fehlende Alt-Texte, falsche Überschriftenebenen, kaputte Verschachtelung.

Scoped Slots ohne Semantik. Ein Slot, der eine <li> erwartet und einen <div> bekommt, zerstört die Listensemantik – dasselbe Muster wie bei Wrapper-Elementen in React.

Den Fokus nach dem Schließen vergessen. Der am häufigsten übersehene Vue-Befund überhaupt, weil er mit der Maus nie auffällt.

Häufige Fragen

Ist Vue barrierefreier als React?

Nein, aber die Templates sind gültiges HTML, und das macht Semantikfehler im Review sichtbarer. Die eigentlichen Befunde entstehen in beiden Werkzeugen an denselben Stellen.

Gibt es für Vue ein Linting wie jsx-a11y?

Ja, eslint-plugin-vuejs-accessibility prüft Templates auf fehlende alt-Attribute, Klick-Handler an nicht interaktiven Elementen und ungültige ARIA-Attribute. Es fängt denselben mechanischen Anteil ab.

Brauche ich useId, wenn ich eigene Zähler habe?

Nein, aber useId ist stabil über Server- und Client-Rendering hinweg – selbst gebaute Zähler sind es oft nicht, und dann kollidieren die IDs nach der Hydration.

Wie prüfe ich eine Vue-Anwendung?

Mechanisch mit axe (etwa @axe-core/playwright), inhaltlich mit einem Screenreader-Durchlauf mit NVDA. Der Ablauf steht unter Barrierefreiheit selbst testen.

Verwandte Themen

Quellen

Gratis E-Book PDF Das Praxishandbuch

Kostenloses E-Book

Das Praxishandbuch für sauberes, zugängliches Web

Alles rund um semantisches HTML, Barrierefreiheit, WCAG & BFSG, GEO und SEO — praxisnah und am echten Code. In mehreren Feedbackschleifen von Leserinnen und Lesern verbessert.

  • 3.000+ Downloads
  • 7. Auflage
  • 37 Seiten
  • PDF

Kein Spam. Abmeldung jederzeit mit einem Klick möglich.