Building component libraries that work for one engineer is easy. Building ones that hold up across a team of ten, spread across multiple products, with varying design requirements — that's where most libraries start to crack.
Here's how I think about it.
Start with the consumer, not the implementation
The most common mistake I see is designing component APIs based on how they're implemented internally rather than how they'll be used. Before writing a single line of component code, I write the usage first.
// What do I *want* this to look like?
<Card variant="elevated" size="md">
<Card.Header>
<Card.Title>Project Alpha</Card.Title>
<Card.Badge status="active" />
</Card.Header>
<Card.Body>
<p>Some content here</p>
</Card.Body>
</Card>If that usage feels clean and readable, the API is probably right. If it feels awkward, the API needs work — before the implementation exists.
Composition over configuration
A single <Button> component that accepts 40 props is a sign you've conflated composition with configuration. Instead of:
<Button
icon="arrow-right"
iconPosition="right"
loading={isLoading}
loadingText="Saving..."
size="lg"
variant="primary"
fullWidth
>
Save Changes
</Button>Prefer:
<Button size="lg" variant="primary" className="w-full" disabled={isLoading}>
{isLoading ? "Saving..." : <>Save Changes <ArrowRight /></>}
</Button>The second version is more code at the call site, but it's more flexible, easier to understand, and doesn't require you to document (or remember) a prop API for every edge case.
The asChild pattern
One of the most powerful patterns I've adopted from Radix UI is asChild. It lets consumers change the underlying element without losing the component's styling and behavior.
<Button asChild>
<Link href="/dashboard">Go to Dashboard</Link>
</Button>This renders a <a> tag with all the button styles applied, rather than a <button> wrapping a <a> — which is invalid HTML and causes accessibility issues.
Implementation is straightforward using the Slot primitive from Radix:
import { Slot } from "@radix-ui/react-slot";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
variant?: "primary" | "secondary" | "ghost";
}
export function Button({ asChild, variant = "primary", ...props }: ButtonProps) {
const Comp = asChild ? Slot : "button";
return <Comp className={buttonVariants({ variant })} {...props} />;
}Variant systems with CVA
Once you have more than two or three variants, managing the class combinations gets messy. class-variance-authority solves this cleanly:
import { cva } from "class-variance-authority";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-lg font-medium transition-colors",
{
variants: {
variant: {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4",
lg: "h-12 px-6 text-lg",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);This gives you full TypeScript inference on the variant props, auto-generated classes, and a single source of truth for all the permutations.
When to abstract
Not everything needs to be a component. I use a simple heuristic: if I've copy-pasted the same JSX structure three times with only the data changing, it's time to extract. If I've only done it twice, I wait.
Premature abstraction is worse than duplication. Three similar files is better than one component with six props added over six months to handle every caller's edge case.
The goal isn't a component library that handles every possible case. It's one that makes the common case trivial and the uncommon case possible without bending the API out of shape.