Skip to content

ActionsButton

Button

The primary affordance. Four variants, three sizes, one shape.

4 variants3 sizesLoading state

Variants

Primary is for the single most important action on a page. Secondary is for everything else that needs a button. Ghost is for tertiary actions inside a constrained area. Link is a button styled as a link.

Variants

Sizes

Three sizes. The default is md (2.5 rem / 40 px). Use sm in dense tables and forms. Use lg in marketing and onboarding flows.

Sizes

With an icon

Icons go on the trailing edge by default. A leading icon is allowed only when the button is the only thing in a header or page-CTA; otherwise the asymmetry reads as a sign that something is wrong.

With icon

States

The four states a button has: default, hover, focus-visible, disabled.

States

Loading state

A loading button keeps its width. The icon is replaced with a spinner and the label changes to "Loading…". The button is not interactive while loading.

Loading

Anatomy

  • Background: --foreground for primary, --surface-elevated for secondary, transparent for ghost
  • Text: --background for primary, --foreground for secondary and ghost, weight 500
  • Border: 1 px transparent for primary, 1 px --border for secondary, none for ghost
  • Padding: 12 px / 16 px / 20 px (sm / md / lg)
  • Height: 36 / 40 / 44 px (sm / md / lg)
  • Radius: --radius-md (4 px)
  • Transition: 300 ms --ease-out-quart on background, border, color

Implementation

button.tsx
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react"
import { cn } from "@/lib/utils"
import SiteIcon, type SiteIconProps } from "@/components/site-icon"

type Variant = "primary" | "secondary" | "ghost" | "link"
type Size = "sm" | "md" | "lg"

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: Variant
  size?: Size
  icon?: SiteIconProps["icon"]
  loading?: boolean
  children: ReactNode
}

const variantClass: Record<Variant, string> = {
  primary: "ds-btn--primary",
  secondary: "ds-btn--secondary",
  ghost: "ds-btn--ghost",
  link: "ds-btn--link",
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  function Button({ variant = "primary", size = "md", icon, loading, children, className, ...props }, ref) {
    return (
      <button
        ref={ref}
        className={cn("ds-btn", `ds-btn--${variant}`, `ds-btn--${size}`, className)}
        disabled={loading || props.disabled}
        aria-busy={loading || undefined}
        {...props}
      >
        {loading ? <span className="ds-spinner" aria-hidden="true" /> : null}
        {children}
        {icon && !loading ? <SiteIcon icon={icon} size="sm" /> : null}
      </button>
    )
  },
)

Props

PropTypeDefaultDescription
variant"primary" | "secondary" | "ghost" | "link""primary"Visual style
size"sm" | "md" | "lg""md"Height + padding
asElementType"button"Render as a different element
disabledbooleanfalseDisabled state
loadingbooleanfalseLoading state
iconLucideIconNoneTrailing icon

Accessibility

  • The native <button> element is used. No <div role="button">.
  • Focus ring is the system default; the button does not override it.
  • Disabled state is communicated by both the disabled attribute and reduced color contrast.
  • Loading state uses aria-busy="true" and replaces the label.
  • Icon-only buttons require an aria-label.

When not to use a button

A button should always trigger an action. If the thing you are about to label as a button is actually navigating the user to a new URL, use a link instead. Ads-btn styled as a link is a button, not a link; assistive tech will announce it as "button".