React Compound Components in 2026: Context API, TypeScript Generics, and Flexible Component Design
Build React compound components with TypeScript: Context-based implicit state sharing, generic components, slot patterns, polymorphic as-prop, and real examples with Select, Tabs, and Accordion.
React Compound Components in 2026: Context API, TypeScript Generics, and Flexible Component Design
Compound components are a pattern for building flexible, ergonomic APIs for complex UI components. Instead of passing 15 props to a single <Select> component, you compose sub-components togetherβ<Select.Trigger>, <Select.Options>, <Select.Item>βwhere the parent manages shared state invisibly through Context.
This is how Radix UI, Headless UI, and React Aria are built. This post teaches you the pattern from scratch: the Context trick that makes it work, TypeScript generics for type-safe compound components, the polymorphic as prop, and complete examples for Select, Tabs, and Accordion.
The Core Pattern
The key insight: parent component creates a Context with internal state. Child components consume that Context without needing explicit props to wire them together.
// Without compound components β prop explosion
<Select
value={selected}
options={options}
onChange={setSelected}
placeholder="Choose..."
renderOption={(opt) => <div>{opt.label}</div>}
groupBy="category"
isDisabled={loading}
isSearchable
maxHeight={300}
/>
// With compound components β composable, flexible
<Select value={selected} onChange={setSelected}>
<Select.Trigger placeholder="Choose..." disabled={loading} />
<Select.Options maxHeight={300} searchable>
{options.map((opt) => (
<Select.Item key={opt.value} value={opt.value}>
<div className="flex items-center gap-2">
<img src={opt.icon} className="h-4 w-4" />
{opt.label}
</div>
</Select.Item>
))}
</Select.Options>
</Select>
Building a Select Component
// components/Select/Select.tsx
import {
createContext,
useContext,
useState,
useRef,
useId,
useEffect,
ReactNode,
KeyboardEvent,
} from "react";
import { ChevronDown, Check } from "lucide-react";
// βββ Context βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
interface SelectContextValue {
value: string | null;
onChange: (value: string) => void;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
activeIndex: number;
setActiveIndex: (i: number) => void;
triggerId: string;
listboxId: string;
}
const SelectContext = createContext<SelectContextValue | null>(null);
function useSelectContext(componentName: string): SelectContextValue {
const context = useContext(SelectContext);
if (!context) {
throw new Error(`<${componentName}> must be used inside a <Select> component.`);
}
return context;
}
// βββ Root βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
interface SelectProps {
value: string | null;
onChange: (value: string) => void;
children: ReactNode;
}
function Select({ value, onChange, children }: SelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const triggerId = useId();
const listboxId = useId();
// Close on outside click
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
return (
<SelectContext.Provider
value={{ value, onChange, isOpen, setIsOpen, activeIndex, setActiveIndex, triggerId, listboxId }}
>
<div ref={containerRef} className="relative w-full">
{children}
</div>
</SelectContext.Provider>
);
}
// βββ Trigger ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
interface TriggerProps {
placeholder?: string;
disabled?: boolean;
displayValue?: (value: string) => string; // Custom label renderer
}
function Trigger({ placeholder = "Select...", disabled = false, displayValue }: TriggerProps) {
const { value, isOpen, setIsOpen, triggerId, listboxId } = useSelectContext("Select.Trigger");
const label = value
? (displayValue ? displayValue(value) : value)
: placeholder;
return (
<button
id={triggerId}
type="button"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
className={`w-full flex items-center justify-between px-3 py-2 border rounded-lg text-sm text-left transition-colors
${isOpen ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300 hover:border-gray-400"}
${disabled ? "opacity-50 cursor-not-allowed bg-gray-50" : "bg-white cursor-pointer"}
${!value ? "text-gray-400" : "text-gray-900"}
`}
>
<span className="truncate">{label}</span>
<ChevronDown
className={`h-4 w-4 text-gray-400 flex-shrink-0 transition-transform ${isOpen ? "rotate-180" : ""}`}
/>
</button>
);
}
// βββ Options ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
interface OptionsProps {
children: ReactNode;
maxHeight?: number;
searchable?: boolean;
}
function Options({ children, maxHeight = 256, searchable = false }: OptionsProps) {
const { isOpen, listboxId, triggerId, setIsOpen, onChange } = useSelectContext("Select.Options");
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen && searchable) {
searchRef.current?.focus();
}
}, [isOpen, searchable]);
if (!isOpen) return null;
return (
<div
className="absolute z-50 left-0 right-0 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden"
style={{ maxHeight }}
>
{searchable && (
<div className="p-2 border-b border-gray-100">
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
className="w-full px-2 py-1 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
)}
<ul
id={listboxId}
role="listbox"
aria-labelledby={triggerId}
className="overflow-auto"
style={{ maxHeight: searchable ? maxHeight - 48 : maxHeight }}
>
{children}
</ul>
</div>
);
}
// βββ Item βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
interface ItemProps {
value: string;
children: ReactNode;
disabled?: boolean;
}
function Item({ value, children, disabled = false }: ItemProps) {
const { value: selectedValue, onChange, setIsOpen } = useSelectContext("Select.Item");
const isSelected = selectedValue === value;
const handleSelect = () => {
if (disabled) return;
onChange(value);
setIsOpen(false);
};
return (
<li
role="option"
aria-selected={isSelected}
aria-disabled={disabled}
onClick={handleSelect}
className={`flex items-center gap-2 px-3 py-2 text-sm cursor-pointer transition-colors
${isSelected ? "bg-blue-50 text-blue-900" : "text-gray-900 hover:bg-gray-50"}
${disabled ? "opacity-40 cursor-not-allowed" : ""}
`}
>
<span className="flex-1">{children}</span>
{isSelected && <Check className="h-4 w-4 text-blue-600 flex-shrink-0" />}
</li>
);
}
// βββ Group ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function Group({ label, children }: { label: string; children: ReactNode }) {
return (
<li role="group" aria-label={label}>
<div className="px-3 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wide">
{label}
</div>
<ul>{children}</ul>
</li>
);
}
// βββ Separator ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function Separator() {
return <li role="separator" className="border-t border-gray-100 my-1" />;
}
// βββ Compose ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Select.Trigger = Trigger;
Select.Options = Options;
Select.Item = Item;
Select.Group = Group;
Select.Separator = Separator;
export { Select };
Usage:
// Usage example
const [framework, setFramework] = useState<string | null>(null);
<Select value={framework} onChange={setFramework}>
<Select.Trigger placeholder="Choose a framework" />
<Select.Options searchable maxHeight={300}>
<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.Group>
<Select.Separator />
<Select.Group label="Meta-frameworks">
<Select.Item value="nextjs">Next.js</Select.Item>
<Select.Item value="nuxt">Nuxt</Select.Item>
<Select.Item value="sveltekit">SvelteKit</Select.Item>
</Select.Group>
</Select.Options>
</Select>
π Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different β senior engineers only, direct Slack access, and a 5.0β Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript β production-grade stack
- Fixed-price contracts β no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Generic Compound Component (Type-Safe Values)
When you want type-safe values (not just string):
// components/Tabs/Tabs.tsx
import { createContext, useContext, ReactNode } from "react";
// Generic context trick β pass value type through
interface TabsContextValue<T extends string> {
activeTab: T;
setActiveTab: (tab: T) => void;
}
// Context with a default that throws
function createTabsContext<T extends string>() {
return createContext<TabsContextValue<T> | null>(null);
}
// Build component factory for a given value type
function createTabs<T extends string>() {
const TabsContext = createTabsContext<T>();
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Must be used inside <Tabs>");
return ctx;
}
function Root({
value,
onValueChange,
children,
}: {
value: T;
onValueChange: (value: T) => void;
children: ReactNode;
}) {
return (
<TabsContext.Provider value={{ activeTab: value, setActiveTab: onValueChange }}>
<div>{children}</div>
</TabsContext.Provider>
);
}
function List({ children }: { children: ReactNode }) {
return (
<div role="tablist" className="flex border-b border-gray-200">
{children}
</div>
);
}
function Tab({ value, children }: { value: T; children: ReactNode }) {
const { activeTab, setActiveTab } = useTabs();
const isActive = activeTab === value;
return (
<button
role="tab"
aria-selected={isActive}
onClick={() => setActiveTab(value)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px
${isActive
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{children}
</button>
);
}
function Panel({ value, children }: { value: T; children: ReactNode }) {
const { activeTab } = useTabs();
if (activeTab !== value) return null;
return (
<div role="tabpanel" className="py-4">
{children}
</div>
);
}
return { Root, List, Tab, Panel };
}
// Create a type-safe Tabs for a specific set of tabs
type SettingsTab = "general" | "billing" | "security" | "team";
const Tabs = createTabs<SettingsTab>();
// Usage β TypeScript enforces valid tab values
function SettingsPage() {
const [tab, setTab] = useState<SettingsTab>("general");
return (
<Tabs.Root value={tab} onValueChange={setTab}>
<Tabs.List>
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="billing">Billing</Tabs.Tab>
<Tabs.Tab value="security">Security</Tabs.Tab>
<Tabs.Tab value="team">Team</Tabs.Tab>
{/* TS Error: Argument of type '"invalid"' is not assignable to type 'SettingsTab' */}
{/* <Tabs.Tab value="invalid">Bad</Tabs.Tab> */}
</Tabs.List>
<Tabs.Panel value="general"><GeneralSettings /></Tabs.Panel>
<Tabs.Panel value="billing"><BillingSettings /></Tabs.Panel>
<Tabs.Panel value="security"><SecuritySettings /></Tabs.Panel>
<Tabs.Panel value="team"><TeamSettings /></Tabs.Panel>
</Tabs.Root>
);
}
Polymorphic as Prop
Let the caller choose the rendered HTML element:
// components/Button/Button.tsx
import { ElementType, ComponentPropsWithoutRef, ReactNode } from "react";
// The magic: infer props from the `as` element type
type PolymorphicButtonProps<T extends ElementType = "button"> = {
as?: T;
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg";
children: ReactNode;
} & ComponentPropsWithoutRef<T>;
const SIZE_CLASSES = {
sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
const VARIANT_CLASSES = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
ghost: "text-gray-600 hover:bg-gray-100",
danger: "bg-red-600 text-white hover:bg-red-700",
};
export function Button<T extends ElementType = "button">({
as,
variant = "primary",
size = "md",
children,
className = "",
...props
}: PolymorphicButtonProps<T>) {
const Component = as ?? "button";
return (
<Component
className={`inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed
${SIZE_CLASSES[size]} ${VARIANT_CLASSES[variant]} ${className}`}
{...props}
>
{children}
</Component>
);
}
// Usage β TypeScript infers the correct props for each element
<Button>Click me</Button> // renders <button>
<Button as="a" href="/dashboard">Dashboard</Button> // renders <a> with href
<Button as={Link} href="/settings">Settings</Button> // renders Next.js Link
π Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4β8 weeks, full platforms in 3β5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Accordion with Compound Components
// components/Accordion/Accordion.tsx
import { createContext, useContext, useState, ReactNode } from "react";
import { ChevronRight } from "lucide-react";
interface AccordionContextValue {
openItems: Set<string>;
toggle: (id: string) => void;
multiple: boolean;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
const ItemContext = createContext<{ id: string } | null>(null);
function Accordion({ children, multiple = false }: { children: ReactNode; multiple?: boolean }) {
const [openItems, setOpenItems] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setOpenItems((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
if (!multiple) next.clear(); // Close others if not multiple
next.add(id);
}
return next;
});
};
return (
<AccordionContext.Provider value={{ openItems, toggle, multiple }}>
<div className="divide-y divide-gray-200 border border-gray-200 rounded-lg overflow-hidden">
{children}
</div>
</AccordionContext.Provider>
);
}
function Item({ id, children }: { id: string; children: ReactNode }) {
return (
<ItemContext.Provider value={{ id }}>
<div>{children}</div>
</ItemContext.Provider>
);
}
function Trigger({ children }: { children: ReactNode }) {
const { openItems, toggle } = useContext(AccordionContext)!;
const { id } = useContext(ItemContext)!;
const isOpen = openItems.has(id);
return (
<button
type="button"
aria-expanded={isOpen}
onClick={() => toggle(id)}
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-left hover:bg-gray-50 transition-colors"
>
<span>{children}</span>
<ChevronRight
className={`h-4 w-4 text-gray-400 transition-transform ${isOpen ? "rotate-90" : ""}`}
/>
</button>
);
}
function Content({ children }: { children: ReactNode }) {
const { openItems } = useContext(AccordionContext)!;
const { id } = useContext(ItemContext)!;
if (!openItems.has(id)) return null;
return <div className="px-4 pb-4 text-sm text-gray-600">{children}</div>;
}
Accordion.Item = Item;
Accordion.Trigger = Trigger;
Accordion.Content = Content;
export { Accordion };
When to Use Compound Components
| Scenario | Compound Components | Single Component + Props |
|---|---|---|
| Complex state shared by multiple sub-parts | β | Hard to manage |
| Caller needs to control layout/order of parts | β | Difficult |
| Component has optional sub-features | β | Prop explosion |
| Simple, one-piece component | β Over-engineered | β |
| Used in one place only | β Over-engineered | β |
| Design system / shared component library | β | β |
Cost and Timeline
| Work | Timeline | Cost (USD) |
|---|---|---|
| Single compound component (Select/Tabs) | 2β3 days | $1,600β$2,500 |
| Component library (10 components) | 3β4 weeks | $12,000β$20,000 |
| Design system with compound pattern | 6β10 weeks | $25,000β$50,000 |
| Audit + refactor existing components | 1β2 weeks | $5,000β$10,000 |
See Also
- React Hook Form + Zod β Form handling for compound form components
- React Form Builder β Dynamic forms using compound component patterns
- TypeScript Decorators β Advanced TypeScript patterns
- React Testing Library Patterns β Testing compound components
Working With Viprasol
We build React component libraries and design systems using compound component patternsβfrom individual complex components through full-scale design systems used across multiple products. Our team has shipped component libraries for SaaS products with 10+ development teams.
What we deliver:
- Compound component design and TypeScript API design
- Accessibility-first implementation with ARIA patterns
- Storybook documentation with interaction testing
- Design token integration (CSS custom properties or Tailwind)
- Migration from prop-heavy components to compound patterns
Explore our web development services or contact us to discuss your component library.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms β we build it all with Next.js and React.
Free consultation β’ No commitment β’ Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse β₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β from architecture through deployment.