Button — the primary interaction pattern
The button sets the tone for the whole design system: how accent looks, how hover feels, how focus and disabled appear. If Button is inconsistent, the rest of the library drifts too.
Why start with Button
- It is the most common action in a product.
- Buttons have a rich state set: default, hover, active, focus, disabled, loading.
- The Button API becomes a template for other controls (variant + size + state).
Anatomy
Visually, a button is a container with content:
- background and/or border;
- text (and optional left/right icons);
- padding and radius;
- a focus ring, separate from “pretty” hover.
Semantically you almost always want a native <button type="button"> (or type="submit" in forms). Do not build a button from div + onClick — you lose keyboard support and the default role.
Variants
Minimum product set:
| Variant | Role |
|---|---|
| primary | One main action on the screen |
| secondary | Secondary action next to primary |
| outline | Softer accent, often in dense panels |
| ghost | Quiet actions in toolbars / cards |
| destructive | Delete, irreversible actions |
Example style tokens (conceptual):
const buttonLooks = {
primary: {
bg: 'color.primary.500',
color: 'color.white',
hoverBg: 'color.primary.600',
activeBg: 'color.primary.700',
},
secondary: {
bg: 'color.neutral.200',
color: 'color.neutral.900',
hoverBg: 'color.neutral.300',
},
outline: {
bg: 'transparent',
color: 'color.primary.500',
border: '2px solid color.primary.500',
hoverBg: 'color.primary.50',
},
ghost: {
bg: 'transparent',
color: 'color.primary.500',
hoverBg: 'color.primary.50',
},
};
One screen should have one clear primary. Two “main” buttons side by side flatten hierarchy.
Sizes
Usually sm | md | lg is enough (sometimes xl for marketing CTAs). Size changes height, padding, and font-size together — not text alone.
const buttonSizes = {
sm: { height: 32, paddingX: 12, fontSize: 'sm' },
md: { height: 40, paddingX: 16, fontSize: 'base' },
lg: { height: 48, paddingX: 24, fontSize: 'lg' },
};
Keep a minimum hit target around 44×44 px on mobile (you can do that with padding without making the button look visually bulky).
States
Required set:
- Default — calm resting state.
- Hover — pointer feedback (do not rely on color alone: slight darken/lighten of the background).
- Active / pressed — the button feels pressed in.
- Focus-visible — keyboard focus ring; do not remove outline without a replacement.
- Disabled — reduced contrast +
disabled/aria-disabled, no pointer events for the action. - Loading — spinner,
disabledoraria-busy, keep the action text (or replace it deliberately).
Component API
A stable public contract:
<Button
variant="primary"
size="md"
disabled={false}
loading={false}
leftIcon={null}
rightIcon={null}
fullWidth={false}
type="button"
onClick={handleClick}
>
Save
</Button>
A practical implementation with cva + cn:
import { cva } from 'class-variance-authority';
import { cn } from '../../../utils/cn';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md 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-primary-500 text-white hover:bg-primary-600 active:bg-primary-700',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
outline: 'border-2 border-primary-500 text-primary-500 hover:bg-primary-50',
ghost: 'text-primary-500 hover:bg-primary-50',
destructive: 'bg-error-500 text-white hover:bg-error-600',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-base',
lg: 'h-12 px-6 text-lg',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
}
);
export function Button({ className, variant, size, loading, disabled, children, ...props }) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props}
>
{loading ? 'Loading…' : children}
</button>
);
}
Accessibility
- A native button already gets Enter/Space — do not invent role= " button " on a div unless you must.
- Icon-only buttons need
aria-label(for example, “Close dialog”). - Text-to-background contrast ≥ 4.5:1 (WCAG AA) for normal text.
- The focus ring must be visible on light and dark backgrounds (
ring-offsethelps). - In loading state, announce status:
aria-busyand/or a live region near the form.
Practice
- Describe the TypeScript/PropTypes API.
- Implement variants + sizes + disabled/loading.
- Check Tab → Enter/Space, axe, contrast.
- Add stories for all combinations (useful in Module 5).
“Done” checklist: all variants/sizes/states, icons, a11y, unit tests, and stories.