Skip to content

Button

The complete action surface: nine variants, five sizes, and a loading state that preserves the control’s measured width.

ActionsstarterFeaturedactionctasubmitloadingdestructiveicon

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { ArrowRight, Download, Plus, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DemoRow, DemoStage } from './_kit'

export default function ButtonDemo() {
  return (
    <DemoStage>
      <DemoRow label="Variants" description="Each one signals a different level of commitment.">
        <Button variant="primary">Deploy</Button>
        <Button variant="secondary">Preview</Button>
        <Button variant="outline">Cancel</Button>
        <Button variant="ghost">Dismiss</Button>
        <Button variant="soft">Duplicate</Button>
        <Button variant="link">Read the guide</Button>
      </DemoRow>

      <DemoRow label="Intent" description="Reserved for outcomes, not decoration.">
        <Button variant="destructive" leadingIcon={<Trash2 className="size-4" />}>
          Delete project
        </Button>
        <Button variant="success">Approve</Button>
        <Button variant="warning">Pause billing</Button>
      </DemoRow>

      <DemoRow label="Sizes">
        <Button size="sm">Small</Button>
        <Button size="md">Medium</Button>
        <Button size="lg">Large</Button>
        <Button size="icon" aria-label="Add item">
          <Plus className="size-4" />
        </Button>
        <Button size="icon-sm" variant="outline" aria-label="Download">
          <Download className="size-3.5" />
        </Button>
      </DemoRow>

      <DemoRow label="With icons">
        <Button leadingIcon={<Plus className="size-4" />}>New workspace</Button>
        <Button variant="outline" trailingIcon={<ArrowRight className="size-4" />}>
          Continue
        </Button>
      </DemoRow>

      <DemoRow label="States" description="Loading keeps the button's measured width.">
        <Button loading loadingLabel="Deploying">
          Deploy
        </Button>
        <Button variant="outline" loading>
          Saving changes
        </Button>
        <Button disabled>Disabled</Button>
        <Button variant="outline" disabled>
          Disabled
        </Button>
      </DemoRow>

      <DemoRow label="Full width" className="max-w-sm">
        <Button block>Create account</Button>
      </DemoRow>
    </DemoStage>
  )
}

components/demos/button.tsx

import { ArrowRight, Download, Plus, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DemoRow, DemoStage } from './_kit'

export default function ButtonDemo() {
  return (
    <DemoStage>
      <DemoRow label="Variants" description="Each one signals a different level of commitment.">
        <Button variant="primary">Deploy</Button>
        <Button variant="secondary">Preview</Button>
        <Button variant="outline">Cancel</Button>
        <Button variant="ghost">Dismiss</Button>
        <Button variant="soft">Duplicate</Button>
        <Button variant="link">Read the guide</Button>
      </DemoRow>

      <DemoRow label="Intent" description="Reserved for outcomes, not decoration.">
        <Button variant="destructive" leadingIcon={<Trash2 className="size-4" />}>
          Delete project
        </Button>
        <Button variant="success">Approve</Button>
        <Button variant="warning">Pause billing</Button>
      </DemoRow>

      <DemoRow label="Sizes">
        <Button size="sm">Small</Button>
        <Button size="md">Medium</Button>
        <Button size="lg">Large</Button>
        <Button size="icon" aria-label="Add item">
          <Plus className="size-4" />
        </Button>
        <Button size="icon-sm" variant="outline" aria-label="Download">
          <Download className="size-3.5" />
        </Button>
      </DemoRow>

      <DemoRow label="With icons">
        <Button leadingIcon={<Plus className="size-4" />}>New workspace</Button>
        <Button variant="outline" trailingIcon={<ArrowRight className="size-4" />}>
          Continue
        </Button>
      </DemoRow>

      <DemoRow label="States" description="Loading keeps the button's measured width.">
        <Button loading loadingLabel="Deploying">
          Deploy
        </Button>
        <Button variant="outline" loading>
          Saving changes
        </Button>
        <Button disabled>Disabled</Button>
        <Button variant="outline" disabled>
          Disabled
        </Button>
      </DemoRow>

      <DemoRow label="Full width" className="max-w-sm">
        <Button block>Create account</Button>
      </DemoRow>
    </DemoStage>
  )
}

components/ui/button.tsx

import type { ButtonHTMLAttributes, ReactNode } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/cn'
import { variants } from '@/lib/variants'
import { Spinner } from './spinner'

/**
 * Button
 *
 * The whole action surface of Foundry in one component. It is intentionally a
 * *shared* component (no `'use client'`): rendered from a Server Component it
 * ships zero JavaScript, and it upgrades to a client island automatically when
 * a Client Component imports it.
 *
 * Height, padding and radius resolve from density and radius tokens, so a
 * button restyles itself when the density or palette axis changes. Focus is
 * handled once, globally, by the `:focus-visible` rule in `globals.css`.
 */
const buttonVariants = variants(
  cn(
    'relative inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium',
    'transition-colors duration-150 ease-standard select-none',
    'disabled:pointer-events-none disabled:opacity-50',
    'aria-disabled:pointer-events-none aria-disabled:opacity-50',
  ),
  {
    variants: {
      variant: {
        primary: 'bg-accent text-accent-ink hover:bg-accent-hover active:bg-accent-active',
        secondary: 'bg-surface-inverse text-ink-inverse hover:opacity-90 active:opacity-80',
        outline: 'border border-line-strong bg-surface text-ink hover:bg-surface-sunken',
        ghost: 'text-ink hover:bg-surface-sunken active:bg-surface-sunken',
        destructive: 'bg-danger text-white hover:opacity-90 active:opacity-80',
        success: 'bg-success text-white hover:opacity-90 active:opacity-80',
        warning: 'bg-warning text-white hover:opacity-90 active:opacity-80',
        link: 'text-accent underline underline-offset-4 hover:text-accent-hover',
        soft: 'bg-accent-soft text-accent-soft-ink border border-accent-line hover:brightness-[0.97]',
      },
      size: {
        sm: 'h-control-sm px-3 text-xs rounded-md',
        md: 'h-control px-[var(--density-control-padding-x)] text-sm rounded-md',
        lg: 'h-control-lg px-5 text-base rounded-md',
        icon: 'h-control w-control p-0 rounded-md',
        'icon-sm': 'h-control-sm w-control-sm p-0 rounded-sm',
      },
      block: { true: 'w-full', false: '' },
    },
    defaultVariants: { variant: 'primary', size: 'md', block: false },
    compound: [
      { variant: 'link', size: 'sm', class: 'h-auto px-0' },
      { variant: 'link', size: 'md', class: 'h-auto px-0' },
      { variant: 'link', size: 'lg', class: 'h-auto px-0' },
    ],
  },
)

export type ButtonVariant =
  | 'primary'
  | 'secondary'
  | 'outline'
  | 'ghost'
  | 'destructive'
  | 'success'
  | 'warning'
  | 'link'
  | 'soft'

export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm'

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant
  size?: ButtonSize
  block?: boolean
  /** Swaps content for a spinner while preserving the button's measured width. */
  loading?: boolean
  /** Announced by assistive tech while `loading` is true. */
  loadingLabel?: string
  leadingIcon?: ReactNode
  trailingIcon?: ReactNode
}

export function Button({
  variant = 'primary',
  size = 'md',
  block = false,
  loading = false,
  loadingLabel = 'Working',
  leadingIcon,
  trailingIcon,
  className,
  children,
  disabled,
  type = 'button',
  ...props
}: ButtonProps) {
  return (
    <button
      type={type}
      className={buttonVariants({ variant, size, block, className })}
      disabled={disabled ?? loading}
      aria-busy={loading || undefined}
      {...props}
    >
      {loading ? (
        <>
          {/* Label stays in the DOM but hidden so the control never collapses
              to spinner width halfway through an interaction. */}
          <span className="invisible flex items-center gap-2" aria-hidden="true">
            {leadingIcon}
            {children}
            {trailingIcon}
          </span>
          <span className="absolute inset-0 flex items-center justify-center">
            <Spinner size="sm" />
            <span className="sr-only">{loadingLabel}</span>
          </span>
        </>
      ) : (
        <>
          {leadingIcon}
          {children}
          {trailingIcon}
        </>
      )}
    </button>
  )
}

export interface ButtonLinkProps {
  href: string
  variant?: ButtonVariant
  size?: ButtonSize
  block?: boolean
  className?: string
  children?: ReactNode
  leadingIcon?: ReactNode
  trailingIcon?: ReactNode
  'aria-label'?: string
  'aria-current'?: 'page' | 'step' | 'true' | undefined
  target?: string
  rel?: string
  prefetch?: boolean
}

/** Anchor styled as a button, for when the action is really navigation. */
export function ButtonLink({
  href,
  variant = 'primary',
  size = 'md',
  block = false,
  className,
  children,
  leadingIcon,
  trailingIcon,
  ...props
}: ButtonLinkProps) {
  return (
    <Link href={href} className={buttonVariants({ variant, size, block, className })} {...props}>
      {leadingIcon}
      {children}
      {trailingIcon}
    </Link>
  )
}

export { buttonVariants }

components/ui/spinner.tsx

import { cn } from '@/lib/cn'

/**
 * Spinner
 *
 * A pure-CSS indeterminate indicator. Drawn with a border rather than an SVG
 * so it inherits `currentColor` and costs nothing to render. Under
 * `prefers-reduced-motion` the global reset stops the rotation, leaving a
 * static ring — which is why the accessible label lives on the wrapper.
 */
export interface SpinnerProps {
  size?: 'xs' | 'sm' | 'md' | 'lg'
  className?: string
  /** Omit for decorative use inside an already-labelled control. */
  label?: string
}

const sizeMap = {
  xs: 'size-3 border',
  sm: 'size-4 border-[1.5px]',
  md: 'size-5 border-2',
  lg: 'size-8 border-2',
} as const

export function Spinner({ size = 'md', className, label }: SpinnerProps) {
  return (
    <span
      className={cn('inline-flex items-center justify-center', className)}
      role={label ? 'status' : undefined}
    >
      <span
        className={cn(
          'animate-spin-token rounded-full border-current border-t-transparent opacity-70',
          sizeMap[size],
        )}
        aria-hidden="true"
      />
      {label ? <span className="sr-only">{label}</span> : null}
    </span>
  )
}

lib/variants.ts

import { cn, type ClassValue } from './cn'

/**
 * A tiny, fully typed variant resolver.
 *
 * Foundry does not depend on `cva`. The API below covers the two cases the
 * library actually needs — a map of named variants and a set of compound
 * overrides — in ~40 lines, and keeps variant keys inferred so a typo in
 * `variant="primry"` is a type error at the call site.
 */
type VariantMap = Record<string, Record<string, ClassValue>>

type VariantProps<M extends VariantMap> = {
  /** Boolean variants may be passed as real booleans; they resolve to the
      `'true'` / `'false'` keys of the variant map. */
  [K in keyof M]?: keyof M[K] | boolean
}

interface VariantConfig<M extends VariantMap> {
  variants: M
  defaultVariants?: VariantProps<M>
  compound?: Array<VariantProps<M> & { class: ClassValue }>
}

export function variants<M extends VariantMap>(base: ClassValue, config: VariantConfig<M>) {
  return function resolve(props: VariantProps<M> & { className?: ClassValue } = {}): string {
    const selected = { ...config.defaultVariants, ...props } as VariantProps<M>
    const classes: ClassValue[] = [base]

    for (const key of Object.keys(config.variants) as Array<keyof M>) {
      const value = selected[key]
      if (value == null) continue
      const group = config.variants[key]
      if (!group) continue
      classes.push(group[typeof value === 'boolean' ? String(value) : (value as string)])
    }

    for (const rule of config.compound ?? []) {
      const { class: className, ...conditions } = rule
      const matches = Object.entries(conditions).every(
        ([key, value]) => selected[key as keyof M] === value,
      )
      if (matches) classes.push(className)
    }

    classes.push(props.className)
    return cn(...classes)
  }
}

export type { VariantProps }

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Reach for Button whenever an interaction changes state. When the interaction navigates instead, use ButtonLink — it renders a real anchor with identical styling, which keeps middle-click, "open in new tab" and crawling intact.

  • One primary button per view. If two actions compete, one of them is secondary.
  • Loading replaces the label with a spinner but keeps the label in the DOM, so the button never resizes mid-click.
  • Icon-only buttons must pass `aria-label`; the icon itself is always `aria-hidden`.
  • Destructive actions should be paired with ConfirmDialog rather than relying on the red fill alone.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Primary — the single most important action in a view
  • Secondary — an equally valid alternative on an inverted fill
  • Outline — the neutral default for toolbars and dialogs
  • Ghost — for dense rows where a border would add noise
  • Soft — tinted, for repeated non-primary actions
  • Link — inline navigation that must read as prose
  • Destructive, Success, Warning — reserved for outcomes

Accessibility

Focus
Focus is drawn by the global `:focus-visible` rule, so it is identical on every control in the library and never appears for pointer users.
Busy state
`aria-busy` is set while loading, and a visually hidden label announces what is happening.
Disabled
Disabled buttons are removed from the tab order. If the reason matters, explain it in adjacent text rather than a tooltip on an unfocusable control.
Touch target
The default and large sizes exceed the 44px minimum at Default and Relaxed density; Compact is intended for pointer-first admin surfaces.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.