Agent skill
radix-ui
Radix UI primitive patterns. Use when building accessible, unstyled UI components like dialogs, dropdowns, tooltips, tabs, and selects. Covers Tailwind styling, keyboard navigation, animations, and portal management.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/other/radix-ui-dralgorhythm-claude-agentic-frame
SKILL.md
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
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?