| name | compound-component |
| description | Trigger: dot-notation components, Modal.Header, Tabs.Panel, shared state without prop drilling, composable widgets, Radix UI style, shadcn/ui. Build Compound Component pattern with React Context. |
| author | Gonzalo Astudillo |
| version | 1.0.0 |
| date | "2026-04-14T00:00:00.000Z" |
| user-invocable | true |
Compound Component Pattern
Activation Contract
Use when building a React component where:
- Multiple sub-parts share implicit state (Accordion open/closed, Tabs active tab)
- Consumers need flexible slot-based APIs (reorder parts, omit parts)
- The goal is a dot-notation API:
Modal.Header, Tabs.Panel, Select.Option
Skip when the component has no shared state between parts — use plain props instead.
Hard Rules
- Never use this pattern for components with no shared state — use plain props instead
- Every sub-component MUST throw if used outside its parent Context:
if (!ctx) throw new Error("...")
- Root component is ALWAYS the Context provider — sub-components NEVER provide context themselves
- Dot-notation attachment (
Card.Header = Header) MUST happen at module level, not inside a function
Decision Gates
| Situation | Pattern |
|---|
| Sub-parts share state (accordion, tabs) | Compound Component with Context |
| Sub-parts are independent, just grouped | Plain composition with children |
| Only one sub-part, no shared state | Single component with props |
| Deep nesting across unrelated components | Context + useContext (not compound component) |
Implementation (TypeScript)
import { createContext, useContext, ReactNode } from "react"
interface CardContext {
variant?: "default" | "bordered" | "elevated"
}
const CardContext = createContext<CardContext | null>(null)
function useCardContext() {
const ctx = useContext(CardContext)
if (!ctx) throw new Error("Card sub-components must be used inside <Card>")
return ctx
}
function Card({ children, variant = "default" }: { children: ReactNode; variant?: CardContext["variant"] }) {
return (
<CardContext.Provider value={{ variant }}>
<div className={`card card--${variant}`}>{children}</div>
</CardContext.Provider>
)
}
function Header({ children }: { children: ReactNode }) {
const { variant } = useCardContext()
return <div className={`card__header card__header--${variant}`}>{children}</div>
}
function Body({ children }: { children: ReactNode }) {
useCardContext()
return <div className="card__body">{children}</div>
}
function Footer({ children }: { children: ReactNode }) {
useCardContext()
return <div className="card__footer">{children}</div>
}
Card.Header = Header
Card.Body = Body
Card.Footer = Footer
export { Card }
TypeScript: Typing the Compound Component
interface CardComponent {
(props: { children: ReactNode; variant?: CardContext["variant"] }): JSX.Element
Header: typeof Header
Body: typeof Body
Footer: typeof Footer
}
const Card = function Card(...) { ... } as CardComponent
Card.Header = Header
Output Contract
Return a single file {ComponentName}.tsx containing:
- Context interface +
createContext<T | null>(null)
use{ComponentName}Context() hook with throw guard
- Root component as Context provider
- All sub-components consuming the context
- Dot-notation attachments at module level
- Named export of root component only
Gotchas
- Forgetting
useCardContext() in sub-components that don't need state — still call it for the throw guard
- Attaching sub-components inside the component function body — dot-notation must be at module scope
- Using
{} as the default context value instead of null — masks missing parent errors
- Exporting sub-components individually breaks the dot-notation API contract for consumers
Supporting Files
- references/implementation-examples.md — Full examples: Accordion, Tabs, Select, Modal