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-primaryor--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.
- 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 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
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.