A complete implementation of the compound component pattern in React TypeScript, demonstrating how to build a flexible Tabs component (Tabs, Tabs.List, Tabs.Tab, Tabs.Panel) using Context API to share state without prop drilling.
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
type ReactNode,
type KeyboardEvent,
} from 'react';
// Types
interface TabsContextValue {
activeTab: string;
setActiveTab: (id: string) => void;
registerTab: (id: string) => void;
tabs: string[];
}
interface TabsProps {
children: ReactNode;
defaultTab?: string;
onChange?: (tabId: string) => void;
}
interface TabsListProps {
children: ReactNode;
className?: string;
}
interface TabProps {
children: ReactNode;
tabId: string;
disabled?: boolean;
className?: string;
}
interface TabPanelProps {
children: ReactNode;
tabId: string;
className?: string;
}
// Context
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext(): TabsContextValue {
const context = useContext(TabsContext);
if (!context) {
throw new Error(
'Tabs compound components must be rendered within a Tabs parent'
);
}
return context;
}
// Main Tabs Component
function Tabs({ children, defaultTab = '', onChange }: TabsProps): JSX.Element {
const [activeTab, setActiveTabState] = useState<string>(defaultTab);
const [tabs, setTabs] = useState<string[]>([]);
const registerTab = useCallback((id: string) => {
setTabs((prev) => {
if (prev.includes(id)) return prev;
return [...prev, id];
});
}, []);
const setActiveTab = useCallback(
(id: string) => {
setActiveTabState(id);
onChange?.(id);
},
[onChange]
);
const contextValue = useMemo<TabsContextValue>(
() => ({
activeTab: activeTab || tabs[0] || '',
setActiveTab,
registerTab,
tabs,
}),
[activeTab, setActiveTab, registerTab, tabs]
);
return (
<TabsContext.Provider value={contextValue}>
<div className="tabs-container" role="tablist" aria-orientation="horizontal">
{children}
</div>
</TabsContext.Provider>
);
}
// Tabs.List Component
function TabsList({ children, className = '' }: TabsListProps): JSX.Element {
const { tabs, activeTab, setActiveTab } = useTabsContext();
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const currentIndex = tabs.indexOf(activeTab);
let newIndex = currentIndex;
switch (event.key) {
case 'ArrowRight':
newIndex = (currentIndex + 1) % tabs.length;
break;
case 'ArrowLeft':
newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return;
}
event.preventDefault();
setActiveTab(tabs[newIndex]);
};
return (
<div
className={`tabs-list ${className}`.trim()}
role="tablist"
onKeyDown={handleKeyDown}
style={{
display: 'flex',
gap: '4px',
borderBottom: '2px solid #e2e8f0',
paddingBottom: '0',
}}
>
{children}
</div>
);
}
// Tabs.Tab Component
function Tab({
children,
tabId,
disabled = false,
className = '',
}: TabProps): JSX.Element {
const { activeTab, setActiveTab, registerTab } = useTabsContext();
const isActive = activeTab === tabId;
React.useEffect(() => {
registerTab(tabId);
}, [tabId, registerTab]);
const handleClick = () => {
if (!disabled) {
setActiveTab(tabId);
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (!disabled) {
setActiveTab(tabId);
}
}
};
return (
<button
role="tab"
id={`tab-${tabId}`}
aria-selected={isActive}
aria-controls={`panel-${tabId}`}
aria-disabled={disabled}
tabIndex={isActive ? 0 : -1}
onClick={handleClick}
onKeyDown={handleKeyDown}
disabled={disabled}
className={`tab ${isActive ? 'tab-active' : ''} ${className}`.trim()}
style={{
padding: '12px 24px',
border: 'none',
background: isActive ? '#3b82f6' : 'transparent',
color: isActive ? '#ffffff' : disabled ? '#9ca3af' : '#374151',
cursor: disabled ? 'not-allowed' : 'pointer',
borderRadius: '8px 8px 0 0',
fontWeight: isActive ? 600 : 400,
fontSize: '14px',
transition: 'all 0.2s ease',
opacity: disabled ? 0.5 : 1,
}}
>
{children}
</button>
);
}
// Tabs.Panel Component
function TabPanel({
children,
tabId,
className = '',
}: TabPanelProps): JSX.Element | null {
const { activeTab } = useTabsContext();
const isActive = activeTab === tabId;
if (!isActive) {
return null;
}
return (
<div
role="tabpanel"
id={`panel-${tabId}`}
aria-labelledby={`tab-${tabId}`}
tabIndex={0}
className={`tab-panel ${className}`.trim()}
style={{
padding: '24px',
animation: 'fadeIn 0.2s ease',
}}
>
{children}
</div>
);
}
// Attach compound components
Tabs.List = TabsList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// Usage Example Component
function TabsDemo(): JSX.Element {
return (
<div style={{ maxWidth: '600px', margin: '40px auto', fontFamily: 'system-ui' }}>
<h2 style={{ marginBottom: '24px' }}>Compound Component Tabs</h2>
<Tabs defaultTab="overview" onChange={(tab) => console.log('Active tab:', tab)}>
<Tabs.List>
<Tabs.Tab tabId="overview">Overview</Tabs.Tab>
<Tabs.Tab tabId="features">Features</Tabs.Tab>
<Tabs.Tab tabId="pricing">Pricing</Tabs.Tab>
<Tabs.Tab tabId="disabled" disabled>Disabled</Tabs.Tab>
</Tabs.List>
<Tabs.Panel tabId="overview">
<h3>Product Overview</h3>
<p>
Welcome to our product! This panel demonstrates the compound component
pattern with full keyboard navigation and accessibility support.
</p>
</Tabs.Panel>
<Tabs.Panel tabId="features">
<h3>Key Features</h3>
<ul>
<li>Type-safe with full TypeScript support</li>
<li>Keyboard navigation (Arrow keys, Home, End)</li>
<li>ARIA attributes for screen readers</li>
<li>No prop drilling - uses React Context</li>
</ul>
</Tabs.Panel>
<Tabs.Panel tabId="pricing">
<h3>Pricing Plans</h3>
<p>
Choose from our flexible pricing options. The compound pattern makes
it easy to customize each tab panel independently.
</p>
</Tabs.Panel>
<Tabs.Panel tabId="disabled">
<p>This content is not accessible because the tab is disabled.</p>
</Tabs.Panel>
</Tabs>
</div>
);
}
export { Tabs, TabsDemo };
export type { TabsProps, TabsListProps, TabProps, TabPanelProps };The compound component pattern is a powerful React design pattern that enables components to share implicit state while maintaining a clean, declarative API. This implementation creates a Tabs component where child components (List, Tab, Panel) communicate through React Context without explicit prop passing.
The architecture centers around a TabsContext that holds the active tab state, a setter function, a registration mechanism, and an array of registered tab IDs. The parent Tabs component acts as the state owner and context provider, while child components consume this context through the useTabsContext hook. This hook includes a runtime check that throws a descriptive error if components are used outside their parent, providing excellent developer experience.
The Tab registration system deserves special attention. Each Tab component registers itself with the parent on mount using useEffect, allowing the parent to track available tabs for keyboard navigation. The registerTab function uses a callback pattern with setTabs to avoid stale closure issues and prevents duplicate registrations. This approach means tabs can be dynamically added or removed, and the navigation system automatically adapts.
Accessibility is built into every layer of this implementation. The component follows WAI-ARIA Tabs pattern with proper role attributes (tablist, tab, tabpanel), aria-selected for active state, aria-controls linking tabs to panels, and aria-labelledby for reverse association. Keyboard navigation supports Arrow keys for cycling through tabs, Home/End for jumping to first/last tabs, and proper tabIndex management ensuring only the active tab is in the focus order.
Use this pattern when building UI components with related pieces that need to share state (accordions, menus, form fields). It provides flexibility for consumers to arrange and style components while maintaining internal logic. Avoid it for simple components where prop drilling is sufficient, or when the implicit nature might confuse team members unfamiliar with the pattern. The Context overhead is minimal but worth considering in extremely performance-critical scenarios with frequent re-renders.