Component Library Architecture
The Architecture Decision That Shapes Everything
Building a component library is not about making buttons pretty. It is an API design problem. The decisions you make about component structure on day one determine whether your library is a joy to use at 100 components or a nightmare to maintain at 20.
Here is the core tension: flexibility vs. simplicity. A component that does everything is complicated to use. A component that does one thing is simple but inflexible. The best component libraries resolve this tension through composition — small, focused pieces that combine naturally.
Think of component architecture like LEGO. A LEGO set has two kinds of pieces: basic bricks (2x4, 1x1) and specialized pieces (wheels, windows, minifig heads). Basic bricks compose infinitely — you can build anything. Specialized pieces solve specific problems elegantly but are limited. The best component libraries provide both: composable primitives for custom layouts, and pre-assembled components for common patterns.
Compound Components Pattern
The compound component pattern splits a complex component into multiple sub-components that work together through shared context. Think of HTML's native <select> and <option> — they are useless alone, but together they form a complete dropdown.
The Problem
Here is the typical "kitchen sink" approach:
<Select
label="Country"
placeholder="Select a country"
options={countries}
value={selected}
onChange={setSelected}
renderOption={(option) => (
<div className="flex items-center gap-2">
<Flag code={option.code} />
{option.name}
</div>
)}
renderValue={(option) => option.name}
isMulti={false}
isSearchable={true}
isDisabled={false}
isClearable={true}
menuPlacement="bottom"
maxMenuHeight={300}
noOptionsMessage="No countries found"
loadingMessage="Loading..."
/>
Seventeen props. Render props for customization. Boolean flags for features. This API surface is massive, hard to remember, and painful to extend. Want to add a section divider between options? New prop. Want to group options? Another prop. Every new feature bloats the API.
The Compound Components Solution
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger>
<SelectValue placeholder="Select a country" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>North America</SelectLabel>
<SelectItem value="us">
<Flag code="us" /> United States
</SelectItem>
<SelectItem value="ca">
<Flag code="ca" /> Canada
</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Europe</SelectLabel>
<SelectItem value="gb">
<Flag code="gb" /> United Kingdom
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
Same result, but the structure is explicit in JSX. You can see the groups, separators, and custom content. Adding a section divider is not a new prop — it is dropping <SelectSeparator /> where you want it. Adding an icon is not a renderOption prop — it is putting an icon next to the text.
How Compound Components Share State
The sub-components communicate through React Context, not prop drilling:
const SelectContext = createContext<SelectContextValue | null>(null);
function Select({ value, onValueChange, children }: SelectProps) {
const [open, setOpen] = useState(false);
const contextValue = useMemo(
() => ({ value, onValueChange, open, setOpen }),
[value, onValueChange, open]
);
return (
<SelectContext value={contextValue}>
{children}
</SelectContext>
);
}
function SelectItem({ value, children }: SelectItemProps) {
const ctx = use(SelectContext);
if (!ctx) throw new Error('SelectItem must be used within Select');
const isSelected = ctx.value === value;
return (
<div
role="option"
aria-selected={isSelected}
onClick={() => {
ctx.onValueChange(value);
ctx.setOpen(false);
}}
>
{children}
</div>
);
}
Each sub-component reads what it needs from context. SelectItem knows about selection state. SelectTrigger knows how to toggle the dropdown. SelectContent knows whether to show or hide. They work together without knowing about each other directly.
Headless Components
Headless components take compound components one step further: they provide behavior, accessibility, and state management with zero styling. You bring all the visuals.
Why Headless?
The problem with styled component libraries is lock-in. You adopt a library, it looks great initially, then your designer creates something that does not fit the library's visual model. Now you are fighting the library's styles instead of leveraging them.
Headless components solve this by separating concerns completely:
- Behavior layer (keyboard navigation, focus management, ARIA) — provided by the library
- State management (open/closed, selected value, active item) — provided by the library
- Visual layer (colors, spacing, layout, animation) — entirely yours
The Big Three Headless Libraries
| Library | Approach | Component Count | Best For |
|---|---|---|---|
| Radix UI | Unstyled compound components with CSS variable-based animation hooks | 30+ primitives | Teams that want maximum composition with minimal friction |
| React Aria (Adobe) | Hooks that return ARIA props — you attach to your own elements | 40+ hooks | Teams that need total DOM control and complex accessibility |
| Headless UI (Tailwind) | Unstyled components designed for Tailwind CSS | 10 core components | Tailwind-first teams that want quick, accessible components |
Radix UI Example
import * as Dialog from '@radix-ui/react-dialog';
function ConfirmDialog({ onConfirm }: { onConfirm: () => void }) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button className="rounded-lg bg-red-600 px-4 py-2 text-white">
Delete Account
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-xl">
<Dialog.Title className="text-lg font-semibold">
Are you sure?
</Dialog.Title>
<Dialog.Description className="mt-2 text-gray-600">
This action cannot be undone.
</Dialog.Description>
<div className="mt-4 flex justify-end gap-3">
<Dialog.Close asChild>
<button className="rounded-lg px-4 py-2">Cancel</button>
</Dialog.Close>
<button
onClick={onConfirm}
className="rounded-lg bg-red-600 px-4 py-2 text-white"
>
Delete
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Radix handles: focus trapping, escape-to-close, click-outside-to-close, scroll locking, ARIA attributes, and portal rendering. You handle: every visual detail.
React Aria Example
import { useDialog } from 'react-aria';
import { useOverlayTriggerState } from 'react-stately';
function ConfirmDialog({ onConfirm }: { onConfirm: () => void }) {
const state = useOverlayTriggerState({});
const dialogRef = useRef<HTMLDivElement>(null);
const { dialogProps, titleProps } = useDialog({}, dialogRef);
return (
<>
<button onClick={() => state.open()}>Delete Account</button>
{state.isOpen && (
<div className="fixed inset-0 bg-black/50">
<div {...dialogProps} ref={dialogRef} className="modal">
<h3 {...titleProps}>Are you sure?</h3>
<p>This action cannot be undone.</p>
<button onClick={() => state.close()}>Cancel</button>
<button onClick={onConfirm}>Delete</button>
</div>
</div>
)}
</>
);
}
React Aria gives you hooks that return props — you spread them onto your elements. Maximum control, but more wiring.
Controlled vs. Uncontrolled Components
Every interactive component needs a decision: who owns the state?
// Uncontrolled: component owns its own state
<Accordion defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
// Controlled: parent owns the state
<Accordion value={openItem} onValueChange={setOpenItem}>
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
The best practice: support both. Use the "default value" convention from HTML:
function useControllableState<T>({
value,
defaultValue,
onChange,
}: {
value?: T;
defaultValue: T;
onChange?: (value: T) => void;
}) {
const [internal, setInternal] = useState(defaultValue);
const isControlled = value !== undefined;
const currentValue = isControlled ? value : internal;
const setValue = useCallback(
(next: T | ((prev: T) => T)) => {
const nextValue = typeof next === 'function'
? (next as (prev: T) => T)(currentValue)
: next;
if (!isControlled) setInternal(nextValue);
onChange?.(nextValue);
},
[isControlled, currentValue, onChange]
);
return [currentValue, setValue] as const;
}
- 1Support both controlled (value + onChange) and uncontrolled (defaultValue) for every stateful component
- 2Use compound components for complex UI with variable layouts — they compose, props do not
- 3Headless libraries provide behavior and a11y — you provide all styling
- 4Radix for composition-friendly APIs, React Aria for total DOM control
- 5Never mix controlled and uncontrolled patterns — if value is provided, ignore defaultValue
Polymorphic Components
Sometimes you need a component to render as different HTML elements. A Button might need to be an <a> tag when it has an href. A Text component might need to be a <p>, <span>, or <label>.
The as prop pattern handles this:
type PolymorphicProps<E extends React.ElementType, P = object> = P &
Omit<React.ComponentPropsWithoutRef<E>, keyof P | 'as'> & {
as?: E;
};
function Text<E extends React.ElementType = 'p'>({
as,
className,
...props
}: PolymorphicProps<E, { className?: string }>) {
const Component = as || 'p';
return <Component className={className} {...props} />;
}
// Usage
<Text>Default paragraph</Text>
<Text as="span">Inline text</Text>
<Text as="label" htmlFor="email">Email label</Text>
<Text as="a" href="/about">Link text</Text>
The type system ensures that when as="a" is set, href becomes a valid prop, and when as="label", htmlFor becomes valid. This is full type safety without manual overloads.
The polymorphic as prop has a subtle performance issue: the PolymorphicProps type is complex and can slow down TypeScript's language server in large codebases. If your component library has 50+ polymorphic components, you might notice IDE lag. The alternative is the asChild pattern used by Radix — instead of changing the rendered element, you pass a child element and the component merges its props onto the child. This is simpler to type and avoids the generic gymnastics.
Component API Design Principles
After building and maintaining several large component libraries, here are the principles that separate good APIs from great ones.
1. Minimal Surface Area
Every prop you add is a maintenance commitment. Ask: can this be achieved with composition instead?
// Too many props
<Card
title="Settings"
subtitle="Manage preferences"
icon={<SettingsIcon />}
actions={<Button>Save</Button>}
footer={<Text>Last saved: 2 min ago</Text>}
/>
// Composition instead
<Card>
<CardHeader>
<SettingsIcon />
<div>
<CardTitle>Settings</CardTitle>
<CardDescription>Manage preferences</CardDescription>
</div>
</CardHeader>
<CardContent>...</CardContent>
<CardFooter>Last saved: 2 min ago</CardFooter>
</Card>
2. Sensible Defaults, Easy Overrides
// Good: works great with zero props, customizable when needed
<Button>Submit</Button> // variant="default", size="md"
<Button variant="danger" size="lg">Delete</Button>
// Bad: requires configuration to be useful
<Button variant="primary" size="md" type="button">Submit</Button>
3. Escape Hatches
Always forward className, style, and ref. Never block the consumer from reaching the underlying DOM:
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'default', size = 'md', className, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}
);
| What developers do | What they should do |
|---|---|
| Building a styled component library from scratch instead of using headless primitives Accessibility is incredibly hard to get right. Headless libraries have hundreds of hours of a11y testing baked in. Rebuilding that is wasteful and risky | Start with Radix UI or React Aria for behavior, add your styles on top |
| Using boolean props for variants (isPrimary, isLarge, isOutline) Booleans cannot be mutually exclusive — what does isPrimary + isOutline mean? String unions are self-documenting and prevent invalid combinations | Use string union props (variant='primary', size='lg') |
| Not forwarding ref on wrapper components Without ref forwarding, consumers cannot use tooltips, focus management, measurement, or any library that needs DOM access | Always use forwardRef (or ref prop in React 19) on every component |
| Exposing internal state through imperative handle refs Imperative handles create tight coupling and make components harder to test. Declarative props are predictable and composable | Expose behavior through props and callbacks |
The Slot Pattern: An Alternative to Compound Components
Radix introduced the asChild / Slot pattern as an alternative to the as prop. Instead of polymorphism, you pass a child element and the component merges its behavior onto it:
<Dialog.Trigger asChild>
<MyFancyButton>Open Dialog</MyFancyButton>
</Dialog.Trigger>The Slot component merges props (event handlers, className, aria attributes) from the parent onto the child element. This avoids the TypeScript complexity of polymorphic types while achieving the same result. The pattern is gaining traction and is now used by shadcn/ui, Radix, and other modern libraries.
The key insight: instead of the component deciding what element to render (as="a"), the consumer wraps their own element and the component enhances it with behavior. This is inversion of control applied to component rendering.