Build accessible design systems with Radix UI primitives. Headless component customization, theming strategies, and compound component patterns for production-grade UI libraries.
Add this skill
npx mdskills install sickn33/radix-ui-design-systemComprehensive guide with clear patterns, theming strategies, and accessibility focus for building Radix UI design systems
1---2name: radix-ui-design-system3description: Build accessible design systems with Radix UI primitives. Headless component customization, theming strategies, and compound component patterns for production-grade UI libraries.4risk: safe5source: self6---78# Radix UI Design System910Build production-ready, accessible design systems using Radix UI primitives with full customization control and zero style opinions.1112## Overview1314Radix UI provides unstyled, accessible components (primitives) that you can customize to match any design system. This skill guides you through building scalable component libraries with Radix UI, focusing on accessibility-first design, theming architecture, and composable patterns.1516**Key Strengths:**17- **Headless by design**: Full styling control without fighting defaults18- **Accessibility built-in**: WAI-ARIA compliant, keyboard navigation, screen reader support19- **Composable primitives**: Build complex components from simple building blocks20- **Framework agnostic**: Works with React, but styles work anywhere2122## When to Use This Skill2324- Creating a custom design system from scratch25- Building accessible UI component libraries26- Implementing complex interactive components (Dialog, Dropdown, Tabs, etc.)27- Migrating from styled component libraries to unstyled primitives28- Setting up theming systems with CSS variables or Tailwind29- Need full control over component behavior and styling30- Building applications requiring WCAG 2.1 AA/AAA compliance3132## Do not use this skill when3334- You need pre-styled components out of the box (use shadcn/ui, Mantine, etc.)35- Building simple static pages without interactivity36- The project doesn't use React 16.8+ (Radix requires hooks)37- You need components for frameworks other than React3839---4041## Core Principles4243### 1. Accessibility First4445Every Radix primitive is built with accessibility as the foundation:4647- **Keyboard Navigation**: Full keyboard support (Tab, Arrow keys, Enter, Escape)48- **Screen Readers**: Proper ARIA attributes and live regions49- **Focus Management**: Automatic focus trapping and restoration50- **Disabled States**: Proper handling of disabled and aria-disabled5152**Rule**: Never override accessibility features. Enhance, don't replace.5354### 2. Headless Architecture5556Radix provides **behavior**, you provide **appearance**:5758```tsx59// ❌ Don't fight pre-styled components60<Button className="override-everything" />6162// ✅ Radix gives you behavior, you add styling63<Dialog.Root>64 <Dialog.Trigger className="your-button-styles" />65 <Dialog.Content className="your-modal-styles" />66</Dialog.Root>67```6869### 3. Composition Over Configuration7071Build complex components from simple primitives:7273```tsx74// Primitive components compose naturally75<Tabs.Root>76 <Tabs.List>77 <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>78 <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>79 </Tabs.List>80 <Tabs.Content value="tab1">Content 1</Tabs.Content>81 <Tabs.Content value="tab2">Content 2</Tabs.Content>82</Tabs.Root>83```8485---8687## Getting Started8889### Installation9091```bash92# Install individual primitives (recommended)93npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu9495# Or install multiple at once96npm install @radix-ui/react-{dialog,dropdown-menu,tabs,tooltip}9798# For styling (optional but common)99npm install clsx tailwind-merge class-variance-authority100```101102### Basic Component Pattern103104Every Radix component follows this pattern:105106```tsx107import * as Dialog from '@radix-ui/react-dialog';108109export function MyDialog() {110 return (111 <Dialog.Root>112 {/* Trigger the dialog */}113 <Dialog.Trigger asChild>114 <button className="trigger-styles">Open</button>115 </Dialog.Trigger>116117 {/* Portal renders outside DOM hierarchy */}118 <Dialog.Portal>119 {/* Overlay (backdrop) */}120 <Dialog.Overlay className="overlay-styles" />121122 {/* Content (modal) */}123 <Dialog.Content className="content-styles">124 <Dialog.Title>Title</Dialog.Title>125 <Dialog.Description>Description</Dialog.Description>126127 {/* Your content here */}128129 <Dialog.Close asChild>130 <button>Close</button>131 </Dialog.Close>132 </Dialog.Content>133 </Dialog.Portal>134 </Dialog.Root>135 );136}137```138139---140141## Theming Strategies142143### Strategy 1: CSS Variables (Framework-Agnostic)144145**Best for**: Maximum portability, SSR-friendly146147```css148/* globals.css */149:root {150 --color-primary: 220 90% 56%;151 --color-surface: 0 0% 100%;152 --radius-base: 0.5rem;153 --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);154}155156[data-theme="dark"] {157 --color-primary: 220 90% 66%;158 --color-surface: 222 47% 11%;159}160```161162```tsx163// Component.tsx164<Dialog.Content165 className="166 bg-[hsl(var(--color-surface))]167 rounded-[var(--radius-base)]168 shadow-[var(--shadow-lg)]169 "170/>171```172173### Strategy 2: Tailwind + CVA (Class Variance Authority)174175**Best for**: Tailwind projects, variant-heavy components176177```tsx178// button.tsx179import { cva, type VariantProps } from 'class-variance-authority';180import { cn } from '@/lib/utils';181182const buttonVariants = cva(183 // Base styles184 "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50",185 {186 variants: {187 variant: {188 default: "bg-primary text-primary-foreground hover:bg-primary/90",189 destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",190 outline: "border border-input bg-background hover:bg-accent",191 ghost: "hover:bg-accent hover:text-accent-foreground",192 },193 size: {194 default: "h-10 px-4 py-2",195 sm: "h-9 rounded-md px-3",196 lg: "h-11 rounded-md px-8",197 icon: "h-10 w-10",198 },199 },200 defaultVariants: {201 variant: "default",202 size: "default",203 },204 }205);206207interface ButtonProps extends VariantProps<typeof buttonVariants> {208 children: React.ReactNode;209}210211export function Button({ variant, size, children }: ButtonProps) {212 return (213 <button className={cn(buttonVariants({ variant, size }))}>214 {children}215 </button>216 );217}218```219220### Strategy 3: Stitches (CSS-in-JS)221222**Best for**: Runtime theming, scoped styles223224```tsx225import { styled } from '@stitches/react';226import * as Dialog from '@radix-ui/react-dialog';227228const StyledContent = styled(Dialog.Content, {229 backgroundColor: '$surface',230 borderRadius: '$md',231 padding: '$6',232233 variants: {234 size: {235 small: { width: '300px' },236 medium: { width: '500px' },237 large: { width: '700px' },238 },239 },240241 defaultVariants: {242 size: 'medium',243 },244});245```246247---248249## Component Patterns250251### Pattern 1: Compound Components with Context252253**Use case**: Share state between primitive parts254255```tsx256// Select.tsx257import * as Select from '@radix-ui/react-select';258import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons';259260export function CustomSelect({ items, placeholder, onValueChange }) {261 return (262 <Select.Root onValueChange={onValueChange}>263 <Select.Trigger className="select-trigger">264 <Select.Value placeholder={placeholder} />265 <Select.Icon>266 <ChevronDownIcon />267 </Select.Icon>268 </Select.Trigger>269270 <Select.Portal>271 <Select.Content className="select-content">272 <Select.Viewport>273 {items.map((item) => (274 <Select.Item275 key={item.value}276 value={item.value}277 className="select-item"278 >279 <Select.ItemText>{item.label}</Select.ItemText>280 <Select.ItemIndicator>281 <CheckIcon />282 </Select.ItemIndicator>283 </Select.Item>284 ))}285 </Select.Viewport>286 </Select.Content>287 </Select.Portal>288 </Select.Root>289 );290}291```292293### Pattern 2: Polymorphic Components with `asChild`294295**Use case**: Render as different elements without losing behavior296297```tsx298// ✅ Render as Next.js Link but keep Radix behavior299<Dialog.Trigger asChild>300 <Link href="/settings">Open Settings</Link>301</Dialog.Trigger>302303// ✅ Render as custom component304<DropdownMenu.Item asChild>305 <YourCustomButton icon={<Icon />}>Action</YourCustomButton>306</DropdownMenu.Item>307```308309**Why `asChild` matters**: Prevents nested button/link issues in accessibility tree.310311### Pattern 3: Controlled vs Uncontrolled312313```tsx314// Uncontrolled (Radix manages state)315<Tabs.Root defaultValue="tab1">316 <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>317</Tabs.Root>318319// Controlled (You manage state)320const [activeTab, setActiveTab] = useState('tab1');321322<Tabs.Root value={activeTab} onValueChange={setActiveTab}>323 <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>324</Tabs.Root>325```326327**Rule**: Use controlled when you need to sync with external state (URL, Redux, etc.).328329### Pattern 4: Animation with Framer Motion330331```tsx332import * as Dialog from '@radix-ui/react-dialog';333import { motion, AnimatePresence } from 'framer-motion';334335export function AnimatedDialog({ open, onOpenChange }) {336 return (337 <Dialog.Root open={open} onOpenChange={onOpenChange}>338 <Dialog.Portal forceMount>339 <AnimatePresence>340 {open && (341 <>342 <Dialog.Overlay asChild>343 <motion.div344 initial={{ opacity: 0 }}345 animate={{ opacity: 1 }}346 exit={{ opacity: 0 }}347 className="dialog-overlay"348 />349 </Dialog.Overlay>350351 <Dialog.Content asChild>352 <motion.div353 initial={{ opacity: 0, scale: 0.95 }}354 animate={{ opacity: 1, scale: 1 }}355 exit={{ opacity: 0, scale: 0.95 }}356 className="dialog-content"357 >358 {/* Content */}359 </motion.div>360 </Dialog.Content>361 </>362 )}363 </AnimatePresence>364 </Dialog.Portal>365 </Dialog.Root>366 );367}368```369370---371372## Common Primitives Reference373374### Dialog (Modal)375376```tsx377<Dialog.Root> {/* State container */}378 <Dialog.Trigger /> {/* Opens dialog */}379 <Dialog.Portal> {/* Renders in portal */}380 <Dialog.Overlay /> {/* Backdrop */}381 <Dialog.Content> {/* Modal content */}382 <Dialog.Title /> {/* Required for a11y */}383 <Dialog.Description /> {/* Required for a11y */}384 <Dialog.Close /> {/* Closes dialog */}385 </Dialog.Content>386 </Dialog.Portal>387</Dialog.Root>388```389390### Dropdown Menu391392```tsx393<DropdownMenu.Root>394 <DropdownMenu.Trigger />395 <DropdownMenu.Portal>396 <DropdownMenu.Content>397 <DropdownMenu.Item />398 <DropdownMenu.Separator />399 <DropdownMenu.CheckboxItem />400 <DropdownMenu.RadioGroup>401 <DropdownMenu.RadioItem />402 </DropdownMenu.RadioGroup>403 <DropdownMenu.Sub> {/* Nested menus */}404 <DropdownMenu.SubTrigger />405 <DropdownMenu.SubContent />406 </DropdownMenu.Sub>407 </DropdownMenu.Content>408 </DropdownMenu.Portal>409</DropdownMenu.Root>410```411412### Tabs413414```tsx415<Tabs.Root defaultValue="tab1">416 <Tabs.List>417 <Tabs.Trigger value="tab1" />418 <Tabs.Trigger value="tab2" />419 </Tabs.List>420 <Tabs.Content value="tab1" />421 <Tabs.Content value="tab2" />422</Tabs.Root>423```424425### Tooltip426427```tsx428<Tooltip.Provider delayDuration={200}>429 <Tooltip.Root>430 <Tooltip.Trigger />431 <Tooltip.Portal>432 <Tooltip.Content side="top" align="center">433 Tooltip text434 <Tooltip.Arrow />435 </Tooltip.Content>436 </Tooltip.Portal>437 </Tooltip.Root>438</Tooltip.Provider>439```440441### Popover442443```tsx444<Popover.Root>445 <Popover.Trigger />446 <Popover.Portal>447 <Popover.Content side="bottom" align="start">448 Content449 <Popover.Arrow />450 <Popover.Close />451 </Popover.Content>452 </Popover.Portal>453</Popover.Root>454```455456---457458## Accessibility Checklist459460### Every Component Must Have:461462- [ ] **Focus Management**: Visible focus indicators on all interactive elements463- [ ] **Keyboard Navigation**: Full keyboard support (Tab, Arrows, Enter, Esc)464- [ ] **ARIA Labels**: Meaningful labels for screen readers465- [ ] **Color Contrast**: WCAG AA minimum (4.5:1 for text, 3:1 for UI)466- [ ] **Error States**: Clear error messages with `aria-invalid` and `aria-describedby`467- [ ] **Loading States**: Proper `aria-busy` during async operations468469### Dialog-Specific:470- [ ] `Dialog.Title` is present (required for screen readers)471- [ ] `Dialog.Description` provides context472- [ ] Focus trapped inside modal when open473- [ ] Escape key closes dialog474- [ ] Focus returns to trigger on close475476### Dropdown-Specific:477- [ ] Arrow keys navigate items478- [ ] Type-ahead search works479- [ ] First/last item wrapping behavior480- [ ] Selected state indicated visually and with ARIA481482---483484## Best Practices485486### ✅ Do This4874881. **Always use `asChild` to avoid wrapper divs**489 ```tsx490 <Dialog.Trigger asChild>491 <button>Open</button>492 </Dialog.Trigger>493 ```4944952. **Provide semantic HTML**496 ```tsx497 <Dialog.Content asChild>498 <article role="dialog" aria-labelledby="title">499 {/* content */}500 </article>501 </Dialog.Content>502 ```5035043. **Use CSS variables for theming**505 ```css506 .dialog-content {507 background: hsl(var(--surface));508 color: hsl(var(--on-surface));509 }510 ```5115124. **Compose primitives for complex components**513 ```tsx514 function CommandPalette() {515 return (516 <Dialog.Root>517 <Dialog.Content>518 <Combobox /> {/* Radix Combobox inside Dialog */}519 </Dialog.Content>520 </Dialog.Root>521 );522 }523 ```524525### ❌ Don't Do This5265271. **Don't skip accessibility parts**528 ```tsx529 // ❌ Missing Title and Description530 <Dialog.Content>531 <div>Content</div>532 </Dialog.Content>533 ```5345352. **Don't fight the primitives**536 ```tsx537 // ❌ Overriding internal behavior538 <Dialog.Content onClick={(e) => e.stopPropagation()}>539 ```5405413. **Don't mix controlled and uncontrolled**542 ```tsx543 // ❌ Inconsistent state management544 <Tabs.Root defaultValue="tab1" value={activeTab}>545 ```5465474. **Don't ignore keyboard navigation**548 ```tsx549 // ❌ Disabling keyboard behavior550 <DropdownMenu.Item onKeyDown={(e) => e.preventDefault()}>551 ```552553---554555## Real-World Examples556557### Example 1: Command Palette (Combo Dialog)558559```tsx560import * as Dialog from '@radix-ui/react-dialog';561import { Command } from 'cmdk';562563export function CommandPalette() {564 const [open, setOpen] = useState(false);565566 useEffect(() => {567 const down = (e: KeyboardEvent) => {568 if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {569 e.preventDefault();570 setOpen((open) => !open);571 }572 };573 document.addEventListener('keydown', down);574 return () => document.removeEventListener('keydown', down);575 }, []);576577 return (578 <Dialog.Root open={open} onOpenChange={setOpen}>579 <Dialog.Portal>580 <Dialog.Overlay className="fixed inset-0 bg-black/50" />581 <Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">582 <Command>583 <Command.Input placeholder="Type a command..." />584 <Command.List>585 <Command.Empty>No results found.</Command.Empty>586 <Command.Group heading="Suggestions">587 <Command.Item>Calendar</Command.Item>588 <Command.Item>Search Emoji</Command.Item>589 </Command.Group>590 </Command.List>591 </Command>592 </Dialog.Content>593 </Dialog.Portal>594 </Dialog.Root>595 );596}597```598599### Example 2: Dropdown Menu with Icons600601```tsx602import * as DropdownMenu from '@radix-ui/react-dropdown-menu';603import { DotsHorizontalIcon } from '@radix-ui/react-icons';604605export function ActionsMenu() {606 return (607 <DropdownMenu.Root>608 <DropdownMenu.Trigger asChild>609 <button className="icon-button" aria-label="Actions">610 <DotsHorizontalIcon />611 </button>612 </DropdownMenu.Trigger>613614 <DropdownMenu.Portal>615 <DropdownMenu.Content className="dropdown-content" align="end">616 <DropdownMenu.Item className="dropdown-item">617 Edit618 </DropdownMenu.Item>619 <DropdownMenu.Item className="dropdown-item">620 Duplicate621 </DropdownMenu.Item>622 <DropdownMenu.Separator className="dropdown-separator" />623 <DropdownMenu.Item className="dropdown-item text-red-500">624 Delete625 </DropdownMenu.Item>626 </DropdownMenu.Content>627 </DropdownMenu.Portal>628 </DropdownMenu.Root>629 );630}631```632633### Example 3: Form with Radix Select + React Hook Form634635```tsx636import * as Select from '@radix-ui/react-select';637import { useForm, Controller } from 'react-hook-form';638639interface FormData {640 country: string;641}642643export function CountryForm() {644 const { control, handleSubmit } = useForm<FormData>();645646 return (647 <form onSubmit={handleSubmit((data) => console.log(data))}>648 <Controller649 name="country"650 control={control}651 render={({ field }) => (652 <Select.Root onValueChange={field.onChange} value={field.value}>653 <Select.Trigger className="select-trigger">654 <Select.Value placeholder="Select a country" />655 <Select.Icon />656 </Select.Trigger>657658 <Select.Portal>659 <Select.Content className="select-content">660 <Select.Viewport>661 <Select.Item value="us">United States</Select.Item>662 <Select.Item value="ca">Canada</Select.Item>663 <Select.Item value="uk">United Kingdom</Select.Item>664 </Select.Viewport>665 </Select.Content>666 </Select.Portal>667 </Select.Root>668 )}669 />670 <button type="submit">Submit</button>671 </form>672 );673}674```675676---677678## Troubleshooting679680### Problem: Dialog doesn't close on Escape key681682**Cause**: `onEscapeKeyDown` event prevented or `open` state not synced683684**Solution**:685```tsx686<Dialog.Root open={open} onOpenChange={setOpen}>687 {/* Don't prevent default on escape */}688</Dialog.Root>689```690691### Problem: Dropdown menu positioning is off692693**Cause**: Parent container has `overflow: hidden` or transform694695**Solution**:696```tsx697// Use Portal to render outside overflow container698<DropdownMenu.Portal>699 <DropdownMenu.Content />700</DropdownMenu.Portal>701```702703### Problem: Animations don't work704705**Cause**: Portal content unmounts immediately706707**Solution**:708```tsx709// Use forceMount + AnimatePresence710<Dialog.Portal forceMount>711 <AnimatePresence>712 {open && <Dialog.Content />}713 </AnimatePresence>714</Dialog.Portal>715```716717### Problem: TypeScript errors with `asChild`718719**Cause**: Type inference issues with polymorphic components720721**Solution**:722```tsx723// Explicitly type your component724<Dialog.Trigger asChild>725 <button type="button">Open</button>726</Dialog.Trigger>727```728729---730731## Performance Optimization732733### 1. Code Splitting734735```tsx736// Lazy load heavy primitives737const Dialog = lazy(() => import('@radix-ui/react-dialog'));738const DropdownMenu = lazy(() => import('@radix-ui/react-dropdown-menu'));739```740741### 2. Portal Container Reuse742743```tsx744// Create portal container once745<Tooltip.Provider>746 {/* All tooltips share portal container */}747 <Tooltip.Root>...</Tooltip.Root>748 <Tooltip.Root>...</Tooltip.Root>749</Tooltip.Provider>750```751752### 3. Memoization753754```tsx755// Memoize expensive render functions756const SelectItems = memo(({ items }) => (757 items.map((item) => <Select.Item key={item.value} value={item.value} />)758));759```760761---762763## Integration with Popular Tools764765### shadcn/ui (Built on Radix)766767shadcn/ui is a collection of copy-paste components built with Radix + Tailwind.768769```bash770npx shadcn-ui@latest init771npx shadcn-ui@latest add dialog772```773774**When to use shadcn vs raw Radix**:775- Use shadcn: Quick prototyping, standard designs776- Use raw Radix: Full customization, unique designs777778### Radix Themes (Official Styled System)779780```tsx781import { Theme, Button, Dialog } from '@radix-ui/themes';782783function App() {784 return (785 <Theme accentColor="crimson" grayColor="sand">786 <Button>Click me</Button>787 </Theme>788 );789}790```791792---793794## Related Skills795796- `@tailwind-design-system` - Tailwind + Radix integration patterns797- `@react-patterns` - React composition patterns798- `@frontend-design` - Overall frontend architecture799- `@accessibility-compliance` - WCAG compliance testing800801---802803## Resources804805### Official Documentation806- [Radix UI Docs](https://www.radix-ui.com/primitives)807- [Radix Colors](https://www.radix-ui.com/colors) - Accessible color system808- [Radix Icons](https://www.radix-ui.com/icons) - Icon library809810### Community Resources811- [shadcn/ui](https://ui.shadcn.com) - Component collection812- [Radix UI Discord](https://discord.com/invite/7Xb99uG) - Community support813- [CVA Documentation](https://cva.style/docs) - Variant management814815### Examples816- [Radix Playground](https://www.radix-ui.com/primitives/docs/overview/introduction#try-it-out)817- [shadcn/ui Source](https://github.com/shadcn-ui/ui) - Production examples818819---820821## Quick Reference822823### Installation824```bash825npm install @radix-ui/react-{primitive-name}826```827828### Basic Pattern829```tsx830<Primitive.Root>831 <Primitive.Trigger />832 <Primitive.Portal>833 <Primitive.Content />834 </Primitive.Portal>835</Primitive.Root>836```837838### Key Props839- `asChild` - Render as child element840- `defaultValue` - Uncontrolled default841- `value` / `onValueChange` - Controlled state842- `open` / `onOpenChange` - Open state843- `side` / `align` - Positioning844845---846847**Remember**: Radix gives you **behavior**, you give it **beauty**. Accessibility is built-in, customization is unlimited.848
Full transparency — inspect the skill content before installing.