Back to Blog

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.

Viprasol Tech Team
January 19, 2027
13 min read

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

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

See Also


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.

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 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.

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.