ReactDesign SystemsTypeScriptAccessibility

Building a Reusable UI Component Library

By Ezequiel Carrizo
Ezquiel, the author Picture
Published on
Design tools and UI components

Every project sooner or later faces the same question: do we use an off-the-shelf component library, or build our own? For a recent project, I chose to build from scratch. Not because I wanted to reinvent the wheel, but because the specific requirements around accessibility, composability, and branding made existing solutions a poor fit. Here is what I learned.

Hi there! Want to support me?

Start with the API, Not the Implementation

The most important decision in a component library is the developer experience of using it. Before writing a single component, I designed the API: how would a developer compose a form, a dialog, a table? What props are required, what are optional, and what should be impossible to get wrong?

I landed on a compound component pattern for complex UIs. Instead of a single component with dozens of props, you compose smaller, focused components that share context implicitly.

// Compound component pattern for building flexible UIs
function Card({ children, className }: CardProps) {
return <div className={clsx('rounded-lg shadow', className)}>{children}</div>
}
function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="border-b p-4">{children}</div>
}
function CardBody({ children }: { children: React.ReactNode }) {
return <div className="p-4">{children}</div>
}
Card.Header = CardHeader
Card.Body = CardBody
// Usage
<Card>
<Card.Header>
<h2>Title</h2>
</Card.Header>
<Card.Body>
<p>Content goes here</p>
</Card.Body>
</Card>

This pattern has been popularized by libraries like Radix UI and Headless UI, and for good reason. It gives the consumer full control over markup while the library handles logic and accessibility.

Polymorphism with TypeScript

A button should be able to render as a <button>, an <a>, or even a Next.js <Link> component without losing type safety. This requires polymorphic components with proper generic typing.

// Polymorphic component that renders as any HTML element
type ButtonProps<T extends React.ElementType = 'button'> = {
as?: T
variant?: 'primary' | 'secondary'
} & React.ComponentPropsWithoutRef<T>
function Button<T extends React.ElementType = 'button'>({
as,
variant = 'primary',
className,
...props
}: ButtonProps<T>) {
const Component = as || 'button'
return (
<Component
className={clsx(
'rounded px-4 py-2 font-medium',
variant === 'primary' && 'bg-blue-600 text-white',
variant === 'secondary' && 'bg-gray-200 text-gray-900',
className
)}
{...props}
/>
)
}
// Renders as <button>
<Button onClick={handleClick}>Click me</Button>
// Renders as <a>
<Button as="a" href="/learn-more">Learn more</Button>

The key is using React.ElementType as a generic constraint and spreading ComponentPropsWithoutRef <T> to get proper prop inference for the rendered element. This gives you a component that is as flexible as native HTML with full TypeScript support.

Hi there! Want to support me?

Accessibility from Day One

Retrofitting accessibility is painful. Baking it in from the start is surprisingly easy. Every interactive component in the library includes proper ARIA attributes, keyboard navigation, focus management, and screen reader support.

The library has 27 components covering forms, navigation, dialogs, data display, and layout. Each one handles focus traps for modals, ARIA labels for icon buttons, and keyboard interactions for dropdowns. These are not nice-to-haves — they are table stakes for a production UI.

Distribution Matters

The component library lives in the same monorepo as the consuming apps, so distribution is a TypeScript path alias rather than an npm publish. This means instant iteration: change a component in the library, see the result in the app immediately. No version bumps, no linking, no waiting.

Build vs Buy: When to DIY

Building your own component library is a significant investment. It only makes sense when you have specific requirements that off-the-shelf solutions do not meet, and when you have the bandwidth to maintain it. For most projects, a library like Radix UI or Headless UI will get you 90% there. But if you need that last 10% — custom design tokens, specific accessibility patterns, or deep integration with your app's state management — building your own can be the right call.