Radix UI — Accessible Headless Components for React

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

Accessible UI components are hard to build correctly. Focus trapping, ARIA attributes, keyboard navigation, and screen reader announcements must all work together. Radix UI handles all of this internally while leaving styling entirely to you — unlike libraries that impose their own design system.

What "Headless" Means

Radix components render semantic HTML with ARIA attributes and manage keyboard interactions. They ship with zero CSS. You bring all visual styling via Tailwind, CSS modules, or any other approach.

Installation

Install only the primitives you need:

npm install @radix-ui/react-dialog
npm install @radix-ui/react-dropdown-menu
npm install @radix-ui/react-tabs
npm install @radix-ui/react-tooltip
npm install @radix-ui/react-select
npm install @radix-ui/react-switch

Accessible Dialog (Modal)

'use client'
 
import * as Dialog from '@radix-ui/react-dialog'
import { useState } from 'react'
 
export function DeleteConfirmDialog({
  onConfirm,
  itemName
}: {
  onConfirm: () => Promise<void>
  itemName: string
}) {
  const [open, setOpen] = useState(false)
  const [loading, setLoading] = useState(false)
 
  async function handleConfirm() {
    setLoading(true)
    await onConfirm()
    setLoading(false)
    setOpen(false)
  }
 
  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger asChild>
        <button className="text-red-600 hover:text-red-800 text-sm font-medium">Delete</button>
      </Dialog.Trigger>
 
      <Dialog.Portal>
        {/* Overlay dims the background */}
        <Dialog.Overlay className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
 
        <Dialog.Content className="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-xl shadow-xl p-6 focus:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95">
          <Dialog.Title className="text-lg font-semibold text-gray-900">
            Delete {itemName}?
          </Dialog.Title>
          <Dialog.Description className="mt-2 text-sm text-gray-500">
            This action cannot be undone. The item will be permanently removed.
          </Dialog.Description>
 
          <div className="flex gap-3 mt-6 justify-end">
            <Dialog.Close asChild>
              <button className="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">Cancel</button>
            </Dialog.Close>
            <button
              onClick={handleConfirm}
              disabled={loading}
              className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
            >
              {loading ? 'Deleting...' : 'Delete'}
            </button>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Accessible Dropdown Menu

'use client'
 
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { ChevronDownIcon } from 'lucide-react'
 
type MenuItem = { label: string; icon?: React.ReactNode; onClick: () => void; danger?: boolean }
 
export function ActionMenu({ label, items }: { label: string; items: MenuItem[] }) {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <button className="flex items-center gap-1 px-3 py-2 text-sm border rounded-lg hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500">
          {label}
          <ChevronDownIcon className="w-4 h-4" />
        </button>
      </DropdownMenu.Trigger>
 
      <DropdownMenu.Portal>
        <DropdownMenu.Content
          className="min-w-[180px] bg-white rounded-lg border shadow-lg p-1 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0"
          sideOffset={4}
        >
          {items.map((item, i) => (
            <DropdownMenu.Item
              key={i}
              onClick={item.onClick}
              className={`flex items-center gap-2 px-3 py-2 text-sm rounded-md cursor-pointer select-none outline-none focus:bg-gray-100 ${
                item.danger ? 'text-red-600 focus:bg-red-50' : 'text-gray-700'
              }`}
            >
              {item.icon}
              {item.label}
            </DropdownMenu.Item>
          ))}
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  )
}

Accessible Tooltip

'use client'
 
import * as Tooltip from '@radix-ui/react-tooltip'
 
export function TooltipWrapper({
  children,
  content
}: {
  children: React.ReactNode
  content: string
}) {
  return (
    <Tooltip.Provider delayDuration={300}>
      <Tooltip.Root>
        <Tooltip.Trigger asChild>
          {children}
        </Tooltip.Trigger>
        <Tooltip.Portal>
          <Tooltip.Content
            className="bg-gray-900 text-white text-xs rounded px-2 py-1 shadow-lg z-50 select-none animate-in fade-in-0 zoom-in-95"
            sideOffset={4}
          >
            {content}
            <Tooltip.Arrow className="fill-gray-900" />
          </Tooltip.Content>
        </Tooltip.Portal>
      </Tooltip.Root>
    </Tooltip.Provider>
  )
}

Accessible Switch (Toggle)

'use client'
 
import * as Switch from '@radix-ui/react-switch'
 
export function ToggleSetting({
  label,
  description,
  checked,
  onCheckedChange
}: {
  label: string
  description?: string
  checked: boolean
  onCheckedChange: (checked: boolean) => void
}) {
  const id = label.toLowerCase().replace(/\s/g, '-')
 
  return (
    <div className="flex items-center justify-between gap-4">
      <div>
        <label htmlFor={id} className="text-sm font-medium text-gray-900 cursor-pointer">{label}</label>
        {description && <p className="text-xs text-gray-500 mt-0.5">{description}</p>}
      </div>
      <Switch.Root
        id={id}
        checked={checked}
        onCheckedChange={onCheckedChange}
        className="w-10 h-6 bg-gray-200 rounded-full relative transition-colors focus-visible:ring-2 focus-visible:ring-blue-500 data-[state=checked]:bg-blue-600"
      >
        <Switch.Thumb className="block w-4 h-4 bg-white rounded-full shadow transition-transform translate-x-1 data-[state=checked]:translate-x-5" />
      </Switch.Root>
    </div>
  )
}

Common Mistakes

  • Not using asChild on Trigger when wrapping a custom button — without it, Radix renders a nested button
  • Forgetting <Dialog.Portal> — without it, the dialog renders inside the component tree, breaking stacking context
  • Skipping Dialog.Title — it is required for screen reader announcements; hide it visually with sr-only if needed
  • Not testing keyboard navigation — Radix handles it but verify with keyboard-only navigation

Best Practices

  • Always include Dialog.Title and Dialog.Description even if visually hidden — they are read by screen readers
  • Use data-[state=open]:animate-in Tailwind variants for smooth open/close animations
  • Use asChild on Trigger components to render your own button instead of Radix's default

Key Takeaways

  • Radix UI is headless — it handles accessibility, keyboard navigation, and ARIA attributes with zero CSS
  • Every Radix component implements WAI-ARIA patterns correctly — dialogs trap focus, dropdowns handle arrow key navigation
  • Portal renders dialogs and dropdowns into document.body to escape CSS stacking context issues
  • asChild merges Radix behavior onto your custom element instead of adding a wrapper DOM node
  • data-[state=open/closed] attributes enable CSS and Tailwind animation based on component state
  • shadcn/ui is built on Radix UI — learning Radix helps you understand and customize shadcn components
  • Test all Radix components with keyboard navigation (Tab, Escape, Arrow keys) and a screen reader
  • Radix has zero bundle size overhead beyond the component you import — each primitive is a separate package

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading