P

Headless UI in React 19: A hands-on report on full design control

TypeScript patterns for large codebases
React internals and practical implications
·21 Min. Lesezeit
Prasath Soosaithasan
von Prasath Soosaithasan
A headless developer zombie sits at a messy desk in a cozy bedroom studio, with question marks floating above its neck.

A designer drops a Figma file into the channel. The select component has a custom search field with fuzzy matching, grouped options, keyboard navigation that matches the platform's native behavior, and an animation on open that no off-the-shelf library supports. The deadline is Thursday. The component needs to pass an accessibility audit next month.

You have two options. Reach for a monolithic component library and spend three days fighting its styling internals, overriding its opinions, and wrapping it in enough hacks to match the design — knowing the audit team will struggle to verify the ARIA implementation buried under your customizations. Or: use a headless hook for the keyboard navigation and ARIA attributes, link it to your own markup, style it with your own system, and ship. Pixel-perfect. Spec-compliant. Auditable in an afternoon. Done.

This article is about the second option. Not as theory — as a specific, opinionated architecture running in production across an Nx monorepo with ~70 components, atomic design layers, React 19, TailwindCSS v4, and CVA.

What you're about to get is a complete mental model for building design systems with headless primitives. First, what headless components and headless utilities actually are — not the marketing pitch, the engineering reality. A headless component or utility provides behavior and accessibility logic with zero visual output. No CSS. No DOM opinions. No styling runtime. Just hooks and prop-getters that handle the genuinely difficult parts — keyboard navigation, ARIA attribute management, focus trapping, positioning math — and hand you back the controls. You provide the markup. You provide the styling. You own every pixel, every class name, every rendered element.

That distinction matters because it directly solves the problems outlined above. When the behavior layer ships no opinions about appearance, there's nothing to override. When ARIA patterns are implemented in isolated, well-documented hooks, auditing them is tractable. When there's no CSS-in-JS runtime baked in, server components work without 'use client' escape hatches. The headless approach doesn't work around the problems of monolithic libraries — it sidesteps them entirely by never creating them in the first place.

Critically, this is not about picking one headless library and committing to everything inside it. There is no single headless mega-library that does it all — and that's the point. You hand-pick the best-in-class solution for each concern. Floating UI for positioning. Downshift for select and combobox behavior. TanStack Table for table state. Embla for carousels. Each library is independently installable, independently upgradable, and ships only what you actually use — no tree-shaking gymnastics to avoid pulling in an entire component suite when you needed one hook. You assemble an ensemble of focused tools, each earning its place on technical merit, not because it came bundled with something else.

And for a significant number of components — roughly 30% of this system — the right headless library turned out to be no library at all. When the ARIA pattern is simple and the browser platform already handles the hard parts, a custom hook with native APIs beats adding a dependency. This article covers both sides: when to reach for a headless library, and when to recognize that the complexity doesn't warrant one.

The libraries that earn their place, the ones that don't, and the specific architectural decisions that make the whole system framework-agnostic. The same components ship unchanged into Next.js, Vite, Remix, Astro — wherever React runs. That portability isn't an accident. It's a direct consequence of the headless architecture.

The Old World: Why Pre-Built Component Libraries Made Sense — And Then Stopped

Material UI and Chakra UI solved a real problem. In 2018, it was genuinely difficult to ship a production-ready select component with proper keyboard navigation, ARIA attributes, and focus management. These libraries bundled behavior, styling, and accessibility into a single dependency, and teams moved faster because of it.

The deal was simple: you accept the library's opinions about how components look and behave, and in return you get speed. For internal tools and MVPs, that was — and sometimes still is — the right trade-off.

Three things broke this deal:

  • Brand requirements diverged. The moment a designer says "this dropdown needs to look like our product, not Google's Material Spec," you're fighting the library. MUI's sx prop and theme overrides are powerful, but they're escape hatches — and escape hatches used on every component aren't escape hatches anymore, they're the actual styling strategy, just with worse ergonomics.
  • React Server Components arrived. Runtime CSS-in-JS (Emotion, styled-components) requires JavaScript execution to generate styles. In any RSC-capable framework, that means wrapping components in 'use client' boundaries, which undermines the entire point of Server Components. MUI v5's whole styling layer is built on Emotion. Chakra v2 as well. You're now paying real architectural costs for a library's styling opinions that you're already fighting against. And the problem compounds: components tightly coupled to a CSS-in-JS runtime can't be cleanly moved between frameworks. They're locked in.
  • WCAG 2.2 became a hard requirement. When accessibility is a compliance criterion, you need to be able to audit your ARIA implementation. Auditing a black box is slow and expensive. Auditing a headless primitive where the ARIA pattern implementation is documented and the styling layer is separate? That's a solvable problem.

This became tangible during an engagement with AXA Versicherungen AG in January 2025. The design system had to serve multiple brand contexts, pass accessibility audits according to EN 301 549, and be deployed across several React applications — some server-rendered, some SPAs, some not yet finalized. The existing component library — with its runtime CSS-in-JS layer — was struggling against every single one of these requirements simultaneously. Theme overrides for multi-brand support were fragile. The Emotion dependency forced client boundaries everywhere server rendering was used. The audit team couldn't cleanly trace ARIA attributes through layers of styled wrappers. And the components were tied to the rendering model of a single framework. This project was the turning point: the monolithic library was no longer saving time — it was costing it.

What "Headless + Styled" Actually Means: The Three-Layer Model

The pattern is a deliberate separation of three responsibilities that monolithic libraries previously conflated. In a production codebase, this separation should be enforced structurally — not through conventions, not through code reviews, but through the file system and the API contracts between the layers.

  1. Behavior (Headless Layer) — Either a third-party hook (downshift, @floating-ui/react, @tanstack/react-table) or custom state and event logic. This layer manages ARIA attributes, keyboard navigation, focus trapping, WAI-ARIA patterns, and component state. You don't touch this layer when the design changes. It's the part that is genuinely hard to get right and dangerous to get wrong.
  2. Styling (CVA Layer) — All visual styles live in dedicated ComponentName.cva.ts files. Components never contain inline className strings. CVA (Class Variance Authority) provides variant-based class generation with a typed, BEM-like identification system. This is the bridge between what the designer specifies and what the components render.
  3. Composition (Compound Components) — Context providers and compound components (Modal.Body, Tabs.Tab, Switch.Option) create declarative APIs. This is the consumer-facing surface that makes the behavior and styling layers invisible to feature teams.
1Behavior (headless) → Styling (CVA) → Composition (compound components)

The contract is clear: The headless layer owns spec compliance, CVA owns the visual variants, and Compound Components own the developer experience.

Every component in the monorepo follows this structure. Each component gets its own directory with index.ts, KomponentenName.tsx, KomponentenName.cva.ts, types.ts, and often stories/. This KomponentenName.cva.ts file is non-negotiable — that's where every Tailwind class lives, organized by variants. No Tailwind strings scattered across JSX. No "I'll just hardcode this one class." The styling layer is a first-class architectural boundary, not an afterthought.

That means: a headless library swap only affects the behavior layer — styling and composition remain intact. A visual redesign touches neither the behavior nor the composition. And a change to the consumer API doesn't require rethinking the ARIA patterns. Each layer changes independently.

Here we see a less obvious but equally important advantage: because no layer depends on the rendering model of a specific framework, the entire component system is portable. The same SelectInput runs in a Vite SPA, a server-rendered application, or a statically generated page. No rewrite. No framework-specific forks. This is a direct consequence of the clean separation of behavior, styling, and composition — and the consistent framework-agnosticism of all three layers.

That's not "MUI without styles." This framing misses the point. Headless libraries don't deliver styling opinions that you strip away. They deliver no styling at all — intentionally. The API surface is behavior, not appearance.

Context as the Backbone of Compound Components

Nearly every multi-part component uses React.createContext for state sharing. This isn't a preference — it's the mechanism that makes Compound Components work in the first place. Without it, <Modal.Footer> would need the parent's handleClose as a prop, and you'd be back to prop-drilling through every layer.

The pattern shows up everywhere in the codebase: SelectInputContext shares items, the selected value, and loading state. SwitchContext shares value, onChange, variant, and size. CalendarContext shares the current month, selected dates, and the locale. TabsContext shares the active index, the scroll position, and the tab registry. TableContext wraps the entire TanStack Table instance. The Compound Component Pattern has established itself as the standard API surface:

1<Select.Root>
2 <Select.Trigger>
3 <Select.Value placeholder="Select..." />
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>

Engineers who adopt this must understand React.createContext and composition patterns — not just consume components, but understand how they compose. If the team treats Compound Components as opaque black boxes, they'll hit limitations as soon as requirements deviate from the defaults.

The Headless Stack: Libraries That Earn Their Place

Not all headless libraries operate at the same level of abstraction. This distinction matters more than most comparisons acknowledge. Floating UI provides positioning primitives — low-level. Downshift provides select and combobox behavior — mid-level. TanStack Table provides complete table state management — high-level. The right level of abstraction depends on how much control you need and how specific your design requirements are.

Here is the stack that has proven itself in a production design system with ~70 components, deployed across multiple React applications — from server-rendered setups to pure SPAs. The rule for every single component is simple: take the headless parts that get the job done, not the library that can do the most.

@floating-ui/react — Positioning Engine

The foundation for everything that floats: popovers, tooltips, selects, context menus. Hooks like useFloating, useClick, useHover, useDismiss, useFocus, useRole, and useInteractions compose cleanly. Components like FloatingPortal and FloatingArrow handle the DOM plumbing. This library touches Atoms, Molecules, Organisms, Forms, Tables, and Charts — it is the most widely used headless dependency in the entire 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]);

What's happening here: The hooks compose. You pick the interaction modes you need (click, hover, or both), add dismiss behavior, and useInteractions merges all event handlers into a single props object. No monolithic configuration. No fighting against a library's opinion about when a popover should close.

Downshift — Select and Combobox Behavior

The gold standard for headless select and combobox logic. Hooks: useSelect, useCombobox, useMultipleSelection. Provides getMenuProps, getItemProps, getInputProps, getToggleButtonProps — prop getters that wire up keyboard navigation and ARIA without touching the markup. Combined with Floating UI for positioning, you get a select component that's accessible, correctly positioned, and styled exactly the way the design spec demands.

The SelectInput component in the codebase is a textbook example of composing multiple headless libraries: Downshift provides keyboard navigation, selection state, ARIA attributes, and menu open/close logic. Floating UI provides dynamic positioning, portal rendering, and collision detection. Fuse.js provides fuzzy search filtering. CVA provides all the visual styling. Each library handles one responsibility. The component orchestrates them.

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 at the highest level of abstraction in the stack. Sorting, filtering, pagination, row selection, column visibility, column ordering, expanding — all managed through a single useReactTable hook that returns a table instance. Zero DOM opinions. Zero styling. Just 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});

The Table instance is shared via TableContext, and Compound Components (Table.Header, Table.Body, Table.Pagination) consume it. Feature teams define columns and provide data. The design system handles rendering, sorting indicators, filter UI, and pagination controls.

An implementation note: TanStack Table's ColumnDef type system is powerful but has sharp edges. Generic column definitions across multiple data types require careful typing — the accessorKey and cell renderer types are tightly coupled to the row data shape. Define reusable column helpers early and type them cleanly — otherwise as any casts accumulate, undermining exactly the type safety you adopted TypeScript for in the first place.

embla-carousel-react — Carousel Behavior

Lightweight, headless carousel with a hook-based API. Handles scroll snapping, drag interactions, autoplay, and loop behavior. No DOM output — just state and handlers that you connect to your own markup.

The carousel is one of those components where every team thinks "how hard can it be?" and then discovers that touch event handling across iOS Safari, Chrome on Android, and desktop browsers is a minefield of edge cases. Embla handles that minefield. You handle the styling.

Components Without Libraries: Where Custom Code Wins

This is the part most "Headless UI" articles leave out: Some components don't need a headless library at all. The design requirements are specific enough that a library adds indirection without proportional value, or the browser platform already handles the hard parts.

In a ~70-component system, around 30% of the components are fully hand-built — no third-party behavior library. This isn't NIH syndrome. It's a deliberate decision made per component, based on complexity analysis: Does this component have ARIA patterns complex enough to justify a library, or can the behavior be correctly implemented with native browser APIs and a custom hook?

Calendar — Date-fns + Custom State

A calendar component is complex, but the complexity is computational in nature (date arithmetic, locale-aware formatting, range validation), not behavioral in a way that requires a headless library. date-fns provides the date math — startOfMonth, eachDayOfInterval, isSameDay, format — and the rest is React state and keyboard event handling that is specific to the design.

Calendar libraries exist (react-day-picker, react-dates), but they enforce structural opinions about the rendered DOM that clash with custom designs. When you need a calendar that looks and behaves exactly like the Figma spec — with custom month navigation, specific week-start behavior for Swiss locales, and date-range selection with custom visual indicators — the library overhead isn't worth the behavioral advantage.

Switch — Context + Swipe-Hook

A toggle switch is a <input type="checkbox"> with custom visual treatment and optional swipe-to-toggle on touch devices. The ARIA pattern is trivial — role="switch" and aria-checked. No focus trapping, no complex keyboard navigation, no positioning logic. A context provider shares the state, a useSwipe hook handles the touch interaction, and CVA handles the styling variants. Library cost: zero. Maintenance cost: minimal.

Modal — Portal + Focus Trap + Escape Handling

A modal requires three behaviors: portal rendering (outside the parent's DOM tree), focus trapping (tab cycling within the modal), and escape-to-close. These are well-understood patterns with clear WAI-ARIA specifications. A useFocusTrap hook handles focus cycling, usePreventScroll locks the body scroll, and FloatingPortal (from Floating UI) handles portal rendering. Context shares the modal state and provides the consumer API via context.

Focus trapping has edge cases — shadow DOM boundaries, nested modals, and focus restoration on close — but these are well-documented patterns. The implementation is roughly 80 lines of hook code, fully tested and fully owned. No dependency to update. No breaking change to track.

Tabs — Scroll Detection + Keyboard Navigation

Tabs need keyboard navigation (arrow keys), scroll overflow handling (fade indicators when tabs overflow the container), and ARIA tablist/tab/tabpanel roles. A useScrollDetection hook monitors container overflow via ResizeObserver and scroll position. A keyboard handler implements the WAI-ARIA tabs pattern. The compound component API (Tabs.Root, Tabs.Tab, Tabs.Panel) ties everything together. No library needed — the ARIA pattern is clearly specified and the implementation is straightforward.

Accordion

Expandable sections with single or multiple open modes. AccordionContext manages which sections are open. Animated height transitions via CSS grid-template-rows (the modern approach that avoids JavaScript height calculation). ARIA: aria-expanded, aria-controls, heading level management for correct document outline.

Dropzone

File upload via drag-and-drop or click-to-browse. Based on native dragenter, dragover, dragleave, drop events — no react-dropzone dependency. A useDropzone hook manages drag state (idle, hovering, invalid file type), file validation (type, size, count), and the hidden <input type="file"> trigger. The native drag events are sufficient; the library would add dependency weight without proportional value.

Tree View

Recursive tree rendering with expand/collapse, keyboard navigation (arrow keys for traversal, Enter/Space for toggle, Home/End for first/last visible node) and aria-expanded/aria-level management. Uses TreeContext for global state (expanded nodes, selected node) and recursive TreeNode components. The dnd-kit integration is optional — only activated when reordering is needed.

Custom Hooks as Headless Primitives

Beyond the complete components, several reusable hooks serve as internal headless primitives:

  • useFocusTrap — Focus cycling within a container, used by Modal and every overlay
  • usePreventScroll — Body scroll locking with iOS Safari handling
  • useScrollDetection — Scroll boundary detection for fade indicators
  • useSwipe — Touch swipe gestures, used by Switch and mobile interactions
  • useClickOutside — Click-outside detection, used in ContextMenu and custom dropdowns
  • useHotkeys — Keyboard shortcut binding, used in Modal (Escape), Switch (Space), and ContextMenu (Escape)
  • useElementSize — Reactive tracking of element dimensions, used in DataTable for responsive column widths
  • useSingleAndDoubleClick — Differentiation between single and double clicks, used in tree node interactions
  • useEventListener — Type-safe event listener with cleanup, foundation for useSwipe and various other hooks
  • useIsland — Dark/light mode detection for portal content, used in Popover, Tooltip, SelectInput

These are the interaction primitives that you neither need to search for in a library nor reimplement in every component. They solve a single behavior problem, they are testable in isolation, and they compose with everything else.

Controlled/Uncontrolled Duality

Several components support both controlled and uncontrolled usage: Dropzone with the droppedFiles prop for controlled and internal state for uncontrolled. Tree with expandedKeys/selectedKeys for controlled and defaultExpandedKeys for uncontrolled. DataTable with the rowSelection prop for controlled. Accordion with activeTabIndex for controlled.

This pattern appears consistently throughout the entire system. Components that manage internal state always offer an escape hatch for external control. This is a hallmark of well-designed headless APIs — and the system applies it consistently to its own components.

React Server Components Have Changed the Calculus

Runtime CSS-in-JS is a dead end in React Server Components. This isn't a matter of opinion — it's a technical fact. Emotion and styled-components require JavaScript execution in the browser to inject styles. Server Components send HTML without client-side JavaScript. These two models are incompatible.

The consequences for existing codebases are significant:

  • MUI components require 'use client' boundaries around practically everything interactive — and much that isn't, but needs MUI's styling runtime.
  • MUI's answer to this is @pigment-css, a compile-time replacement for Emotion. It's an indirect admission that the runtime model isn't future-proof.
  • Headless libraries don't have this problem by architectural design. The behavior layer only needs 'use client' for actually interactive components (Dialog, Select, Tooltip). Non-interactive markup composition stays on the server.

An important practical note: Even headless components require 'use client' for anything interactive. Teams that naively import a Dialog into a Server Component get confusing hydration errors. The solution is a thin client wrapper — but the architecture needs to account for this from the start.

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

The rule of thumb: Headless + Tailwind (or CSS Modules) wins in the RSC context not through particular brilliance, but through compatibility. It's the combination that works the least against the rendering model — regardless of which framework provides the Server Components.

The Enterprise Case: Accessibility, Audits, and Multi-Brand Theming

Regulatory Reality in the DACH Region

WCAG 2.2 AA has been the standard since October 2023. In the German-speaking region, this has concrete consequences: BITV 2.0 in Germany and EN 301 549 as a European standard are procurement criteria for public contracts and increasingly for regulated industries. Swiss financial institutions under FINMA supervision require WCAG 2.1 AA and keyboard-only operability for internal tools.

Monolithic component libraries make compliance work harder than it needs to be. When ARIA attributes and focus management are buried in bundled, minified CSS and JavaScript, every audit becomes a reverse-engineering project. Headless primitives make the ARIA pattern implementation documented and independently testable.

But — and this is a mistake that happens frequently in practice — "accessible primitives" are not the same as "accessible product." A headless dialog implements focus trapping correctly. That does not make the dialog accessible. Heading hierarchy, live region announcements, button labels, and content semantics are your own responsibility. The library solves the ARIA pattern. You solve the content layer.

Multi-Brand Theming

A SaaS provider shipping the same product under different brands — different colors, typography, spacing, but identical functionality. This is a standard scenario in DACH enterprise contexts: white-label platforms, corporate-internal tools with division-level branding, B2B products with tenant-specific theming.

The headless architecture solves this problem elegantly: one behavior layer, multiple token sets as CSS custom properties, injected via a <ThemeProvider> at the root. No forked component trees. No conditional imports. One codebase, N brands.

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

With monolithic libraries, this scenario is technically solvable but painful: MUI's createTheme overrides global styles, which leads to specificity conflicts when two themes are loaded simultaneously. With headless + CSS custom properties, this problem doesn't exist.

Where this leads: State Machines, AI-Codegen, and the limits of the pattern

State Machines as the Next Evolution

Zag.js — the state machine layer underneath Ark UI — and XState model component behavior as explicit finite automata. For simple components (Button, Toggle), that's overkill. For complex components (Datepicker with range selection, Combobox with async loading, multi-step wizard with conditional paths), it's the correct abstraction.

The reason: Bugs in complex UI components are almost always undeclared state transitions. A datepicker that gets stuck in a certain state because a useEffect doesn't catch an edge case. A dialog that won't close because two useState calls have ended up in an inconsistent state. State machines make these transitions explicit — and therefore testable, visualizable, and debuggable.

AI Code Generation and Headless Architecture

One aspect that's underestimated: headless component architecture is more readable for AI code-gen tools than deeply configured monolithic libraries. Copilot and Claude reliably generate headless + Tailwind wiring. With MUI configurations involving nested sx props, styled() overrides, and theme extensions, they regularly fail.

This is no coincidence: Headless + Tailwind produces flat, declarative JSX. Monolithic libraries produce implicit, configuration-driven abstraction. Flat and declarative is exactly what language models process well.

Summary: When This Pattern Is Right — and When It's Not

Headless + Styled is not a universal solution. It is the right architecture when:

  • The product has its own branding that goes beyond theme overrides of a standard library
  • WCAG compliance is a hard requirement and will be audited
  • React Server Components are being used or planned — regardless of the framework
  • Multiple teams are working on the same design system and need clear ownership boundaries
  • Multi-brand or white-label scenarios are foreseeable
  • The components need to run across multiple applications or frameworks — portability over lock-in

It's not the right architecture when:

  • You're building an MVP with three developers and it needs to be live in six weeks — go with MUI or Chakra
  • The team has no experience with composition patterns and can't handle the learning curve
  • Layout primitives (grids, stacks, containers) are supposed to be "headless" — that's overkill, just use CSS directly

One last point: Don't abstract too early. Teams build a <DesignSystemButton> that wraps <Button>, which wraps a headless primitive, with four layers of prop forwarding and CVA variant logic — before a single feature has shipped. The abstraction boundary should be discovered, not designed upfront. Headless gives you the freedom to abstract exactly where it hurts — and nowhere else.

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.