Radix UI — Accessible Headless Components for React
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-switchAccessible 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
asChildon 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 withsr-onlyif needed - Not testing keyboard navigation — Radix handles it but verify with keyboard-only navigation
Best Practices
- Always include
Dialog.TitleandDialog.Descriptioneven if visually hidden — they are read by screen readers - Use
data-[state=open]:animate-inTailwind variants for smooth open/close animations - Use
asChildon 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
Portalrenders dialogs and dropdowns intodocument.bodyto escape CSS stacking context issuesasChildmerges Radix behavior onto your custom element instead of adding a wrapper DOM nodedata-[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