What is Compound components?
Compound components: Compound components are a set of React components, like Tabs, TabsList and TabsTrigger, that share state through context and are composed together in JSX.
A compound component is an API made of several components that only make sense together. Instead of one Tabs taking an items array, you write Tabs, TabsList, TabsTrigger and TabsContent in JSX, nesting them however the layout needs. The parent holds the state, the selected tab, and shares it through React context; each child reads what it needs and reports changes back. The native select and option elements work this way, and most headless libraries, including Base UI and Radix, expose their primitives in this shape.
Why use the pattern
- Layout stays in your hands. Put the tab list above or beside the panels, add a badge inside one trigger, wrap a panel in a card. A config-array API would need a prop for each of those.
- Each piece is a real element that takes
className, refs and event handlers, so styling and testing work as they do for plain HTML. - State is still coordinated. The parts cannot drift out of sync, because the only state lives in the root.
- The cost is verbosity for the simple case. Some MiniDev components, like SegmentedControl, take an
itemsarray instead because their layout never varies.
How to build one in React
"use client"
import * as React from "react"
type Ctx = { open: boolean; setOpen: (open: boolean) => void; id: string }
const DisclosureContext = React.createContext<Ctx | null>(null)
function useDisclosure(part: string) {
const ctx = React.useContext(DisclosureContext)
if (!ctx) throw new Error(part + " must be used inside <Disclosure>")
return ctx
}
export function Disclosure({ defaultOpen = false, children }: { defaultOpen?: boolean; children: React.ReactNode }) {
const [open, setOpen] = React.useState(defaultOpen)
const id = React.useId()
const value = React.useMemo(() => ({ open, setOpen, id }), [open, id])
return <DisclosureContext.Provider value={value}>{children}</DisclosureContext.Provider>
}
export function DisclosureTrigger({ children }: { children: React.ReactNode }) {
const { open, setOpen, id } = useDisclosure("DisclosureTrigger")
return (
<button type="button" aria-expanded={open} aria-controls={id} onClick={() => setOpen(!open)}>
{children}
</button>
)
}
export function DisclosurePanel({ children }: { children: React.ReactNode }) {
const { open, id } = useDisclosure("DisclosurePanel")
return <div id={id} hidden={!open}>{children}</div>
}
The context carries the state and a generated id, so the trigger and panel can wire aria-expanded and aria-controls without anyone passing ids by hand. The error in useDisclosure turns a misplaced part into a clear message instead of a silent failure, and the memoized value keeps the parts from re-rendering when the root re-renders for unrelated reasons.
Details that matter
- Export the parts as separate named exports (
Tabs,TabsList) rather than properties likeTabs.List. Separate exports tree-shake, and a property attached to a client module is not visible from a server component in React Server Components. - Allow a controlled root. Offer
valueandonValueChangealongsidedefaultValue, as described under controlled components. - Let parts render as other elements through a
renderprop orasChild, the polymorphic pattern, so a trigger can be your Button. - Name the parts by role, Trigger, Content, Title, so a reader of the JSX can tell what each one does.
MiniDev's Tabs, Accordion, Dialog, DropdownMenu and Card are all compound components, the first four backed by Base UI primitives and the last a pure layout set.
TabsuiTabs on Base UI with a raised thumb or a line underline that springs to the active tab, optional count badges, vertical orientation and roving keyboard focus.npx shadcn@latest add @minidev/tabsDialoguiShadcn compatible modal dialog on Base UI with a blurred backdrop, enter and exit animation, close button, header, title, description and a footer bar.npx shadcn@latest add @minidev/dialog