Some UI pieces are not one component but a small family: a modal has a header, a body and a footer; tabs have a list, triggers and panels; an accordion has items that open and close. The naive way to build these is one big component configured by props. The Compound Components pattern builds them the way HTML builds <select> and <option>: several components that only make sense together and share state behind the scenes.
The problem: prop soup
Here is a modal that started simple and grew one prop at a time.
import type { ReactNode } from "react";
type ModalProps = {
isOpen: boolean;
title: string;
subtitle?: string;
body: ReactNode;
primaryAction?: ReactNode;
secondaryAction?: ReactNode;
showCloseButton?: boolean;
hideFooter?: boolean;
headerIcon?: ReactNode;
footerAlign?: "left" | "right" | "space-between";
onClose: () => void;
};
export function Modal({
isOpen,
title,
subtitle,
body,
primaryAction,
secondaryAction,
showCloseButton = true,
hideFooter = false,
headerIcon,
footerAlign = "right",
onClose,
}: ModalProps) {
if (!isOpen) return null;
return (
<div className="modal-backdrop">
<div className="modal-container" role="dialog" aria-modal="true">
<header className="modal-header">
{headerIcon}
<h2>{title}</h2>
{subtitle && <p>{subtitle}</p>}
{showCloseButton && <button onClick={onClose}>Close</button>}
</header>
<div className="modal-body">{body}</div>
{!hideFooter && (
<footer className={`modal-footer align-${footerAlign}`}>
{secondaryAction}
{primaryAction}
</footer>
)}
</div>
</div>
);
}
Every new design request adds another prop. Want a header with a badge next to the title? New prop. Want two rows in the footer? New prop. Want the body to come before the header on mobile? You cannot, the order is hard-coded. The component has become a configuration language with its own undocumented grammar, and every caller pays for every option, even ones they never use.
This is "prop soup": the parent decides the structure, and callers can only toggle flags. The more callers you have, the worse it gets, because each one wants a small variation and the only way to add one is another prop.
The pattern: let callers compose the structure
Compound components flip control. The parent component provides shared state and behavior. Child components render the pieces. The caller arranges the pieces in JSX however they like:
<Modal isOpen={isOpen} onClose={close}>
<Modal.Header>...</Modal.Header>
<Modal.Body>...</Modal.Body>
<Modal.Footer>...</Modal.Footer>
</Modal>
Two ingredients make it work: children for layout freedom, and context for sharing state between the parent and whichever children need it.
Step 1: structural subcomponents
The simplest version only uses children. Each subcomponent is a styled wrapper.
import type { ReactNode } from "react";
type SlotProps = { children: ReactNode; className?: string };
function ModalHeader({ children, className = "" }: SlotProps) {
return <header className={`modal-header ${className}`}>{children}</header>;
}
function ModalBody({ children, className = "" }: SlotProps) {
return <div className={`modal-body ${className}`}>{children}</div>;
}
function ModalFooter({ children, className = "" }: SlotProps) {
return <footer className={`modal-footer ${className}`}>{children}</footer>;
}
Already the header with a badge, the two-row footer and the custom order are free: callers write whatever JSX they need inside each piece.
Step 2: share state with context
A close button inside the header needs onClose, but we do not want callers to pass it again. The root puts it in context; any piece can read it.
import { createContext, use, type ReactNode } from "react";
type ModalContextValue = { onClose: () => void; titleId: string };
const ModalContext = createContext<ModalContextValue | null>(null);
function useModalContext(componentName: string) {
const context = use(ModalContext);
if (!context) throw new Error(`<${componentName}> must be used inside <Modal>`);
return context;
}
The guard turns a confusing undefined crash into a clear message when someone renders Modal.Close outside a modal.
Step 3: the root and the attached parts
import { useId, type ReactNode } from "react";
type ModalRootProps = { isOpen: boolean; onClose: () => void; children: ReactNode };
function ModalRoot({ isOpen, onClose, children }: ModalRootProps) {
const titleId = useId();
if (!isOpen) return null;
return (
<ModalContext value={{ onClose, titleId }}>
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal-container"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(event) => event.stopPropagation()}
>
{children}
</div>
</div>
</ModalContext>
);
}
function ModalTitle({ children }: { children: ReactNode }) {
const { titleId } = useModalContext("Modal.Title");
return <h2 id={titleId}>{children}</h2>;
}
function ModalClose({ children = "Close" }: { children?: ReactNode }) {
const { onClose } = useModalContext("Modal.Close");
return <button type="button" onClick={onClose}>{children}</button>;
}
export const Modal = Object.assign(ModalRoot, {
Header: ModalHeader,
Title: ModalTitle,
Body: ModalBody,
Footer: ModalFooter,
Close: ModalClose,
});
Object.assign gives TypeScript a correct type for Modal.Header and friends without declaring an interface by hand. Note also the accessibility win: Modal.Title wires aria-labelledby automatically, something the old prop-based version never did.
Step 4: using it
import { useState } from "react";
import { Modal } from "./Modal";
export function DeleteProjectButton({ projectName }: { projectName: string }) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Delete project</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<Modal.Header>
<Modal.Title>Delete {projectName}?</Modal.Title>
<span className="badge">Irreversible</span>
</Modal.Header>
<Modal.Body>
<p>All environments and deploy history will be removed.</p>
</Modal.Body>
<Modal.Footer className="justify-between">
<Modal.Close>Cancel</Modal.Close>
<button className="danger" onClick={() => setIsOpen(false)}>
Delete
</button>
</Modal.Footer>
</Modal>
</>
);
}
There is no showCloseButton, hideFooter or footerAlign. You show a close button by rendering one, hide the footer by not rendering it, and align it with a class.
A second example: an accordion with shared state
The modal mostly shares callbacks. An accordion shows the stronger form of the pattern, where the parent owns state that coordinates siblings. A simple version where every item keeps its own isOpen works, but you cannot express "only one open at a time". Lifting that state into the root fixes it.
import { createContext, use, useId, useState, type ReactNode } from "react";
type AccordionContextValue = {
isOpen: (itemId: string) => boolean;
toggle: (itemId: string) => void;
};
const AccordionContext = createContext<AccordionContextValue | null>(null);
const AccordionItemContext = createContext<string | null>(null);
function useAccordion() {
const context = use(AccordionContext);
if (!context) throw new Error("Accordion parts must be used inside <Accordion>");
return context;
}
function useItemId() {
const itemId = use(AccordionItemContext);
if (!itemId) throw new Error("Use Accordion.Trigger and Accordion.Panel inside <Accordion.Item>");
return itemId;
}
type AccordionProps = { children: ReactNode; allowMultiple?: boolean };
function AccordionRoot({ children, allowMultiple = false }: AccordionProps) {
const [openIds, setOpenIds] = useState<string[]>([]);
function toggle(itemId: string) {
setOpenIds((current) => {
if (current.includes(itemId)) return current.filter((id) => id !== itemId);
return allowMultiple ? [...current, itemId] : [itemId];
});
}
return (
<AccordionContext value={{ isOpen: (itemId) => openIds.includes(itemId), toggle }}>
<div className="accordion">{children}</div>
</AccordionContext>
);
}
function AccordionItem({ children }: { children: ReactNode }) {
const itemId = useId();
return (
<AccordionItemContext value={itemId}>
<div className="accordion-item">{children}</div>
</AccordionItemContext>
);
}
function AccordionTrigger({ children }: { children: ReactNode }) {
const { isOpen, toggle } = useAccordion();
const itemId = useItemId();
return (
<button
className="accordion-title"
aria-expanded={isOpen(itemId)}
aria-controls={`${itemId}-panel`}
onClick={() => toggle(itemId)}
>
{children}
</button>
);
}
function AccordionPanel({ children }: { children: ReactNode }) {
const { isOpen } = useAccordion();
const itemId = useItemId();
if (!isOpen(itemId)) return null;
return <div id={`${itemId}-panel`} className="accordion-content">{children}</div>;
}
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Trigger: AccordionTrigger,
Panel: AccordionPanel,
});
import { Accordion } from "./Accordion";
export function PatternFaq() {
return (
<Accordion>
<Accordion.Item>
<Accordion.Trigger>What is the Compound Components pattern?</Accordion.Trigger>
<Accordion.Panel>
A parent and its children cooperate through shared state while the caller controls the layout.
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item>
<Accordion.Trigger>Why use it?</Accordion.Trigger>
<Accordion.Panel>It keeps modals, tabs, menus and accordions flexible without prop soup.</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
There are two levels of context here: the root shares "which items are open", each item shares "who am I". The trigger and panel do not receive any props about state, yet they stay in sync. Switching to multi-open is one prop on the root, and the caller can put an icon, a badge or a subtitle inside any trigger without the accordion knowing.
In 2026 / React 19
Compound components are, if anything, more common today than when the pattern was named. Headless libraries such as Radix UI, React Aria, Base UI and Ark UI are built on it, and shadcn/ui copies Radix's compound APIs straight into your codebase. A few things have changed in how you write it:
<Context value>instead of<Context.Provider>. In React 19 a context object can be rendered directly as a provider, as in the examples above.Context.Providerstill works but is on its way out.use(Context). TheuseAPI reads context likeuseContext, and unlike hooks it can be called inside conditions. Either works for this pattern.- Prefer context over
React.ChildrenandcloneElement. Older implementations walkedchildrenand injected props withcloneElement. That breaks as soon as a caller wraps a part in a<div>or a custom component. The React docs list both APIs as legacy. Context works at any depth. - Server Components boundary. Any part that uses state or context needs
"use client", which usually means the whole family lives in a client module. Also, dotting into a client component from a Server Component (<Modal.Header>whereModalis imported into a server file) is not supported by RSC, because only the exported names cross the boundary. Libraries like Radix avoid this by exporting each part by name (Root,Trigger,Content). If your compound component must be used from Server Components, export named parts (ModalRoot,ModalHeader) as well as, or instead of, the attached properties. - Native platform features. For modals, the
<dialog>element withshowModal()now makes the rest of the page inert, closes on Escape and renders in the top layer, all for free. A compoundModalbuilt on<dialog>is much less code than one built on divs. - React Compiler can memoize the context value object for you, which mostly removes the old advice to wrap it in
useMemoby hand.
When to use it, and when not to
Use it when:
- A component has several related regions that callers want to arrange or customize.
- Siblings must coordinate state (tabs and panels, accordion items, menu items and keyboard focus).
- You are building a design system or shared library used by many teams.
Skip it when:
- The component has one fixed layout and two or three props. A simple
<Badge tone="success">does not needBadge.Icon. - Callers should not be able to change the structure, for example a legally required consent banner.
- You need it consumed directly from Server Components and do not want to manage named exports.
Common pitfalls
- Forgetting the context guard. Without a clear error, a part rendered outside its root crashes with "cannot read properties of null", far from the real mistake.
- Relying on child order or type checks. Code like
children.find((child) => child.type === ModalHeader)breaks with wrappers, fragments and memoized components. Use context and let each part render itself. - One giant context for everything. If the root shares fast-changing values (hover position, input text) with every part, all parts re-render together. Split contexts by how often they change.
- No controlled mode. Real apps need to open an accordion item from a URL or close a modal after a save. Offer
value/defaultValue/onValueChangeon the root, as in the previous chapter. - Over-nesting. Five levels of
Card.Header.Title.Iconis a sign the family is too granular. Keep parts to what callers actually customize.
Exercises
- Build a
CardwithCard.Header,Card.BodyandCard.Footer. - Add
Card.Imageand use it in three layouts (top image, side image, image in the footer). Compare the JSX with a singleCardthat takes ten props. - Build
TabswithTabs.List,Tabs.TriggerandTabs.Panel, sharing the active tab through context. Add arrow-key navigation between triggers. - Give
Accordiona controlled mode (valueandonValueChange) so the open item can be synced with the URL hash. - Rebuild
Modalon top of the native<dialog>element and remove the manual backdrop click and Escape handling where the browser now does it for you.
Video and source code
This chapter is adapted from Day 03 of tapaScript's "15 Days of React Design Patterns" by Tapas Adhikary; the video walks through the prop-soup modal and the accordion step by step.