Skip to main content
Back to module
Front-End Engineering · Quality at Scale

Component libraries

Reading35mLesson 1 of 3 in this module

Build a reusable, accessible button with a small, predictable API.

Why it matters

Every product team eventually notices it has eleven slightly different buttons, four modal implementations and three shades of "primary blue". A component library (the code half of a design system) fixes this: one well-built Button, Input or Modal that everyone uses. Fix a focus bug once and it is fixed on every page. As a mid-level front-end developer you will both consume a component library and contribute to it, so you need to know what makes a component good to reuse.

What a design system contains

  • Design tokens: named values for colour, spacing, radius, type and motion, such as --color-primary or --space-4. Components use tokens, never raw hex codes, so a theme or dark mode is a token swap.
  • Primitives: Button, Input, Select, Checkbox, Modal, Tabs, Tooltip. They are accessible and unopinionated about business logic.
  • Patterns and docs: how to compose primitives into forms, empty states and page headers, usually documented in a tool such as Storybook with live examples.

You do not always build primitives from scratch. Headless libraries (Radix UI, React Aria, Headless UI) supply the hard keyboard and ARIA behaviour; your library adds the styling and the API your team needs.

A good component API

A reusable component is a contract. Good contracts are small, predictable and hard to misuse.

  1. Start from the native element. A button is a <button type="button">, not a div with a click handler. You get focus, Enter and Space, disabled behaviour and the right role for free.
  2. Variants, not style props. Offer variant: "primary" | "secondary" | "danger" and size: "sm" | "md", mapped to classes. Avoid color and padding props that let every caller reinvent the design.
  3. Pass through native attributes. In React, spread the rest of the props onto the element (...rest) and forward the ref, so callers can add aria-describedby, form, data-testid or focus it programmatically.
  4. Make accessibility required where it matters. An icon-only button has no visible text, so its accessible name must come from somewhere. Make aria-label required for that case, in the types and with a development-time error.
  5. Visible focus by default. Never ship outline: none without a :focus-visible replacement.
  6. Consistent states. Hover, active, focus-visible, disabled and loading look the same on every variant and are driven by tokens.

In React and TypeScript the shape looks like this:

type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
  variant?: "primary" | "secondary";
  icon?: React.ReactNode;
} & ({ children: React.ReactNode } | { children?: never; "aria-label": string });

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  function Button({ variant = "primary", icon, children, className, type = "button", ...rest }, ref) {
    return (
      <button ref={ref} type={type} className={cx("btn", `btn--${variant}`, className)} {...rest}>
        {icon && <span aria-hidden="true">{icon}</span>}
        {children}
      </button>
    );
  }
);

The union at the end says: either you pass visible children, or you pass an aria-label. TypeScript then refuses <Button icon={<XIcon />} /> with no name.

Disabled, loading and links

  • Disabled. The native disabled attribute removes the button from the tab order and blocks clicks. If users need to discover why an action is unavailable, consider keeping it focusable with aria-disabled="true" and explaining the reason nearby.
  • Loading. Keep the button's width stable (no layout shift), show a spinner with an accessible status message, and prevent double submits.
  • Links that look like buttons. Navigation is a link (<a href>); actions are buttons. A library often lets Button render an anchor when given href, keeping one visual style with the right semantics.

Versioning and change

A shared component is used in hundreds of places, so changes need care:

  • Add props in a backward-compatible way; deprecate before removing.
  • Use semantic versioning if the library is a package: breaking changes bump the major version.
  • Cover each component with tests (role, name, keyboard, states) and visual examples, so a refactor does not quietly break focus rings everywhere.

Common mistakes

  • Div buttons that cannot be reached by keyboard.
  • Unlimited style props (bg, px, rounded), which turn the library into an inline-style generator.
  • Swallowing native props, so callers cannot add aria-* attributes or a ref.
  • Icon buttons with no name, announced only as "button".
  • Business logic inside primitives, such as a Button that checks user permissions.
  • Hard-coded colours instead of tokens, which breaks dark mode.

Quick recap

  • A component library gives one accessible, tested implementation of each primitive, styled with tokens.
  • Build on native elements, expose variants rather than style props, pass through native attributes and refs.
  • Make accessible names mandatory for icon-only controls and always provide a visible focus style.
  • Treat changes as API changes: backward compatible, versioned and tested.

Try it

In the Do tab, turn a div-based button into a reusable createButton factory with variants, an icon-only mode that requires a label, and a proper focus style.

Also in this lesson: