Compound Components
Compound components are a set of components that work together to form a complete UI element. They share an implicit state, managed by a parent, while giving the consumer full control over the rendering and ordering of child parts. The pattern mirrors native HTML elements like <select> and <option> — individually meaningless, but powerful when combined.
The Problem Compound Components Solve
Consider a naive Select component:
// The prop-heavy approach
<Select
options={[
{ value: 'apple', label: 'Apple', icon: <AppleIcon />, disabled: false },
{ value: 'banana', label: 'Banana', icon: <BananaIcon />, disabled: true },
{ value: 'cherry', label: 'Cherry', icon: <CherryIcon />, disabled: false },
]}
placeholder="Choose a fruit"
value={selected}
onChange={setSelected}
searchable
clearable
renderOption={(option) => (
<div className="flex gap-2">
{option.icon}
<span>{option.label}</span>
</div>
)}
renderSelectedValue={(option) => (
<span className="font-bold">{option.label}</span>
)}
/>Problems with this approach:
- Prop explosion — every customization point is another prop
- Rigid API — want to add a divider between options? Need a new prop for that
- Config objects as UI — the
optionsarray mixes data with rendering concerns - renderX callbacks — these are render props in disguise, but attached as random props
Now compare with compound components:
// The compound component approach
<Select value={selected} onChange={setSelected}>
<Select.Trigger placeholder="Choose a fruit" />
<Select.Content>
<Select.Item value="apple">
<AppleIcon /> Apple
</Select.Item>
<Select.Item value="banana" disabled>
<BananaIcon /> Banana
</Select.Item>
<Select.Separator />
<Select.Item value="cherry">
<CherryIcon /> Cherry
</Select.Item>
</Select.Content>
</Select>This approach:
- No prop explosion — each part has a focused, minimal API
- Composable — add a
Separatorwherever you want, wrap items in groups, add headers - Familiar — reads like HTML (
<select>+<option>) - Customizable — each
Select.Itemreceives arbitrary children for rendering
Implementation Approaches
Approach 1: React.Children API (Legacy)
The original compound component pattern uses React.Children and React.cloneElement to inject props into children. This approach is well-understood but has significant limitations.
import { Children, cloneElement, useState, isValidElement, type ReactNode, type ReactElement } from 'react';
// ─── Types ──────────────────────────────────────────────────────────
type TabsProps = {
defaultIndex?: number;
onChange?: (index: number) => void;
children: ReactNode;
className?: string;
};
type TabProps = {
/** Injected by parent — do NOT set manually */
index?: number;
/** Injected by parent — do NOT set manually */
isActive?: boolean;
/** Injected by parent — do NOT set manually */
onSelect?: (index: number) => void;
children: ReactNode;
disabled?: boolean;
};
type TabPanelProps = {
/** Injected by parent — do NOT set manually */
index?: number;
/** Injected by parent — do NOT set manually */
isActive?: boolean;
children: ReactNode;
};
// ─── Components ─────────────────────────────────────────────────────
function Tab({ isActive, onSelect, index, disabled, children }: TabProps) {
return (
<button
role="tab"
aria-selected={isActive}
tabIndex={isActive ? 0 : -1}
disabled={disabled}
onClick={() => !disabled && onSelect?.(index!)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
isActive
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
{children}
</button>
);
}
function TabPanel({ isActive, children }: TabPanelProps) {
if (!isActive) return null;
return (
<div role="tabpanel" className="p-4">
{children}
</div>
);
}
function Tabs({ defaultIndex = 0, onChange, children, className }: TabsProps) {
const [activeIndex, setActiveIndex] = useState(defaultIndex);
const handleSelect = (index: number) => {
setActiveIndex(index);
onChange?.(index);
};
// Separate Tab and TabPanel children
const tabs: ReactElement[] = [];
const panels: ReactElement[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) return;
if (child.type === Tab) tabs.push(child);
if (child.type === TabPanel) panels.push(child);
});
return (
<div className={className}>
<div role="tablist" className="flex border-b">
{tabs.map((tab, index) =>
cloneElement(tab, {
index,
isActive: index === activeIndex,
onSelect: handleSelect,
})
)}
</div>
{panels.map((panel, index) =>
cloneElement(panel, {
index,
isActive: index === activeIndex,
})
)}
</div>
);
}
// Usage:
function TabsExample() {
return (
<Tabs defaultIndex={0} onChange={(i) => console.log('Tab:', i)}>
<Tab>Account</Tab>
<Tab>Notifications</Tab>
<Tab disabled>Billing</Tab>
<TabPanel>Account settings content</TabPanel>
<TabPanel>Notification preferences content</TabPanel>
<TabPanel>Billing information content</TabPanel>
</Tabs>
);
}Limitations of React.Children + cloneElement:
- Fragile to wrapping — if a consumer wraps a
Tabin a<div>or a custom component,cloneElementwill inject props into the wrong element - Only direct children — cannot inject into deeply nested children without recursive traversal
- TypeScript friction — the injected props (
isActive,onSelect) are technically optional even though they are always present, leading to!assertions - Performance —
cloneElementcreates new element objects on every render - React team discourages it — the React documentation recommends Context over
cloneElementfor compound components
Approach 2: Context-Based (Modern Standard)
The modern approach uses React Context to share state between compound components. This eliminates all React.Children limitations.
import {
createContext,
useContext,
useState,
useCallback,
useId,
forwardRef,
useRef,
useEffect,
type ReactNode,
type KeyboardEvent,
} from 'react';
import { cn } from '@/lib/utils';
// ─── Context ────────────────────────────────────────────────────────
type TabsContextValue = {
activeIndex: number;
setActiveIndex: (index: number) => void;
registerTab: (index: number, id: string, disabled: boolean) => void;
tabs: Map<number, { id: string; disabled: boolean }>;
baseId: string;
};
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const context = useContext(TabsContext);
if (!context) {
throw new Error(
'Tabs compound components must be rendered within a <Tabs> parent. ' +
'Found a Tabs sub-component rendered outside of its parent.'
);
}
return context;
}
// ─── Root Component ─────────────────────────────────────────────────
type TabsProps = {
defaultValue?: number;
value?: number;
onChange?: (index: number) => void;
children: ReactNode;
className?: string;
};
function Tabs({ defaultValue = 0, value, onChange, children, className }: TabsProps) {
const [internalValue, setInternalValue] = useState(defaultValue);
const baseId = useId();
const tabsRef = useRef(new Map<number, { id: string; disabled: boolean }>());
// Support both controlled and uncontrolled modes
const activeIndex = value !== undefined ? value : internalValue;
const setActiveIndex = useCallback(
(index: number) => {
if (value === undefined) {
setInternalValue(index);
}
onChange?.(index);
},
[value, onChange]
);
const registerTab = useCallback((index: number, id: string, disabled: boolean) => {
tabsRef.current.set(index, { id, disabled });
}, []);
return (
<TabsContext.Provider
value={{
activeIndex,
setActiveIndex,
registerTab,
tabs: tabsRef.current,
baseId,
}}
>
<div className={cn('w-full', className)}>{children}</div>
</TabsContext.Provider>
);
}
// ─── TabList ────────────────────────────────────────────────────────
type TabListProps = {
children: ReactNode;
className?: string;
'aria-label'?: string;
};
const TabList = forwardRef<HTMLDivElement, TabListProps>(
({ children, className, 'aria-label': ariaLabel }, ref) => {
const { activeIndex, setActiveIndex, tabs } = useTabsContext();
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const tabIndices = Array.from(tabs.keys()).sort((a, b) => a - b);
const currentPos = tabIndices.indexOf(activeIndex);
const getNextEnabled = (start: number, direction: 1 | -1): number => {
let pos = start;
const len = tabIndices.length;
for (let i = 0; i < len; i++) {
pos = (pos + direction + len) % len;
const tab = tabs.get(tabIndices[pos]);
if (tab && !tab.disabled) return tabIndices[pos];
}
return activeIndex;
};
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
setActiveIndex(getNextEnabled(currentPos, 1));
break;
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
setActiveIndex(getNextEnabled(currentPos, -1));
break;
case 'Home':
e.preventDefault();
setActiveIndex(getNextEnabled(-1, 1));
break;
case 'End':
e.preventDefault();
setActiveIndex(getNextEnabled(tabIndices.length, -1));
break;
}
};
return (
<div
ref={ref}
role="tablist"
aria-label={ariaLabel}
onKeyDown={handleKeyDown}
className={cn('flex border-b', className)}
>
{children}
</div>
);
}
);
TabList.displayName = 'TabList';
// ─── Tab ────────────────────────────────────────────────────────────
type TabProps = {
index: number;
disabled?: boolean;
children: ReactNode;
className?: string;
};
const Tab = forwardRef<HTMLButtonElement, TabProps>(
({ index, disabled = false, children, className }, ref) => {
const { activeIndex, setActiveIndex, registerTab, baseId } = useTabsContext();
const isActive = activeIndex === index;
const tabId = `${baseId}-tab-${index}`;
const panelId = `${baseId}-panel-${index}`;
useEffect(() => {
registerTab(index, tabId, disabled);
}, [index, tabId, disabled, registerTab]);
const internalRef = useRef<HTMLButtonElement>(null);
const mergedRef = (ref as React.RefObject<HTMLButtonElement>) || internalRef;
useEffect(() => {
if (isActive && mergedRef.current) {
mergedRef.current.focus();
}
}, [isActive, mergedRef]);
return (
<button
ref={mergedRef}
id={tabId}
role="tab"
aria-selected={isActive}
aria-controls={panelId}
tabIndex={isActive ? 0 : -1}
disabled={disabled}
onClick={() => !disabled && setActiveIndex(index)}
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors whitespace-nowrap',
isActive
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
>
{children}
</button>
);
}
);
Tab.displayName = 'Tab';
// ─── TabPanel ───────────────────────────────────────────────────────
type TabPanelProps = {
index: number;
children: ReactNode;
className?: string;
/** Keep the panel in the DOM when inactive (for preserving state) */
keepMounted?: boolean;
};
const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
({ index, children, className, keepMounted = false }, ref) => {
const { activeIndex, baseId } = useTabsContext();
const isActive = activeIndex === index;
const tabId = `${baseId}-tab-${index}`;
const panelId = `${baseId}-panel-${index}`;
if (!isActive && !keepMounted) return null;
return (
<div
ref={ref}
id={panelId}
role="tabpanel"
aria-labelledby={tabId}
tabIndex={0}
hidden={!isActive}
className={cn('p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', className)}
>
{children}
</div>
);
}
);
TabPanel.displayName = 'TabPanel';
// ─── Attach sub-components ──────────────────────────────────────────
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
export { Tabs };
export type { TabsProps, TabListProps, TabProps, TabPanelProps };Usage:
function TabsDemo() {
return (
<Tabs defaultValue={0}>
<Tabs.List aria-label="Account settings">
<Tabs.Tab index={0}>Profile</Tabs.Tab>
<Tabs.Tab index={1}>Security</Tabs.Tab>
<Tabs.Tab index={2} disabled>Billing</Tabs.Tab>
<Tabs.Tab index={3}>Notifications</Tabs.Tab>
</Tabs.List>
<Tabs.Panel index={0}>
<h3>Profile Settings</h3>
<p>Update your name, email, and avatar.</p>
</Tabs.Panel>
<Tabs.Panel index={1}>
<h3>Security Settings</h3>
<p>Change password and enable 2FA.</p>
</Tabs.Panel>
<Tabs.Panel index={2}>
<h3>Billing</h3>
<p>This tab is disabled.</p>
</Tabs.Panel>
<Tabs.Panel index={3}>
<h3>Notification Preferences</h3>
<p>Choose which notifications you receive.</p>
</Tabs.Panel>
</Tabs>
);
}Complete Implementation: Compound Select/Dropdown
Here is a full, production-quality compound Select component with keyboard navigation, accessibility, and TypeScript types.
import {
createContext,
useContext,
useState,
useCallback,
useRef,
useEffect,
useId,
forwardRef,
type ReactNode,
type KeyboardEvent,
type MouseEvent,
} from 'react';
import { cn } from '@/lib/utils';
// ─── Types ──────────────────────────────────────────────────────────
type SelectContextValue = {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
selectedValue: string | undefined;
setSelectedValue: (value: string) => void;
highlightedIndex: number;
setHighlightedIndex: (index: number) => void;
options: Array<{ value: string; disabled: boolean; label: string }>;
registerOption: (value: string, label: string, disabled: boolean) => void;
unregisterOption: (value: string) => void;
baseId: string;
triggerRef: React.RefObject<HTMLButtonElement>;
listRef: React.RefObject<HTMLUListElement>;
};
const SelectContext = createContext<SelectContextValue | null>(null);
function useSelectContext() {
const ctx = useContext(SelectContext);
if (!ctx) {
throw new Error(
'Select compound components must be used within a <Select> parent.'
);
}
return ctx;
}
// ─── Root ───────────────────────────────────────────────────────────
type SelectProps = {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
children: ReactNode;
className?: string;
};
function Select({ value, defaultValue, onChange, children, className }: SelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [internalValue, setInternalValue] = useState(defaultValue);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [options, setOptions] = useState<Array<{ value: string; disabled: boolean; label: string }>>([]);
const baseId = useId();
const triggerRef = useRef<HTMLButtonElement>(null!);
const listRef = useRef<HTMLUListElement>(null!);
const selectedValue = value !== undefined ? value : internalValue;
const setSelectedValue = useCallback(
(val: string) => {
if (value === undefined) {
setInternalValue(val);
}
onChange?.(val);
setIsOpen(false);
},
[value, onChange]
);
const registerOption = useCallback((optValue: string, label: string, disabled: boolean) => {
setOptions((prev) => {
if (prev.some((o) => o.value === optValue)) {
return prev.map((o) => (o.value === optValue ? { ...o, label, disabled } : o));
}
return [...prev, { value: optValue, disabled, label }];
});
}, []);
const unregisterOption = useCallback((optValue: string) => {
setOptions((prev) => prev.filter((o) => o.value !== optValue));
}, []);
// Close on outside click
useEffect(() => {
if (!isOpen) return;
const handleClick = (e: globalThis.MouseEvent) => {
if (
triggerRef.current?.contains(e.target as Node) ||
listRef.current?.contains(e.target as Node)
) {
return;
}
setIsOpen(false);
};
const handleEscape = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
// Reset highlight when closed
useEffect(() => {
if (!isOpen) {
setHighlightedIndex(-1);
}
}, [isOpen]);
return (
<SelectContext.Provider
value={{
isOpen,
setIsOpen,
selectedValue,
setSelectedValue,
highlightedIndex,
setHighlightedIndex,
options,
registerOption,
unregisterOption,
baseId,
triggerRef,
listRef,
}}
>
<div className={cn('relative inline-block', className)}>{children}</div>
</SelectContext.Provider>
);
}
// ─── Trigger ────────────────────────────────────────────────────────
type SelectTriggerProps = {
placeholder?: string;
children?: ReactNode;
className?: string;
};
const SelectTrigger = forwardRef<HTMLButtonElement, SelectTriggerProps>(
({ placeholder = 'Select...', children, className }, externalRef) => {
const ctx = useSelectContext();
const selectedOption = ctx.options.find((o) => o.value === ctx.selectedValue);
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
switch (e.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
e.preventDefault();
ctx.setIsOpen(true);
// Highlight first non-disabled option
const firstEnabled = ctx.options.findIndex((o) => !o.disabled);
ctx.setHighlightedIndex(firstEnabled >= 0 ? firstEnabled : 0);
break;
case 'ArrowUp':
e.preventDefault();
ctx.setIsOpen(true);
// Highlight last non-disabled option
const lastEnabled = ctx.options.findLastIndex((o) => !o.disabled);
ctx.setHighlightedIndex(lastEnabled >= 0 ? lastEnabled : ctx.options.length - 1);
break;
}
};
return (
<button
ref={(node) => {
// Merge refs
(ctx.triggerRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
if (typeof externalRef === 'function') externalRef(node);
else if (externalRef) (externalRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
}}
type="button"
role="combobox"
aria-expanded={ctx.isOpen}
aria-haspopup="listbox"
aria-controls={`${ctx.baseId}-listbox`}
aria-activedescendant={
ctx.highlightedIndex >= 0
? `${ctx.baseId}-option-${ctx.highlightedIndex}`
: undefined
}
onClick={() => ctx.setIsOpen(!ctx.isOpen)}
onKeyDown={handleKeyDown}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2',
'text-sm ring-offset-background',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
!selectedOption && 'text-muted-foreground',
className
)}
>
<span className="truncate">
{children || selectedOption?.label || placeholder}
</span>
<svg
className={cn('ml-2 h-4 w-4 shrink-0 opacity-50 transition-transform', ctx.isOpen && 'rotate-180')}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
);
}
);
SelectTrigger.displayName = 'SelectTrigger';
// ─── Content ────────────────────────────────────────────────────────
type SelectContentProps = {
children: ReactNode;
className?: string;
};
const SelectContent = forwardRef<HTMLUListElement, SelectContentProps>(
({ children, className }, externalRef) => {
const ctx = useSelectContext();
const handleKeyDown = (e: KeyboardEvent<HTMLUListElement>) => {
const enabledIndices = ctx.options
.map((o, i) => (!o.disabled ? i : -1))
.filter((i) => i >= 0);
switch (e.key) {
case 'ArrowDown': {
e.preventDefault();
const current = enabledIndices.indexOf(ctx.highlightedIndex);
const next = enabledIndices[(current + 1) % enabledIndices.length];
ctx.setHighlightedIndex(next);
break;
}
case 'ArrowUp': {
e.preventDefault();
const current = enabledIndices.indexOf(ctx.highlightedIndex);
const prev = enabledIndices[(current - 1 + enabledIndices.length) % enabledIndices.length];
ctx.setHighlightedIndex(prev);
break;
}
case 'Enter':
case ' ': {
e.preventDefault();
if (ctx.highlightedIndex >= 0) {
const option = ctx.options[ctx.highlightedIndex];
if (option && !option.disabled) {
ctx.setSelectedValue(option.value);
ctx.triggerRef.current?.focus();
}
}
break;
}
case 'Home': {
e.preventDefault();
ctx.setHighlightedIndex(enabledIndices[0] ?? 0);
break;
}
case 'End': {
e.preventDefault();
ctx.setHighlightedIndex(enabledIndices[enabledIndices.length - 1] ?? 0);
break;
}
case 'Tab':
ctx.setIsOpen(false);
break;
}
};
if (!ctx.isOpen) return null;
return (
<ul
ref={(node) => {
(ctx.listRef as React.MutableRefObject<HTMLUListElement | null>).current = node;
if (typeof externalRef === 'function') externalRef(node);
else if (externalRef) (externalRef as React.MutableRefObject<HTMLUListElement | null>).current = node;
// Auto-focus the list when it opens
node?.focus();
}}
id={`${ctx.baseId}-listbox`}
role="listbox"
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cn(
'absolute z-50 mt-1 w-full overflow-auto rounded-md border bg-popover p-1 shadow-md',
'max-h-60 focus-visible:outline-none',
'animate-in fade-in-0 zoom-in-95',
className
)}
>
{children}
</ul>
);
}
);
SelectContent.displayName = 'SelectContent';
// ─── Item ───────────────────────────────────────────────────────────
type SelectItemProps = {
value: string;
disabled?: boolean;
children: ReactNode;
className?: string;
};
const SelectItem = forwardRef<HTMLLIElement, SelectItemProps>(
({ value, disabled = false, children, className }, ref) => {
const ctx = useSelectContext();
const index = ctx.options.findIndex((o) => o.value === value);
const isSelected = ctx.selectedValue === value;
const isHighlighted = ctx.highlightedIndex === index;
// Derive label from children for display in trigger
const label = typeof children === 'string' ? children : value;
useEffect(() => {
ctx.registerOption(value, label, disabled);
return () => ctx.unregisterOption(value);
}, [value, label, disabled, ctx.registerOption, ctx.unregisterOption]);
const handleClick = (e: MouseEvent<HTMLLIElement>) => {
e.preventDefault();
if (!disabled) {
ctx.setSelectedValue(value);
ctx.triggerRef.current?.focus();
}
};
const handleMouseEnter = () => {
if (!disabled) {
ctx.setHighlightedIndex(index);
}
};
return (
<li
ref={ref}
id={`${ctx.baseId}-option-${index}`}
role="option"
aria-selected={isSelected}
aria-disabled={disabled || undefined}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
isHighlighted && 'bg-accent text-accent-foreground',
isSelected && 'font-medium',
disabled && 'pointer-events-none opacity-50',
className
)}
>
<span className="flex-1">{children}</span>
{isSelected && (
<svg
className="ml-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5" />
</svg>
)}
</li>
);
}
);
SelectItem.displayName = 'SelectItem';
// ─── Separator ──────────────────────────────────────────────────────
function SelectSeparator({ className }: { className?: string }) {
return (
<li
role="separator"
className={cn('my-1 h-px bg-muted', className)}
aria-hidden="true"
/>
);
}
// ─── Group ──────────────────────────────────────────────────────────
type SelectGroupProps = {
label: string;
children: ReactNode;
className?: string;
};
function SelectGroup({ label, children, className }: SelectGroupProps) {
const groupId = useId();
return (
<li role="presentation" className={className}>
<div
id={groupId}
className="px-2 py-1.5 text-xs font-semibold text-muted-foreground"
role="presentation"
>
{label}
</div>
<ul role="group" aria-labelledby={groupId} className="space-y-0.5">
{children}
</ul>
</li>
);
}
// ─── Attach Sub-Components ──────────────────────────────────────────
Select.Trigger = SelectTrigger;
Select.Content = SelectContent;
Select.Item = SelectItem;
Select.Separator = SelectSeparator;
Select.Group = SelectGroup;
export { Select };
export type {
SelectProps,
SelectTriggerProps,
SelectContentProps,
SelectItemProps,
SelectGroupProps,
};Usage with groups and separators:
function SelectDemo() {
const [value, setValue] = useState<string>();
return (
<div className="w-64">
<Select value={value} onChange={setValue}>
<Select.Trigger placeholder="Choose a framework..." />
<Select.Content>
<Select.Group label="Frontend">
<Select.Item value="react">React</Select.Item>
<Select.Item value="vue">Vue</Select.Item>
<Select.Item value="svelte">Svelte</Select.Item>
<Select.Item value="angular" disabled>Angular (deprecated)</Select.Item>
</Select.Group>
<Select.Separator />
<Select.Group label="Backend">
<Select.Item value="express">Express</Select.Item>
<Select.Item value="fastify">Fastify</Select.Item>
<Select.Item value="hono">Hono</Select.Item>
</Select.Group>
</Select.Content>
</Select>
<p className="mt-4 text-sm text-muted-foreground">
Selected: {value || 'none'}
</p>
</div>
);
}TypeScript Patterns for Compound Components
Typing the Compound Object
The standard TypeScript pattern for attaching sub-components to a parent component:
import { type FC } from 'react';
// Define each sub-component's types
type SelectComponent = FC<SelectProps> & {
Trigger: typeof SelectTrigger;
Content: typeof SelectContent;
Item: typeof SelectItem;
Separator: typeof SelectSeparator;
Group: typeof SelectGroup;
};
// Cast the parent component
const Select = (({ value, defaultValue, onChange, children, className }) => {
// ... implementation
}) as SelectComponent;
// Attach sub-components (TypeScript now knows about these)
Select.Trigger = SelectTrigger;
Select.Content = SelectContent;
Select.Item = SelectItem;
Select.Separator = SelectSeparator;
Select.Group = SelectGroup;Generic Compound Components
When the compound component needs to work with generic data (like a generic <Listbox> that works with any value type):
import { createContext, useContext, type ReactNode } from 'react';
// ─── Generic Context ────────────────────────────────────────────────
type ListboxContextValue<T> = {
selectedValue: T | undefined;
onChange: (value: T) => void;
compare: (a: T, b: T) => boolean;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ListboxContext = createContext<ListboxContextValue<any> | null>(null);
function useListboxContext<T>() {
const ctx = useContext(ListboxContext) as ListboxContextValue<T> | null;
if (!ctx) throw new Error('Listbox components must be used within <Listbox>');
return ctx;
}
// ─── Generic Root ───────────────────────────────────────────────────
type ListboxProps<T> = {
value?: T;
onChange: (value: T) => void;
compare?: (a: T, b: T) => boolean;
children: ReactNode;
};
function Listbox<T>({
value,
onChange,
compare = (a, b) => a === b,
children,
}: ListboxProps<T>) {
return (
<ListboxContext.Provider value={{ selectedValue: value, onChange, compare }}>
<div role="listbox">{children}</div>
</ListboxContext.Provider>
);
}
// ─── Generic Option ─────────────────────────────────────────────────
type ListboxOptionProps<T> = {
value: T;
disabled?: boolean;
children: ReactNode | ((props: { selected: boolean; disabled: boolean }) => ReactNode);
};
function ListboxOption<T>({ value, disabled = false, children }: ListboxOptionProps<T>) {
const ctx = useListboxContext<T>();
const selected = ctx.selectedValue !== undefined && ctx.compare(ctx.selectedValue, value);
return (
<div
role="option"
aria-selected={selected}
aria-disabled={disabled || undefined}
onClick={() => !disabled && ctx.onChange(value)}
className={cn(
'cursor-pointer px-3 py-2 text-sm',
selected && 'bg-primary text-primary-foreground',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{typeof children === 'function' ? children({ selected, disabled }) : children}
</div>
);
}
// ─── Usage with complex objects ─────────────────────────────────────
type User = { id: string; name: string; email: string };
function UserListbox() {
const [selected, setSelected] = useState<User>();
return (
<Listbox<User>
value={selected}
onChange={setSelected}
compare={(a, b) => a.id === b.id}
>
<ListboxOption value={{ id: '1', name: 'Alice', email: 'alice@example.com' }}>
{({ selected }) => (
<div className="flex items-center gap-2">
<span>{selected ? '\u2713' : ' '}</span>
<span>Alice</span>
</div>
)}
</ListboxOption>
<ListboxOption value={{ id: '2', name: 'Bob', email: 'bob@example.com' }}>
{({ selected }) => (
<div className="flex items-center gap-2">
<span>{selected ? '\u2713' : ' '}</span>
<span>Bob</span>
</div>
)}
</ListboxOption>
</Listbox>
);
}Flexible API Design Principles
1. Sensible Defaults, Full Override
Every compound component should work with minimal props while allowing full customization:
// Minimal usage — everything has defaults
<Select value={value} onChange={setValue}>
<Select.Trigger />
<Select.Content>
<Select.Item value="a">Alpha</Select.Item>
<Select.Item value="b">Beta</Select.Item>
</Select.Content>
</Select>
// Fully customized — every part is overridden
<Select value={value} onChange={setValue}>
<Select.Trigger className="custom-trigger" placeholder="Pick one">
<CustomSelectedDisplay value={value} />
</Select.Trigger>
<Select.Content className="custom-popover">
<Select.Group label="Letters">
<Select.Item value="a" className="custom-item">
<LetterIcon letter="A" />
<span>Alpha</span>
<Badge>New</Badge>
</Select.Item>
</Select.Group>
</Select.Content>
</Select>2. Meaningful Error Messages
When a sub-component is used outside its parent, throw a clear error:
function useSelectContext() {
const ctx = useContext(SelectContext);
if (!ctx) {
throw new Error(
'Select.Item must be used within a <Select> component. ' +
'Example:\n' +
' <Select>\n' +
' <Select.Content>\n' +
' <Select.Item value="...">...</Select.Item>\n' +
' </Select.Content>\n' +
' </Select>'
);
}
return ctx;
}3. Ref Forwarding on Every Part
Every sub-component should forward refs. Consumers need refs for focus management, positioning, measuring, and integration with third-party libraries:
const SelectTrigger = forwardRef<HTMLButtonElement, SelectTriggerProps>(
(props, ref) => {
// ... implementation
return <button ref={ref} {...buttonProps} />;
}
);4. Data Attributes for Styling
Expose component state through data attributes so consumers can style based on state without JavaScript:
<button
data-state={isOpen ? 'open' : 'closed'}
data-highlighted={isHighlighted || undefined}
data-disabled={disabled || undefined}
// CSS: [data-state="open"] { background: var(--accent); }
>This is the approach Radix UI uses, and it is more powerful than className toggling because CSS selectors can query data attributes across the tree.
Real-World Compound Component: Accordion
A complete Accordion implementation demonstrating all the principles:
import {
createContext,
useContext,
useState,
useCallback,
useRef,
useId,
forwardRef,
type ReactNode,
type KeyboardEvent,
} from 'react';
import { cn } from '@/lib/utils';
// ─── Context ────────────────────────────────────────────────────────
type AccordionContextValue = {
expandedItems: Set<string>;
toggleItem: (value: string) => void;
type: 'single' | 'multiple';
};
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordionContext() {
const ctx = useContext(AccordionContext);
if (!ctx) throw new Error('Accordion components must be used within <Accordion>');
return ctx;
}
type AccordionItemContextValue = {
value: string;
isOpen: boolean;
triggerId: string;
contentId: string;
disabled: boolean;
};
const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
function useAccordionItemContext() {
const ctx = useContext(AccordionItemContext);
if (!ctx) throw new Error('AccordionTrigger/Content must be used within <Accordion.Item>');
return ctx;
}
// ─── Root ───────────────────────────────────────────────────────────
type AccordionProps = {
type?: 'single' | 'multiple';
defaultExpanded?: string[];
children: ReactNode;
className?: string;
};
function Accordion({
type = 'single',
defaultExpanded = [],
children,
className,
}: AccordionProps) {
const [expandedItems, setExpandedItems] = useState<Set<string>>(
new Set(defaultExpanded)
);
const toggleItem = useCallback(
(value: string) => {
setExpandedItems((prev) => {
const next = new Set(prev);
if (next.has(value)) {
next.delete(value);
} else {
if (type === 'single') {
next.clear();
}
next.add(value);
}
return next;
});
},
[type]
);
return (
<AccordionContext.Provider value={{ expandedItems, toggleItem, type }}>
<div className={cn('divide-y rounded-md border', className)}>{children}</div>
</AccordionContext.Provider>
);
}
// ─── Item ───────────────────────────────────────────────────────────
type AccordionItemProps = {
value: string;
disabled?: boolean;
children: ReactNode;
className?: string;
};
const AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(
({ value, disabled = false, children, className }, ref) => {
const { expandedItems } = useAccordionContext();
const baseId = useId();
const isOpen = expandedItems.has(value);
const triggerId = `${baseId}-trigger`;
const contentId = `${baseId}-content`;
return (
<AccordionItemContext.Provider value={{ value, isOpen, triggerId, contentId, disabled }}>
<div
ref={ref}
data-state={isOpen ? 'open' : 'closed'}
data-disabled={disabled || undefined}
className={cn('px-0', className)}
>
{children}
</div>
</AccordionItemContext.Provider>
);
}
);
AccordionItem.displayName = 'AccordionItem';
// ─── Trigger ────────────────────────────────────────────────────────
type AccordionTriggerProps = {
children: ReactNode;
className?: string;
};
const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>(
({ children, className }, ref) => {
const { toggleItem } = useAccordionContext();
const { value, isOpen, triggerId, contentId, disabled } = useAccordionItemContext();
return (
<h3 className="flex">
<button
ref={ref}
id={triggerId}
type="button"
aria-expanded={isOpen}
aria-controls={contentId}
disabled={disabled}
onClick={() => toggleItem(value)}
className={cn(
'flex flex-1 items-center justify-between py-4 px-4 text-sm font-medium transition-all',
'hover:underline [&[data-state=open]>svg]:rotate-180',
disabled && 'cursor-not-allowed opacity-50',
className
)}
data-state={isOpen ? 'open' : 'closed'}
>
{children}
<svg
className="h-4 w-4 shrink-0 transition-transform duration-200"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</h3>
);
}
);
AccordionTrigger.displayName = 'AccordionTrigger';
// ─── Content ────────────────────────────────────────────────────────
type AccordionContentProps = {
children: ReactNode;
className?: string;
};
const AccordionContent = forwardRef<HTMLDivElement, AccordionContentProps>(
({ children, className }, ref) => {
const { isOpen, triggerId, contentId } = useAccordionItemContext();
const contentRef = useRef<HTMLDivElement>(null);
return (
<div
ref={ref}
id={contentId}
role="region"
aria-labelledby={triggerId}
hidden={!isOpen}
data-state={isOpen ? 'open' : 'closed'}
className={cn(
'overflow-hidden text-sm transition-all',
isOpen ? 'animate-accordion-down' : 'animate-accordion-up',
className
)}
>
<div ref={contentRef} className="pb-4 px-4 pt-0">
{children}
</div>
</div>
);
}
);
AccordionContent.displayName = 'AccordionContent';
// ─── Attach Sub-Components ──────────────────────────────────────────
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;
export { Accordion };
export type { AccordionProps, AccordionItemProps, AccordionTriggerProps, AccordionContentProps };Usage:
function AccordionDemo() {
return (
<Accordion type="single" defaultExpanded={['item-1']}>
<Accordion.Item value="item-1">
<Accordion.Trigger>What is a compound component?</Accordion.Trigger>
<Accordion.Content>
A compound component is a set of components that work together through shared
implicit state to form a complete UI element.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>When should I use this pattern?</Accordion.Trigger>
<Accordion.Content>
Use compound components when you have a multi-part UI element where the
consumer needs control over the rendering and ordering of parts.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-3" disabled>
<Accordion.Trigger>Can I disable an item?</Accordion.Trigger>
<Accordion.Content>
Yes, this item is disabled.
</Accordion.Content>
</Accordion.Item>
</Accordion>
);
}Testing Compound Components
Testing compound components requires testing the collaboration between parts, not just individual elements:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Select } from './Select';
describe('Select', () => {
it('shows placeholder when no value is selected', () => {
render(
<Select>
<Select.Trigger placeholder="Choose..." />
<Select.Content>
<Select.Item value="a">Alpha</Select.Item>
</Select.Content>
</Select>
);
expect(screen.getByRole('combobox')).toHaveTextContent('Choose...');
});
it('opens dropdown on click and selects an item', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<Select onChange={onChange}>
<Select.Trigger />
<Select.Content>
<Select.Item value="a">Alpha</Select.Item>
<Select.Item value="b">Beta</Select.Item>
</Select.Content>
</Select>
);
// Open the dropdown
await user.click(screen.getByRole('combobox'));
expect(screen.getByRole('listbox')).toBeVisible();
// Select an option
await user.click(screen.getByRole('option', { name: 'Alpha' }));
expect(onChange).toHaveBeenCalledWith('a');
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
it('supports keyboard navigation', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<Select onChange={onChange}>
<Select.Trigger />
<Select.Content>
<Select.Item value="a">Alpha</Select.Item>
<Select.Item value="b" disabled>Beta</Select.Item>
<Select.Item value="c">Gamma</Select.Item>
</Select.Content>
</Select>
);
const trigger = screen.getByRole('combobox');
await user.click(trigger);
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}'); // Skips disabled "Beta"
await user.keyboard('{Enter}');
expect(onChange).toHaveBeenCalledWith('c');
});
it('closes on Escape and restores focus to trigger', async () => {
const user = userEvent.setup();
render(
<Select>
<Select.Trigger />
<Select.Content>
<Select.Item value="a">Alpha</Select.Item>
</Select.Content>
</Select>
);
const trigger = screen.getByRole('combobox');
await user.click(trigger);
expect(screen.getByRole('listbox')).toBeVisible();
await user.keyboard('{Escape}');
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
it('throws when sub-component is used outside parent', () => {
// Suppress console.error for expected error
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
render(<Select.Item value="a">Alpha</Select.Item>);
}).toThrow('Select compound components must be used within a <Select> parent');
spy.mockRestore();
});
});Further Reading
- Radix UI source code: The gold standard for compound components with accessibility
- Kent C. Dodds: "Compound Components" pattern explanation
- Next: Render Props & Hooks — the evolution from render props to hooks for logic sharing
- Related: Headless Components — when you want compound components without any UI opinions