1
ComponentsActions

Button

Button is the standard control for visible actions, with built-in variants, sizes, loading behavior, and accessible disabled states.

Purpose

Button is the standard control for visible actions. Use a semantic link when navigation is the primary behavior.

Default button

import { Button } from "@/components/ui/button";

export function Example() {
  return <Button>Save changes</Button>;
}

Installation

With the Siteplane registry alias configured, add the component and import the installed source from your app.

CLI

pnpm dlx shadcn@4.16.1 add @siteplane/button

Import

import { Button } from "@/components/ui/button";
import { ButtonWithIcon, IconButton, LoadingButton } from "@/components/ui/button-recipes";

Need the machine-readable source metadata? View registry JSON.

Usage

Use the default Button for the primary action in a workflow.

import { Button } from "@/components/ui/button";

export function Example() {
  return <Button>Save changes</Button>;
}

Examples

Visual variants are one Button API. Pick the variant by workflow priority and semantic risk, not as separate components. Icon-only buttons need an accessible label.

Variants

import { Button } from "@/components/ui/button";

export function Variants() {
  return (
    <div className="flex flex-wrap gap-3">
      <Button>Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="link">Link</Button>
      <Button variant="destructive">Delete</Button>
      <Button variant="destructive-outline">Danger</Button>
      <Button variant="success">Approve</Button>
    </div>
  );
}

With icons

import { MailIcon, PlusIcon } from "lucide-react";
import { ButtonWithIcon } from "@/components/ui/button-recipes";

export function WithIcons() {
  return (
    <div className="flex gap-3">
      <ButtonWithIcon icon={<PlusIcon />}>Add client</ButtonWithIcon>
      <ButtonWithIcon icon={<MailIcon />} variant="outline">
        Email
      </ButtonWithIcon>
    </div>
  );
}

Icon only

import { SettingsIcon } from "lucide-react";
import { IconButton } from "@/components/ui/button-recipes";

export function IconOnly() {
  return <IconButton icon={<SettingsIcon />} label="Settings" />;
}

Sizes

Use the size prop to match the control to its surrounding density and hierarchy.

import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/button-recipes";

export function Sizes() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button size="xs">XS</Button>
      <Button size="sm">SM</Button>
      <Button>Default</Button>
      <Button size="lg">LG</Button>
      <Button size="xl">XL</Button>
      <IconButton icon={<PlusIcon />} label="Extra small icon" size="icon-xs" />
      <IconButton icon={<PlusIcon />} label="Icon" size="icon-sm" />
      <IconButton icon={<PlusIcon />} label="Default icon" />
      <IconButton icon={<PlusIcon />} label="Large icon" size="icon-lg" />
      <IconButton icon={<PlusIcon />} label="Extra large icon" size="icon-xl" />
    </div>
  );
}

States

Set scale={false} when a button should keep its color, shadow and focus feedback without moving on hover or press.

import { Button } from "@/components/ui/button";

export function States() {
  return (
    <div className="flex gap-3">
      <Button loading variant="secondary">Saving</Button>
      <Button disabled>Disabled</Button>
      <Button scale={false} variant="outline">No scale</Button>
    </div>
  );
}

Loading action

"use client";

import { LoadingButton } from "@/components/ui/button-recipes";

function wait(ms: number) {
  return new Promise((resolve) => {
    window.setTimeout(resolve, ms);
  });
}

export function LoadingAction() {
  return (
    <LoadingButton action={() => wait(1200)} loadingText="Saving">
      Save changes
    </LoadingButton>
  );
}

Grouped actions

import { Button } from "@/components/ui/button";
import { Group } from "@/components/ui/group";

export function GroupedActions() {
  return (
    <Group>
      <Button variant="outline">Back</Button>
      <Button variant="outline">Next</Button>
    </Group>
  );
}

With shortcut

import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";

export function ShortcutButton() {
  return (
    <Button variant="outline">
      Command
      <KbdGroup>
        <Kbd>⌘</Kbd>
        <Kbd>K</Kbd>
      </KbdGroup>
    </Button>
  );
}

Alignment and width

align positions the label inside the button; width controls how much space the button claims.

import { ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";

export function AlignmentAndWidth() {
  return (
    <div className="grid w-64 gap-3">
      <Button align="start" variant="outline" width="full">
        Align start
      </Button>
      <Button align="between" variant="outline" width="full">
        Align between
        <ChevronRightIcon />
      </Button>
      <Button variant="outline" width="fit">
        Width fit
      </Button>
    </div>
  );
}

Surface button

surface only takes effect with the ghost variant: the row reads as a whole area and gets the muted full-area hover.

{/* surface only changes ghost buttons: full-area hover, no control feedback. */}
<div className="grid gap-1 rounded-xl border border-border p-1">
  <Button align="between" surface variant="ghost" width="full">
    Manage members
    <ChevronRightIcon />
  </Button>
  <Button align="between" surface variant="ghost" width="full">
    Billing settings
    <ChevronRightIcon />
  </Button>
</div>

Bleed in a toolbar

bleed stretches the button to the container height and gives up its own corners, so it forms the edge of the bar.

3 drafts selected
{/* bleed stretches to the container edge and gives up its own corners. */}
<div className="flex items-stretch justify-between overflow-hidden rounded-lg border border-border">
  <span className="px-3 py-2 text-muted-foreground text-sm">
    3 drafts selected
  </span>
  <Button bleed variant="ghost">Clear</Button>
</div>

Multiline label

multiline releases the fixed height and lets the label wrap.

<Button variant="outline" width="full">Single-line label</Button>

{/* multiline releases the fixed height and wraps the label. */}
<Button multiline variant="outline" width="full">
  Approve the estimate and notify the assigned team
</Button>

Loading with error feedback

When the action rejects, LoadingButton calls onError and returns to idle.

No sync attempted yet.

"use client";

import { useState } from "react";
import { LoadingButton } from "@/components/ui/button-recipes";

export function LoadingWithErrorFeedback() {
  const [status, setStatus] = useState("No sync attempted yet.");

  return (
    <div className="grid justify-items-center gap-3">
      <LoadingButton
        action={async () => {
          await syncWorkspace(); // rejects in this demo
        }}
        loadingText="Syncing"
        onError={() => setStatus("Sync failed. The button returns to idle.")}
        variant="outline"
      >
        Sync workspace
      </LoadingButton>
      <p aria-live="polite" className="text-muted-foreground text-xs">
        {status}
      </p>
    </div>
  );
}

API Reference

The reference lists the source-owned exports and props that are easy to miss in visual examples. Inherited Base UI props remain available unless the wrapper narrows them.

  • variant accepts "default", "secondary", "outline", "ghost", "destructive", "destructive-outline", "link", and "success".
  • size accepts text sizes from "xs" through "xl" and the matching "icon-*" sizes. Without a local size, Button inherits SiteplaneUIProvider controlSize.
  • size="xl" is the opt-in customer-facing CTA format: 56px high with an 18px/24px label. Combine it with width="full" for a container-filling CTA and with a surrounding SiteplaneUIProvider radius="sm" for the global 8px main corner; size itself never overrides radius.
  • render={<Link href="…" />} changes the underlying element while retaining Button styling and omits the invalid button type attribute.
  • loading disables the control, sets busy semantics, and displays the native loading indicator.
  • scale={false} disables both hover and press scaling while preserving color, shadow, focus, loading, and disabled states. Use it when a trigger sits inside a larger custom element that must not move.
  • align, width, and multiline control content layout.
  • bleed makes the Button form a flush container edge. surface is for full-row or full-card actions and disables scale automatically.
  • IconButton requires an accessible label; ButtonWithIcon and LoadingButton are shared recipes, not new primitives.

Motion

Component motion uses the shared Siteplane motion contract. See the global motion guide for provider setup, tokens and reduced-motion behavior.

  • Default hover and press states use the central Button scale, duration, shadow, and reduced-motion tokens.
  • scale={false} removes hover and press transforms only.
  • surface avoids scale for full-area actions.
  • Loading recipes keep their idle width and crossfade between label and pending content without a second nested animation.

Accessibility

  • Use Button for actions and a semantic anchor for navigation through the render prop.
  • Every icon-only Button needs an accessible label.
  • Loading and disabled Buttons must remain unavailable and expose their state; do not simulate disabled state with styling alone.
  • Keep visible focus and the coarse-pointer target supplied by the primitive.

Implementation Guidance

  • Never use a raw HTML button for a visible Siteplane control.
  • Use existing variants, sizes, and recipes instead of copying primitive class strings.
  • Prefer scale={false} for embedded triggers that must remain stationary; do not remove the remaining hover, pressed, or focus feedback.

On This Page