Skip to content

Component Library Architecture

advanced25 min read

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.

Mental Model

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.

Quiz
You need to add a 'Recently Used' section to the top of a select dropdown, with a divider between it and the main list. With compound components, how do you implement this?

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

LibraryApproachComponent CountBest For
Radix UIUnstyled compound components with CSS variable-based animation hooks30+ primitivesTeams that want maximum composition with minimal friction
React Aria (Adobe)Hooks that return ARIA props — you attach to your own elements40+ hooksTeams that need total DOM control and complex accessibility
Headless UI (Tailwind)Unstyled components designed for Tailwind CSS10 core componentsTailwind-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.

Quiz
When should you choose React Aria hooks over Radix UI compound components?

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;
}
Key Rules
  1. 1Support both controlled (value + onChange) and uncontrolled (defaultValue) for every stateful component
  2. 2Use compound components for complex UI with variable layouts — they compose, props do not
  3. 3Headless libraries provide behavior and a11y — you provide all styling
  4. 4Radix for composition-friendly APIs, React Aria for total DOM control
  5. 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.

Common Trap

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}
      />
    );
  }
);
Quiz
A consumer wants to add a tooltip to your Button component but your Button does not accept a ref. What happens?
What developers doWhat 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.