Design wechseln

shadcn/ui und Radix: Barrierefreiheit bei eigenen Komponenten bewahren

Letzte Woche fragte mich ein Kollege: „Warum lässt sich dieser Button nicht mit der Tastatur bedienen?“

Ich war kurz perplex. Wir nutzen doch shadcn/ui – wie kann das passieren? In den DevTools fiel auf: Er hatte Tooltip.Trigger in ein <div> gewickelt – wegen eines eigenen Styles. Genau dort lag das Problem.

Ehrlich gesagt bin ich ähnliche Fallen auch schon reingelaufen. Am Anfang mit shadcn/ui dachte ich, man könne die Komponenten „einfach so anpassen“ – schließlich liegt der Code ja im Projekt. Styling ändern, Tag tauschen, Wrapper drumherum – sieht oft harmlos aus. Bis QA eines Tages meldete: Tastatur bedient nichts, Screenreader liest nichts, der Flow bricht ab.

Dann wurde klar: Die „Freiheit“ von shadcn/ui hat einen Preis. Du bekommst Quellcode, darunter steckt aber Radix’ Barrierefreiheits-Magie. Verändert man das falsch, bricht sie weg.

Dieser Artikel erklärt das Verhältnis von shadcn/ui und Radix – mit Fokus darauf, wie du beim Anpassen Barrierefreiheit erhältst. Danach solltest du wissen, wie asChild funktioniert, wie Fokusmanagement läuft und wie ARIA vererbt wird – und beim nächsten Umbau, was geht und was tabu ist.


shadcn/ui und Radix: Was ist eigentlich was?

Zuerst etwas, das viele missverstehen: shadcn/ui ist kein npm-Paket.

Du installierst es nicht mit npm install @shadcn/ui. Es ist im Kern eine „Code-Distributionsplattform“ – du kopierst Komponentenquellcode ins Projekt, danach gehört er dir. Ändern, löschen – niemand hält dich auf.

Woher kommt die Barrierefreiheit? Von Radix.

Radix UI ist eine „unstyled“ Komponentenbibliothek, auch Primitives genannt. Kein Styling, nur Verhalten – wie Dialoge den Fokus setzen, Dropdown-Menüs Pfeiltasten behandeln, Tooltips vor Screenreadern verborgen bleiben. Alles nach WAI-ARIA, getestet mit NVDA, JAWS und VoiceOver.

shadcn/ui legt Tailwind CSS darüber: schönes Aussehen, Radix-Verhalten darunter. Der Button-Code wirkt wie ein paar Tailwind-Klassen – drin steckt aber Radix-Logik.

Kurz gesagt:

  • Radix sorgt für „funktioniert“: aria-Attribute, role, Fokusmanagement, Tastaturnavigation
  • shadcn/ui sorgt für „sieht gut aus“: Tailwind-Styling, Design-Konsistenz

Wenn du shadcn/ui-Komponenten änderst, bearbeitest du die Oberfläche – die Logik kommt von Radix. Oberfläche frei, Unterbau falsch angefasst: Ärger.


asChild: Magie oder Falle?

asChild ist ein besonderes Radix-Attribut. Die meisten Radix-Teile unterstützen es.

Beispiel: Tooltip.Trigger rendert standardmäßig ein <button>. Du willst den Tooltip aber an einen Link – dann asChild:

<Tooltip.Trigger asChild>
  <a href="/help">Hilfe-Center</a>
</Tooltip.Trigger>

Mit asChild={true} rendert Radix kein eigenes <button>, sondern klont dein Kind und übergibt Verhalten und Attribute. Der Link hat alle Tooltip-Trigger-Funktionen: Hover, Tastaturfokus, korrekte aria-Attribute.

Praktisch – und tückisch.

Wechselst du zu einem nicht fokussierbaren Element, ist Barrierefreiheit weg.

// ❌ Falsches Beispiel
<Tooltip.Trigger asChild>
  <div className="my-custom-wrapper">Klick mich</div>
</Tooltip.Trigger>

Ein div ist per Tastatur nicht fokussierbar (ohne tabIndex={0}), reagiert nicht auf Enter/Space. Screenreader sehen keinen Button. Tastatur-Nutzer „erreichen“ den Tooltip nicht.

Radix-Doku: „If you were to switch it to a div, it would no longer be accessible.“

Meist tauscht man nicht direkt gegen div. Häufiger: eigene React-Komponente:

<Tooltip.Trigger asChild>
  <MyButton>Klick mich</MyButton>
</Tooltip.Trigger>

Das geht – mit zwei Pflichtregeln:

1. Deine Komponente muss props spreaden

Radix klont das Kind und übergibt Handler, aria-Attribute, ref. Nimmst du die nicht an, bricht die Funktion ab.

// ❌ Falsch: keine props
const MyButton = () => <button className="btn">...</button>

// ✅ Richtig: alle props spreaden
const MyButton = (props) => <button className="btn" {...props}>...</button>

2. Deine Komponente muss forward ref nutzen

Radix greift manchmal direkt auf das DOM zu (Größe, Fokus). Ohne ref gibt es Fehler.

// ❌ Falsch: kein ref
const MyButton = (props) => <button {...props}>...</button>

// ✅ Richtig: forward ref
const MyButton = React.forwardRef((props, ref) => (
  <button {...props} ref={ref}>...</button>
))

Diese Regeln gelten nicht nur für Radix – jede „Leaf“-Komponente sollte props und ref durchreichen.

Noch ein Trick: mehrere Radix-Komponenten verschachteln.

<Tooltip.Trigger asChild>
  <Dialog.Trigger asChild>
    <MyButton>Dialog öffnen</MyButton>
  </Dialog.Trigger>
</Tooltip.Trigger>

Ein Button, gleichzeitig Tooltip- und Dialog-Trigger – beides funktioniert.


Fokusmanagement und Tastaturnavigation

Fokus ist der am leichtesten übersehene Teil von Barrierefreiheit.

Viele denken nur ans Styling – Tastatur- und Screenreader-Nutzer hängen am Fokus.

Radix automatisiert viel. Beispiel:

Beim Öffnen eines AlertDialog springt der Fokus auf Cancel.

Bewusst so designed: AlertDialog bestätigt gefährliche Aktionen (Löschen, Beenden). Wahrscheinlichste Aktion ist „Abbrechen“. Fokus auf Cancel – Enter schließt, kein versehentliches Bestätigen.

Liege der Fokus auf Confirm und jemand drückt Enter – gelöscht. Radix folgt WAI-ARIA Authoring Practices; du musst das nicht selbst coden.

Aber: Eigener AlertDialog-Inhalt kann den Fokus verlegen.

Du fügst ein Eingabefeld hinzu:

<AlertDialog.Content>
  <AlertDialog.Title>Löschen bestätigen?</AlertDialog.Title>
  <AlertDialog.Description>Bitte „DELETE“ eingeben</AlertDialog.Description>
  <input placeholder="DELETE eingeben" />  {/* dein Feld */}
  <AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
  <AlertDialog.Action>Bestätigen</AlertDialog.Action>
</AlertDialog.Content>

Wohin geht der Fokus beim Öffnen?

Radix sucht das erste fokussierbare Element. Dein input steht vor Cancel – Fokus landet dort. Mehrere Tabs bis Cancel. Flow unterbrochen.

Lösung: autoFocus setzen oder Reihenfolge anpassen.

<AlertDialog.Content>
  <AlertDialog.Title>Löschen bestätigen?</AlertDialog.Title>
  <AlertDialog.Description>Bitte „DELETE“ eingeben</AlertDialog.Description>
  <AlertDialog.Cancel autoFocus>Abbrechen</AlertDialog.Cancel>  {/* Fokus erzwingen */}
  <input placeholder="DELETE eingeben" />
  <AlertDialog.Action>Bestätigen</AlertDialog.Action>
</AlertDialog.Content>

Cancel nach vorne oder autoFocus – dann bleibt der Fokus richtig.

Tastaturnavigation: ähnliche Fallen.

Tabs: Links/Rechts wechseln Tabs – WAI-ARIA-Standard. Überschreibst du role="tab" beim Styling, bricht die Navigation.

Dropdown Menu: Pfeile wählen, Enter bestätigt, Esc schließt – Radix intern. onClick statt onSelect auf Menüeinträgen kann Tastaturverhalten stören.

Test simpel: Maus weg, nur Tastatur durch den ganzen Flow.

  • Tab erreicht die Komponente?
  • Pfeiltasten wechseln Optionen?
  • Enter löst Aktionen aus?
  • Esc schließt Dialoge?

Hakt eine Stelle – Barrierefreiheit kaputt.


Automatische ARIA-Vererbung

Bei ARIA spart Radix viel Arbeit.

Es setzt passende role- und aria-*-Attribute:

  • Dialog: role="dialog" und aria-modal="true"
  • Tabs.Tab: role="tab" und aria-selected
  • Switch: role="switch" und aria-checked

Radix erledigt das intern.

Eine Sache bleibt bei dir: accessible name für jedes Steuerelement.

Screenreader-Nutzer müssen wissen, was ein Button tut, wie ein Dialog heißt, wofür ein Feld ist. Ohne Namen nur Raten.

Radix bietet Label primitive:

<Label.Root htmlFor="email-input">E-Mail-Adresse</Label.Root>
<Input id="email-input" />

Label.Root verknüpft mit dem input – der Screenreader sagt zuerst „E-Mail-Adresse“, dann den Wert.

Bei eigenen Controls (kein natives input) Name manuell setzen:

<Switch aria-label="Nachtmodus aktivieren" />
<Tabs.Tab aria-label="Produktdetails" />

Oder aria-labelledby mit sichtbarem Text:

<div id="mode-label">Nachtmodus</div>
<Switch aria-labelledby="mode-label" />

Validierung: Screenreader an, einmal durchklicken.

Mac: VoiceOver (Cmd+F5). Windows: NVDA (kostenlos). Klingt es „Button“ statt „Bestellung absenden“ – fehlt der accessible name.

Außerdem: Farbkontrast.

Radix styled nicht – Kontrast ist deine Aufgabe. WCAG: mindestens 4,5:1 (Normaltext) oder 3:1 (großer Text). shadcn/ui-Defaults meist ok; eigene Farben prüfen.

Tool: WebAIM Contrast Checker – Vorder- und Hintergrundfarbe eingeben.


Praxis-Checkliste

Nach jeder Anpassung an shadcn/ui-Komponenten:

asChild

  • Ist das Kind fokussierbar? (button/a/input, kein div)
  • Spreadet die eigene Komponente alle props?
  • Forward ref?

Fokusmanagement

  • Landet der Fokus beim Öffnen richtig?
  • Kehrt er nach Schließen zum Trigger zurück?
  • Bei verschachtelten Fokus-Elementen: sinnvolle Tab-Reihenfolge?

Tastaturnavigation

  • Tab erreicht die Komponente?
  • Pfeiltasten bei Tabs/Dropdown?
  • Enter löst Aktionen aus?
  • Esc schließt Dialoge?
  • Space toggelt Switch/Checkbox?

ARIA

  • Hat jedes Steuerelement einen accessible name?
  • Liest der Screenreader Rolle und Status korrekt?
  • Dynamische Änderungen mit passendem aria-live?

Visuell

  • Fokus-Indikator klar sichtbar?
  • Kontrast 4,5:1 bzw. 3:1?
  • Information nicht nur über Farbe (Icons/Text dazu)?

Test-Tools

  • Tastatur: Maus weg, Flow nur mit Tastatur
  • Screenreader: VoiceOver (Mac) oder NVDA (Windows)
  • Automatisiert: axe DevTools Browser-Erweiterung

Fazit

shadcn/ui gibt dir Code-Freiheit – mit Grenzen.

Die Grenze ist Radix’ Barrierefreiheits-Verhalten. Styling, Layout, Klassen – ok. Unterbau kaputt machen – nicht ok. button durch div, props vergessen – Tastatur-Nutzer leiden.

Merken:

  • asChild: Kind fokussierbar, spread props + forward ref
  • Fokus: Bei eigenem Dialog-Inhalt prüfen, wohin der Fokus geht
  • ARIA: Radix setzt role – du lieferst label

Beim nächsten Umbau: erst Tastatur durch den Flow. Problem gefunden – fixen, nicht auf QA warten.

Barrierefreiheit ist keine Extra-Funktion, sondern Basis. shadcn/ui und Radix erledigen das Schwerste – übrig bleibt, die gute Arbeit nicht zu zerstören.

FAQ

Wie hängen shadcn/ui und Radix UI zusammen?
shadcn/ui ist eine „Code-Distributionsplattform“ – du kopierst Quellcode ins Projekt. Barrierefreiheit kommt von Radix Primitives: aria-Attribute, Fokusmanagement, Tastaturnavigation. shadcn/ui liefert nur Tailwind-Styling.
Wie nutzt man asChild, ohne Barrierefreiheit zu zerstören?
Drei Regeln: Das Kind muss fokussierbar sein (button/a/input), kein div; die eigene Komponente muss props spreaden, also props => <button {...props} />; die Komponente muss forward ref nutzen.
Worauf achten beim Fokus in eigenen Dialog-Inhalten?
AlertDialog setzt den Fokus standardmäßig auf Cancel. Fügst du Eingabefelder hinzu, kann der Fokus daneben landen. Mit autoFocus das Ziel setzen oder die Element-Reihenfolge anpassen.
Wie testet man, ob Barrierefreiheit funktioniert?
Am einfachsten: Maus weglegen und nur die Tastatur nutzen. Tab erreicht die Komponente? Pfeiltasten wechseln Optionen? Enter löst Aktionen aus? Esc schließt Dialoge? Danach Screenreader testen (Mac: VoiceOver, Windows: NVDA).
Radix setzt aria-Attribute automatisch – was bleibt für mich?
Du musst jedem Steuerelement einen accessible name geben. Screenreader-Nutzer müssen wissen, was ein Button tut oder wie ein Dialog heißt. Nutze aria-label oder aria-labelledby mit sichtbarem Text.

7 Min. Lesezeit · Veröffentlicht am: 30. März 2026 · Aktualisiert am: 13. Juli 2026

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog