eigene barrierefreie Komponenten jenseits von Radix
Headless UI Patterns trennen Interaktionslogik konsequent von der visuellen Darstellung, sodass Teams eigenes Design ohne Kompromisse bei Tastatursteuerung und ARIA-Semantik umsetzen koennen. Wer versteht, wie Custom Hooks, Compound Components und State Machines zusammenspielen, braucht fuer viele Anwendungsfaelle keine externe Headless-Bibliothek mehr.
Inhaltsverzeichnis
- 1. Was Headless UI Patterns wirklich loesen
- 2. Das Grundprinzip: Logik und Darstellung trennen
- 3. Custom Hooks als Headless Fundament
- 4. Compound Components ohne fremde Bibliothek
- 5. State Machines fuer komplexe Interaktionslogik
- 6. Eigenes Headless Primitive: eine Listbox von Grund auf
- 7. Barrierefreiheit in Headless Komponenten
- 8. Wann Radix, wann eigene Headless Patterns
- 9. Headless UI Patterns im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was Headless UI Patterns wirklich loesen
Ein Headless UI Pattern trennt die Interaktionslogik einer Komponente, also Tastatursteuerung, Fokus-Management, ARIA-Attribute und Zustandsverwaltung, konsequent von deren visueller Darstellung. Anders als bei fertigen Design Systemen liefert ein Headless Pattern keine CSS-Klassen und kein fertiges Aussehen, sondern nur die Logik plus die richtigen Props, damit man selbst rendern kann. Das Ergebnis ist eine Komponente, die sich in jedes Design System einfuegt, ohne dass man gegen fremde Stile ankaempfen muss.
In der Praxis begegnen viele Teams Headless UI Patterns zuerst ueber Bibliotheken wie Radix oder Headless UI von Tailwind Labs. Das ist ein guter Startpunkt, aber es ist wichtig zu verstehen, dass hinter diesen Bibliotheken keine Magie steckt, sondern wiederkehrende Headless UI Patterns: Custom Hooks fuer Zustand, Compound Components fuer Komposition und teils State Machines fuer komplexe Ablaeufe. Wer diese Bausteine selbst beherrscht, kann eigene, schlankere Headless Komponenten bauen, wenn eine fertige Bibliothek zu viel Gewicht oder zu wenig Flexibilitaet mitbringt.
2. Das Grundprinzip: Logik und Darstellung trennen
Der Kern jedes Headless UI Patterns ist die radikale Trennung zwischen dem, was eine Komponente tut, und dem, wie sie aussieht. Ein Dropdown muss wissen, ob es geoeffnet ist, welches Element den Fokus hat und wie es auf Pfeiltasten reagiert. Es muss aber nicht wissen, ob der Hintergrund weiss oder dunkelblau ist. Diese Trennung erreicht man in React durch drei komplementaere Techniken: Custom Hooks kapseln Zustand und Verhalten, Compound Components teilen diesen Zustand ueber Context an mehrere Kindkomponenten, und Render Props oder die Children-as-Function Technik geben dem Aufrufer volle Kontrolle ueber das Markup.
Der Vorteil dieser Headless UI Patterns zeigt sich besonders in groesseren Organisationen mit mehreren Produktteams. Jedes Team kann dieselbe Interaktionslogik wiederverwenden und trotzdem ein eigenes visuelles Erscheinungsbild pflegen, das zum jeweiligen Markenauftritt passt. Ohne diese Trennung wuerde jedes Team entweder die komplette Logik neu implementieren oder gezwungen sein, ein fremdes visuelles Grundgeruest zu uebernehmen, das nicht zur eigenen Marke passt.
3. Custom Hooks als Headless Fundament
Der einfachste Einstieg in Headless UI Patterns ist ein Custom Hook, der den kompletten Zustand einer Komponente kapselt und dabei keine JSX zurueckgibt. Ein useDisclosure-Hook etwa verwaltet nur ein Boolean plus Funktionen zum Oeffnen, Schliessen und Umschalten. Ein useListNavigation-Hook verwaltet den aktiven Index einer Liste und reagiert auf Tastaturereignisse. Diese Hooks sind vollstaendig UI-agnostisch: Sie funktionieren identisch, ob man ein Modal, ein Dropdown oder ein Toast baut.
Wichtig bei diesem Headless UI Pattern ist, dass der Hook alle noetigen ARIA-Props als fertiges Objekt zurueckgibt, das per Spread-Operator auf das jeweilige Element angewendet wird. Dadurch muss der Aufrufer nicht wissen, welche aria-*-Attribute im Detail noetig sind, sondern bekommt sie automatisch korrekt gesetzt. Das reduziert Fehler bei der Barrierefreiheit erheblich, weil die ARIA-Logik an genau einer Stelle im Hook gepflegt wird und nicht in jeder Komponente neu abgeschrieben werden muss.
// useDisclosure.js — headless state hook, no rendering logic at all
import { useCallback, useId, useState } from "react";
export function useDisclosure(defaultOpen = false) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const contentId = useId();
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
// Ready-to-spread prop bundles — the caller renders, the hook decides
const triggerProps = {
"aria-expanded": isOpen,
"aria-controls": contentId,
onClick: toggle,
};
const contentProps = {
id: contentId,
hidden: !isOpen,
role: "region",
};
return { isOpen, open, close, toggle, triggerProps, contentProps };
}
// Usage — the hook has zero opinion about markup or styling
function Accordion({ title, children }) {
const { triggerProps, contentProps } = useDisclosure();
return (
<div className="my-own-styles">
<button {...triggerProps}>{title}</button>
<div {...contentProps}>{children}</div>
</div>
);
}
4. Compound Components ohne fremde Bibliothek
Sobald eine Komponente aus mehreren zusammengehoerigen Teilen besteht, etwa Tabs mit Tabliste und Panels, reicht ein einzelner Hook nicht mehr aus. Hier kommt das Headless UI Pattern Compound Components ins Spiel: Eine Elternkomponente haelt den gemeinsamen Zustand in einem Context, und mehrere Kindkomponenten konsumieren diesen Context, um sich passend zu verhalten. Der Aufrufer entscheidet dabei komplett selbst, wie viele Tabs es gibt und in welcher Reihenfolge sie im Markup erscheinen, waehrend die Elternkomponente nur Koordination uebernimmt.
Der entscheidende Unterschied zu einer monolithischen Komponente mit einer riesigen Props-Liste ist Flexibilitaet. Statt <Tabs items={[...]} renderTab={...} /> mit dutzenden Konfigurationsoptionen schreibt man einfach JSX, das die Struktur direkt abbildet. Dieses Headless UI Pattern ist auch der Grund, warum Radix und Headless UI fast durchgaengig auf Compound Components setzen: Es skaliert mit der Komplexitaet der UI, ohne dass die API dabei explodiert.
// Tabs.jsx — compound component built from scratch, no external library
import { createContext, useContext, useId, useState } from "react";
const TabsContext = createContext(null);
function Tabs({ defaultValue, children }) {
const [active, setActive] = useState(defaultValue);
const groupId = useId();
return (
<TabsContext.Provider value={{ active, setActive, groupId }}>
<div className="tabs-root">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }) {
return <div role="tablist">{children}</div>;
}
function Tab({ value, children }) {
const { active, setActive, groupId } = useContext(TabsContext);
const isActive = active === value;
return (
<button
role="tab"
id={`${groupId}-tab-${value}`}
aria-selected={isActive}
aria-controls={`${groupId}-panel-${value}`}
tabIndex={isActive ? 0 : -1}
onClick={() => setActive(value)}
>
{children}
</button>
);
}
function Panel({ value, children }) {
const { active, groupId } = useContext(TabsContext);
if (active !== value) return null;
return (
<div role="tabpanel" id={`${groupId}-panel-${value}`} tabIndex={0}>
{children}
</div>
);
}
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = Panel;
export { Tabs };
// Usage — the shape of the JSX mirrors the shape of the UI
// <Tabs defaultValue="overview">
// <Tabs.List>
// <Tabs.Tab value="overview">Overview</Tabs.Tab>
// <Tabs.Tab value="details">Details</Tabs.Tab>
// </Tabs.List>
// <Tabs.Panel value="overview">...</Tabs.Panel>
// <Tabs.Panel value="details">...</Tabs.Panel>
// </Tabs>
5. State Machines fuer komplexe Interaktionslogik
Einfache Boolean-Zustaende reichen fuer Accordions und Toggles, aber sobald eine Komponente mehrere sich gegenseitig ausschliessende Zustaende hat, etwa ein Combobox mit den Zustaenden geschlossen, geoeffnet, filternd und ausgewaehlt, wird ein einfacher useState-Aufruf schnell unuebersichtlich. Genau hier zeigt ein weiteres Headless UI Pattern seine Staerke: eine explizite State Machine, entweder handgeschrieben mit useReducer oder mit einer Bibliothek wie XState. Jeder Zustand definiert klar, welche Ereignisse in ihm erlaubt sind und in welchen Folgezustand sie fuehren.
Der Vorteil dieses Headless UI Patterns liegt in der Unmoeglichkeit ungueltiger Zustandskombinationen. Ein Reducer mit klar definierten Transitions kann nicht in einen Zustand geraten, in dem gleichzeitig "geschlossen" und "Option ausgewaehlt" gilt, was bei mehreren unabhaengigen Booleans leicht passiert. Fuer Headless Komponenten mit vielen Interaktionspfaden, etwa Datepicker oder Multi-Select, ist eine State Machine oft der einzige Weg, die Komplexitaet langfristig beherrschbar zu halten.
// useComboboxMachine.js — explicit states instead of scattered booleans
import { useReducer } from "react";
const initialState = { status: "closed", query: "", activeIndex: -1 };
function reducer(state, action) {
switch (action.type) {
case "OPEN":
return { ...state, status: "open", activeIndex: 0 };
case "TYPE":
return { ...state, status: "filtering", query: action.query, activeIndex: 0 };
case "MOVE":
return { ...state, activeIndex: action.index };
case "SELECT":
return { status: "closed", query: action.label, activeIndex: -1 };
case "CLOSE":
return { ...state, status: "closed" };
default:
return state;
}
}
export function useComboboxMachine() {
const [state, dispatch] = useReducer(reducer, initialState);
// Every transition is explicit — no impossible combined states possible
return { state, dispatch };
}
6. Eigenes Headless Primitive: eine Listbox von Grund auf
Um zu zeigen, wie sich die vorherigen Bausteine zu einem vollstaendigen Headless UI Pattern zusammenfuegen, lohnt ein konkretes Beispiel: eine Listbox, wie sie ein Select-Element ersetzt, wenn man mehr Kontrolle ueber die Darstellung braucht. Der Hook useListbox verwaltet den aktiven Index, reagiert auf Pfeil-hoch, Pfeil-runter, Home, End und Enter, und liefert fertige Props fuer die Liste und jedes Item. Die visuelle Komponente selbst enthaelt keine einzige Zeile Interaktionslogik mehr, sondern nur noch Markup und Klassen.
Dieses Muster laesst sich auf beliebig komplexere Komponenten uebertragen: Ein Datepicker braucht zusaetzlich Datumsberechnungen, ein Multi-Select braucht ein Array statt eines einzelnen Werts, aber das grundlegende Headless UI Pattern aus Zustands-Hook, Tastaturbehandlung und fertigen Props bleibt identisch. Wer dieses eine Beispiel verinnerlicht hat, kann es auf die meisten interaktiven Komponenten uebertragen, ohne jedes Mal bei null anzufangen.
// useListbox.js — full keyboard-driven headless listbox primitive
import { useCallback, useState } from "react";
export function useListbox(items) {
const [activeIndex, setActiveIndex] = useState(0);
const [selected, setSelected] = useState(null);
const onKeyDown = useCallback(
(event) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setActiveIndex((i) => Math.min(i + 1, items.length - 1));
break;
case "ArrowUp":
event.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
break;
case "Home":
event.preventDefault();
setActiveIndex(0);
break;
case "End":
event.preventDefault();
setActiveIndex(items.length - 1);
break;
case "Enter":
case " ":
event.preventDefault();
setSelected(items[activeIndex]);
break;
default:
break;
}
},
[activeIndex, items]
);
const getListProps = () => ({ role: "listbox", tabIndex: 0, onKeyDown });
const getItemProps = (index) => ({
role: "option",
"aria-selected": items[index] === selected,
"data-active": index === activeIndex,
onClick: () => setSelected(items[index]),
});
return { activeIndex, selected, getListProps, getItemProps };
}
7. Barrierefreiheit in Headless Komponenten
Barrierefreiheit ist keine nachtraegliche Ergaenzung zu Headless UI Patterns, sondern deren eigentlicher Existenzgrund. Der wichtigste Grundsatz ist Roving Tabindex: Innerhalb einer zusammengehoerigen Gruppe wie einer Listbox oder Toolbar hat immer nur ein Element tabIndex={0}, alle anderen haben tabIndex={-1}. Beim Wechsel des aktiven Elements per Pfeiltaste wird der Tabindex verschoben und der Fokus programmatisch mit element.focus() gesetzt. So springt der Tab-Fokus des Browsers direkt in die Gruppe hinein und wieder heraus, statt jedes einzelne Item einzeln anzuspringen.
Genauso wichtig ist Fokus-Restauration: Wenn ein Modal oder Popover per Escape geschlossen wird, muss der Fokus zuverlaessig zu dem Element zurueckkehren, das es geoeffnet hat. Ein Headless UI Pattern dafuer ist, die Referenz auf das aufrufende Element in einem Ref zu speichern, bevor der Dialog geoeffnet wird, und beim Schliessen previouslyFocusedElement.current.focus() aufzurufen. Ergaenzt um korrekte aria-*-Attribute und einen Fokus-Trap innerhalb von Modals ergibt sich eine Komponente, die mit Tastatur und Screenreader genauso gut funktioniert wie mit der Maus.
8. Wann Radix, wann eigene Headless Patterns
Radix und Headless UI sind hervorragend getestete, produktionsreife Implementierungen genau der Headless UI Patterns, die in diesem Artikel erklaert wurden. Fuer Standardkomponenten wie Dialog, Popover, Dropdown-Menu oder Tooltip spricht viel dafuer, eine dieser Bibliotheken zu nutzen, statt Randfaelle bei Fokus-Trapping und Portal-Rendering selbst zu loesen. Der Aufwand, ein Dialog-Primitive mit korrektem Fokus-Trap, Escape-Handling und Scroll-Lock von Grund auf zu bauen und in allen Browsern zu testen, ist real und wird leicht unterschaetzt.
Eigene Headless UI Patterns lohnen sich dagegen, wenn eine Komponente sehr spezifisches Fachwissen abbildet, das keine generische Bibliothek anbietet, wenn das Bundle-Budget besonders eng ist, oder wenn ein Team bereits eine eigene State-Management-Architektur hat, in die eine fremde Bibliothek nicht sauber passt. In der Praxis ist eine Mischung typisch: Radix fuer Standardprimitive, eigene Hooks fuer domaenenspezifische Komponenten wie einen Produktkonfigurator oder eine Preistabelle mit ungewoehnlicher Interaktionslogik.
9. Headless UI Patterns im direkten Vergleich
Die folgende Tabelle stellt die wichtigsten Ansaetze fuer Headless UI Patterns gegenueber und zeigt, wann welcher Ansatz die bessere Wahl ist.
| Ansatz | Staerke | Schwaeche | Typischer Einsatz |
|---|---|---|---|
| Custom Hook | Minimal, leicht testbar | Reicht nicht bei mehreren Kindkomponenten | Toggle, Disclosure, einfacher Zaehler |
| Compound Components | Flexible JSX-Struktur, kein Props-Wildwuchs | Braucht Context, mehr Boilerplate | Tabs, Accordion, Steps |
| State Machine | Unmoegliche Zustaende ausgeschlossen | Hoehere Einstiegshuerde | Combobox, Multi-Step-Formular |
| Radix / Headless UI | Getestet, barrierefrei, Portal inklusive | Zusaetzliche Abhaengigkeit, weniger Kontrolle | Dialog, Popover, Dropdown-Menu |
| Eigenes Primitive | Volle Kontrolle, kleinstes Bundle | Pflegeaufwand liegt komplett im Team | Domaenenspezifische Komponenten |
Die Wahl zwischen diesen Headless UI Patterns ist selten binaer. Die meisten produktionsreifen Design Systeme kombinieren mehrere Ansaetze innerhalb desselben Projekts, je nachdem, wie komplex und wie spezifisch die jeweilige Komponente ist. Wichtig ist, die Entscheidung bewusst zu treffen und nicht aus Gewohnheit immer denselben Ansatz zu waehlen, unabhaengig von der tatsaechlichen Komplexitaet der Komponente.
Mironsoft
React Component Design und Barrierefreiheit
Eigene Headless Komponenten fuer euer Design System?
Wir entwerfen Headless UI Patterns, die zu eurer bestehenden Architektur passen, von Custom Hooks bis zu vollstaendig barrierefreien Compound Components mit Tastatursteuerung und ARIA-Semantik.
Architektur-Review
Bestehende Komponenten auf Headless-Tauglichkeit pruefen
Komponenten-Bau
Hooks, Compound Components und State Machines nach Mass
Barrierefreiheits-Audit
Tastatursteuerung und ARIA gegen WCAG pruefen
10. Zusammenfassung
Headless UI Patterns loesen ein wiederkehrendes Problem: Interaktionslogik soll wiederverwendbar sein, ohne ein bestimmtes visuelles Erscheinungsbild zu erzwingen. Custom Hooks kapseln einfachen Zustand, Compound Components koordinieren mehrere zusammengehoerige Kindkomponenten ueber Context, und State Machines schliessen unmoegliche Zustandskombinationen bei komplexen Interaktionen aus. Barrierefreiheit, insbesondere Roving Tabindex und Fokus-Restauration, ist dabei kein optionales Extra, sondern der eigentliche Grund, warum sich der zusaetzliche Aufwand lohnt.
Radix und Headless UI bleiben fuer Standardkomponenten die pragmatische Wahl, weil sie diese Headless UI Patterns bereits getestet implementiert haben. Fuer domaenenspezifische Komponenten, bei denen keine fertige Bibliothek passt, oder wenn das Bundle-Budget eng ist, lohnt sich der Blick auf die zugrunde liegenden Muster selbst. Wer Custom Hooks, Compound Components und State Machines beherrscht, ist nicht mehr von einer einzelnen Bibliothek abhaengig und kann fundierte Entscheidungen treffen, welcher Ansatz fuer welche Komponente passt.
Headless UI Patterns in React — Das Wichtigste auf einen Blick
Grundprinzip
Logik und Darstellung trennen. Der Hook entscheidet was passiert, der Aufrufer entscheidet wie es aussieht.
Bausteine
Custom Hooks fuer Zustand, Compound Components fuer Komposition, State Machines fuer komplexe Ablaeufe.
Barrierefreiheit
Roving Tabindex und Fokus-Restauration sind Pflicht, nicht optional. ARIA-Props gehoeren in den Hook.
Radix vs. eigen
Radix fuer Standardprimitive nutzen, eigene Patterns fuer domaenenspezifische Komponenten bauen.