What you'll learn
- Build a reusable, accessible button
- Explain what design tokens, primitives and documented patterns contribute to a design system
- Design a small component API with variants, passed-through attributes and required names
- Change shared components safely with backward-compatible, versioned, tested updates
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 libraryThe code half of a design system: one well-built, shared implementation of each component such as Button or Modal. (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 tokensNamed values for colour, spacing, radius, type and motion that components use instead of raw values.: named values for colour, spacing, radius, type and motion, such as
--color-primaryor--space-4. Components use tokens, never raw hex codes, so a theme or dark mode is a token swap. - PrimitivesBasic, accessible building-block components such as Button, Input and Modal that hold no business logic.: 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 librariesLibraries such as Radix UI or React Aria that supply keyboard and ARIA behaviour without styling. (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.
- Start from the native element. A button is a
<button type="button">, not adivwith a click handler. You get focus, Enter and Space, disabled behaviour and the right role for free. - Variants, not style props. Offer
variant: "primary" | "secondary" | "danger"andsize: "sm" | "md", mapped to classes. Avoidcolorandpaddingprops that let every caller reinvent the design. - Pass through native attributes. In React, spread the rest of the props onto the element (
...rest) and forward the ref, so callers can addaria-describedby,form,data-testidor focus it programmatically. - Make accessibility required where it matters. An icon-only button has no visible text, so its accessible name must come from somewhere. Make
aria-labelrequired for that case, in the types and with a development-time error. - Visible focus by default. Never ship
outline: nonewithout a:focus-visiblereplacement. - Consistent states. Hover, active, focus-visible, disabled and loading look the same on every variantA named style option on a component, such as primary or secondary, mapped to classes instead of free-form style props. 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
disabledattribute removes the button from the tab order and blocks clicks. If users need to discover why an action is unavailable, consider keeping it focusable witharia-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 letsButtonrender an anchor when givenhref, 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.
Key terms (5)
- component library
- The code half of a design system: one well-built, shared implementation of each component such as Button or Modal.
- Design tokens
- Named values for colour, spacing, radius, type and motion that components use instead of raw values.
- Primitives
- Basic, accessible building-block components such as Button, Input and Modal that hold no business logic.
- Headless libraries
- Libraries such as Radix UI or React Aria that supply keyboard and ARIA behaviour without styling.
- variant
- A named style option on a component, such as primary or secondary, mapped to classes instead of free-form style props.