Back to Blog

React Compound Components in 2026: Context API, TypeScript Generics

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.

Viprasol Tech Team
13 min read
Updated 2027

React Compound Components in 2026: Context API, TypeScript Generics, and Flexible Component Design

Quick answer. Compound components replace passing 15 props to one component with composed sub-components like Select.Trigger, Select.Options, and Select.Item, where the parent shares state invisibly through Context. It's how Radix UI, Headless UI, and React Aria are built, combining the Context trick, TypeScript generics, and a polymorphic as prop.

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 1000+ 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

React - React Compound Components in 2026: Context API, TypeScript Generics

πŸš€ 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

ScenarioCompound ComponentsSingle 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

WorkTimelineCost (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 pattern6–10 weeks$25,000–$50,000
Audit + refactor existing components1–2 weeks$5,000–$10,000

You Might Also Like


Our Approach at 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.

Related: react-resizable-panels: PanelOnCollapse + Drag Handles β€” building resizable panel layouts in React.

ReactTypeScriptComponent DesignPatternsContext API
Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol Β· Web Development

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.