Building a component library isn't about creating a design system from scratch—it's about establishing a shared language across your product that scales as your team grows. By pairing headless primitives with variant-driven styling and living documentation, you can ship consistent, accessible UI components that your team actually wants to use. In this post, I'll walk through the patterns that work, the tools that matter, and the mistakes I've seen derail this effort.
Why a component library matters in production
Over my 5+ years building platforms for e-commerce, CRM, and SaaS products, I've seen the cost of ad-hoc UI decisions. Two years in, you have buttons that behave differently in your app than in your admin panel. Your forms handle errors inconsistently. Keyboard navigation works in some places but not others. Accessibility audits become nightmares.
A component library solves this by establishing a single source of truth. Every button, input, modal, and dropdown is built once, tested once, and reused everywhere. That consistency isn't just nice for users—it's essential for teams shipping fast without technical debt.
Start from headless primitives
The first mistake I see teams make is building components from scratch and then bolting on accessibility later. You'll end up refactoring focus management, keyboard handling, and ARIA attributes months later when you realize your custom dropdown doesn't work with screen readers.
Instead, start with headless primitives. Libraries like Radix UI and React Aria handle the hard parts: focus trapping, keyboard event delegation, ARIA labeling, and browser quirks. They provide unstyled components you compose into your design system.
Here's what I mean by headless. Radix gives you the behavior:
import * as Dialog from '@radix-ui/react-dialog';
export function Modal({ open, onOpenChange, children }) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Trigger asChild>
<button>Open</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
{children}
<Dialog.Close />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
You own the styling. Radix handles focus trap, overlay click behavior, ESC key dismissal, and proper ARIA attributes. That's the division of labor that makes scaling sustainable.
Practical tip: Spend a week evaluating Radix or React Aria for your use cases. If you're building web-first, Radix covers most patterns. If you need multi-platform, React Aria gives you iOS/Android primitives alongside web components.
Variants with cva—Typed, scalable styling
Once you've picked your primitive library, you need a way to compose styling consistently. This is where cva (class variance authority) shines. It lets you define component variants as data, generate class combinations, and get TypeScript safety for free.
I'll show you a real button component I've used in production:
import { cva, type VariantProps } from 'class-variance-authority';
import { forwardRef } from 'react';
const buttonStyles = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed',
{
variants: {
intent: {
primary: 'bg-black text-white hover:bg-gray-800 focus:ring-black',
secondary: 'bg-gray-200 text-black hover:bg-gray-300 focus:ring-gray-400',
ghost: 'bg-transparent text-black hover:bg-gray-100 focus:ring-gray-400',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-600',
},
size: {
sm: 'h-8 px-3 text-sm gap-2',
md: 'h-10 px-4 text-base gap-2',
lg: 'h-12 px-6 text-lg gap-3',
},
fullWidth: {
true: 'w-full',
false: 'w-auto',
},
},
defaultVariants: {
intent: 'primary',
size: 'md',
fullWidth: false,
},
}
);
type ButtonVariants = VariantProps<typeof buttonStyles>;
interface ButtonProps extends ButtonVariants {
asChild?: boolean;
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ intent, size, fullWidth, children, asChild, type = 'button', ...props },
ref
) => {
const Comp = asChild ? 'span' : 'button';
return (
<Comp
ref={ref}
className={buttonStyles({ intent, size, fullWidth })}
type={type}
{...props}
>
{children}
</Comp>
);
}
);
Button.displayName = 'Button';
What makes this powerful:
- Type safety. TypeScript knows that
intent="invalid"is an error. Your IDE autocompletes valid combinations. - Single source of truth. Every button in your app uses this one definition. Change a color, it updates everywhere.
- Compound defaults. You define sensible defaults (
primaryintent,mdsize), so 80% of uses are concise:<Button>Click me</Button>. - Easy auditing. Want to know every button style in your app? Read the variants object.
I've shipped this pattern across trading platforms, ride-hailing apps, and SaaS products. It scales from 10 components to 100+.
Document in Storybook—The living spec
A component library without documentation is just code that lives in node_modules. Storybook is the standard for this, and it's worth the setup investment.
Every component and every variant gets a story. Here's how I document the Button:
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta = {
component: Button,
tags: ['autodocs'],
argTypes: {
intent: {
control: 'select',
options: ['primary', 'secondary', 'ghost', 'danger'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
fullWidth: { control: 'boolean' },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
intent: 'primary',
children: 'Primary Button',
},
};
export const Secondary: Story = {
args: {
intent: 'secondary',
children: 'Secondary Button',
},
};
export const AllSizes: Story = {
render: () => (
<div className="flex gap-4">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
),
};
export const Disabled: Story = {
args: {
children: 'Disabled Button',
disabled: true,
},
};
Why Storybook matters:
- Designers and QA can see every state without running your app.
- New team members learn your patterns in one place.
- You can test accessibility, visual regression, and interactions in isolation.
- It's a communication tool. "Does this match Storybook?" ends a lot of debates.
I host Storybook in CI and link it in PRs. "See it live here" closes a lot of review cycles early.
Own the code—Copying in vs. building from scratch
Here's a tactical decision that changes how your library evolves: copying components in vs. pulling from an external package.
shadcn/ui popularized the copy-in pattern. Instead of npm install shadcn-button, you run npx shadcn-ui@latest add button, and it copies source code into your repo. You own every line. You can modify it without waiting for upstream changes.
I've used both approaches in production:
External package approach (pros and cons):
- Pro: Centralized updates. Bug fix released, everyone gets it on upgrade.
- Con: Your component surface area is frozen. Need to add a variant? Fork it or wait for a PR.
- Con: Version mismatch headaches. Your payment SaaS uses Button v2, your admin uses Button v3, they behave differently.
Copy-in approach (pros and cons):
- Pro: Full control. Add variants, change styling, adjust ARIA without asking permission.
- Pro: Auditable. Every component is in your codebase. Security review is straightforward.
- Con: Maintenance burden. If Radix fixes a focus bug, you need to cherry-pick it into your copy.
- Con: Repo bloat. 100 components × 50 lines each = 5,000 lines of component code.
My recommendation: Copy in your core primitives (Button, Input, Modal, Dropdown), build the rest custom. This gives you control where it matters most (your most-used components) and keeps repo size reasonable. Use a monorepo structure so components live in a dedicated packages/ui package.
Common mistakes
1. Skipping accessibility from the start. I've seen teams ship 30 components, then discover focus management is broken everywhere. Headless primitives aren't optional—they're table stakes.
2. Over-engineering variants. You don't need a variant for every possible state. Stick to semantic ones: intent (primary, secondary, danger), size (sm, md, lg), state (loading, disabled). Compound them instead of creating primarySmLoading.
3. Treating the library as a side project. It needs a single owner—someone who reviews PRs, maintains Storybook, decides what gets merged. Without that, it becomes a dumping ground and loses credibility.
4. Not versioning or documenting breaking changes. If you change Button's API, document it. Use semantic versioning. Let teams upgrade on their schedule.
5. Skipping visual regression testing. Pair Storybook with Chromatic or Percy. Automate the "does this still look right?" question. I've caught color drift, spacing shifts, and regressions before they shipped.
Key takeaways
- Start with headless primitives (Radix UI or React Aria) to handle focus, keyboard, and ARIA. Don't reinvent this.
- Use cva to define variants as data. Combine type safety, scalability, and auditability in one pattern.
- Document every component and variant in Storybook. It's your team's single source of truth.
- Copy in core primitives for full control; build the rest custom. This balances maintainability and ownership.
- Assign a single owner. A library without a maintainer becomes a liability.
- Add visual regression testing early. Catch drifts automatically, not in production.






Comments
Loading comments…