Chapter 3
Component API Design — Props, Variants, and Composition
A design system is judged by its components, and components are judged by their APIs. Visual polish and clean tokens don't save a Dialog whose props collection has grown to 23 boolean flags over three years. Conversely, a slightly rough but well-designed API survives years of feature requests because each new requirement has somewhere natural to fit.
This chapter is about the API surface of components — what props mean, when to add a variant vs a separate component, when to use compound patterns, and how to write component types that stay compile-time correct as the system grows. We'll redesign the Button from chapter 2 into a proper system component along the way.
The four API patterns
Almost every component you'll design uses one of four patterns or a combination of them. Knowing when to reach for which is most of the skill of component API design.
Pattern 1: Variant props
Discrete styles selected via prop values:
<Button variant="primary" size="md">Save</Button>
<Button variant="secondary" size="sm">Cancel</Button>
<Button variant="ghost" size="lg">More</Button>This is the right pattern when:
- The component has a closed, finite set of visual variations
- All variations share the same structure (a button is always one rectangle with text)
- The variations differ in style, not behaviour
Almost every "primitive" component in a design system uses this pattern: Button, Badge, Input, Alert, Toast. It's the default — start here.
Pattern 2: Compound components
Multiple components that work together as a unit, with shared state managed by a parent:
<Tabs defaultValue="account">
<Tabs.List>
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="password">Password</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="account">…</Tabs.Panel>
<Tabs.Panel value="password">…</Tabs.Panel>
</Tabs>This is the right pattern when:
- The component has multiple related sub-elements with different behaviour
- Consumers need to control the layout/order of those sub-elements
- A "props for everything" approach would balloon to 15+ props
Compound components are how Radix UI ships almost all its primitives. They're more verbose than variant-prop components, but they scale to genuinely complex UI without the prop explosion. Dialog, DropdownMenu, Tabs, Accordion, Select should almost always be compound.
Pattern 3: Composition with asChild (slot pattern)
A component delegates its rendering to a child element while keeping its behaviour:
{/* Default: renders a <button> */}
<Button>Save</Button>
{/* Renders an <a> while keeping Button's styles + behaviour */}
<Button asChild>
<Link href="/settings">Settings</Link>
</Button>This is the right pattern when:
- The component's visual appearance is its identity but consumers might need it to be different DOM elements
- Buttons that need to navigate (and thus be
<a>tags), inputs that need to be<textarea>sometimes, etc.
Radix popularised this with Slot — a component that merges its props into its single child. It's the right escape hatch for "this is a Button, but sometimes it's a link."
Pattern 4: Render props / function children
A component exposes its internal state to consumers via a function:
<Disclosure>
{({ isOpen }) => (
<>
<Disclosure.Button>{isOpen ? "Hide" : "Show"} details</Disclosure.Button>
<Disclosure.Panel>…</Disclosure.Panel>
</>
)}
</Disclosure>Use this sparingly. It's powerful but dense, and most modern systems prefer compound components instead. Render props are appropriate when the state genuinely needs to be threaded into custom consumer logic that compound components can't express.
Building the running example: Button
Let's redesign the chapter 2 Button properly. Starting requirements:
- Variants:
primary,secondary,ghost,destructive(visual style) - Sizes:
sm,md,lg - Loading state, disabled state, optional icon
- Should support being rendered as a different element (link button)
Step 1: Variant props with cva
The class-variance-authority (cva) library is the modern way to express variants in Tailwind. It's small (1KB), typed, and used by shadcn/ui internally.
import { cva, type VariantProps } from "class-variance-authority";
const buttonVariants = cva(
// Base styles applied to all variants
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none",
{
variants: {
variant: {
primary: "bg-brand text-white hover:bg-brand-hover focus-visible:ring-brand",
secondary: "border border-gray-300 text-gray-700 hover:bg-gray-50 focus-visible:ring-brand",
ghost: "text-gray-700 hover:bg-gray-100 focus-visible:ring-brand",
destructive: "bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-base",
lg: "h-12 px-6 text-lg",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
type ButtonVariants = VariantProps<typeof buttonVariants>;cva does three useful things at once:
- Defines the variant classes in one place (no scattered ternaries in JSX).
- Generates a TypeScript type (
VariantProps) that you can use on the component's props. - Picks the right combination of classes based on the variant + size you pass in.
Step 2: The component
import { Slot } from "@radix-ui/react-slot";
import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/cn";
import { buttonVariants } from "./button-variants";
import type { VariantProps } from "class-variance-authority";
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
leftIcon?: React.ReactNode;
}
export function Button({
className,
variant,
size,
asChild = false,
loading = false,
disabled,
leftIcon,
children,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
data-loading={loading}
{...props}
>
{loading ? (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
) : leftIcon ? (
<span className="mr-2 flex">{leftIcon}</span>
) : null}
{children}
</Comp>
);
}A few decisions worth calling out:
asChildvia Radix'sSlot— gives consumers the escape hatch to render as<a>or any other element while keeping all the button styles and props.disabled || loading— a loading button is also functionally disabled, but we want to track the two semantically distinct states. Thedata-loadingattribute lets stylesheets and tests target it.cnutility — the classicclsx + tailwind-mergecombination, so consumer-suppliedclassNameoverrides default classes correctly.leftIconas a discrete slot — better than letting consumers render whatever insidechildren, because we can space it consistently.
Step 3: Usage
<Button>Save</Button>
<Button variant="secondary" size="sm">Cancel</Button>
<Button variant="destructive" loading>Deleting…</Button>
<Button asChild>
<Link href="/settings">Open settings</Link>
</Button>
<Button leftIcon={<PlusIcon className="h-4 w-4" />}>Add task</Button>This is roughly the API shadcn/ui ships. It's not the most flexible button possible — but it's the right amount of flexibility for 95% of use cases, and the rest can be solved with asChild or className overrides.
When variants stop being enough
The Button above is closed: a finite list of variants and sizes. That's the correct shape for atoms. Compound patterns become necessary when the component has multiple sub-elements with different behaviour.
Take Dialog. The variant-props approach is:
{/* This API gets ugly fast */}
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
title="Delete account"
description="This action cannot be undone."
primaryActionLabel="Delete"
onPrimaryAction={handleDelete}
secondaryActionLabel="Cancel"
onSecondaryAction={() => setIsOpen(false)}
primaryDestructive
showCloseButton
size="md"
/>Six props in, you can already feel the weight. Twelve months in, this prop list will be 25 long.
The compound version:
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Delete account</Dialog.Title>
<Dialog.Description>This action cannot be undone.</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="ghost" onClick={() => setIsOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={handleDelete}>Delete</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>More verbose? Yes. But the layout is now in the consumer's hands. Want a third action? Add another <Button>. Want a custom header? Replace <Dialog.Header> with whatever. The component never has to grow a tertiaryActionLabel prop.
The rule of thumb: once a component has more than two semantically-distinct sub-regions, switch to compound.
The props explosion anti-pattern
Three signs your component API has gone wrong:
1. Boolean prop accumulation
<Card
bordered
rounded
shadowed
hoverable
selectable
collapsible
draggable
loading
disabled
/>Each boolean is a binary visual switch. Eight booleans = 256 combinations, of which maybe 12 are intentional. The rest are bugs waiting to be discovered.
Refactor: turn the booleans into a discriminated variant or break the component apart. A Card that's bordered + rounded + shadowed is one variant; bordered + rounded (no shadow) is another. Name them.
2. Props that contradict each other
<Button primary secondary /> {/* which is it? */}
<Input small large />
<Modal open closed />The compiler can't help you. Your team won't either. Refactor: any time you have multiple booleans that represent positions on the same axis, replace with a single string-typed prop (variant: "primary" | "secondary").
3. Render-prop everything
<Table
data={rows}
renderRow={...}
renderHeader={...}
renderCell={...}
renderEmpty={...}
renderLoading={...}
renderError={...}
renderFooter={...}
/>If half your props are functions, you've reinvented compound components badly. Refactor: <Table.Header>, <Table.Body>, <Table.Row>, <Table.Empty> as compound subcomponents.
Polymorphism: the as prop
A pattern with a clear name and a clear cost. as lets a component render as a different element:
<Heading as="h1">…</Heading>
<Heading as="h2">…</Heading>
<Text as="p">…</Text>
<Text as="span">…</Text>This is appropriate for components that exist because of their styling, where the underlying HTML element varies by usage (typography components are the classic example). For these, as is a clean API.
The cost is in the types — making as correctly typed (so consumers get IntelliSense for <Heading as="h1"> knowing it's an h1) requires generic prop forwarding that's notoriously difficult in TypeScript. Most teams either use a library (react-polymorphic-types) or accept that as typing has rough edges.
For most components — buttons, inputs, dialogs — prefer asChild over as. asChild doesn't need polymorphic typing because the consumer brings their own element (with its own types). It's the cleaner pattern in 2026.
Strict TypeScript: the API contract in code
The TypeScript types for a component are part of its API. Make them strict.
Use string unions for variant-like props
// Good: discriminated union, IntelliSense suggests valid values
type Variant = "primary" | "secondary" | "ghost";
// Bad: any string compiles
type Variant = string;Make required props required
// Good: TypeScript blocks <Input /> with no value or onChange
interface InputProps {
value: string;
onChange: (next: string) => void;
}
// Bad: optional everything → consumers can build broken inputs
interface InputProps {
value?: string;
onChange?: (next: string) => void;
}Use discriminated unions for mutually-exclusive prop combinations
// Either show a tooltip OR not — never both
type ButtonProps = (
| { tooltip: string; "aria-label"?: never }
| { tooltip?: never; "aria-label"?: string }
) & {
/* shared props */
};Discriminated unions catch real bugs at the type level — combinations of props that don't make sense in production simply won't compile in dev.
Going deeper. A useful design exercise: write the
interface ButtonPropsbefore writing any of the component code. The shape of the props forces you to articulate what the component is and isn't. If you can't write 8 props that fully specify the component without overlap, the component itself is probably trying to do too much.
API stability — the unspoken contract
The moment a component is consumed by another team's code, its props are an API. Same rules apply as backend APIs:
- Adding optional props is non-breaking. Existing consumers keep working.
- Adding a required prop is breaking. Every consumer needs to update.
- Removing a prop is breaking. Even if "nobody's using it" — somebody is.
- Renaming a prop is breaking. This is a rename + deprecation window, not a single change.
- Changing the meaning of a prop is breaking, and worse than removing it because it silently breaks consumers.
Chapter 5 covers this in depth — semver applied to design systems, deprecation policies, codemods. For now, internalize: once a component's props ship, they're a contract you owe consumers.
A component-API checklist
Before you ship a new component:
- API pattern picked deliberately — variant props, compound,
asChild, render-props — not "I just started typing." - Variants are discriminated string unions, not boolean accumulations.
- No more than ~6 props on any single sub-component. More than that, refactor toward compound.
asChildavailable if the component might need to render as a link or different element.- TypeScript types are strict. Required props are required. Mutually-exclusive props are a discriminated union.
- Disabled, loading, focus, hover states all designed and tested.
cnutility used forclassNamemerging — consumers can override.- Component ships with at least 3 example usages in Storybook (chapter 4) showing the variants.
What you should walk away with
- Four API patterns — variant props, compound,
asChild, render props — cover ~95% of components. Pick deliberately; don't drift in by accident. - Variant props are the default for atoms (Button, Badge, Input). Use
cvafor the implementation. - Compound components scale to complex UIs (Dialog, Tabs, Menu) without prop explosion. Switch to compound the moment a component has >2 semantic sub-regions.
asChildis the modern escape hatch for "render as a different element." Prefer it overasfor non-typography components.- TypeScript types are the API contract. Strict types catch real bugs and document the component for free.
In chapter 4 we move from designing components in isolation to displaying and testing them: Storybook, the canonical workflow, and what makes a story collection that earns its keep.
Next up — Chapter 4: Storybook in Practice — Documentation, Testing, and Living Specs. Why every well-run design system has a Storybook, and what makes the difference between a Storybook that gets used and one that gets abandoned.