Compound components create flexible, expressive APIs. Here's how to build them effectively.
Basic Compound Pattern#
1// Simple compound component - Menu example
2interface MenuProps {
3 children: React.ReactNode;
4}
5
6interface MenuButtonProps {
7 children: React.ReactNode;
8 onClick?: () => void;
9}
10
11interface MenuListProps {
12 children: React.ReactNode;
13}
14
15interface MenuItemProps {
16 children: React.ReactNode;
17 onClick?: () => void;
18}
19
20function Menu({ children }: MenuProps) {
21 const [isOpen, setIsOpen] = useState(false);
22
23 return (
24 <MenuContext.Provider value={{ isOpen, setIsOpen }}>
25 <div className="menu">{children}</div>
26 </MenuContext.Provider>
27 );
28}
29
30function MenuButton({ children, onClick }: MenuButtonProps) {
31 const { setIsOpen } = useMenuContext();
32
33 const handleClick = () => {
34 setIsOpen((prev) => !prev);
35 onClick?.();
36 };
37
38 return (
39 <button className="menu-button" onClick={handleClick}>
40 {children}
41 </button>
42 );
43}
44
45function MenuList({ children }: MenuListProps) {
46 const { isOpen } = useMenuContext();
47
48 if (!isOpen) return null;
49
50 return <ul className="menu-list">{children}</ul>;
51}
52
53function MenuItem({ children, onClick }: MenuItemProps) {
54 const { setIsOpen } = useMenuContext();
55
56 const handleClick = () => {
57 onClick?.();
58 setIsOpen(false);
59 };
60
61 return (
62 <li className="menu-item" onClick={handleClick}>
63 {children}
64 </li>
65 );
66}
67
68// Attach sub-components
69Menu.Button = MenuButton;
70Menu.List = MenuList;
71Menu.Item = MenuItem;
72
73// Usage
74<Menu>
75 <Menu.Button>Options</Menu.Button>
76 <Menu.List>
77 <Menu.Item onClick={() => console.log('Edit')}>Edit</Menu.Item>
78 <Menu.Item onClick={() => console.log('Delete')}>Delete</Menu.Item>
79 <Menu.Item onClick={() => console.log('Share')}>Share</Menu.Item>
80 </Menu.List>
81</Menu>Context for State Sharing#
1// Create context with proper typing
2interface TabsContextType {
3 activeTab: string;
4 setActiveTab: (id: string) => void;
5}
6
7const TabsContext = createContext<TabsContextType | undefined>(undefined);
8
9function useTabsContext() {
10 const context = useContext(TabsContext);
11 if (!context) {
12 throw new Error('Tabs components must be used within a Tabs provider');
13 }
14 return context;
15}
16
17// Main component
18interface TabsProps {
19 defaultValue: string;
20 value?: string;
21 onChange?: (value: string) => void;
22 children: React.ReactNode;
23}
24
25function Tabs({ defaultValue, value, onChange, children }: TabsProps) {
26 const [internalValue, setInternalValue] = useState(defaultValue);
27
28 const activeTab = value ?? internalValue;
29
30 const setActiveTab = (newValue: string) => {
31 setInternalValue(newValue);
32 onChange?.(newValue);
33 };
34
35 return (
36 <TabsContext.Provider value={{ activeTab, setActiveTab }}>
37 <div className="tabs">{children}</div>
38 </TabsContext.Provider>
39 );
40}
41
42// Sub-components
43function TabList({ children }: { children: React.ReactNode }) {
44 return (
45 <div className="tab-list" role="tablist">
46 {children}
47 </div>
48 );
49}
50
51interface TabProps {
52 value: string;
53 children: React.ReactNode;
54 disabled?: boolean;
55}
56
57function Tab({ value, children, disabled }: TabProps) {
58 const { activeTab, setActiveTab } = useTabsContext();
59 const isActive = activeTab === value;
60
61 return (
62 <button
63 role="tab"
64 aria-selected={isActive}
65 disabled={disabled}
66 className={`tab ${isActive ? 'active' : ''}`}
67 onClick={() => setActiveTab(value)}
68 >
69 {children}
70 </button>
71 );
72}
73
74interface TabPanelProps {
75 value: string;
76 children: React.ReactNode;
77}
78
79function TabPanel({ value, children }: TabPanelProps) {
80 const { activeTab } = useTabsContext();
81
82 if (activeTab !== value) return null;
83
84 return (
85 <div role="tabpanel" className="tab-panel">
86 {children}
87 </div>
88 );
89}
90
91function TabPanels({ children }: { children: React.ReactNode }) {
92 return <div className="tab-panels">{children}</div>;
93}
94
95// Attach components
96Tabs.List = TabList;
97Tabs.Tab = Tab;
98Tabs.Panels = TabPanels;
99Tabs.Panel = TabPanel;
100
101// Usage
102<Tabs defaultValue="tab1" onChange={(value) => console.log(value)}>
103 <Tabs.List>
104 <Tabs.Tab value="tab1">First Tab</Tabs.Tab>
105 <Tabs.Tab value="tab2">Second Tab</Tabs.Tab>
106 <Tabs.Tab value="tab3" disabled>Disabled</Tabs.Tab>
107 </Tabs.List>
108 <Tabs.Panels>
109 <Tabs.Panel value="tab1">Content 1</Tabs.Panel>
110 <Tabs.Panel value="tab2">Content 2</Tabs.Panel>
111 <Tabs.Panel value="tab3">Content 3</Tabs.Panel>
112 </Tabs.Panels>
113</Tabs>Flexible Accordion#
1interface AccordionContextType {
2 expandedItems: Set<string>;
3 toggleItem: (id: string) => void;
4 allowMultiple: boolean;
5}
6
7const AccordionContext = createContext<AccordionContextType | undefined>(undefined);
8
9interface AccordionProps {
10 children: React.ReactNode;
11 allowMultiple?: boolean;
12 defaultExpanded?: string[];
13}
14
15function Accordion({
16 children,
17 allowMultiple = false,
18 defaultExpanded = [],
19}: AccordionProps) {
20 const [expandedItems, setExpandedItems] = useState(
21 new Set(defaultExpanded)
22 );
23
24 const toggleItem = (id: string) => {
25 setExpandedItems((prev) => {
26 const next = new Set(prev);
27
28 if (next.has(id)) {
29 next.delete(id);
30 } else {
31 if (!allowMultiple) {
32 next.clear();
33 }
34 next.add(id);
35 }
36
37 return next;
38 });
39 };
40
41 return (
42 <AccordionContext.Provider value={{ expandedItems, toggleItem, allowMultiple }}>
43 <div className="accordion">{children}</div>
44 </AccordionContext.Provider>
45 );
46}
47
48interface AccordionItemProps {
49 value: string;
50 children: React.ReactNode;
51}
52
53const AccordionItemContext = createContext<{ value: string } | undefined>(undefined);
54
55function AccordionItem({ value, children }: AccordionItemProps) {
56 return (
57 <AccordionItemContext.Provider value={{ value }}>
58 <div className="accordion-item">{children}</div>
59 </AccordionItemContext.Provider>
60 );
61}
62
63function AccordionTrigger({ children }: { children: React.ReactNode }) {
64 const { expandedItems, toggleItem } = useContext(AccordionContext)!;
65 const { value } = useContext(AccordionItemContext)!;
66 const isExpanded = expandedItems.has(value);
67
68 return (
69 <button
70 className="accordion-trigger"
71 aria-expanded={isExpanded}
72 onClick={() => toggleItem(value)}
73 >
74 {children}
75 <ChevronIcon className={isExpanded ? 'rotate-180' : ''} />
76 </button>
77 );
78}
79
80function AccordionContent({ children }: { children: React.ReactNode }) {
81 const { expandedItems } = useContext(AccordionContext)!;
82 const { value } = useContext(AccordionItemContext)!;
83 const isExpanded = expandedItems.has(value);
84
85 if (!isExpanded) return null;
86
87 return (
88 <div className="accordion-content">
89 {children}
90 </div>
91 );
92}
93
94Accordion.Item = AccordionItem;
95Accordion.Trigger = AccordionTrigger;
96Accordion.Content = AccordionContent;
97
98// Usage
99<Accordion allowMultiple defaultExpanded={['item-1']}>
100 <Accordion.Item value="item-1">
101 <Accordion.Trigger>Section 1</Accordion.Trigger>
102 <Accordion.Content>Content for section 1</Accordion.Content>
103 </Accordion.Item>
104 <Accordion.Item value="item-2">
105 <Accordion.Trigger>Section 2</Accordion.Trigger>
106 <Accordion.Content>Content for section 2</Accordion.Content>
107 </Accordion.Item>
108</Accordion>Slot Pattern#
1// Slot-based component
2interface SlotProps {
3 children: React.ReactNode;
4}
5
6interface CardSlots {
7 header?: React.ReactNode;
8 media?: React.ReactNode;
9 content?: React.ReactNode;
10 actions?: React.ReactNode;
11}
12
13function Card({ children }: { children: React.ReactNode }) {
14 const slots: CardSlots = {};
15
16 React.Children.forEach(children, (child) => {
17 if (!React.isValidElement(child)) return;
18
19 switch (child.type) {
20 case CardHeader:
21 slots.header = child;
22 break;
23 case CardMedia:
24 slots.media = child;
25 break;
26 case CardContent:
27 slots.content = child;
28 break;
29 case CardActions:
30 slots.actions = child;
31 break;
32 }
33 });
34
35 return (
36 <div className="card">
37 {slots.media}
38 {slots.header}
39 {slots.content}
40 {slots.actions}
41 </div>
42 );
43}
44
45function CardHeader({ children }: SlotProps) {
46 return <div className="card-header">{children}</div>;
47}
48
49function CardMedia({ src, alt }: { src: string; alt: string }) {
50 return <img className="card-media" src={src} alt={alt} />;
51}
52
53function CardContent({ children }: SlotProps) {
54 return <div className="card-content">{children}</div>;
55}
56
57function CardActions({ children }: SlotProps) {
58 return <div className="card-actions">{children}</div>;
59}
60
61Card.Header = CardHeader;
62Card.Media = CardMedia;
63Card.Content = CardContent;
64Card.Actions = CardActions;
65
66// Order doesn't matter - slots are placed correctly
67<Card>
68 <Card.Actions>
69 <Button>Share</Button>
70 </Card.Actions>
71 <Card.Content>
72 <p>Card description</p>
73 </Card.Content>
74 <Card.Header>
75 <h3>Card Title</h3>
76 </Card.Header>
77 <Card.Media src="image.jpg" alt="Card image" />
78</Card>Render Props Variant#
1// Compound with render props for more control
2interface ToggleRenderProps {
3 isOn: boolean;
4 toggle: () => void;
5 setOn: () => void;
6 setOff: () => void;
7}
8
9interface ToggleProps {
10 children: (props: ToggleRenderProps) => React.ReactNode;
11 defaultOn?: boolean;
12}
13
14function Toggle({ children, defaultOn = false }: ToggleProps) {
15 const [isOn, setIsOn] = useState(defaultOn);
16
17 const toggle = () => setIsOn((prev) => !prev);
18 const setOn = () => setIsOn(true);
19 const setOff = () => setIsOn(false);
20
21 return <>{children({ isOn, toggle, setOn, setOff })}</>;
22}
23
24// Usage
25<Toggle>
26 {({ isOn, toggle }) => (
27 <div>
28 <span>{isOn ? 'ON' : 'OFF'}</span>
29 <button onClick={toggle}>Toggle</button>
30 </div>
31 )}
32</Toggle>Best Practices#
Design:
✓ Use context for state sharing
✓ Provide clear error messages
✓ Support controlled and uncontrolled
✓ Keep components focused
API:
✓ Attach sub-components to parent
✓ Use descriptive component names
✓ Allow customization via props
✓ Document expected children
Accessibility:
✓ Use proper ARIA attributes
✓ Support keyboard navigation
✓ Manage focus appropriately
✓ Test with screen readers
Conclusion#
Compound components create intuitive, flexible APIs. Use context for shared state, attach sub-components for discoverability, and provide both controlled and uncontrolled modes. This pattern scales well for complex UI components like tabs, accordions, and menus.