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:

VariantRole
primaryOne main action on the screen
secondarySecondary action next to primary
outlineSofter accent, often in dense panels
ghostQuiet actions in toolbars / cards
destructiveDelete, 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',
  },
};
Important

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:

  1. Default — calm resting state.
  2. Hover — pointer feedback (do not rely on color alone: slight darken/lighten of the background).
  3. Active / pressed — the button feels pressed in.
  4. Focus-visible — keyboard focus ring; do not remove outline without a replacement.
  5. Disabled — reduced contrast + disabled / aria-disabled, no pointer events for the action.
  6. Loading — spinner, disabled or aria-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-offset helps).
  • In loading state, announce status: aria-busy and/or a live region near the form.

Practice

  1. Describe the TypeScript/PropTypes API.
  2. Implement variants + sizes + disabled/loading.
  3. Check Tab → Enter/Space, axe, contrast.
  4. Add stories for all combinations (useful in Module 5).

“Done” checklist: all variants/sizes/states, icons, a11y, unit tests, and stories.