EmpireUI
Get Pro
← Blog9 min read#react#api design#props

React Component API Design: Props, Variants and Compound Patterns

How to design React component APIs that don't make developers hate you — props, variants, compound patterns, and the decisions that actually matter.

Developer working on React component code on dark monitor screen

The API Is the Product

Nobody cares how clever your internal implementation is. What people actually interact with — what they praise or quietly curse in Slack — is the API surface you expose. The props, the composition model, the escape hatches. Get those wrong and every component you ship becomes a liability.

This matters more than most devs realise. A button with a confusing variant prop naming scheme will generate Stack Overflow questions internally for years. A modal that doesn't forward ref properly will turn a two-line integration into a 45-minute debugging session. Good component API design is mostly about reducing that friction.

In practice, the best component libraries succeed not because they have the most features, but because they have the most predictable surface area. You can browse something like Empire UI and instantly understand what each component expects — that predictability is earned through deliberate API choices, not by accident.

So let's talk about those choices concretely — props shape, variant systems, compound patterns, and where each one actually belongs.

Designing Props That Don't Surprise People

Start with naming. This sounds trivial until you've inherited a codebase where isOpen, open, visible, show, and active all mean roughly the same thing across different components. Pick a convention and apply it everywhere. isLoading, isDisabled, isOpen — the is prefix for booleans is common for a reason. It reads like English: <Modal isOpen={...} />.

Boolean props should default to false. Always. A <Button disabled /> makes sense; a component that's disabled by default and requires disabled={false} to undo it is a trap. Related: don't use negative booleans like noAnimation or hideLabel. You end up with double negatives in JSX — <Card hideLabel={false} /> — and that's just hostile to readers.

For string-typed props like size or color, lean on TypeScript union types hard. In React 18+ with strict mode, an untyped string prop that silently accepts garbage is genuinely dangerous in larger apps. This pattern has been standard since TypeScript 4.0, and if you're not using it in 2026, that's a problem: ``tsx type ButtonSize = 'sm' | 'md' | 'lg' | 'xl'; type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'link'; interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { size?: ButtonSize; variant?: ButtonVariant; isLoading?: boolean; leftIcon?: React.ReactNode; } ``

Worth noting: extending native HTML element props via React.ButtonHTMLAttributes (or the equivalent for div, input, etc.) is one of those things that seems like a detail until you're wrestling with an onClick that isn't forwarding, or a form where your custom input ignores the native name attribute. Just do it from the start.

One more thing — always forward refs. React.forwardRef adds maybe 5 lines and saves consumers from having to wrap your component in a div just to get a DOM handle. If you're building any interactive component and you're not forwarding the ref, you're making someone else's day worse.

Variant Systems That Scale

The naive approach to variants is one boolean per visual state: isPrimary, isSecondary, isDanger, isGhost. This falls apart at four variants and becomes unmaintainable by ten. You also get combinations that don't make sense — <Button isPrimary isDanger /> — and no clear winner when both are true.

A single variant string prop solves the mutual-exclusion problem cleanly. But where it gets interesting is when variants need to compose with other dimensions. Size is independent of visual style. Color intent (primary, danger, success) is independent of both. Honest answer: most components need at most three orthogonal axes — variant, size, and colorScheme (or intent). Beyond that, you're probably not building a component, you're building a system that needs sub-components.

Tools like class-variance-authority (CVA) hit v1.0 in 2024 and have become the standard for this in Tailwind-based systems: ``tsx import { cva, type VariantProps } from 'class-variance-authority'; const button = cva( 'inline-flex items-center justify-center rounded-md font-medium transition-colors', { variants: { variant: { solid: 'bg-primary text-white hover:bg-primary/90', outline: 'border border-primary text-primary hover:bg-primary/10', ghost: 'text-primary hover:bg-primary/10', }, size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4 text-base', lg: 'h-12 px-6 text-lg', }, }, defaultVariants: { variant: 'solid', size: 'md', }, } ); type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof button>; export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( ({ variant, size, className, ...props }, ref) => ( <button ref={ref} className={button({ variant, size, className })} {...props} /> ) ); ``

That className pass-through in CVA is the key escape hatch. Consumers can always add one-off overrides without forking your component. Honestly, that single feature prevents 80% of the "I need to customise this component" PRs that show up in component library repos.

If you're pulling styles from a more visual direction — say, building glassmorphism or aurora components — the variant system needs to account for layered effects, not just flat color swaps. Check how the glassmorphism components handle layered backdrop filters and you'll see why variant alone sometimes isn't enough; you might need a depth or blur prop alongside it.

Compound Components: When to Actually Use Them

Compound components get recommended constantly and misapplied almost as often. The pattern makes sense when a component has multiple meaningful sub-parts that consumers need independent control over. A <Select> with a trigger and a dropdown list. A <Tabs> with a tab list and tab panels. A <Card> with a header, body, and footer. These all have parts that need to render independently and communicate state between them.

The implementation usually involves React context to share state between the parent and children, without prop-drilling: ``tsx const TabsContext = React.createContext<{ activeTab: string; setActiveTab: (id: string) => void; } | null>(null); const Tabs = ({ defaultTab, children }: TabsProps) => { const [activeTab, setActiveTab] = React.useState(defaultTab); return ( <TabsContext.Provider value={{ activeTab, setActiveTab }}> <div className="tabs-root">{children}</div> </TabsContext.Provider> ); }; const TabList = ({ children }: { children: React.ReactNode }) => ( <div role="tablist" className="flex border-b">{children}</div> ); const Tab = ({ id, children }: { id: string; children: React.ReactNode }) => { const ctx = React.useContext(TabsContext); if (!ctx) throw new Error('Tab must be used inside Tabs'); return ( <button role="tab" aria-selected={ctx.activeTab === id} onClick={() => ctx.setActiveTab(id)} > {children} </button> ); }; // Attach sub-components as static properties Tabs.List = TabList; Tabs.Tab = Tab; ``

The usage then reads like structured HTML, which is exactly what you want: ``tsx <Tabs defaultTab="overview"> <Tabs.List> <Tabs.Tab id="overview">Overview</Tabs.Tab> <Tabs.Tab id="settings">Settings</Tabs.Tab> </Tabs.List> <Tabs.Panel id="overview">...</Tabs.Panel> <Tabs.Panel id="settings">...</Tabs.Panel> </Tabs> ``

That said, don't reach for this pattern just because a component has multiple props. A <Avatar> with src, name, and size doesn't need sub-components — those are just props. Compound components add conceptual weight. Use them when the consumer genuinely needs to control the placement or rendering of sub-parts independently, not as a default architecture for everything.

Quick aside: if you're building a component library and want consumers to be able to swap specific sub-components entirely — say, replacing the trigger of a dropdown with a completely custom element — the compound pattern handles that cleanly in a way that a single-component-with-many-props approach simply cannot.

Composition vs Configuration: The Real Tradeoff

Here's the tension that every component API decision circles back to: do you expose configuration (lots of props) or do you expose composition (children, render props, slots)? Both are valid. Neither is universally correct. And picking the wrong model for a given component is how you end up with an <Alert> component that needs 12 props to cover every use case.

Configuration-first (many props) works well for atomic, leaf-level components. A <Badge> with label, variant, and size — that's it, nothing to compose, the prop API is complete. Trying to make this composable just adds overhead. On the other extreme, a <Dialog> that accepts a title string, a description string, a confirmLabel string, and an onConfirm callback is too rigid. What if someone needs the title to be a custom element with an icon? What if the body needs a form? Props won't cover it.

The sweet spot for complex components is usually an asChild pattern (popularised by Radix UI) or explicit slot props. asChild lets consumers replace the rendered element entirely without wrapping: ``tsx // Without asChild — wraps in an extra DOM node <Button> <Link href="/pricing">See pricing</Link> </Button> // With asChild — merges props onto Link, renders only one element <Button asChild> <Link href="/pricing">See pricing</Link> </Button> ``

The implementation uses React.cloneElement or Radix's Slot utility to merge props — including event handlers and refs — onto the child. It's 24px of thinking to save users from div soup. If your library doesn't have this, it's worth adding before you ship v1.

Honestly, looking at how tools like the box shadow generator expose their underlying controls gives you a real example of this tension: you could expose every visual parameter as a prop, or you could expose a structured config object and render children around it. The right answer depends entirely on who your consumers are and how much they'll want to deviate from defaults.

Versioning, Breaking Changes and the Trust Problem

You can design the most elegant API in the world and still destroy trust by changing it carelessly. Component API versioning is where most internal design systems go wrong — they ship, devs adopt, then the component team renames variant="primary" to variant="brand" in a minor version and suddenly every PR for the next two weeks is a migration.

The rule that actually holds up: additive changes are safe, removals and renames are breaking. Adding a new optional prop is fine. Changing the shape of an existing prop, removing a variant, or making a previously optional prop required — those are all major version bumps, full stop. Semantic versioning exists for this reason, and ignoring it because your library is "internal" is how you rack up 40 hours of migration work across the org.

Deprecation warnings are your friend here. Before you remove anything, add a console.warn in development that tells the consumer exactly what to change: ``tsx if (process.env.NODE_ENV !== 'production' && props.isFullWidth !== undefined) { console.warn( '[Button] The isFullWidth prop is deprecated as of v3.2.0. ' + 'Use width="full" instead. ' + 'Support for isFullWidth will be removed in v4.0.0.' ); } ``

That message — specific version, specific replacement, specific removal timeline — turns a potentially frustrating upgrade into a manageable one. It respects the consumer's time. In practice, teams that do this well can run two prop APIs in parallel for one major version cycle without anyone having a bad day.

Putting It Together: What a Solid Component API Actually Looks Like

Let's look at a complete example — a Card component with a compound API, variant support, and proper TypeScript. This isn't theoretical; this is the kind of thing you'd actually ship: ``tsx import { cva, type VariantProps } from 'class-variance-authority'; import React from 'react'; const card = cva('rounded-xl border transition-shadow', { variants: { variant: { default: 'bg-white border-gray-200 shadow-sm hover:shadow-md', glass: 'bg-white/10 backdrop-blur-md border-white/20 shadow-glass', elevated: 'bg-white border-transparent shadow-lg hover:shadow-xl', }, padding: { none: '', sm: 'p-4', md: 'p-6', lg: 'p-8', }, }, defaultVariants: { variant: 'default', padding: 'md' }, }); type CardProps = React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof card>; const Card = React.forwardRef<HTMLDivElement, CardProps>( ({ variant, padding, className, ...props }, ref) => ( <div ref={ref} className={card({ variant, padding, className })} {...props} /> ) ); const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( ({ className, ...props }, ref) => ( <div ref={ref} className={flex flex-col gap-1.5 pb-4 ${className ?? ''}} {...props} /> ) ); const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>( ({ className, ...props }, ref) => ( <h3 ref={ref} className={text-xl font-semibold ${className ?? ''}} {...props} /> ) ); const CardBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( ({ className, ...props }, ref) => ( <div ref={ref} className={className} {...props} /> ) ); Card.displayName = 'Card'; CardHeader.displayName = 'CardHeader'; CardTitle.displayName = 'CardTitle'; CardBody.displayName = 'CardBody'; export { Card, CardHeader, CardTitle, CardBody }; ``

Usage reads cleanly, sub-parts are individually controllable, and consumers can still pass className to override anything: ``tsx <Card variant="glass" padding="lg"> <CardHeader> <CardTitle>Workspace stats</CardTitle> </CardHeader> <CardBody> <p>Monthly active users: 12,400</p> </CardBody> </Card> ``

This is close to how the glassmorphism components are structured — the variant="glass" style pulls in backdrop filters and border opacity without the consumer needing to know anything about those CSS properties. The abstraction is doing real work.

One thing this example doesn't show: displayName. It's set explicitly here because DevTools and error messages get confusing fast with forwardRef-wrapped components — React will show ForwardRef instead of Card without it. Small thing, genuinely useful.

Look, none of this is magic. Good component API design is mostly saying no to clever things, staying consistent with what you've already shipped, and making the 90% use case require zero configuration. The other 10% is what escape hatches are for.

FAQ

Should I use a single `variant` prop or multiple boolean props for visual states?

Single variant string prop, always. Multiple booleans create invalid combinations like isPrimary isDanger with no clear winner. Union types on variant make the valid states explicit and TypeScript will enforce them.

When does the compound component pattern actually make sense?

When consumers need independent control over where sub-parts render, or need to replace a sub-part entirely. If you just have a lot of configuration, that's a props problem, not a compound component problem.

Do I really need to forward refs on every component?

Yes, on any interactive or focusable component. Without it, consumers can't access the DOM node for things like focus management, positioning, or third-party library integrations. React.forwardRef is five extra lines — just add it.

How do I handle prop renames between major versions without breaking consumers?

Keep the old prop working and add a console.warn in development that names the deprecated prop, the replacement, and the version it'll be removed in. Give consumers one full major version cycle to migrate.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Button Design System: Variants, States, Sizes and the API That ScalesBuilding a Component Library with Storybook 8 + TypeScriptReact Compound Components Pattern: Flexible APIs Without Prop HellButton Component Variants in Tailwind: Primary, Ghost, Icon, Loading