Radix UI
Platform: Web only. For mobile modals/sheets, see the expo-sdk and react-native-patterns skills.
Overview
Unstyled, accessible UI primitives for React with built-in keyboard navigation, focus management, and ARIA attributes. Designed to be composed with Tailwind CSS and Framer Motion.
Version: Latest (individual packages) or radix-ui unified package
Install (individual packages):
pnpm add @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-select @radix-ui/react-tooltip @radix-ui/react-tabs
Install (unified package):
pnpm add radix-ui
The unified radix-ui package bundles all primitives - use this for simpler dependency management.
Workflows
Adding a Dialog:
-
Install: pnpm add @radix-ui/react-dialog
-
Import Dialog parts: Root, Trigger, Portal, Overlay, Content
-
Wrap Overlay and Content in Portal for proper stacking
-
Style with Tailwind and data-[state=] selectors
-
Test keyboard navigation (Esc to close, Tab trap)
-
Add Framer Motion animations if needed
Adding a Select:
-
Install: pnpm add @radix-ui/react-select
-
Import Select parts: Root, Trigger, Portal, Content, Item
-
Add Icon and Value to Trigger for visual feedback
-
Style open/closed states with data-[state=open]
-
Test keyboard (Arrow keys, Enter, Type-ahead)
-
Ensure proper z-index for Portal
Adding Tooltips:
-
Install: pnpm add @radix-ui/react-tooltip
-
Wrap app with TooltipProvider
-
Compose Trigger and Content for each tooltip
-
Set delayDuration for hover timing
-
Style with Tailwind arrows using data-[side=]
-
Verify screen reader announcements
Dialog (Modal)
Basic Modal Pattern
// Individual package import import * as Dialog from '@radix-ui/react-dialog';
// OR unified package import // import { Dialog } from 'radix-ui';
function ModalExample() { return ( <Dialog.Root> <Dialog.Trigger asChild> <button className="px-4 py-2 bg-blue-600 text-white rounded"> Open Dialog </button> </Dialog.Trigger>
<Dialog.Portal>
<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" />
<Dialog.Content className="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] max-h-[85vh] w-[90vw] max-w-[500px] rounded-lg bg-white p-6 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]">
<Dialog.Title className="text-lg font-semibold mb-4">
Dialog Title
</Dialog.Title>
<Dialog.Description className="text-sm text-gray-600 mb-4">
Make changes to your profile here. Click save when you're done.
</Dialog.Description>
<div className="space-y-4">
{/* Form content */}
<input type="text" className="w-full px-3 py-2 border rounded" />
</div>
<div className="flex justify-end gap-2 mt-6">
<Dialog.Close asChild>
<button className="px-4 py-2 border rounded">Cancel</button>
</Dialog.Close>
<button className="px-4 py-2 bg-blue-600 text-white rounded">
Save changes
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
); }
Controlled Dialog
import { useState } from 'react'; import * as Dialog from '@radix-ui/react-dialog';
function ControlledDialog() { const [open, setOpen] = useState(false);
const handleSubmit = () => { // Process form setOpen(false); };
return ( <Dialog.Root open={open} onOpenChange={setOpen}> <Dialog.Trigger asChild> <button>Open</button> </Dialog.Trigger> {/* Portal, Overlay, Content... */} </Dialog.Root> ); }
Dialog with Framer Motion
import * as Dialog from '@radix-ui/react-dialog'; import { motion, AnimatePresence } from 'framer-motion';
function AnimatedDialog() { const [open, setOpen] = useState(false);
return ( <Dialog.Root open={open} onOpenChange={setOpen}> <Dialog.Trigger asChild> <button>Open</button> </Dialog.Trigger>
<AnimatePresence>
{open && (
<Dialog.Portal forceMount>
<Dialog.Overlay asChild>
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
</Dialog.Overlay>
<Dialog.Content asChild>
<motion.div
className="fixed left-[50%] top-[50%] max-w-[500px] rounded-lg bg-white p-6"
initial={{ opacity: 0, scale: 0.95, x: '-50%', y: '-50%' }}
animate={{ opacity: 1, scale: 1, x: '-50%', y: '-50%' }}
exit={{ opacity: 0, scale: 0.95, x: '-50%', y: '-50%' }}
transition={{ duration: 0.2 }}
>
{/* Content */}
</motion.div>
</Dialog.Content>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
); }
Select (Dropdown)
Basic Select
import * as Select from '@radix-ui/react-select'; import { ChevronDownIcon, CheckIcon } from '@radix-ui/react-icons';
function SelectExample() { return ( <Select.Root defaultValue="apple"> <Select.Trigger className="inline-flex items-center justify-between rounded px-4 py-2 text-sm bg-white border gap-2 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 data-[placeholder]:text-gray-400 min-w-[200px]"> <Select.Value placeholder="Select a fruit..." /> <Select.Icon> <ChevronDownIcon /> </Select.Icon> </Select.Trigger>
<Select.Portal>
<Select.Content className="overflow-hidden bg-white rounded-md shadow-lg border">
<Select.Viewport className="p-1">
<Select.Item value="apple" className="relative flex items-center px-8 py-2 rounded text-sm hover:bg-blue-50 focus:bg-blue-100 outline-none cursor-pointer data-[disabled]:opacity-50 data-[disabled]:pointer-events-none">
<Select.ItemIndicator className="absolute left-2">
<CheckIcon />
</Select.ItemIndicator>
<Select.ItemText>Apple</Select.ItemText>
</Select.Item>
<Select.Item value="banana" className="relative flex items-center px-8 py-2 rounded text-sm hover:bg-blue-50 focus:bg-blue-100 outline-none cursor-pointer">
<Select.ItemIndicator className="absolute left-2">
<CheckIcon />
</Select.ItemIndicator>
<Select.ItemText>Banana</Select.ItemText>
</Select.Item>
<Select.Item value="orange" className="relative flex items-center px-8 py-2 rounded text-sm hover:bg-blue-50 focus:bg-blue-100 outline-none cursor-pointer">
<Select.ItemIndicator className="absolute left-2">
<CheckIcon />
</Select.ItemIndicator>
<Select.ItemText>Orange</Select.ItemText>
</Select.Item>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
); }
Grouped Select with Labels
<Select.Root> <Select.Trigger>{/* ... */}</Select.Trigger>
<Select.Portal> <Select.Content> <Select.Viewport> <Select.Group> <Select.Label className="px-8 py-2 text-xs font-semibold text-gray-500"> Fruits </Select.Label> <Select.Item value="apple">{/* ... /}</Select.Item> <Select.Item value="banana">{/ ... */}</Select.Item> </Select.Group>
<Select.Separator className="h-px bg-gray-200 my-1" />
<Select.Group>
<Select.Label className="px-8 py-2 text-xs font-semibold text-gray-500">
Vegetables
</Select.Label>
<Select.Item value="carrot">{/* ... */}</Select.Item>
<Select.Item value="broccoli">{/* ... */}</Select.Item>
</Select.Group>
</Select.Viewport>
</Select.Content>
</Select.Portal> </Select.Root>
Tooltip
Basic Tooltip
import * as Tooltip from '@radix-ui/react-tooltip';
// Wrap your app once function App() { return ( <Tooltip.Provider delayDuration={200}> <YourApp /> </Tooltip.Provider> ); }
// Use in components function TooltipExample() { return ( <Tooltip.Root> <Tooltip.Trigger asChild> <button className="px-4 py-2 bg-gray-100 rounded"> Hover me </button> </Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="bg-gray-900 text-white text-sm px-3 py-2 rounded shadow-lg max-w-xs data-[state=delayed-open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=delayed-open]:zoom-in-95"
sideOffset={5}
>
This is a helpful tooltip
<Tooltip.Arrow className="fill-gray-900" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
); }
Tooltip with Dynamic Positioning
<Tooltip.Content side="top" // top | right | bottom | left align="center" // start | center | end sideOffset={5} className="bg-gray-900 text-white px-3 py-2 rounded"
Content <Tooltip.Arrow className="fill-gray-900" /> </Tooltip.Content>
DropdownMenu
Basic Dropdown Menu
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
function DropdownExample() { return ( <DropdownMenu.Root> <DropdownMenu.Trigger asChild> <button className="px-4 py-2 bg-white border rounded hover:bg-gray-50"> Options </button> </DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[220px] bg-white rounded-md shadow-lg border p-1"
sideOffset={5}
>
<DropdownMenu.Item className="flex items-center px-3 py-2 text-sm rounded cursor-pointer hover:bg-blue-50 focus:bg-blue-100 outline-none">
New Tab
<span className="ml-auto text-xs text-gray-500">⌘T</span>
</DropdownMenu.Item>
<DropdownMenu.Item className="flex items-center px-3 py-2 text-sm rounded cursor-pointer hover:bg-blue-50 focus:bg-blue-100 outline-none">
New Window
<span className="ml-auto text-xs text-gray-500">⌘N</span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-gray-200 my-1" />
<DropdownMenu.Item
className="flex items-center px-3 py-2 text-sm rounded cursor-pointer hover:bg-red-50 focus:bg-red-100 text-red-600 outline-none"
onSelect={() => console.log('Delete')}
>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
); }
Dropdown with Checkboxes and Radio Groups
<DropdownMenu.Content> <DropdownMenu.CheckboxItem checked={showBookmarks} onCheckedChange={setShowBookmarks} className="flex items-center px-3 py-2 text-sm rounded cursor-pointer hover:bg-blue-50 outline-none"
<DropdownMenu.ItemIndicator className="mr-2">
<CheckIcon />
</DropdownMenu.ItemIndicator>
Show Bookmarks
</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator className="h-px bg-gray-200 my-1" />
<DropdownMenu.RadioGroup value={view} onValueChange={setView}> <DropdownMenu.RadioItem value="grid" className="flex items-center px-3 py-2 text-sm rounded cursor-pointer hover:bg-blue-50 outline-none" > <DropdownMenu.ItemIndicator className="mr-2"> <DotFilledIcon /> </DropdownMenu.ItemIndicator> Grid View </DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="list" className="flex items-center px-3 py-2 text-sm rounded cursor-pointer hover:bg-blue-50 outline-none">
<DropdownMenu.ItemIndicator className="mr-2">
<DotFilledIcon />
</DropdownMenu.ItemIndicator>
List View
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup> </DropdownMenu.Content>
Tabs
Basic Tabs
import * as Tabs from '@radix-ui/react-tabs';
function TabsExample() { return ( <Tabs.Root defaultValue="tab1" className="w-full"> <Tabs.List className="flex border-b"> <Tabs.Trigger value="tab1" className="px-4 py-2 text-sm font-medium border-b-2 border-transparent hover:text-blue-600 data-[state=active]:border-blue-600 data-[state=active]:text-blue-600 outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" > Account </Tabs.Trigger>
<Tabs.Trigger
value="tab2"
className="px-4 py-2 text-sm font-medium border-b-2 border-transparent hover:text-blue-600 data-[state=active]:border-blue-600 data-[state=active]:text-blue-600 outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Password
</Tabs.Trigger>
<Tabs.Trigger
value="tab3"
className="px-4 py-2 text-sm font-medium border-b-2 border-transparent hover:text-blue-600 data-[state=active]:border-blue-600 data-[state=active]:text-blue-600 outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Settings
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="tab1" className="p-4">
<h3 className="text-lg font-semibold mb-2">Account Settings</h3>
<p className="text-gray-600">Manage your account details here.</p>
</Tabs.Content>
<Tabs.Content value="tab2" className="p-4">
<h3 className="text-lg font-semibold mb-2">Password Settings</h3>
<p className="text-gray-600">Change your password here.</p>
</Tabs.Content>
<Tabs.Content value="tab3" className="p-4">
<h3 className="text-lg font-semibold mb-2">General Settings</h3>
<p className="text-gray-600">Configure application settings.</p>
</Tabs.Content>
</Tabs.Root>
); }
Vertical Tabs
<Tabs.Root defaultValue="tab1" orientation="vertical" className="flex gap-4"> <Tabs.List className="flex flex-col gap-1 border-r pr-4"> <Tabs.Trigger value="tab1" className="px-4 py-2 text-left text-sm rounded hover:bg-gray-100 data-[state=active]:bg-blue-100 data-[state=active]:text-blue-700" > Profile </Tabs.Trigger> <Tabs.Trigger value="tab2" className="px-4 py-2 text-left text-sm rounded hover:bg-gray-100 data-[state=active]:bg-blue-100 data-[state=active]:text-blue-700" > Billing </Tabs.Trigger> </Tabs.List>
<div className="flex-1"> <Tabs.Content value="tab1">{/* ... /}</Tabs.Content> <Tabs.Content value="tab2">{/ ... */}</Tabs.Content> </div> </Tabs.Root>
Styling with Tailwind
Data Attribute Selectors
// State-based styling className="data-[state=open]:bg-blue-50 data-[state=closed]:bg-gray-50"
// Side-based styling (for positioned elements) className="data-[side=top]:animate-slide-down data-[side=bottom]:animate-slide-up"
// Disabled state className="data-[disabled]:opacity-50 data-[disabled]:pointer-events-none"
// Highlighted state (for keyboard navigation) className="data-[highlighted]:bg-blue-100"
// Checked state className="data-[state=checked]:bg-blue-600"
Common Tailwind Patterns
// Focus ring (keyboard navigation) className="outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
// Backdrop overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm"
// Centered modal className="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%]"
// Dropdown content z-index className="z-50 bg-white rounded-md shadow-lg"
// Smooth transitions className="transition-colors duration-150"
Accessibility
Keyboard Navigation
All Radix components handle keyboard navigation automatically:
-
Dialog: Esc to close, Tab trap inside modal
-
Select: Arrow keys to navigate, Enter to select, type-ahead search
-
DropdownMenu: Arrow keys to navigate, Enter to select
-
Tabs: Arrow keys to switch tabs, Home/End for first/last
-
Tooltip: Focus trigger shows tooltip
ARIA Attributes
Radix components automatically add proper ARIA attributes:
// Dialog adds: // role="dialog" // aria-labelledby (references Dialog.Title) // aria-describedby (references Dialog.Description)
// Select adds: // role="combobox" // aria-expanded // aria-controls
// Always provide Dialog.Title and Dialog.Description <Dialog.Content> <Dialog.Title>Required for a11y</Dialog.Title> <Dialog.Description>Screen readers announce this</Dialog.Description> </Dialog.Content>
Focus Management
// Auto-focus on mount <Dialog.Content onOpenAutoFocus={(e) => { e.preventDefault(); // Prevent default focus customElementRef.current?.focus(); // Custom focus target }}>
// Focus on close <Dialog.Content onCloseAutoFocus={(e) => { e.preventDefault(); triggerRef.current?.focus(); }}>
Controlled vs Uncontrolled
Uncontrolled (Default)
// Component manages its own state <Dialog.Root defaultOpen={false}> <Dialog.Trigger>Open</Dialog.Trigger> {/* ... */} </Dialog.Root>
<Select.Root defaultValue="apple"> <Select.Trigger>{/* ... */}</Select.Trigger> </Select.Root>
Controlled (Recommended for Complex UIs)
// Parent manages state const [open, setOpen] = useState(false); const [value, setValue] = useState('');
<Dialog.Root open={open} onOpenChange={setOpen}> {/* ... */} </Dialog.Root>
<Select.Root value={value} onValueChange={setValue}> {/* ... */} </Select.Root>
Portal Usage
Why Use Portals
Portals render components outside the DOM hierarchy to avoid:
-
z-index conflicts
-
overflow: hidden clipping
-
CSS transform issues
// Without portal (may be clipped) <Dialog.Content>{/* ... */}</Dialog.Content>
// With portal (renders at document.body) <Dialog.Portal> <Dialog.Content>{/* ... */}</Dialog.Content> </Dialog.Portal>
// Custom portal container <Dialog.Portal container={customContainerRef.current}> <Dialog.Content>{/* ... */}</Dialog.Content> </Dialog.Portal>
Portal Best Practices
// Always portal Overlay and Content together <Dialog.Portal> <Dialog.Overlay /> <Dialog.Content /> </Dialog.Portal>
// Use forceMount with AnimatePresence <AnimatePresence> {open && ( <Dialog.Portal forceMount> {/* Framer Motion components */} </Dialog.Portal> )} </AnimatePresence>
Best Practices
-
Use asChild prop to compose with your own elements without wrapper divs
-
Always Portal overlays and dropdowns to avoid z-index issues
-
Provide Title and Description for Dialogs (accessibility requirement)
-
Use data-[state=] selectors for styling open/closed states
-
Prefer controlled components for complex state management
-
Add focus rings with Tailwind outline-none + focus:ring-2
-
Use TooltipProvider once at app root, not per tooltip
-
Combine with Framer Motion using asChild and forceMount
-
Test keyboard navigation for all interactive components
-
Set proper sideOffset (usually 5-10px) for floating elements
-
Use consistent styling patterns across all Radix components
Anti-Patterns
-
❌ Forgetting Dialog.Portal (causes z-index issues)
-
❌ Missing Dialog.Title or Dialog.Description (fails a11y)
-
❌ Not using asChild with custom triggers (creates wrapper divs)
-
❌ Hardcoding colors instead of using data-[state=] selectors
-
❌ Multiple TooltipProviders (unnecessary overhead)
-
❌ Blocking onSelect propagation without e.preventDefault()
-
❌ Forgetting focus:ring styles (poor keyboard UX)
-
❌ Not testing with keyboard navigation
-
❌ Using controlled without onOpenChange/onValueChange
-
❌ Mixing controlled and uncontrolled patterns
Feedback Loops
Accessibility testing:
Test with keyboard only (no mouse)
Tab through all interactive elements
Esc should close Dialogs, Dropdowns, Selects
Arrow keys should navigate menus and selects
Screen reader testing:
macOS: VoiceOver (Cmd+F5)
Verify Dialog.Title and Dialog.Description are announced
Verify Select options are announced correctly
Check for proper role attributes
Visual regression:
// Test all states: // - Closed vs Open // - Hover vs Focus // - Selected vs Unselected // - Disabled states // - Different viewport sizes
Integration with Framer Motion:
// Use forceMount to control mounting // Wrap in AnimatePresence for exit animations // Test that focus management still works with animations