Building a Button Component That Handles Every Real-World Variant

The Button is the first component every UI library builds and the one most libraries get wrong. Not because buttons are hard — because the naive implementation handles the happy path and nothing else. Loading states, disabled behavior, icon composition, and rendering as a link all require deliberate decisions. Here's how to make them once and never revisit them.
Every UI library starts with a Button. It seems like the simplest component to build — and it is, until you actually use it in a real product. Then you need a loading state. Then you need it to render as a link. Then you need a destructive variant for a delete confirmation. Then you need an icon-only version with a tooltip.
The naive Button handles none of that. The one we're building handles all of it.
The naive implementation and what it can't handle
Most developers start here:
This works for one use case. In a real product you immediately run into:
- No variant system — every callsite manually applies className
- No loading state — consumers bolt on a spinner externally with inconsistent results
- No TypeScript — no autocomplete, no prop validation
- No ref forwarding — form libraries and focus management can't reach the underlying element
- No asChild pattern — you can't render it as a
<a>or a router<Link>without a separate component
We're going to fix all of that in one component.
Defining the types
Create src/components/Button/Button.types.ts:
Extending React.ButtonHTMLAttributes<HTMLButtonElement> means our Button automatically accepts every valid HTML button attribute — onClick, type, form, aria-label, all of it — without us having to declare them manually. Consumers get the full HTML button API plus our custom props.
VariantProps<typeof buttonVariants> pulls the variant types directly from our CVA definition. When we add a new variant, the types update automatically.
Defining the variants with CVA
In src/components/Button/Button.tsx:
A few decisions worth noting:
The base classes are in an array for readability — CVA flattens them. The disabled:pointer-events-none class prevents click events from firing on a visually disabled button even without the disabled attribute — important for the loading state where we want to visually disable without semantically disabling.
The icon size variant sets equal width and height — used for icon-only buttons that need a square hit target.
The full component
Install the Slot dependency:
What each decision is doing
React.forwardRef — form libraries like react-hook-form and focus management utilities need to access the underlying DOM element. Without forwardRef, the ref is swallowed by our component and never reaches the <button>. Every component in this library that wraps a DOM element will use forwardRef.
asChild with Slot — when asChild is true, Slot merges our props and className onto whatever child element is passed. This lets consumers render the Button as a router Link without a separate ButtonLink component:
The Link gets all the Button styles and behaviour. No wrapper div. No extra component to maintain.
disabled || isLoading — when loading, the button is functionally disabled. We pass this to the HTML disabled attribute so the browser treats it correctly. We also set aria-busy so screen readers announce the loading state.
aria-busy — tells assistive technology that the button is processing. Without it, a screen reader user clicks the button and hears nothing — no indication that anything is happening.
displayName — required for components created with forwardRef. Without it, React DevTools shows ForwardRef instead of Button, which makes debugging painful.
The component index
src/components/Button/index.ts:
We export buttonVariants alongside the component — consumers who want to apply button styles to a non-button element (a custom link, a card that acts as a button) can use the variants directly without re-implementing them.
Update the root entry point
Add to src/index.ts:
Usage
The decision rule
If a new requirement can be handled by adding a variant or a boolean prop — add it to this component. If it requires a fundamentally different DOM structure — make a new component. A ButtonGroup that renders multiple buttons in a row is a new component. A loading state on an existing button is a prop.
Up next: Building an Input component with validation states and accessibility built in — the second primitive, and the one that introduces ref forwarding, error states, and the label connection pattern we'll use across every form field.
Related: Building an Icon component — wrapping SVGs the right way — the Button uses icons heavily. Before you start composing icon-only buttons, it's worth building the Icon primitive that keeps SVG usage consistent across the library.
Muhammad Athar is the founder of DesignDev.io and the engineer behind everything you read here. He writes about the decisions behind building real products — the components, the architecture, and the tradeoffs that don't make it into the tutorial.
Photo by Kelly Sikkema on Unsplash

