Button Design System: Variants, States, Sizes and the API That Scales
Build a button system that actually scales — variants, states, sizes, and a component API that won't collapse under real product pressure.
Why Buttons Break Design Systems First
Buttons are deceptively small. You ship one, it works, you copy it twelve times with slightly different styles, and six months later you've got a codebase where btn-primary, ButtonPrimary, PrimaryButton, and button--cta all do almost-but-not-quite the same thing. Sound familiar?
The problem isn't that developers are lazy. It's that buttons accumulate pressure from every direction simultaneously — product wants a new destructive variant, design wants a ghost button for the nav, marketing wants an oversized CTA that pulses on hover. Without a deliberate API, each request is a patch on top of a patch.
In practice, the moment a second engineer touches your button component without a shared contract, divergence starts. A well-designed button system costs you maybe two hours up front and saves you weeks of CSS archaeology later. That's the trade-off, and it's obviously worth it.
This guide walks through how to think about variants, states, and sizes as a proper taxonomy — then shows you the component API shape that lets the whole thing scale without becoming a prop nightmare.
Variant Taxonomy: What You Actually Need
Most teams end up with too many variants because they conflate visual appearance with semantic intent. Those are two different dimensions. Your variant should answer "what is this button's role in the UI?" not "what does it look like?"
A minimal taxonomy that covers 90% of real products: primary (the main action on a page or modal), secondary (supporting or alternative action), ghost (low-emphasis, often used in toolbars), destructive (delete, archive, revoke — always red-adjacent), and link (looks like text, behaves like a button for accessibility). Five variants. That's it. Resist the urge to add success or warning until you have a concrete use case with more than one instance in your product.
Worth noting: some teams split ghost into outline (has a border) and ghost (no border, no background). Honestly, that's fine if your design system has both distinct use cases. But don't add both speculatively — you'll never kill the unused one.
Here's what a clean variant type looks like in 2026 TypeScript:
``typescript
type ButtonVariant =
| 'primary'
| 'secondary'
| 'ghost'
| 'destructive'
| 'link';
``
That's a closed union. Not a string. If someone needs a new variant, they open a PR and the conversation happens at review time, not six months later when you're auditing why you have 23 button classes.
States: The Part Everyone Underspecifies
Default, hover, active, focus, disabled, loading. Six states. Most button implementations handle two of them well and wing the rest. The result is buttons that feel broken to keyboard users and confusing to anyone hitting a slow API.
Focus is the one that gets butchered most often. The browser default outline looks ugly so teams write outline: none and call it a day. Don't do that. Keyboard navigation matters, WCAG 2.1 AA requires a visible focus indicator, and you can make it look good. A 2px offset ring at 2px distance, using your brand color, is completely fine visually and actually accessible. Something like:
``css
.btn:focus-visible {
outline: 2px solid var(--color-brand-500);
outline-offset: 2px;
}
`
Note the :focus-visible` — that means it only shows for keyboard navigation, not mouse clicks. No more ugly rings on click.
Loading state is worth spending real time on. When a user clicks a form submit and nothing visible happens, they click again. Then again. Now you've got three duplicate API requests. A loading state with a spinner and pointer-events: none (or aria-disabled="true") solves this. The spinner should replace the label, not sit next to it — that way your button width doesn't jump:
``tsx
<button disabled={isLoading} aria-busy={isLoading}>
{isLoading ? <Spinner size={16} /> : children}
</button>
`
Quick aside: disabled and loading aren't the same thing semantically. disabled means the action is not available. loading` means the action has been triggered and is in progress. Don't conflate them in your API.
Active state (the 100ms press feedback) is undervalued. A subtle transform: scale(0.97) or a 4px inset shadow gives tactile feedback that feels like a real button press. Users notice when it's missing, even if they can't articulate why.
Size Scale: Constraints Beat Flexibility
Three sizes is almost always the right answer: sm, md, lg. You'll want to add xs and xl eventually — wait until you have a real product requirement for both, not just a vague "we might need them."
The size prop should control padding, font-size, icon size, and border-radius together as a unit. If you let those be independently configurable, you end up with a sm button with lg text that looks like a design mistake. Co-vary them:
``typescript
const sizeMap = {
sm: { px: '12px', py: '6px', fontSize: '13px', iconSize: 14, radius: '6px' },
md: { px: '16px', py: '8px', fontSize: '14px', iconSize: 16, radius: '8px' },
lg: { px: '20px', py: '12px', fontSize: '16px', iconSize: 18, radius: '10px' },
};
``
Those specific px values aren't arbitrary — they're derived from an 8px base grid, which keeps things optically consistent with the rest of your layout.
Icon buttons (square buttons with just an icon, no label) need a separate treatment. The padding math changes because you want equal padding on all sides. You can handle this with an iconOnly boolean prop that switches the padding mode, or just accept that icon buttons are a distinct component variant.
Look, fullWidth isn't a size. It's a layout constraint. Model it as a boolean prop fullWidth rather than a size="full" value. Size and layout are different concerns and conflating them makes your API harder to understand at a glance.
The Component API: What Good Looks Like
Here's the full prop interface for a production-grade button component. Every prop has a reason to exist and nothing is in there "just in case":
``typescript
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant; // 'primary' | 'secondary' | 'ghost' | 'destructive' | 'link'
size?: 'sm' | 'md' | 'lg'; // defaults to 'md'
isLoading?: boolean; // shows spinner, blocks interaction
leftIcon?: React.ReactNode; // icon before label
rightIcon?: React.ReactNode; // icon after label
fullWidth?: boolean; // 100% width of container
asChild?: boolean; // render as child element (Radix pattern)
}
`
The extends React.ButtonHTMLAttributes<HTMLButtonElement> is key. You get onClick, type, disabled, aria-* props for free without re-declaring them. The asChild pattern (popularised by Radix UI in 2023) lets you render the button as a <Link>` or any other element while keeping all the visual styles and accessible semantics.
The implementation using CVA (Class Variance Authority) keeps variant logic clean and co-located:
``typescript
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
// base classes always applied
'inline-flex items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-brand-600 text-white hover:bg-brand-700 focus-visible:ring-brand-500',
secondary: 'bg-neutral-100 text-neutral-900 hover:bg-neutral-200 focus-visible:ring-neutral-400',
ghost: 'hover:bg-neutral-100 text-neutral-700 focus-visible:ring-neutral-400',
destructive: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500',
link: 'text-brand-600 underline-offset-4 hover:underline focus-visible:ring-brand-500',
},
size: {
sm: 'h-8 px-3 text-sm rounded-md',
md: 'h-9 px-4 text-sm rounded-lg',
lg: 'h-11 px-5 text-base rounded-xl',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
`
This pattern scales. You add a variant by adding a key to the object. You change the sm` size globally by editing one string. No cascade of conditional classes scattered across five files.
One more thing — the loading state spinner should be accessible. Add aria-busy and keep the button's accessible label intact so screen reader users know something is happening:
``tsx
<button
{...props}
disabled={isLoading || props.disabled}
aria-busy={isLoading}
className={buttonVariants({ variant, size, className })}
>
{isLoading && <Spinner className="mr-2" aria-hidden="true" />}
{children}
</button>
`
The aria-hidden="true"` on the spinner means screen readers skip the icon and just announce the button label with the busy state. Clean.
For style-heavy projects — if you're building something with a strong visual identity like glassmorphism components or neobrutalism — you'd swap out the Tailwind classes in buttonVariants but keep the API identical. The consumer code doesn't change. That's the point of this abstraction.
Handling Icons Without Breaking Layout
Icon alignment is where button implementations silently break. The optical center of most icons isn't the mathematical center, so items-center alone sometimes looks slightly off. Specific icon sizes matter: a 16px icon inside an md button, a 14px icon inside sm. If you're using Lucide or Heroicons, both have a size prop — use it explicitly rather than relying on 1em scaling.
Left and right icon slots with consistent 8px gaps work better than an icon prop plus a position prop. Two named slots are clearer to read at usage time:
``tsx
<Button leftIcon={<PlusIcon size={16} />}>
Create project
</Button>
<Button rightIcon={<ArrowRightIcon size={16} />} variant="link">
View all
</Button>
`
Versus the prop-soup alternative <Button icon={<PlusIcon />} iconPosition="left">`. Same outcome, worse ergonomics.
When there's no label — pure icon button — you need an aria-label. Make this a runtime warning if you're building a design system for a team:
``typescript
if (iconOnly && !props['aria-label'] && process.env.NODE_ENV !== 'production') {
console.warn('[Button] Icon-only buttons require an aria-label for accessibility.');
}
``
That warning in development catches 90% of accessibility mistakes before they hit production. Worth the 3 lines of code.
Testing and Documentation Strategies
A button component with no tests is a liability. But testing every visual permutation isn't the move either. Test the behaviour: does clicking trigger onClick? Does isLoading block clicks? Does disabled render the right attributes? Does asChild render as the expected element? That's four tests and they cover the critical paths.
For visual regression, a Storybook story per variant/size combination works well. You render a grid — all variants in one row, all sizes in a column — and snapshot it. Anything that drifts visually shows up in CI. If you're not running Storybook yet, the design system documentation guide covers the setup in detail.
Documentation matters more than most devs admit. The best button documentation shows the prop table, shows each variant rendered, and shows at least one "don't do this" example alongside the correct version. If your docs only show the happy path, engineers will keep making the same mistakes in code review.
Honestly, the thing that kills button systems isn't bad code — it's lack of documentation combined with no linting. Add an ESLint rule that bans the raw <button> element in your app directory and forces the import of your design system button. That rule alone prevents 80% of one-off button styles from creeping back in. You can also check the component API design article for broader patterns around enforcing your system at the linter level.
FAQ
Five covers most products: primary, secondary, ghost, destructive, link. Add more only when you have two or more real, distinct use cases — not speculatively.
Disabled means the action isn't available. Loading means the action was triggered and is in progress. Use separate props for both — they have different semantics and different ARIA implications.
CVA if you're using Tailwind — it co-locates your variant logic and scales cleanly. Raw conditionals get unreadable past two or three variants and they're harder to audit.
Require an aria-label on icon-only buttons. A dev-mode console warning for missing labels catches most issues before they ship.