Radix UI: Headless Components with Full Accessibility
AI generated
</>
{ }
Radix UI · Headless Components · React · Accessibility
Radix UI: Headless Components
with Full Accessibility

Implementing WAI-ARIA correctly for your own UI components costs weeks. Building Dialog, Dropdown, Tooltip and Select with correct keyboard navigation, focus trapping and screen reader support is more complex than most developers realize. Radix UI solves exactly this problem.

16 min read Radix UI · WAI-ARIA · Tailwind CSS · Headless Components React 18+ · Radix UI 2.x

1. Why accessibility without Radix UI is so hard

An accessible dialog is not a simple modal with position: fixed. Correct accessibility requires: focus must jump into the dialog on open, tab navigation must stay trapped inside the dialog (focus trapping), focus must return to the triggering element on close, Escape must close the dialog, the background document must be hidden from screen readers (aria-hidden), and the role dialog with aria-modal="true" and aria-labelledby must be set correctly. All of this is defined by the WAI-ARIA authoring patterns, and implementing all of it takes days, not hours.

Radix UI implements all of that in its primitives. The library consists of individually installable packages per component (@radix-ui/react-dialog, @radix-ui/react-dropdown-menu, etc.) that bring full WAI-ARIA compliance out of the box. No styling, no opinion about colors or spacing, just behavior, accessibility and state management. That is the core of the headless approach: separating behavior from appearance. Anyone who uses Radix UI gets the hard accessibility implementation for free and can invest that time in design and business logic instead.

The popularity of Radix UI reflects this advantage: shadcn/ui, the most popular React component collection in 2026, is built entirely on Radix UI primitives. Tremor, Radix Themes and numerous other component libraries use the same primitives as their foundation. This means that the investment in understanding Radix UI pays off even when you switch to already-styled component libraries or build your own systems.

2. The headless concept: behavior without style

Headless components deliver state, behavior and accessibility attributes, but no CSS. That makes them fundamentally different from classic UI libraries like Material UI or Ant Design, which ship components with a fixed design system. The advantage: Radix UI components look the way you design them, not like a generic library. This is especially important for projects with strong design requirements or their own design system.

Technically, Radix UI works through the compound component pattern: a component like Dialog.Root provides state via React Context, and all child components (Dialog.Trigger, Dialog.Content, Dialog.Title) are wired into that context. You do not have to connect anything manually, the primitives know how they belong together. The accessibility attributes are set automatically: aria-expanded, aria-controls, aria-labelledby, role, tabIndex and every other ARIA attribute is derived automatically from the component's state.


// Basic Radix UI Dialog, full accessibility with zero custom ARIA code
import * as Dialog from '@radix-ui/react-dialog'

export function ConfirmDialog({ onConfirm, children }) {
  return (
    <Dialog.Root>
      {/* Trigger receives aria-haspopup, aria-expanded automatically */}
      <Dialog.Trigger asChild>
        <button className="rounded-lg bg-red-600 px-4 py-2 text-white hover:bg-red-700">
          Delete Item
        </button>
      </Dialog.Trigger>

      {/* Portal renders outside the current DOM tree to avoid z-index issues */}
      <Dialog.Portal>
        {/* Overlay covers background, aria-hidden on body automatically set */}
        <Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />

        {/* Content has role="dialog", aria-modal="true", focus trapped inside */}
        <Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-2xl bg-white p-6 shadow-xl focus:outline-none">
          {/* Title is auto-connected via aria-labelledby */}
          <Dialog.Title className="text-lg font-bold text-gray-900">
            Confirm Delete
          </Dialog.Title>
          <Dialog.Description className="mt-2 text-sm text-gray-600">
            This action cannot be undone.
          </Dialog.Description>

          <div className="mt-6 flex gap-3 justify-end">
            {/* Close dismisses dialog and returns focus to trigger */}
            <Dialog.Close asChild>
              <button className="rounded-lg border px-4 py-2 text-sm">Cancel</button>
            </Dialog.Close>
            <button onClick={onConfirm} className="rounded-lg bg-red-600 px-4 py-2 text-sm text-white">
              Delete
            </button>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

3. Dialog: focus trapping and ARIA modal done right

The Radix UI Dialog primitive resolves all the complex accessibility requirements automatically: on open, focus jumps into the dialog content, more precisely to the first focusable element or to the content element itself. Tab navigation stays inside the dialog (focus trapping), no tab stop leaves the dialog while it is open. On close, whether via the Escape key, a click on the close button, or a click on the overlay, focus returns to the original trigger element.

The asChild prop is a powerful pattern in Radix UI: instead of rendering its own DOM element, the primitive passes its props and event handlers to the direct child element. Dialog.Trigger asChild combined with a <button> does not produce a nested button-in-button, it hands the trigger functionality directly to the button. This allows full control over the DOM element without any accessibility compromise. The asChild pattern works with every Radix UI primitive and is the recommended way to use your own styled components as a trigger.

An accessible dropdown menu requires, per WAI-ARIA: opening with Enter or Space on the trigger, navigating items with arrow keys (ArrowUp, ArrowDown), activating with Enter or Space, closing with Escape, and type-ahead search (pressing a letter jumps to the menu item that starts with that letter). That is eight different keyboard interactions, all of which Radix UI's DropdownMenu primitive implements fully.

The predefined item types are especially helpful: DropdownMenu.Item for regular items, DropdownMenu.CheckboxItem for toggle items with aria-checked, DropdownMenu.RadioGroup and DropdownMenu.RadioItem for single-choice selection with role="menuitemradio". DropdownMenu.Separator renders a visual divider with the correct role="separator". Submenus are realized via DropdownMenu.Sub, DropdownMenu.SubTrigger and DropdownMenu.SubContent, with automatic keyboard navigation through ArrowRight and ArrowLeft. All of it without a single line of custom accessibility code.


// DropdownMenu with keyboard navigation, checkboxes and separators
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { useState } from 'react'

export function UserMenu({ user }) {
  const [showNotifications, setShowNotifications] = useState(true)

  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <button className="flex items-center gap-2 rounded-full p-1 hover:bg-gray-100">
          <img src={user.avatar} alt="" className="h-8 w-8 rounded-full" />
          <span className="text-sm font-medium">{user.name}</span>
        </button>
      </DropdownMenu.Trigger>

      <DropdownMenu.Portal>
        <DropdownMenu.Content
          className="z-50 min-w-48 rounded-xl border bg-white p-1 shadow-lg"
          sideOffset={5}
          align="end"
        >
          {/* Regular item, activated by Enter, Space, or click */}
          <DropdownMenu.Item className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm outline-none focus:bg-sky-50 focus:text-sky-700 data-[highlighted]:bg-sky-50">
            Profile Settings
          </DropdownMenu.Item>

          <DropdownMenu.Separator className="my-1 h-px bg-gray-100" />

          {/* CheckboxItem has aria-checked and renders checkmark automatically */}
          <DropdownMenu.CheckboxItem
            className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm outline-none focus:bg-sky-50 data-[highlighted]:bg-sky-50"
            checked={showNotifications}
            onCheckedChange={setShowNotifications}
          >
            <DropdownMenu.ItemIndicator>✓</DropdownMenu.ItemIndicator>
            Email Notifications
          </DropdownMenu.CheckboxItem>

          <DropdownMenu.Separator className="my-1 h-px bg-gray-100" />

          {/* Destructive item, semantic styling only, no special ARIA needed */}
          <DropdownMenu.Item className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-600 outline-none focus:bg-red-50 data-[highlighted]:bg-red-50">
            Sign Out
          </DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  )
}

5. Tooltip and Popover: hover, focus and touch

Tooltips are deceptively complex: they must appear on hover, appear on keyboard focus, remain visible on touch devices after tap, and they may never be the only way to obtain important information (WCAG 2.1 Criterion 1.4.13). Radix UI's Tooltip primitive implements all of these rules: automatic delay on hover, immediate display on focus, role="tooltip" and an aria-describedby link between trigger and content, and the Escape key closes the tooltip.

The distinction between Tooltip and Popover in Radix UI matters conceptually: a tooltip shows supplementary information and is not interactive. A popover can contain interactive elements (links, buttons, forms). Radix UI's Popover primitive accordingly sets role="dialog" instead of role="tooltip" and implements focus management: on open, focus jumps into the popover content, on close it returns to the trigger. Building a tooltip element with interactive content would be an accessibility mistake, with Radix UI this semantic distinction is clear.

6. Select: why you should not replace native select

The native HTML <select> element is fully accessible by itself, it comes with keyboard navigation, screen reader support and a native mobile system dialog. So why does Radix UI's Select primitive exist? Because native select is barely customizable: custom icons, multi-line options, option groups with dividers, search inside the dropdown, and complex option rendering are all impossible or nearly impossible with a native select.

Radix UI Select reimplements the full accessibility of the native element: role="combobox" on the trigger, role="listbox" on the content, role="option" on every item, ArrowUp/ArrowDown navigation, Home/End, letter-based type-ahead, and correct aria-selected on the active item. On mobile devices, Radix UI Select additionally renders a hidden native select so forms interact correctly with the system keyboard. That is the kind of detail that is easy to miss in a manual implementation.

7. Styling Radix UI with Tailwind CSS: the data-state pattern

Radix UI communicates the current state of components via data attributes on the DOM elements. data-state="open" or data-state="closed" on a dialog overlay, data-state="checked" on a checkbox item, data-highlighted on the currently focused menu item. With Tailwind CSS v3's data-[state=open]:animate-in syntax or Tailwind CSS v4's native data attribute support, you can react directly to these states and trigger CSS animations, color changes and layout transformations.

This makes integrating Radix UI with Tailwind particularly elegant: instead of manually computing state-dependent classes and injecting them into JSX, you declare directly in the className how an element should look in each state. data-[state=checked]:bg-sky-600 on a checkbox trigger turns it blue when the checkbox is checked. data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed makes disabled items visually disabled. The state comes from Radix UI, the visualization comes from Tailwind, a clean separation of concerns.


// Radix UI + Tailwind: data-state styling pattern for animated transitions
import * as Dialog from '@radix-ui/react-dialog'

// tailwind.config.ts, enable data-state variants (Tailwind v3)
// plugins: [require('tailwindcss-animate')]
// Custom variants for data attributes are in Tailwind v4 by default

export function AnimatedDialog({ trigger, title, children }) {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
      <Dialog.Portal>
        {/* data-[state=open] and data-[state=closed] control enter/exit animations */}
        <Dialog.Overlay
          className="
            fixed inset-0 bg-black/40
            data-[state=open]:animate-in data-[state=open]:fade-in-0
            data-[state=closed]:animate-out data-[state=closed]:fade-out-0
            duration-200
          "
        />
        <Dialog.Content
          className="
            fixed left-1/2 top-1/2 w-full max-w-lg -translate-x-1/2 -translate-y-1/2
            rounded-2xl bg-white p-6 shadow-2xl
            data-[state=open]:animate-in data-[state=open]:fade-in-0
            data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-bottom-4
            data-[state=closed]:animate-out data-[state=closed]:fade-out-0
            data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-bottom-4
            duration-200
          "
        >
          <Dialog.Title className="text-xl font-bold text-gray-900">{title}</Dialog.Title>
          <div className="mt-4">{children}</div>
          <Dialog.Close asChild>
            <button
              className="absolute right-4 top-4 rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
              aria-label="Close dialog"
            >
              ✕
            </button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

8. Composing: building your own component library

The real power use case of Radix UI is building your own, brand-consistent component library on top of the primitives. Instead of configuring Dialog, Dropdown and Select from scratch every time, you build styled wrapper components once, using Radix UI primitives and embedding your design system tokens. The result is a library that looks consistent everywhere in the application, is fully accessible, and feels like a first-party component, because it is one.

shadcn/ui demonstrates this approach in its purest form: the components are not installed as an npm package, but copied as source code into the project, then adapted as needed. Every component uses Radix UI primitives, is styled with Tailwind, and includes TypeScript prop interfaces. This allows full customization without framework lock-in. Teams that follow this approach get complete control over every component without losing the accessibility foundation of Radix UI.

9. Radix UI vs. Headless UI vs. Ariakit

There are several headless component libraries for React pursuing similar goals. Headless UI by Tailwind Labs is the most direct alternative to Radix UI: focused on Tailwind integration, a smaller component set, but a very polished developer experience. Ariakit (formerly Reakit) is the most academic solution, with the strongest focus on WAI-ARIA compliance and a more composable API. Radix UI sits between the two: an extensive component set, a mature API design and the strongest community through shadcn/ui.

Property Radix UI Headless UI Ariakit
Component scope Very large (30+) Medium (15+) Large (25+)
shadcn/ui integration Base library Not integrated Not integrated
WAI-ARIA compliance Very high High Very high
API stability Very stable Stable Changing
Tailwind friendliness data-state pattern Render props Props required

The recommendation for 2026: Radix UI is the safest choice for new projects that want to build their own component library. Its ecosystem, stability and shadcn/ui integration give a considerable head start. Headless UI is the better choice when the project already leans heavily on Tailwind and a smaller, more focused component set is enough. Ariakit is the choice for teams that value maximum API flexibility over convenience.

Mironsoft

React component library, accessibility and design system development

Want to build your own accessible component library?

We build a brand-consistent, fully accessible component library for your React project on top of Radix UI and Tailwind CSS, with design tokens, full WCAG compliance and documentation.

Design System

Building a component library with Radix UI primitives, Tailwind and design tokens

Accessibility Audit

Checking existing UI components for WCAG 2.2 compliance and retrofitting them with Radix UI

Implementation

Correctly implementing Dialog, Dropdown, Select, Tooltip and Popover with full test coverage

10. Summary

Radix UI is the most pragmatic answer to the accessibility problem in React applications. Writing correct ARIA implementations for Dialog, Dropdown, Select and Tooltip requires deep knowledge of the WAI-ARIA authoring patterns, extensive testing with screen readers and keyboard navigation, and ongoing maintenance as browsers change. Radix UI carries this responsibility so teams can invest their time in design, business logic and features instead.

The data-state styling pattern combined with Tailwind CSS makes Radix UI a complete foundation for any design system effort. The separation between behavior (Radix) and appearance (Tailwind) is clear, maintainable, and scales from small projects to large component libraries. Teams that use Radix UI as a base and build a brand-consistent layer on top get the best of both worlds: complete design freedom and complete accessibility compliance without compromise.

Radix UI Headless Components: The Essentials at a Glance

WAI-ARIA out of the box

Focus trapping, keyboard navigation, ARIA attributes and focus return automatically implemented. No custom accessibility code needed.

asChild Pattern

Radix UI primitives delegate their props to your own DOM elements. Full DOM control without accessibility compromise.

data-state Styling

State exposed as data attributes (data-state="open", data-highlighted). Tailwind classes like data-[state=open]:animate-in for declarative state styles.

shadcn/ui Ecosystem

shadcn/ui is built on Radix UI primitives. Investing in Radix pays off across the entire shadcn/ui ecosystem.

11. FAQ: Radix UI Headless Components

1Radix UI vs. shadcn/ui: what is the difference?
Radix UI: unstyled primitives (behavior, accessibility). shadcn/ui: Tailwind-styled components built on Radix UI, shipped directly as source code in the project, no npm dependency.
2Install every component individually?
Yes. Its own npm package per primitive: @radix-ui/react-dialog, etc. Enables tree-shaking, only install what is really needed.
3Testing Radix UI for accessibility?
Playwright CT plus axe-core for automated WCAG scans. Manually with a screen reader (NVDA, VoiceOver). Check tab navigation, Escape and focus return.
4Radix UI with Next.js Server Components?
Radix UI primitives are client components. Use them in 'use client' files or as a child of a client component boundary. Not usable directly in server components.
5What does the asChild prop do?
Delegates props, events and ARIA attributes to the child element instead of rendering its own DOM element. Avoids button-in-button, gives full DOM control.
6Usable without Tailwind CSS?
Yes. Framework agnostic. CSS Modules, SCSS, Styled Components or plain CSS. [data-state='open'] { ... } as a CSS attribute selector instead of Tailwind variants.
7Which primitives exist in Radix UI?
Over 30: Dialog, Dropdown Menu, Select, Tooltip, Popover, Accordion, Tabs, Checkbox, Radio Group, Switch, Slider, Progress, Toast, Alert Dialog and many more.
8Animations with Radix UI?
data-state="open"/"closed" act as hooks for CSS animations. Tailwind: data-[state=open]:animate-in, data-[state=closed]:animate-out. Declarative, no manual state needed.
9Is Radix UI free?
Yes, fully. MIT license. Primitives and Radix Themes are both free. No pro tier, no usage limits.
10Why not Material UI or Ant Design?
Fixed design system, hard to override for a custom design. Radix UI gives complete design freedom with the same or better accessibility. More maintainable long term with a custom design.