P

Headless UI in React 19: Ein Erfahrungsbericht über volle Kontrolle im Design

TypeScript patterns for large codebases
React internals and practical implications
·19 Min. Lesezeit
Prasath Soosaithasan
von Prasath Soosaithasan
Ein kopfloser Entwickler-Zombie sitzt an einem unaufgeräumten Schreibtisch in einem gemütlichen Schlafzimmerstudio, über dem Hals schweben Fragezeichen.

Eine Designerin wirft ein Figma-File in den Channel. Die Select-Komponente hat ein Custom-Suchfeld mit Fuzzy-Matching, gruppierte Optionen, Tastaturnavigation, die sich wie das native Plattformverhalten anfühlt, und eine Open-Animation, die keine fertige Bibliothek unterstützt. Deadline ist Donnerstag. Die Komponente muss nächsten Monat ein Accessibility-Audit bestehen.

Man hat zwei Optionen. Eine monolithische Komponentenbibliothek nehmen und drei Tage damit verbringen, gegen deren Styling-Interna zu kämpfen, deren Meinungen zu überschreiben und genug Hacks drum herum zu bauen, damit das Design stimmt — im Wissen, dass das Audit-Team Schwierigkeiten haben wird, die ARIA-Implementierung unter den eigenen Anpassungen zu verifizieren. Oder: einen Headless-Hook für Tastaturnavigation und ARIA-Attribute nehmen, mit dem eigenen Markup verknüpfen, mit dem eigenen System stylen, ausliefern. Pixelgenau. Spec-konform. An einem Nachmittag auditierbar. Fertig.

Dieser Artikel handelt von der zweiten Option. Nicht als Theorie — als spezifische, meinungsstarke Architektur, die produktiv läuft in einem Nx-Monorepo mit ~70 Komponenten, Atomic-Design-Schichten, React 19, TailwindCSS v4 und CVA.

Was hier vermittelt wird, ist ein vollständiges mentales Modell für den Aufbau von Design-Systemen mit Headless-Primitives. Zunächst: Was Headless-Komponenten und Headless-Utilities tatsächlich sind — nicht der Marketing-Pitch, sondern die Engineering-Realität. Eine Headless-Komponente oder ein Headless-Utility liefert Verhalten und Accessibility-Logik ohne jeglichen visuellen Output. Kein CSS. Keine DOM-Meinungen. Keine Styling-Runtime. Nur Hooks und Prop-Getters, die die wahrhaftig schwierigen Teile übernehmen — Tastaturnavigation, ARIA-Attribut-Management, Fokus-Trapping, Positionierungsmathematik — und einem die Kontrolle zurückgeben. Man liefert das Markup. Man liefert das Styling. Man besitzt jedes Pixel, jeden Klassennamen, jedes gerenderte Element.

Diese Unterscheidung ist entscheidend, weil sie die oben skizzierten Probleme direkt löst. Wenn die Behavior-Schicht keine Meinungen über das Aussehen mitliefert, gibt es nichts zu überschreiben. Wenn ARIA-Patterns in isolierten, gut dokumentierten Hooks implementiert sind, ist deren Auditierung ein lösbares Problem. Wenn keine CSS-in-JS-Runtime eingebacken ist, funktionieren Server Components ohne 'use client'-Notausgänge. Der Headless-Ansatz arbeitet nicht um die Probleme monolithischer Libraries herum — er umgeht sie vollständig, indem er sie gar nicht erst erzeugt.

Der entscheidende Punkt: Es geht nicht darum, eine einzige Headless-Bibliothek zu wählen und sich auf alles darin festzulegen. Es gibt keine einzelne Headless-Mega-Library, die alles kann — und genau das ist der Punkt. Man wählt für jede Zuständigkeit die jeweils beste Lösung. Floating UI für Positionierung. Downshift für Select- und Combobox-Verhalten. TanStack Table für Table-State. Embla für Karussells. Jede Bibliothek ist unabhängig installierbar, unabhängig aktualisierbar und liefert nur das aus, was man tatsächlich nutzt — keine Tree-Shaking-Akrobatik, um zu vermeiden, dass eine komplette Komponentensuite mitgezogen wird, wenn man einen einzigen Hook braucht. Man stellt ein Ensemble fokussierter Tools zusammen, von denen jedes seinen Platz durch technische Eignung verdient, nicht weil es mit etwas anderem gebündelt war.

Und bei einer signifikanten Anzahl von Komponenten — rund 30% dieses Systems — hat sich die richtige Headless-Bibliothek als gar keine Bibliothek herausgestellt. Wenn das ARIA-Pattern einfach ist und die Browser-Plattform die schwierigen Teile bereits übernimmt, schlägt ein Custom-Hook mit nativen APIs jede zusätzliche Dependency. Dieser Artikel deckt beide Seiten ab: Wann man zu einer Headless-Bibliothek greifen sollte und wann die Komplexität keine rechtfertigt.

Die Bibliotheken, die ihren Platz verdienen, die, die es nicht tun, und die spezifischen architektonischen Entscheidungen, die das gesamte System framework-agnostisch machen. Dieselben Komponenten laufen unverändert in Next.js, Vite, Remix, Astro — überall dort, wo React läuft. Diese Portabilität ist kein Zufall. Sie ist eine direkte Konsequenz der Headless-Architektur.

Die alte Welt: Warum fertige Component Libraries Sinn ergaben — und dann aufhörten

Material UI und Chakra UI lösten ein reales Problem. 2018 war es tatsächlich schwierig, eine produktionstaugliche Select-Komponente mit korrekter Tastaturnavigation, ARIA-Attributen und Fokus-Management auszuliefern. Diese Bibliotheken bündelten Verhalten, Styling und Accessibility in einer Dependency, und Teams waren dadurch schneller.

Der Deal war einfach: Man akzeptiert die Meinungen der Bibliothek darüber, wie Komponenten aussehen und sich verhalten, und bekommt dafür Geschwindigkeit. Für interne Tools und MVPs war das — und ist es manchmal noch — der richtige Trade-off.

Drei Dinge haben diesen Deal gebrochen:

  • Markenanforderungen divergierten. In dem Moment, in dem eine Designerin sagt „dieses Dropdown muss nach unserem Produkt aussehen, nicht nach Googles Material Spec", kämpft man gegen die Bibliothek. MUIs sx-Prop und Theme-Overrides sind mächtig, aber sie sind Notausgänge — und Notausgänge, die bei jeder Komponente benutzt werden, sind keine Notausgänge mehr, sondern die eigentliche Styling-Strategie, nur mit schlechteren Ergonomics.
  • React Server Components kamen. Runtime CSS-in-JS (Emotion, styled-components) benötigt JavaScript-Ausführung zur Style-Generierung. In jedem RSC-fähigen Framework bedeutet das, Komponenten in 'use client'-Boundaries zu wrappen, was den Sinn von Server Components untergräbt. MUI v5's gesamter Styling-Layer basiert auf Emotion. Chakra v2 ebenso. Man zahlt jetzt reale architektonische Kosten für die Styling-Meinungen einer Bibliothek, gegen die man ohnehin schon kämpft. Und das Problem potenziert sich: Komponenten, die eng an eine CSS-in-JS-Runtime gekoppelt sind, lassen sich nicht sauber zwischen Frameworks verschieben. Sie sind eingesperrt.
  • WCAG 2.2 wurde zur harten Anforderung. Wenn Accessibility ein Compliance-Kriterium ist, muss man seine ARIA-Implementierung auditieren können. Eine Black Box zu auditieren ist langsam und teuer. Ein Headless-Primitive zu auditieren, bei dem die ARIA-Pattern-Implementierung dokumentiert und der Styling-Layer separat ist? Das ist ein lösbares Problem.

Das wurde bei einem Engagement mit der AXA Versicherungen AG im Januar 2025 konkret spürbar. Das Design-System musste mehrere Markenkontexte bedienen, Accessibility-Audits nach EN 301 549 bestehen und in mehreren React-Applikationen deployed werden — einige server-gerendert, einige SPAs, einige noch nicht final entschieden. Die bestehende Komponentenbibliothek — mit ihrem Runtime-CSS-in-JS-Layer — kämpfte gegen jede einzelne dieser Anforderungen gleichzeitig. Theme-Overrides für Multi-Brand-Support waren fragil. Die Emotion-Dependency erzwang überall dort Client-Boundaries, wo Server-Rendering eingesetzt wurde. Das Audit-Team konnte ARIA-Attribute nicht sauber durch Schichten von Styled-Wrappern nachverfolgen. Und die Komponenten waren an das Rendering-Modell eines einzigen Frameworks gebunden. Dieses Projekt war der Wendepunkt: Die monolithische Bibliothek sparte keine Zeit mehr — sie kostete welche.

Was „Headless + Styled" tatsächlich bedeutet: Das Drei-Schichten-Modell

Das Pattern ist eine bewusste Trennung dreier Zuständigkeiten, die monolithische Libraries zuvor vermengt haben. In einer produktiven Codebasis sollte diese Trennung strukturell erzwungen werden — nicht durch Konventionen, nicht durch Code Reviews, sondern durch das Dateisystem und die API-Verträge zwischen den Schichten.

  1. Behavior (Headless Layer) — Entweder ein Third-Party-Hook (downshift, @floating-ui/react, @tanstack/react-table) oder eigene Zustands- und Event-Logik. Diese Schicht verwaltet ARIA-Attribute, Tastaturnavigation, Fokus-Trapping, WAI-ARIA-Patterns und Komponentenzustand. Man fasst diese Schicht nicht an, wenn sich das Design ändert. Es ist der Teil, der wahrhaftig schwer richtig und gefährlich falsch zu implementieren ist.
  2. Styling (CVA Layer) — Alle visuellen Styles leben in dedizierten ComponentName.cva.ts-Dateien. Komponenten enthalten nie inline className-Strings. CVA (Class Variance Authority) bietet variantenbasierte Klassengenerierung mit einem typisierten, BEM-ähnlichen Identifikationssystem. Das ist die Brücke zwischen dem, was die Designerin spezifiziert, und dem, was die Komponenten rendern.
  3. Komposition (Compound Components) — Context-Provider und Compound Components (Modal.Body, Tabs.Tab, Switch.Option) schaffen deklarative APIs. Das ist die Consumer-seitige Oberfläche, die Behavior- und Styling-Schicht für Feature-Teams unsichtbar macht.
1Behavior (headless) → Styling (CVA) → Composition (compound components)

Der Vertrag ist klar: Die Headless-Schicht besitzt die Spezifikationskonformität, CVA besitzt die visuellen Varianten, und Compound Components besitzen die Developer Experience.

Jede Komponente im Monorepo folgt dieser Struktur. Jede Komponente bekommt ihr eigenes Verzeichnis mit index.ts, KomponentenName.tsx, KomponentenName.cva.ts, types.ts, und oft stories/. Diese KomponentenName.cva.ts-Datei ist nicht verhandelbar — dort lebt jede Tailwind-Klasse, organisiert nach Varianten. Keine Tailwind-Strings verstreut im JSX. Kein „ich hardcode nur diese eine Klasse". Der Styling-Layer ist eine erstklassige architektonische Grenze, kein Nachgedanke.

Das bedeutet: Ein Headless-Library-Wechsel betrifft nur die Behavior-Schicht — Styling und Komposition bleiben intakt. Ein visuelles Redesign berührt weder das Verhalten noch die Komposition. Und eine Änderung der Consumer-API erfordert kein Überdenken der ARIA-Patterns. Jede Schicht verändert sich unabhängig.

Hier zeigt sich ein weniger offensichtlicher, aber ebenso wichtiger Vorteil: Weil keine Schicht vom Rendering-Modell eines bestimmten Frameworks abhängt, ist das gesamte Komponentensystem portabel. Dieselbe SelectInput läuft in einer Vite-SPA, einer server-gerenderten Applikation oder einer statisch generierten Seite. Kein Rewrite. Keine framework-spezifischen Forks. Das ist eine direkte Konsequenz der sauberen Trennung von Behavior, Styling und Komposition — und der konsequenten Framework-Agnostik aller drei Schichten.

Das ist nicht „MUI ohne Styles". Dieses Framing verfehlt den Punkt. Headless-Bibliotheken liefern keine Styling-Meinungen, die man wegstreift. Sie liefern gar kein Styling — absichtlich. Die API-Oberfläche ist Verhalten, nicht Erscheinung.

Context als Rückgrat der Compound Components

Nahezu jede mehrteilige Komponente nutzt React.createContext zum State-Sharing. Das ist keine Präferenz — es ist der Mechanismus, der Compound Components überhaupt funktionieren lässt. Ohne ihn bräuchte <Modal.Footer> das handleClose des Parents als Prop, und man wäre zurück beim Prop-Drilling durch jede Schicht.

Das Pattern zeigt sich überall in der Codebasis: SelectInputContext teilt Items, den ausgewählten Wert und Loading-State. SwitchContext teilt Value, onChange, Variante und Grösse. CalendarContext teilt den aktuellen Monat, ausgewählte Daten und die Locale. TabsContext teilt den aktiven Index, die Scroll-Position und die Tab-Registry. TableContext wrappt die gesamte TanStack-Table-Instanz. Das Compound Component Pattern hat sich als Standard-API-Oberfläche durchgesetzt:

1<Select.Root>
2 <Select.Trigger>
3 <Select.Value placeholder="Wählen Sie..." />
4 </Select.Trigger>
5 <Select.Content>
6 <Select.Item value="option-1">Option 1</Select.Item>
7 <Select.Item value="option-2">Option 2</Select.Item>
8 </Select.Content>
9</Select.Root>

Engineure, die das adoptieren, müssen React.createContext und Kompositionsmuster verstehen — nicht nur Komponenten konsumieren, sondern verstehen, wie sie komponieren. Wenn das Team Compound Components als undurchsichtige Black Boxen behandelt, stösst es an Grenzen, sobald die Anforderungen von den Defaults abweichen.

Der Headless-Stack: Bibliotheken, die ihren Platz verdienen

Nicht alle Headless-Bibliotheken operieren auf demselben Abstraktionsniveau. Diese Unterscheidung ist wichtiger, als die meisten Vergleiche einräumen. Floating UI liefert Positionierungs-Primitive — Low-Level. Downshift liefert Select- und Combobox-Verhalten — Mid-Level. TanStack Table liefert vollständiges Table-State-Management — High-Level. Das richtige Abstraktionsniveau hängt davon ab, wie viel Kontrolle man braucht und wie spezifisch die Design-Anforderungen sind.

Hier ist der Stack, der sich in einem produktiven Design-System mit ~70 Komponenten bewährt, deployed in mehreren React-Applikationen — von server-gerenderten Setups bis zu reinen SPAs. Die Regel für jede einzelne Komponente ist simpel: Die Headless-Teile nehmen, die den Job erledigen, nicht die Bibliothek, die am meisten kann.

@floating-ui/react — Positionierungs-Engine

Die Grundlage für alles, was schwebt: Popovers, Tooltips, Selects, Kontextmenüs. Hooks wie useFloating, useClick, useHover, useDismiss, useFocus, useRole und useInteractions komponieren sauber. Komponenten wie FloatingPortal und FloatingArrow übernehmen das DOM-Plumbing. Diese Bibliothek berührt Atoms, Molecules, Organisms, Formulare, Tabellen und Charts — sie ist die am weitesten verbreitete Headless-Dependency im gesamten System.

1// Popover.tsx — headless positioning + CVA styling
2const { refs, floatingStyles, context } = useFloating({
3 open: isOpen,
4 onOpenChange: setOpen,
5 placement: placementMap[position],
6 whileElementsMounted: autoUpdate,
7 middleware: [
8 offset(16),
9 flip(),
10 shift({ padding: 16 }),
11 arrow({ element: arrowRef }),
12 ],
13});
14
15const click = useClick(context, { enabled: interaction === 'click' });
16const hover = useHover(context, { enabled: interaction === 'hover' });
17const dismiss = useDismiss(context);
18const { getReferenceProps, getFloatingProps } = useInteractions([
19 click, hover, dismiss,
20]);

Was hier passiert: Die Hooks komponieren. Man wählt die Interaktionsmodi, die man braucht (click, hover oder beides), fügt Dismiss-Verhalten hinzu, und useInteractions mergt alle Event-Handler in ein einziges Props-Objekt. Keine monolithische Konfiguration. Kein Kampf gegen die Meinung einer Bibliothek darüber, wann ein Popover schliessen sollte.

Downshift — Select- und Combobox-Verhalten

Der Gold-Standard für Headless-Select- und Combobox-Logik. Hooks: useSelect, useCombobox, useMultipleSelection. Liefert getMenuProps, getItemProps, getInputProps, getToggleButtonProps — Prop-Getters, die Tastaturnavigation und ARIA verdrahten, ohne das Markup anzufassen. In Kombination mit Floating UI für die Positionierung hat man eine Select-Komponente, die accessible, korrekt positioniert und exakt so gestaltet ist, wie die Design-Spec es verlangt.

Die SelectInput-Komponente in der Codebasis ist ein Lehrbuchbeispiel für die Komposition mehrerer Headless-Bibliotheken: Downshift liefert Tastaturnavigation, Selection-State, ARIA-Attribute und Menü-Open/Close-Logik. Floating UI liefert dynamische Positionierung, Portal-Rendering und Kollisionserkennung. Fuse.js liefert Fuzzy-Search-Filterung. CVA liefert das gesamte visuelle Styling. Jede Bibliothek übernimmt eine Zuständigkeit. Die Komponente orchestriert sie.

1// SingleComboBoxInput.tsx — Downshift + Fuse.js composition
2const fuse = new Fuse(items, { keys: filterKeys, threshold: 0.2 });
3
4const { getMenuProps, getInputProps, getItemProps, highlightedIndex, reset } =
5 useCombobox({
6 isOpen: menuOpen,
7 items: visualItems,
8 itemToString: itemToDisplayValue,
9 selectedItem,
10 inputValue,
11 onSelectedItemChange: ({ selectedItem }) => {
12 onChange(selectedItem || null);
13 closeMenu();
14 },
15 id: name,
16 });

@tanstack/react-table — Table-State-Management

Headless-Table-State auf dem höchsten Abstraktionsniveau im Stack. Sortierung, Filterung, Pagination, Row-Selection, Column-Visibility, Column-Ordering, Expanding — alles verwaltet über einen einzigen useReactTable-Hook, der eine Table-Instanz zurückgibt. Null DOM-Meinungen. Null Styling. Nur State.

1// DataTable.tsx — TanStack Table + compound components
2const table = useReactTable({
3 data,
4 columns,
5 getCoreRowModel: getCoreRowModel(),
6 getSortedRowModel: getSortedRowModel(),
7 getFilteredRowModel: getFilteredRowModel(),
8 getPaginationRowModel: getPaginationRowModel(),
9 onSortingChange: setSorting,
10 onColumnFiltersChange: setColumnFilters,
11 state: { sorting, columnFilters, pagination },
12});

Die Table-Instanz wird via TableContext geteilt, und Compound Components (Table.Header, Table.Body, Table.Pagination) konsumieren sie. Feature-Teams definieren Spalten und liefern Daten. Das Design-System übernimmt Rendering, Sorting-Indikatoren, Filter-UI und Pagination-Controls.

Ein Implementierungshinweis: TanStack Tables ColumnDef-Typsystem ist mächtig, hat aber scharfe Kanten. Generische Spaltendefinitionen über mehrere Datentypen erfordern sorgfältige Typisierung — die accessorKey- und cell-Renderer-Typen sind eng an die Row-Data-Shape gekoppelt. Wiederverwendbare Column-Helpers früh definieren und sauber typisieren — sonst sammeln sich as any-Casts an, die genau die Typsicherheit untergraben, für die man TypeScript eingeführt hat.

embla-carousel-react — Karussell-Verhalten

Leichtgewichtiges, headless Karussell mit Hook-basierter API. Übernimmt Scroll-Snapping, Drag-Interaktionen, Autoplay und Loop-Verhalten. Kein DOM-Output — nur State und Handler, die man mit dem eigenen Markup verbindet.

Das Karussell ist eine dieser Komponenten, bei denen jedes Team denkt „wie schwer kann das sein?" und dann entdeckt, dass Touch-Event-Handling über iOS Safari, Chrome auf Android und Desktop-Browser ein Minenfeld an Edge Cases ist. Embla handhabt dieses Minenfeld. Man selbst handhabt das Styling.

Komponenten ohne Bibliotheken: Wo Custom Code gewinnt

Das ist der Teil, den die meisten „Headless UI"-Artikel auslassen: Manche Komponenten brauchen gar keine Headless-Bibliothek. Die Design-Anforderungen sind spezifisch genug, dass eine Bibliothek Indirektion ohne proportionalen Mehrwert hinzufügt, oder die Browser-Plattform übernimmt bereits die schwierigen Teile.

In einem ~70-Komponenten-System sind rund 30% der Komponenten vollständig handgebaut — keine Third-Party-Behavior-Library. Das ist kein NIH-Syndrom. Es ist eine bewusste Entscheidung pro Komponente, basierend auf Komplexitätsanalyse: Hat diese Komponente ARIA-Patterns, die komplex genug sind, um eine Bibliothek zu rechtfertigen, oder lässt sich das Verhalten korrekt mit nativen Browser-APIs und einem Custom-Hook implementieren?

Calendar — Date-fns + Custom State

Eine Kalender-Komponente ist komplex, aber die Komplexität ist rechnerischer Natur (Datumsarithmetik, locale-aware Formatierung, Range-Validierung), nicht behavioral in einer Weise, die eine Headless-Bibliothek erfordert. date-fns liefert die Datumsmathematik — startOfMonth, eachDayOfInterval, isSameDay, format — und der Rest ist React-State und Keyboard-Event-Handling, das spezifisch für das Design ist.

Kalender-Bibliotheken existieren (react-day-picker, react-dates), aber sie erzwingen strukturelle Meinungen über das gerenderte DOM, die mit Custom-Designs kollidieren. Wenn man einen Kalender braucht, der exakt wie die Figma-Spec aussieht und sich verhält — mit Custom-Monatsnavigation, spezifischem Wochenstart-Verhalten für Schweizer Locales und Date-Range-Selection mit eigenen visuellen Indikatoren — ist der Library-Overhead den Behavioral-Vorteil nicht wert.

Switch — Context + Swipe-Hook

Ein Toggle-Switch ist ein <input type="checkbox"> mit Custom-Visual-Treatment und optionalem Swipe-to-Toggle auf Touch-Geräten. Das ARIA-Pattern ist trivial — role="switch" und aria-checked. Kein Fokus-Trapping, keine komplexe Tastaturnavigation, keine Positionierungslogik. Ein Context-Provider teilt den State, ein useSwipe-Hook übernimmt die Touch-Interaktion, und CVA übernimmt die Styling-Varianten. Library-Kosten: null. Maintenance-Kosten: minimal.

Modal — Portal + Fokus-Trap + Escape-Handling

Ein Modal braucht drei Verhaltensweisen: Portal-Rendering (ausserhalb des DOM-Baums des Parents), Fokus-Trapping (Tab-Cycling innerhalb des Modals) und Escape-to-Close. Diese sind gut verstandene Patterns mit klaren WAI-ARIA-Spezifikationen. Ein useFocusTrap-Hook übernimmt das Fokus-Cycling, usePreventScroll lockt den Body-Scroll, und FloatingPortal (von Floating UI) übernimmt das Portal-Rendering. Context teilt den Modal-State und liefert die Consumer-API via Context.

Fokus-Trapping hat Edge Cases — Shadow-DOM-Boundaries, verschachtelte Modals und Fokus-Wiederherstellung beim Schliessen — aber das sind gut dokumentierte Patterns. Die Implementierung umfasst ungefähr 80 Zeilen Hook-Code, vollständig getestet und vollständig owned. Keine Dependency zum Updaten. Kein Breaking Change zum Tracken.

Tabs — Scroll-Detection + Tastaturnavigation

Tabs brauchen Tastaturnavigation (Pfeiltasten), Scroll-Overflow-Handling (Fade-Indikatoren wenn Tabs den Container überlaufen) und ARIA tablist/tab/tabpanel-Roles. Ein useScrollDetection-Hook überwacht Container-Overflow via ResizeObserver und Scroll-Position. Ein Keyboard-Handler implementiert das WAI-ARIA-Tabs-Pattern. Die Compound-Component-API (Tabs.Root, Tabs.Tab, Tabs.Panel) verbindet alles. Keine Bibliothek nötig — das ARIA-Pattern ist klar spezifiziert und die Implementierung straightforward.

Accordion

Expandierbare Sektionen mit Single- oder Multiple-Open-Modi. AccordionContext verwaltet, welche Sektionen geöffnet sind. Animierte Höhen-Transitions via CSS grid-template-rows (der moderne Ansatz, der JavaScript-Höhenberechnung vermeidet). ARIA: aria-expanded, aria-controls, Heading-Level-Management für korrekte Dokument-Outline.

Dropzone

Datei-Upload via Drag-and-Drop oder Click-to-Browse. Basiert auf nativen dragenter-, dragover-, dragleave-, drop-Events — keine react-dropzone-Dependency. Ein useDropzone-Hook verwaltet Drag-State (idle, hovering, ungültiger Dateityp), Datei-Validierung (Typ, Grösse, Anzahl) und den versteckten <input type="file">-Trigger. Die nativen Drag-Events reichen aus; die Bibliothek würde Dependency-Gewicht ohne proportionalen Wert hinzufügen.

Tree View

Rekursives Tree-Rendering mit Expand/Collapse, Tastaturnavigation (Pfeiltasten für Traversierung, Enter/Space für Toggle, Home/End für ersten/letzten sichtbaren Node) und aria-expanded/aria-level-Management. Nutzt TreeContext für globalen State (expanded Nodes, selected Node) und rekursive TreeNode-Komponenten. Die dnd-kit-Integration ist optional — aktiviert nur wenn Reordering benötigt wird.

Custom Hooks als Headless-Primitives

Über die vollständigen Komponenten hinaus dienen mehrere wiederverwendbare Hooks als interne Headless-Primitives:

  • useFocusTrap — Fokus-Cycling innerhalb eines Containers, genutzt von Modal und jedem Overlay
  • usePreventScroll — Body-Scroll-Locking mit iOS-Safari-Handling
  • useScrollDetection — Scroll-Boundary-Detection für Fade-Indikatoren
  • useSwipe — Touch-Swipe-Gesten, genutzt von Switch und mobilen Interaktionen
  • useClickOutside — Click-Outside-Detection, genutzt in ContextMenu und Custom-Dropdowns
  • useHotkeys — Keyboard-Shortcut-Binding, genutzt in Modal (Escape), Switch (Space) und ContextMenu (Escape)
  • useElementSize — Reaktives Tracking von Element-Dimensionen, genutzt in der DataTable für responsive Spaltenbreiten
  • useSingleAndDoubleClick — Unterscheidung von Einfach- und Doppelklicks, genutzt in Tree-Node-Interaktionen
  • useEventListener — Typsicherer Event-Listener mit Cleanup, Grundlage für useSwipe und diverse andere Hooks
  • useIsland — Dark/Light-Mode-Erkennung für Portal-Content, genutzt in Popover, Tooltip, SelectInput

Das sind die Interaktions-Primitive, die man weder in einer Bibliothek suchen noch in jeder Komponente neu implementieren muss. Sie lösen ein einzelnes Behavior-Problem, sie sind testbar in Isolation, und sie komponieren mit allem anderen.

Controlled/Uncontrolled-Dualität

Mehrere Komponenten unterstützen sowohl kontrollierten als auch unkontrollierten Einsatz: Dropzone mit droppedFiles-Prop für kontrolliert und internem State für unkontrolliert. Tree mit expandedKeys/selectedKeys für kontrolliert und defaultExpandedKeys für unkontrolliert. DataTable mit rowSelection-Prop für kontrolliert. Accordion mit activeTabIndex für kontrolliert.

Dieses Pattern erscheint konsequent im gesamten System. Komponenten, die internen State verwalten, bieten immer eine Escape-Hatch für externe Kontrolle. Das ist ein Kennzeichen gut designter Headless-APIs — und das System wendet es konsistent auf die eigenen Komponenten an.

React Server Components haben das Kalkül verändert

Runtime CSS-in-JS ist in React Server Components eine Sackgasse. Das ist kein Meinungsstreit — es ist eine technische Tatsache. Emotion und styled-components benötigen JavaScript-Ausführung im Browser, um Styles zu injizieren. Server Components senden HTML ohne clientseitiges JavaScript. Diese beiden Modelle sind inkompatibel.

Die Konsequenzen für bestehende Codebasen sind erheblich:

  • MUI-Komponenten erfordern 'use client'-Boundaries um praktisch alles, was interaktiv ist — und vieles, was es nicht ist, aber MUIs Styling-Runtime benötigt.
  • MUIs Antwort darauf ist @pigment-css, ein Compile-Time-Ersatz für Emotion. Es ist ein indirektes Eingeständnis, dass das Runtime-Modell nicht zukunftsfähig ist.
  • Headless-Bibliotheken haben dieses Problem architekturbedingt nicht. Die Behavior-Schicht benötigt 'use client' nur für tatsächlich interaktive Komponenten (Dialog, Select, Tooltip). Nicht-interaktive Markup-Komposition bleibt auf dem Server.

Ein wichtiger Praxishinweis: Auch Headless-Komponenten benötigen 'use client' für alles Interaktive. Teams, die einen Dialog naiv in eine Server-Komponente importieren, bekommen verwirrende Hydration-Fehler. Die Lösung ist ein dünner Client-Wrapper — aber die Architektur muss das von Anfang an berücksichtigen.

1// components/ui/dialog.tsx
2'use client';
3
4import * as DialogPrimitive from '@radix-ui/react-dialog';
5// Thin wrapper — re-exports with your styling applied
6// Server components import from here, not from @radix-ui directly

Die Faustregel: Headless + Tailwind (oder CSS Modules) gewinnt im RSC-Kontext nicht durch besondere Brillanz, sondern durch Kompatibilität. Es ist die Kombination, die am wenigsten gegen das Rendering-Modell arbeitet — unabhängig davon, welches Framework die Server Components bereitstellt.

Der Enterprise-Case: Accessibility, Audits und Multi-Brand-Theming

Regulatorische Realität im DACH-Raum

WCAG 2.2 AA ist seit Oktober 2023 der Standard. Im deutschsprachigen Raum hat das konkrete Konsequenzen: BITV 2.0 in Deutschland und EN 301 549 als europäische Norm sind Vergabekriterien bei öffentlichen Aufträgen und zunehmend bei regulierten Branchen. Schweizer Finanzinstitute unter FINMA-Aufsicht fordern WCAG 2.1 AA und Keyboard-Only-Bedienbarkeit für interne Tools.

Monolithische Component Libraries machen Compliance-Arbeit schwieriger als nötig. Wenn ARIA-Attribute und Fokus-Management in gebündeltem, minifiziertem CSS und JavaScript vergraben sind, wird jeder Audit zum Reverse-Engineering-Projekt. Headless-Primitives machen die ARIA-Pattern-Implementierung dokumentiert und unabhängig testbar.

Aber — und das ist ein Fehler, der in der Praxis häufig vorkommt — „accessible Primitives" sind nicht gleichbedeutend mit „accessible Product". Ein Headless-Dialog implementiert Fokus-Trapping korrekt. Das macht den Dialog nicht barrierefrei. Heading-Hierarchie, Live-Region-Announcements, Button-Labels und Content-Semantik liegen in der eigenen Verantwortung. Die Bibliothek löst das ARIA-Pattern. Man selbst löst die Inhaltsebene.

Multi-Brand-Theming

Ein SaaS-Anbieter, der dasselbe Produkt unter verschiedenen Marken ausliefert — unterschiedliche Farben, Typografie, Abstände, aber identische Funktionalität. Das ist ein Standard-Szenario in DACH-Enterprise-Kontexten: White-Label-Plattformen, konzerninterne Tools mit Geschäftsbereich-Branding, B2B-Produkte mit mandantenspezifischem Theming.

Die Headless-Architektur löst dieses Problem elegant: Eine Behavior-Schicht, multiple Token-Sets als CSS Custom Properties, injiziert über einen <ThemeProvider> am Root. Keine geforkten Komponentenbäume. Keine konditionalen Imports. Eine Codebasis, N Marken.

1// Theme injection — one component tree, multiple brands
2<ThemeProvider tokens={brandTokens[tenant]}>
3 <App />
4</ThemeProvider>

Mit monolithischen Libraries ist dieses Szenario technisch lösbar, aber schmerzhaft: MUIs createTheme überschreibt globale Styles, was bei zwei gleichzeitig geladenen Themes zu Spezifitätskonflikten führt. Bei Headless + CSS Custom Properties existiert dieses Problem nicht.

Wo das hinführt: State Machines, AI-Codegen und die Grenzen des Patterns

State Machines als nächste Evolution

Zag.js — die State-Machine-Schicht unter Ark UI — und XState modellieren Komponentenverhalten als explizite endliche Automaten. Für einfache Komponenten (Button, Toggle) ist das Overkill. Für komplexe Komponenten (Datepicker mit Range-Selection, Combobox mit Async-Loading, Multi-Step-Wizard mit konditionalen Pfaden) ist es die korrekte Abstraktion.

Der Grund: Bugs in komplexen UI-Komponenten sind fast immer undeklarierte Zustandsübergänge. Ein Datepicker, der in einem bestimmten Zustand hängt, weil ein useEffect einen Edge Case nicht abfängt. Ein Dialog, der sich nicht schliessen lässt, weil zwei useState-Aufrufe in einen inkonsistenten Zustand geraten sind. State Machines machen diese Übergänge explizit — und damit testbar, visualisierbar und debuggbar.

AI-Codegen und Headless-Architektur

Ein Aspekt, der unterschätzt wird: Headless-Komponentenarchitektur ist für AI-Codegen-Tools besser lesbar als tief konfigurierte monolithische Libraries. Copilot und Claude generieren Headless + Tailwind-Wiring zuverlässig. Bei MUI-Konfigurationen mit verschachteltem sx-Prop, styled()-Overrides und Theme-Erweiterungen scheitern sie regelmässig.

Das ist kein Zufall: Headless + Tailwind produziert flaches, deklaratives JSX. Monolithische Libraries produzieren implizite, konfigurationsgetriebene Abstraktion. Flach und deklarativ ist genau das, was Sprachmodelle gut verarbeiten.

Zusammenfassung: Wann dieses Pattern richtig ist — und wann nicht

Headless + Styled ist keine Universallösung. Es ist die richtige Architektur, wenn:

  • Das Produkt eigenes Branding hat, das über Theme-Overrides einer Standard-Library hinausgeht
  • WCAG-Compliance eine harte Anforderung ist und auditiert wird
  • React Server Components eingesetzt werden oder geplant sind — unabhängig vom Framework
  • Mehrere Teams auf demselben Design-System arbeiten und klare Ownership-Grenzen brauchen
  • Multi-Brand- oder White-Label-Szenarien absehbar sind
  • Die Komponenten in mehreren Applikationen oder Frameworks laufen müssen — Portabilität statt Lock-in

Es ist nicht die richtige Architektur, wenn:

  • Ein MVP mit drei Entwicklern gebaut wird und in sechs Wochen live sein muss — dann MUI oder Chakra nehmen
  • Das Team keine Erfahrung mit Kompositionsmustern hat und die Lernkurve nicht tragen kann
  • Layout-Primitive (Grids, Stacks, Container) „headless" gemacht werden sollen — das ist Overkill, CSS direkt verwenden

Ein letzter Punkt: Nicht zu früh abstrahieren. Teams bauen einen <DesignSystemButton>, der <Button> wrappt, der ein Headless-Primitive wrappt, mit vier Ebenen Prop-Forwarding und CVA-Varianten-Logik — bevor ein einziges Feature ausgeliefert ist. Die Abstraktionsgrenze sollte entdeckt werden, nicht im Voraus designt. Headless gibt einem die Freiheit, genau dort zu abstrahieren, wo es weh tut — und nirgendwo sonst.

Kontaktaufnahme

z.B. Ludwig van Beethoven
z.B. ludwig@beethoven.co
Falls das eine Herausforderung anspricht, vor der Sie stehen, freue ich mich auf einen Austausch darüber, wie ich helfen kann.