Skip to main content
  1. Frontends/
  2. React Guides/

Mastering Headless UI: A Senior Dev’s Guide to Radix and React Aria

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

It’s 2025. If you are still wrestling with !important overrides in Material UI or trying to hack the internal DOM structure of a Bootstrap component just to match a Figma design, you’re doing it the hard way.

The React ecosystem has matured. We’ve moved past the era of “All-in-One” component kits that dictate your styling. We are firmly in the era of Headless UI.

As senior developers and architects, our goal isn’t just to put pixels on the screen; it’s to ship accessible, robust, and performant interfaces that scale. We want full control over the CSS (likely via Tailwind or CSS-in-JS) without reinventing the complex logic required for keyboard navigation, focus management, and screen reader support.

This article dives deep into the two heavyweights of the headless world: Radix UI and React Aria. We’ll compare them, build real components, and discuss the architectural implications of choosing one over the other.

The Headless Architecture
#

Before we touch the code, let’s align on the mental model. “Headless” doesn’t mean “no UI”; it means “unopinionated UI”.

In a traditional library (like AntD or MUI), the logic and the styling are coupled. In a Headless library, the library provides the behavior and state, while you provide the rendering and styling.

Here is how the data flow looks in a modern Headless setup:

flowchart LR User((User Interaction)) subgraph Headless_Layer ["Headless Layer (Radix/Aria)"] State[State Management] A11y[WAI-ARIA Roles] Focus[Focus Trap / Loops] Events[Keyboard/Mouse Events] end subgraph View_Layer ["Your Code"] Styles[Tailwind / CSS Modules] Markup[JSX Structure] Animation[Framer Motion] end DOM(Rendered DOM) User --> Events Events --> State State --> A11y State --> Focus A11y --> Markup Focus --> Markup Styles --> Markup Animation --> Markup Markup --> DOM style Headless_Layer fill:#f9f,stroke:#333,stroke-width:2px,color:black style View_Layer fill:#bbf,stroke:#333,stroke-width:2px,color:black

Why does this matter?
#

  1. Accessibility (a11y) is hard: Implementing a fully accessible Dropdown Menu takes weeks of testing across VoiceOver, NVDA, and JAWS. Headless libraries give you this for free.
  2. Design Freedom: You own the className.
  3. Bundle Size: You only import the logic you need.

Prerequisites and Environment
#

To follow along, ensure you have a modern React environment set up. We are assuming a 2025 standard stack:

  • Node.js: v20+ (LTS)
  • React: v19
  • Styling: Tailwind CSS v4 (or v3.4+)
  • Icons: Lucide React (optional but recommended)

Setup
#

We’ll create a lightweight sandbox.

# Create a Vite project
npm create vite@latest headless-demo -- --template react-ts

# Enter directory
cd headless-demo

# Install dependencies (We will use both for comparison)
npm install @radix-ui/react-popover @radix-ui/react-dialog react-aria-components class-variance-authority clsx tailwind-merge framer-motion

# Start dev server
npm run dev

Note: We included class-variance-authority (CVA) and tailwind-merge. These are the bread and butter for handling styles in headless components.


The Contenders: Radix vs. React Aria
#

Choosing between these two is often the first architectural decision when building a Design System.

Feature Radix UI React Aria (Adobe)
Philosophy Component-first. Provides unstyled primitives (e.g., <Dialog.Root>). Hooks-first (historically), now offers Components. “Industrial Grade” a11y.
API Surface simpler, cleaner JSX. Very “React-y”. Extremely granular. Offers useButton, useSelect, etc., plus a new Component API.
Styling Agnostic. Works perfectly with Tailwind. Agnostic. The new react-aria-components has a specific className function for states (hover/focus).
Animation Relies on external libs or CSS keyframes. Works great with Framer Motion. Has built-in animation support in the new components API, but generally external.
Mobile Good, but sometimes lacks nuanced touch interactions. Best in class. Adobe tests on everything. Handles virtual keyboard quirks brilliantly.
Bundle Size Modular. You install packages individually (e.g., @radix-ui/react-tooltip). Modular, but the core logic is heavier due to extreme robustness.

Part 1: Building with Radix UI
#

Radix is generally the favorite for teams that want to move fast but maintain high quality. It powers the popular shadcn/ui collection.

Let’s build a Popover component. This isn’t just a tooltip; it needs to handle focus trapping (optional), closing on outside clicks, and keyboard formatting.

The Implementation
#

We will use Tailwind for styling and lucide-react for an icon.

// src/components/RadixPopover.tsx
import * as React from 'react';
import * as Popover from '@radix-ui/react-popover';
import { Settings2, X } from 'lucide-react';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

// Utility for cleaner classes
function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

export default function UserSettingsPopover() {
  return (
    <Popover.Root>
      <Popover.Trigger asChild>
        <button 
          className={cn(
            "inline-flex items-center justify-center rounded-full w-10 h-10",
            "bg-slate-100 text-slate-900 hover:bg-slate-200",
            "focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2",
            "transition-colors duration-200"
          )}
          aria-label="Update dimensions"
        >
          <Settings2 size={20} />
        </button>
      </Popover.Trigger>

      <Popover.Portal>
        <Popover.Content 
          className={cn(
            "rounded-lg p-5 w-[260px] bg-white shadow-[0_10px_38px_-10px_rgba(22,23,24,0.35),0_10px_20px_-15px_rgba(22,23,24,0.2)]",
            "border border-slate-200",
            "will-change-[transform,opacity]",
            "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
            "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
            "side-top:slide-in-from-bottom-2 side-bottom:slide-in-from-top-2"
          )}
          sideOffset={5}
        >
          <div className="flex flex-col gap-2.5">
            <p className="text-sm font-medium text-slate-900 mb-2">Dimensions</p>
            
            <fieldset className="flex items-center gap-5">
              <label className="text-xs text-slate-500 w-[75px]" htmlFor="width">Width</label>
              <input
                className="w-full h-8 px-2 text-xs border rounded text-slate-700 focus:ring-2 focus:ring-blue-500 outline-none"
                id="width"
                defaultValue="100%"
              />
            </fieldset>
            
            <fieldset className="flex items-center gap-5">
              <label className="text-xs text-slate-500 w-[75px]" htmlFor="height">Height</label>
              <input
                className="w-full h-8 px-2 text-xs border rounded text-slate-700 focus:ring-2 focus:ring-blue-500 outline-none"
                id="height"
                defaultValue="25px"
              />
            </fieldset>
          </div>

          <Popover.Close asChild>
            <button
              className="absolute top-3 right-3 w-6 h-6 inline-flex items-center justify-center rounded-full text-slate-500 hover:bg-slate-100 focus:shadow-[0_0_0_2px] focus:shadow-slate-400 outline-none"
              aria-label="Close"
            >
              <X size={14} />
            </button>
          </Popover.Close>
          
          <Popover.Arrow className="fill-white" />
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  );
}

Analysis
#

  1. asChild Pattern: Notice <Popover.Trigger asChild>. This is Radix’s signature move. It merges the event handlers and ARIA attributes onto your DOM node (<button>) instead of wrapping it in an unnecessary <div>.
  2. The Portal: <Popover.Portal> automatically transports the content to document.body. This solves the classic z-index and overflow: hidden parent clipping issues.
  3. Data Attributes: Styling is driven by data-[state=open]. This allows us to use pure CSS (or Tailwind utilities) for entry/exit animations without complex React state logic.

Part 2: Building with React Aria Components (RAC)
#

React Aria used to be known for its low-level hooks (useButton, useOverlay), which were powerful but verbose. In late 2024/2025, the standard is React Aria Components (RAC). These provide a component-based API similar to Radix but with Adobe’s rigorous “interaction modeling.”

Let’s build a Select (Dropdown). HTML <select> is notoriously hard to style.

The Implementation
#

// src/components/AriaSelect.tsx
import type { Key } from 'react-aria-components';
import {
  Button,
  Label,
  ListBox,
  ListBoxItem,
  Popover,
  Select,
  SelectValue
} from 'react-aria-components';
import { ChevronDown } from 'lucide-react';

export default function AriaSelect() {
  return (
    <Select className="flex flex-col gap-1 w-[200px]">
      <Label className="text-sm font-medium text-slate-700 ml-1">Favorite Framework</Label>
      
      <Button className={({ isPressed, isFocusVisible }) => `
        flex items-center justify-between w-full px-3 py-2 
        bg-white border rounded-lg shadow-sm text-left cursor-default
        ${isFocusVisible ? 'ring-2 ring-blue-500 border-blue-500 outline-none' : 'border-slate-300'}
        ${isPressed ? 'bg-slate-50' : ''}
      `}>
        <SelectValue className="text-sm text-slate-900 placeholder-shown:text-slate-400" />
        <ChevronDown size={16} className="text-slate-500" />
      </Button>

      <Popover className={({ isEntering, isExiting }) => `
        overflow-auto rounded-lg drop-shadow-lg border border-slate-200 bg-white
        w-[var(--trigger-width)] 
        ${isEntering ? 'animate-in fade-in zoom-in-95 duration-200' : ''}
        ${isExiting ? 'animate-out fade-out zoom-out-95 duration-200' : ''}
      `}>
        <ListBox className="p-1 outline-none">
          {['React', 'Vue', 'Svelte', 'Angular', 'Qwik'].map((item) => (
            <ListBoxItem
              key={item}
              id={item}
              textValue={item}
              className={({ isFocused, isSelected }) => `
                cursor-default select-none rounded px-2 py-1.5 text-sm outline-none
                ${isFocused ? 'bg-blue-100 text-blue-900' : 'text-slate-700'}
                ${isSelected ? 'font-semibold' : ''}
              `}
            >
              {({ isSelected }) => (
                <div className="flex items-center justify-between">
                  <span>{item}</span>
                  {isSelected && <span></span>}
                </div>
              )}
            </ListBoxItem>
          ))}
        </ListBox>
      </Popover>
    </Select>
  );
}

Analysis
#

  1. Render Props for Styling: RAC uses render props heavily for styling classes (e.g., className={({ isFocused }) => ...}). This exposes the internal interaction state directly to Tailwind. You don’t need data- attributes; you have direct JS boolean access.
  2. Adaptive Behavior: Adobe put incredible effort into mobile. On mobile devices, this component handles touch cancellation, scrolling behavior, and virtual keyboard avoidance better than almost any other library.
  3. Semantics: The <ListBox> and <ListBoxItem> components ensure correct ARIA roles (role="listbox", role="option") are applied, which are different from a standard navigation menu.

Performance and Common Pitfalls
#

When implementing Headless UI in a production environment, watch out for these traps.

1. The Bundle Size Myth
#

You might think, “I’m importing a huge library!” Not really. Both libraries support tree-shaking.

  • Radix: You usually install specific packages (@radix-ui/react-dialog).
  • React Aria: The monolithic package exports everything, but modern bundlers (Vite/Rollup/Webpack 5) shake out unused exports effectively.

However, React Aria IS logically heavier because it includes code for edge cases you didn’t know existed (like specific screen reader bugs in older iOS versions).

2. Focus Management
#

The number one bug in custom modals is Focus Trapping.

  • Scenario: User opens a modal. User hits Tab. Focus goes behind the modal to the URL bar or the background content.
  • Solution: Both libraries handle this, but you must ensure you don’t accidentally unmount the component before the closing animation finishes. Radix handles this with data-state and animations, but manual conditional rendering ({isOpen && <Dialog />}) without AnimatePresence (if using Framer) can break the focus return feature.

3. Z-Index Wars
#

Both libraries use Portals.

  • Trap: If your global CSS sets a high z-index on a sticky header, your ported modal might end up under it if the portal container isn’t managed correctly.
  • Best Practice: Create a dedicated stacking context or ensure your portal root (usually body) is handled correctly in your Tailwind config (z-50 usually suffices for modals).

Conclusion: Which one should you choose?
#

As an architect, your choice depends on your team’s priorities.

Choose Radix UI if:

  • You want a developer experience that feels “native” to React.
  • You are heavily invested in the Tailwind ecosystem (it pairs beautifully).
  • You need to ship fast and “Very Good” accessibility is acceptable.
  • You are building a standard B2B SaaS dashboard.

Choose React Aria if:

  • Accessibility is non-negotiable. (e.g., Government, Healthcare, Education).
  • You need robust touch/mobile interactions (drag and drop, swipes).
  • You prefer render-props for styling logic over CSS selectors.
  • You are building a complex design system that needs to last 5+ years.

Both libraries represent the pinnacle of React component development in 2025. By separating behavior from design, they allow us to build UIs that are unique to our brand but universal in their usability.

Further Reading
#

Stop reinventing the wheel. Import the wheel, and paint it whatever color you want.