ActionsButton
Button
The primary affordance. Four variants, three sizes, one shape.
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.
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.
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.
States
The four states a button has: default, hover, focus-visible, disabled.
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.
Anatomy
- Background:
--foregroundfor primary,--surface-elevatedfor secondary, transparent for ghost - Text:
--backgroundfor primary,--foregroundfor secondary and ghost, weight 500 - Border: 1 px transparent for primary, 1 px
--borderfor 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-quarton background, border, color
Implementation
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
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "primary" | "secondary" | "ghost" | "link" | "primary" | Visual style |
size | "sm" | "md" | "lg" | "md" | Height + padding |
as | ElementType | "button" | Render as a different element |
disabled | boolean | false | Disabled state |
loading | boolean | false | Loading state |
icon | LucideIcon | None | Trailing 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
disabledattribute 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".