Web Accessibility: WCAG 2.1 AA Compliance Guide

Build WCAG 2.1 AA compliant web apps with this practical guide. Covers semantic HTML, ARIA, keyboard navigation, color contrast, screen readers, and automated testing tools.

E
ECOSIRE Research and Development Team
|19. März 202610 Min. Lesezeit2.1k Wörter|

Teil unserer Compliance & Regulation-Serie

Den vollständigen Leitfaden lesen

Barrierefreiheit im Internet: WCAG 2.1 AA Compliance Guide

Barrierefreiheit ist keine Funktion, die Sie nach dem Start hinzufügen – sie ist ein grundlegendes Qualitätsmerkmal, genau wie Leistung oder Sicherheit. Die Einhaltung von WCAG 2.1 AA ist mittlerweile in der EU (European Accessibility Act, in Kraft getreten im Juni 2025), den USA (Rechtsprechung zu ADA Title III) und vielen anderen Gerichtsbarkeiten gesetzlich vorgeschrieben. Über die Compliance hinaus erzielen barrierefreie Schnittstellen bessere Konvertierungen, ein höheres Ranking in der Suche und dienen schätzungsweise 1,3 Milliarden Menschen mit Behinderungen weltweit.

Bei diesem Leitfaden handelt es sich um ein praktisches Umsetzungshandbuch, nicht um eine Checkliste. Sie lernen die vier WCAG-Prinzipien, die wirkungsvollsten Techniken, das systematische Testen und die Integration der Barrierefreiheit in Ihren React/Next.js-Entwicklungsworkflow kennen, damit diese stabil bleibt.

Wichtige Erkenntnisse

  • WCAG 2.1 AA erfordert alle vier POUR-Prinzipien: wahrnehmbar, bedienbar, verständlich, robust
  • Beginnen Sie mit semantischem HTML – es bietet 70 % der Barrierefreiheit kostenlos, bevor ARIA hinzugefügt wird
  • Mindestfarbkontrastverhältnis: 4,5:1 für normalen Text, 3:1 für großen Text (18pt/14pt fett)
  • Jedes interaktive Element muss über die Tastatur fokussierbar sein und über eine sichtbare Fokusanzeige verfügen – Sprachausgaben von Bildschirmleseprogrammen basieren auf dem Barrierefreiheitsbaum – testen Sie mit NVDA (Windows) und VoiceOver (Mac)
  • ARIA ist der letzte Ausweg – es ändert nur die Art und Weise, wie unterstützende Technologien das DOM interpretieren, nicht das Verhalten
  • Automatisieren Sie mit Axe-Core in Ihrer CI-Pipeline; Manuelle Tests erfassen, was der Automatisierung entgeht
  • Dokumentieren Sie Ihre Erklärung zur Barrierefreiheit und stellen Sie Benutzern einen Feedback-Mechanismus zur Verfügung, um Probleme zu melden

Die vier POUR-Prinzipien

WCAG 2.1 basiert auf vier Prinzipien. Jedes Erfolgskriterium gehört zu einem davon.

Wahrnehmbar: Informationen müssen so darstellbar sein, dass Benutzer sie wahrnehmen können. Dazu gehören Textalternativen für Bilder, Bildunterschriften für Videos, ausreichender Farbkontrast und Inhalte, die sich nicht allein auf die Farbe verlassen, um Bedeutung zu vermitteln.

Bedienbar: Alle Funktionen müssen über die Tastatur bedienbar sein, mit genügend Zeit zur Interaktion, ohne anfallsauslösende Inhalte und einer navigierbaren Struktur (Links überspringen, Seitentitel, Fokusreihenfolge).

Verständlich: Inhalte müssen lesbar und vorhersehbar sein. Die Sprache muss identifiziert werden, Fehlermeldungen müssen beschreibend sein und Formulare müssen klare Beschriftungen und Validierungsrückmeldungen enthalten.

Robust: Inhalte müssen durch aktuelle und zukünftige unterstützende Technologien interpretierbar sein. Dies bedeutet gültiges HTML, ordnungsgemäße ARIA-Verwendung und Statusmeldungen, die ohne Fokussierung angekündigt werden.


Semantisches HTML zuerst

Semantisches HTML ist die Investition mit dem größten Nutzen in die Barrierefreiheit. Native HTML-Elemente verfügen über integrierte Barrierefreiheitsrollen, Zustände und Tastaturverhalten – kein ARIA erforderlich.

// BAD: Generic divs with no semantics
<div class="button" onclick="submit()">Submit</div>
<div class="nav">
  <div class="link" onclick="navigate('/home')">Home</div>
</div>

// GOOD: Native semantics, free keyboard and screen reader support
<button type="submit" onClick={submit}>Submit</button>
<nav aria-label="Main navigation">
  <a href="/home">Home</a>
</nav>

Orientierungsbereiche erleichtern Benutzern von Bildschirmleseprogrammen die schnelle Navigation, indem sie zwischen Abschnitten springen:

// Every page should have these landmarks
<header>        {/* banner landmark */}
  <nav aria-label="Main">...</nav>
</header>
<main>           {/* main landmark */}
  <h1>Page Title</h1>
  <article>...</article>
  <aside aria-label="Related content">...</aside>
</main>
<footer>         {/* contentinfo landmark */}
  <nav aria-label="Footer">...</nav>
</footer>

Die Überschriftenhierarchie muss logisch und ununterbrochen sein:

// BAD: Skipped heading levels
<h1>Page Title</h1>
<h3>Section</h3>  {/* Skipped h2! */}

// GOOD: Sequential hierarchy
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>

Farbkontrast

WCAG 2.1 AA erfordert:

  • 4,5:1 Kontrastverhältnis für normalen Text (unter 18pt / 14pt fett)
  • Kontrastverhältnis 3:1 für großen Text (18pt+ / 14pt+ fett)
  • 3:1 für UI-Komponenten und grafische Objekte (Schaltflächen, Symbole, Eingaberahmen)
// Tailwind color contrast examples
// FAIL: gray-400 on white (#9ca3af on #fff = 2.8:1)
<p className="text-gray-400">This fails AA</p>

// PASS: gray-700 on white (#374151 on #fff = 10.7:1)
<p className="text-gray-700">This passes AA</p>

// For dark mode, test both themes separately
<p className="text-gray-700 dark:text-gray-300">
  gray-700 on white (10.7:1) / gray-300 on gray-900 (9.2:1)
</p>

Verwenden Sie während der Entwicklung den WebAIM Contrast Checker oder das Browser-Kontrasttool DevTools. Fügen Sie dies Ihrem Storybook oder Design-Token-System hinzu, um Regressionen abzufangen:

// contrast-checker.ts
import { getContrast } from 'polished';

function assertContrast(fg: string, bg: string, level: 'AA' | 'AAA' = 'AA') {
  const ratio = getContrast(fg, bg);
  const required = level === 'AA' ? 4.5 : 7;
  if (ratio < required) {
    throw new Error(
      `Contrast ratio ${ratio.toFixed(2)}:1 fails WCAG ${level} (requires ${required}:1)`
    );
  }
}

Tastaturnavigation

Jedes interaktive Element – ​​Links, Schaltflächen, Formularfelder, benutzerdefinierte Widgets – muss über die Tastatur erreichbar und bedienbar sein.

Fokusmanagement

// Skip link: first element on every page
// Allows keyboard users to jump past navigation
export function SkipLink() {
  return (
    <a
      href="#main-content"
      className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4
                 focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white
                 focus:rounded focus:ring-2 focus:ring-white"
    >
      Skip to main content
    </a>
  );
}

// Main content target
<main id="main-content" tabIndex={-1}>
  {/* tabIndex={-1} allows programmatic focus without appearing in tab order */}

Focus Trapping in Modalen

Wenn ein Dialog geöffnet wird, muss der Fokus darin gefangen sein. Beim Schließen kehrt der Fokus zum Auslöser zurück:

// focus-trap.tsx using @radix-ui/react-focus-trap (used internally by shadcn Dialog)
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useRef } from 'react';

export function AccessibleModal({ trigger, children, title }: Props) {
  const triggerRef = useRef<HTMLButtonElement>(null);

  return (
    <Dialog>
      <DialogTrigger ref={triggerRef} asChild>
        <button>Open</button>
      </DialogTrigger>
      <DialogContent
        // shadcn Dialog handles focus trap and returns focus to trigger on close
        aria-describedby="dialog-description"
      >
        <DialogTitle>{title}</DialogTitle>
        <p id="dialog-description" className="sr-only">
          {/* Screen reader description of dialog purpose */}
        </p>
        {children}
      </DialogContent>
    </Dialog>
  );
}

Sichtbare Fokusindikatoren

WCAG 2.1 SC 2.4.11 (AA in WCAG 2.2) erfordert einen Fokusumriss von mindestens 2 Pixeln. Den Fokus niemals ersatzlos unterdrücken:

/* globals.css */
/* NEVER do this: */
:focus { outline: none; }

/* DO this: custom, visible focus ring */
:focus-visible {
  outline: 2px solid hsl(var(--ring));
  outline-offset: 2px;
  border-radius: 4px;
}

/* Remove for mouse users (only show for keyboard) */
:focus:not(:focus-visible) {
  outline: none;
}

ARIA: Wann und wie man es verwendet

ARIA-Attribute (Accessible Rich Internet Applications) verändern die Art und Weise, wie unterstützende Technologien das DOM interpretieren. Die erste Regel von ARIA: Verwenden Sie es nicht, wenn für Ihren Anwendungsfall ein natives HTML-Element vorhanden ist.

ARIA-Etiketten

// Icon-only button — screen reader has nothing to announce without aria-label
<button aria-label="Close dialog">
  <X className="h-4 w-4" aria-hidden="true" />
</button>

// Form field with visible label — use htmlFor, not aria-label
<label htmlFor="email">Email address</label>
<input id="email" type="email" />

// Input with visible description
<input
  id="password"
  type="password"
  aria-describedby="password-requirements"
/>
<p id="password-requirements">Must be at least 12 characters.</p>

ARIA Live-Regionen

Kündigen Sie dynamische Inhaltsänderungen an, ohne den Fokus zu verschieben:

// Status messages (search results count, form submission status)
function SearchResults({ count, loading }: Props) {
  return (
    <>
      {/* aria-live="polite" waits for user to finish current action */}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {loading ? 'Loading results...' : `${count} results found`}
      </div>
      {/* Visual result count (not for screen readers — aria-hidden) */}
      <span aria-hidden="true">{count} results</span>
    </>
  );
}

// Error messages (aria-live="assertive" interrupts immediately)
function FormError({ error }: { error: string | null }) {
  return (
    <div
      role="alert"
      aria-live="assertive"
      className={cn('text-red-500 text-sm', !error && 'hidden')}
    >
      {error}
    </div>
  );
}

ARIA für benutzerdefinierte Widgets

Wenn Sie ein benutzerdefiniertes Widget (Registerkarte, Baumansicht, Kombinationsfeld) erstellen müssen, befolgen Sie genau die Muster des ARIA Authoring Practices Guide (APG):

// Accessible tabs (ARIA tab pattern)
export function Tabs({ items }: { items: Tab[] }) {
  const [active, setActive] = useState(0);

  return (
    <div>
      <div role="tablist" aria-label="Content tabs">
        {items.map((item, i) => (
          <button
            key={item.id}
            role="tab"
            aria-selected={active === i}
            aria-controls={`panel-${item.id}`}
            id={`tab-${item.id}`}
            tabIndex={active === i ? 0 : -1} // Roving tabindex
            onClick={() => setActive(i)}
            onKeyDown={(e) => {
              if (e.key === 'ArrowRight') setActive((active + 1) % items.length);
              if (e.key === 'ArrowLeft') setActive((active - 1 + items.length) % items.length);
            }}
          >
            {item.label}
          </button>
        ))}
      </div>
      {items.map((item, i) => (
        <div
          key={item.id}
          role="tabpanel"
          id={`panel-${item.id}`}
          aria-labelledby={`tab-${item.id}`}
          hidden={active !== i}
        >
          {item.content}
        </div>
      ))}
    </div>
  );
}

Formulare und Fehlerbehandlung

Barrierefreie Formulare gehören zu den wirkungsvollsten Verbesserungen für Benutzer mit kognitiven und motorischen Behinderungen.

// Accessible form field with error state
function TextField({
  id,
  label,
  error,
  required,
  hint,
  ...props
}: TextFieldProps) {
  const hintId = hint ? `${id}-hint` : undefined;
  const errorId = error ? `${id}-error` : undefined;
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;

  return (
    <div>
      <label htmlFor={id} className="font-medium">
        {label}
        {required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
        {required && <span className="sr-only">(required)</span>}
      </label>

      {hint && (
        <p id={hintId} className="text-sm text-gray-500 mt-1">
          {hint}
        </p>
      )}

      <input
        id={id}
        aria-required={required}
        aria-invalid={!!error}
        aria-describedby={describedBy}
        className={cn('input', error && 'border-red-500')}
        {...props}
      />

      {error && (
        <p id={errorId} className="text-sm text-red-500 mt-1" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

Bilder und Medien

// Informative image
<img src="/chart.png" alt="Bar chart showing 40% revenue growth in Q4 2025" />

// Decorative image — empty alt hides it from screen readers
<img src="/divider.png" alt="" role="presentation" />

// Complex image — use aria-describedby for long descriptions
<figure>
  <img
    src="/architecture.png"
    alt="System architecture diagram"
    aria-describedby="arch-desc"
  />
  <figcaption id="arch-desc">
    The diagram shows three tiers: frontend Next.js on port 3000,
    NestJS API on port 3001, and PostgreSQL database on port 5433.
    Redis sits between the API and database layers.
  </figcaption>
</figure>

// SVG icons used as decoration
<svg aria-hidden="true" focusable="false">
  <use href="#icon-search" />
</svg>

Automatisiertes Testen mit Axe-Core

pnpm add -D @axe-core/playwright axe-core
// tests/a11y/homepage.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Homepage accessibility', () => {
  test('should have no WCAG 2.1 AA violations', async ({ page }) => {
    await page.goto('/');

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .exclude('#third-party-widget') // Exclude known external violations
      .analyze();

    expect(results.violations).toEqual([]);
  });

  test('should be keyboard navigable', async ({ page }) => {
    await page.goto('/');
    // Tab through interactive elements and verify focus is visible
    await page.keyboard.press('Tab');
    const focusedElement = await page.evaluate(
      () => document.activeElement?.getAttribute('href')
    );
    expect(focusedElement).toBe('#main-content'); // Skip link
  });
});

Zur CI-Pipeline hinzufügen:

# .github/workflows/ci.yml
- name: Run accessibility tests
  run: cd apps/web && npx playwright test tests/a11y --reporter=html
- uses: actions/upload-artifact@v4
  with:
    name: a11y-report
    path: apps/web/playwright-report/

Häufig gestellte Fragen

Was ist der Unterschied zwischen WCAG 2.1 A, AA und AAA?

Level A ist das Minimum – ein Nichtbestehen von Level A bedeutet, dass der Inhalt für einige Benutzer grundsätzlich nicht zugänglich ist. Stufe AA ist in den meisten Gerichtsbarkeiten der Rechtsstandard und zielt auf die breitesten Benutzerbedürfnisse ab. Die Stufe AAA ist ehrgeizig – einige Kriterien können nicht für alle Inhaltstypen erfüllt werden. Streben Sie die AA-Konformität als Grundlage an und streben Sie, wo möglich, die AAA-Konformität an.

Macht die Verwendung einer Komponentenbibliothek wie shadcn/ui meine App zugänglich?

shadcn/ui basiert auf Radix-UI-Primitiven, die per Design zugänglich sind – sie umfassen korrekte ARIA-Rollen, Tastaturnavigation und Fokusverwaltung. Sie müssen jedoch weiterhin aussagekräftige Beschriftungen hinzufügen, Fehlerzustände verständlich behandeln, einen ausreichenden Farbkontrast zu Ihrem benutzerdefinierten Design sicherstellen und mit echten Hilfstechnologien testen. Komponentenbibliotheken reduzieren den Aufwand, machen aber Barrierefreiheitstests nicht überflüssig.

Wie teste ich mit einem Screenreader?

Verwenden Sie unter Windows NVDA (kostenlos) mit Firefox oder Chrome. Verwenden Sie unter macOS VoiceOver (integriert, Cmd+F5) mit Safari. Verwenden Sie auf Mobilgeräten TalkBack (Android) oder VoiceOver (iOS). Testen Sie wichtige Benutzerreisen: Ausfüllen von Formularen, modale Interaktionen, Navigation über Orientierungspunkte und Lesen dynamischer Inhalte. Screenreader-Tests erfassen Ankündigungen, Lesereihenfolge und Fokusverhalten, die automatisierten Tools entgehen.

Was ist das Roving-Tabindex-Muster?

Roving Tabindex ist das Tastaturmuster für zusammengesetzte Widgets (Registerkartenlisten, Symbolleisten, Optionsgruppen, Baumansichten). Nur ein Element in der Gruppe hat jeweils tabIndex={0} – das aktive Element. Alle anderen erhalten tabIndex={-1}. Mit den Pfeiltasten wird der Fokus innerhalb der Gruppe verschoben und aktualisiert, welches Element den tabIndex 0 hat. Dadurch wird verhindert, dass der Benutzer mit der Tabulatortaste durch jedes Element in der Gruppe scrollt – er betritt die Gruppe mit der Tabulatortaste, navigiert mit den Pfeiltasten und verlässt die Gruppe mit der Tabulatortaste.

Wie gehe ich mit der Barrierefreiheit für dynamische Inhalte um, die über AJAX geladen werden?

Verwenden Sie aria-live-Regionen für Statusaktualisierungen (Zählung der Suchergebnisse, Speicherbestätigungen). Verschieben Sie beim Ersetzen ganzer Seitenabschnitte den Fokus nach dem Laden auf die Überschrift oder den Container des neuen Inhalts. Verwenden Sie für Ladezustände aria-busy="true" für die Region, die aktualisiert wird, und eine aria-live="polite"-Region, um den Abschluss anzukündigen. Testen Sie immer mit einem Screenreader, um sicherzustellen, dass Ankündigungen klar und rechtzeitig sind.


Nächste Schritte

Die Barrierefreiheit im Internet ist eine fortlaufende Praxis und keine einmalige Prüfung. Beginnen Sie mit der Korrektur Ihres semantischen HTML- und Farbkontrasts, fügen Sie dann Tastaturnavigation und ARIA für komplexe Widgets hinzu und automatisieren Sie die WCAG-Validierung in Ihrer CI-Pipeline, um Regressionen zu erkennen.

ECOSIRE erstellt WCAG 2.1 AA-konforme Webanwendungen als Basisstandard für jedes Projekt. Wenn Sie eine Prüfung der Barrierefreiheit benötigen oder von Grund auf konform bauen möchten, entdecken Sie unsere Frontend-Engineering-Dienste.

E

Geschrieben von

ECOSIRE Research and Development Team

Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.

Chatten Sie auf WhatsApp