$npx -y skills add dylantarre/design-system-skills --skill compound-componentsBuilds accessible composable components using Radix/Headless UI patterns. Use when creating Select, Dialog, Tabs, Accordion, Menu, or Dropdown components with proper ARIA, keyboard navigation, and focus management.
| 1 | # Compound Components |
| 2 | |
| 3 | ## Overview |
| 4 | |
| 5 | Build accessible, composable components using the compound component pattern. This is how modern component libraries (Radix UI, Headless UI, React Aria) create flexible, accessible primitives that handle complex interactions. |
| 6 | |
| 7 | ## When to Use |
| 8 | |
| 9 | - Building a component library from scratch |
| 10 | - Creating accessible interactive components (Select, Dialog, Tabs, Accordion) |
| 11 | - Need flexibility in how components render |
| 12 | - Want to separate behavior from styling |
| 13 | - Components with complex state (open/closed, selected, focused) |
| 14 | |
| 15 | ### Implementation Checklist |
| 16 | |
| 17 | Copy this checklist when building a compound component: |
| 18 | |
| 19 | ``` |
| 20 | Compound Component Build: |
| 21 | - [ ] Define component parts and responsibilities (Root, Trigger, Content, Item, etc.) |
| 22 | - [ ] Create Root with context provider (state, refs, generated IDs) |
| 23 | - [ ] Implement child components with ARIA roles and data-state attributes |
| 24 | - [ ] Add keyboard navigation (Arrow keys, Enter/Space, Escape, Tab) |
| 25 | - [ ] Handle focus management (initial focus, focus trap for modals, focus restoration) |
| 26 | - [ ] Test with keyboard-only navigation and screen reader |
| 27 | ``` |
| 28 | |
| 29 | ## Quick Reference: Pattern Comparison |
| 30 | |
| 31 | | Pattern | Example | Flexibility | Complexity | |
| 32 | |---------|---------|-------------|------------| |
| 33 | | Monolithic | `<Select options={[...]} />` | Low | Low | |
| 34 | | Compound | `<Select.Root><Select.Trigger /><Select.Content /></Select.Root>` | High | Medium | |
| 35 | | Headless | `useSelect()` hook | Highest | High | |
| 36 | |
| 37 | ## The Compound Pattern |
| 38 | |
| 39 | ```tsx |
| 40 | // Usage example - caller controls structure |
| 41 | <Dialog.Root> |
| 42 | <Dialog.Trigger>Open</Dialog.Trigger> |
| 43 | <Dialog.Portal> |
| 44 | <Dialog.Overlay /> |
| 45 | <Dialog.Content> |
| 46 | <Dialog.Title>Settings</Dialog.Title> |
| 47 | <Dialog.Description>Manage preferences</Dialog.Description> |
| 48 | {/* Custom content */} |
| 49 | <Dialog.Close>Done</Dialog.Close> |
| 50 | </Dialog.Content> |
| 51 | </Dialog.Portal> |
| 52 | </Dialog.Root> |
| 53 | ``` |
| 54 | |
| 55 | --- |
| 56 | |
| 57 | ## Architecture |
| 58 | |
| 59 | ### Component Parts |
| 60 | |
| 61 | | Part | Purpose | Required | |
| 62 | |------|---------|----------| |
| 63 | | Root | Provides context, manages state | Yes | |
| 64 | | Trigger | Opens/activates the component | Usually | |
| 65 | | Content | Main content area | Yes | |
| 66 | | Portal | Renders outside DOM hierarchy | For overlays | |
| 67 | | Overlay | Backdrop behind content | For modals | |
| 68 | | Title | Accessible heading | For dialogs | |
| 69 | | Description | Accessible description | Optional | |
| 70 | | Close | Closes the component | Usually | |
| 71 | | Item | Individual selectable items | For lists | |
| 72 | |
| 73 | ### Context Flow |
| 74 | |
| 75 | ``` |
| 76 | ┌─────────────────────────────────────────────────┐ |
| 77 | │ Root (Context Provider) │ |
| 78 | │ ┌───────────────────────────────────────────┐ │ |
| 79 | │ │ • open/setOpen state │ │ |
| 80 | │ │ • triggerRef │ │ |
| 81 | │ │ • contentRef │ │ |
| 82 | │ │ • generated IDs │ │ |
| 83 | │ └───────────────────────────────────────────┘ │ |
| 84 | │ │ │ |
| 85 | │ ┌─────────┴─────────┐ │ |
| 86 | │ ▼ ▼ │ |
| 87 | │ Trigger Content │ |
| 88 | │ (reads context) (reads context) │ |
| 89 | │ │ │ |
| 90 | │ ┌────────┴────────┐ │ |
| 91 | │ ▼ ▼ ▼ │ |
| 92 | │ Title Items Close │ |
| 93 | └─────────────────────────────────────────────────┘ |
| 94 | ``` |
| 95 | |
| 96 | --- |
| 97 | |
| 98 | ## Implementation: React |
| 99 | |
| 100 | ### Select Component |
| 101 | |
| 102 | ```tsx |
| 103 | // select.tsx |
| 104 | import * as React from 'react'; |
| 105 | import { createContext, useContext, useId, useRef, useState } from 'react'; |
| 106 | |
| 107 | // Types |
| 108 | interface SelectContextValue { |
| 109 | open: boolean; |
| 110 | setOpen: (open: boolean) => void; |
| 111 | value: string | undefined; |
| 112 | setValue: (value: string) => void; |
| 113 | triggerId: string; |
| 114 | contentId: string; |
| 115 | triggerRef: React.RefObject<HTMLButtonElement>; |
| 116 | } |
| 117 | |
| 118 | interface SelectRootProps { |
| 119 | children: React.ReactNode; |
| 120 | value?: string; |
| 121 | onValueChange?: (value: string) => void; |
| 122 | defaultValue?: string; |
| 123 | } |
| 124 | |
| 125 | // Context |
| 126 | const SelectContext = createContext<SelectContextValue | null>(null); |
| 127 | |
| 128 | function useSelectContext() { |
| 129 | const context = useContext(SelectContext); |
| 130 | if (!context) { |
| 131 | throw new Error('Select components must be used within Select.Root'); |
| 132 | } |
| 133 | return context; |
| 134 | } |
| 135 | |
| 136 | // Root - manages all state |
| 137 | function SelectRoot({ |
| 138 | children, |
| 139 | value: controlledValue, |
| 140 | onValueChange, |
| 141 | defaultValue |
| 142 | }: SelectRootProps) { |
| 143 | const [open, setOpen] = useState(false); |
| 144 | const [internalValue, setInternalValue] = useState(defaultValue); |
| 145 | const triggerRef = useRef<HTMLButtonElement>(null); |
| 146 | |
| 147 | const id = useId(); |
| 148 | const triggerId = `select-trigger-${id}`; |
| 149 | const contentId = `select-content-${id}`; |
| 150 | |
| 151 | // Controlled vs uncontrolled |
| 152 | const value = |