Siteplane UI

Button

Trigger an action with a text label, an icon, or both.

Markdown
"use client";

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

export default function Example() {
  return <Button>Connect account</Button>;
}

Installation

pnpm dlx shadcn@4.18.0 add @siteplane/button

Complete the one-time setup first. Registry JSON lists source files and dependencies.

Includes Button, ButtonWithIcon, IconButton, and LoadingButton. The icon examples use lucide-react.

Usage

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

<Button>Continue</Button>;

Variants

Use variant to set emphasis. link and text only change appearance; use render for real navigation.

default is an opaque neutral surface: white in Light and a slightly raised gray in Dark, with the hairline contour and soft shadow of the kit's controls. Nothing behind it shows through, so it also reads over media, such as a play button on a video or an action over a preview.

accent fills the button with the brand color, and its label takes the text color for accent fills, --sp-rw-fg-on-accent (white by default). A light brand sets that token dark, as Theming shows; destructive keeps its white label. muted-accent is the quieter brand action, with accent text on a tint of the brand color that deepens on hover and press.

Button labels take the kit's typeface tokens. Three tokens of their own let labels differ from body text: --sp-rw-button-font-family, --sp-rw-button-font-weight and --sp-rw-button-letter-spacing. Each is unset by default and falls back to the matching typeface token, so --sp-rw-button-font-weight: 500 sets Button labels in weight 500 and leaves running text at 400. The label size follows the Button's size.

"use client";

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

export default function Example() {
  return (
    <div className="flex w-full flex-wrap items-center gap-3">
      <Button variant="default">default</Button>
      <Button variant="accent">accent</Button>
      <Button variant="inverse">inverse</Button>
      <Button variant="destructive">destructive</Button>
      <Button variant="outline">outline</Button>
      <Button variant="outline-destructive">outline-destructive</Button>
      <Button variant="ghost">ghost</Button>
      <Button variant="ghost-destructive">ghost-destructive</Button>
      <Button variant="muted-accent">muted-accent</Button>
      <Button variant="frosted-accent">frosted-accent</Button>
      <Button variant="link">link</Button>
      <Button variant="text">text</Button>
    </div>
  );
}

Glass

Set variant="glass" for a button that sits on a photo, a video or a color surface, such as the controls over a cover image. The button shows a tinted, blurred view of what lies behind it; focus, hover and press work as on every other button. Glass shows every component with a glass variant.

The gradient only makes the glass visible. It is not part of the component.

"use client";

import { PlayIcon, ShareIcon } from "lucide-react";
import { Button, IconButton } from "@/siteplane/components/button";

export default function Example() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      <Button variant="glass">
        <PlayIcon />
        Watch trailer
      </Button>
      <Button variant="glass">Add to list</Button>
      <IconButton icon={<ShareIcon />} label="Share" variant="glass" />
    </div>
  );
}

Sizes

Omit size to use the control size from SiteplaneUIProvider (lg without a provider). IconButton takes the matching icon-* size and inherits the matching provider size (icon-lg without a provider); plain Button also accepts icon-2xs.

Each size sets the icon and the text with it: 14 px icons and 12 px text at xs, 16 px icons and 14 px text from sm to lg, and 20 px icons and 16 px text at xl; icon-2xs holds a 12 px icon. Toggle, Tabs and Toolbar use the same scale. Menu rows keep 16 px icons at every size, 20 px in a touch-size DrawerMenu. Field actions and the corner close of overlays keep a 16 px icon at every size.

Use shape="square" on a plain icon-only Button, including one supplied through a trigger's render prop, to inherit the matching square size without fixing it to a particular preset. IconButton uses this same behavior. Explicit sizes still take precedence.

"use client";

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

const sizes = [
  { size: "xs", iconSize: "icon-xs", label: "Extra small" },
  { size: "sm", iconSize: "icon-sm", label: "Small" },
  { size: "md", iconSize: "icon-md", label: "Medium" },
  { size: "lg", iconSize: "icon-lg", label: "Large" },
  { size: "xl", iconSize: "icon-xl", label: "Extra large" },
] as const;

export default function Example() {
  return (
    <div className="flex flex-wrap items-center gap-6">
      {sizes.map(({ size, iconSize, label }) => (
        <div key={size} className="flex items-center gap-2">
          <Button size={size}>{label}</Button>
          <IconButton
            size={iconSize}
            icon={<PlusIcon />}
            label="Create project"
          />
        </div>
      ))}
    </div>
  );
}

Shapes

Use shape="pill" for a fully rounded button at every size. Only the radius changes: height, padding, variant styling and every state stay as they are, and the keyboard focus ring follows the rounded outline. It works with every variant and with render. On an icon size such as icon-lg it renders a circle; IconButton keeps its square shape. A profile or Group that owns its geometry, such as profile="avatar" or joined buttons, keeps it. For the square icon-only box, see Sizes.

View pricing
"use client";

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

export default function Example() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button shape="pill" variant="accent">
        Get started
      </Button>
      <Button shape="pill">Book a demo</Button>
      <Button shape="pill" variant="outline" render={<a href="#pricing" />}>
        View pricing
      </Button>
      <Button shape="pill" size="sm" variant="outline">
        Small
      </Button>
      <Button shape="pill" size="xl" variant="outline">
        Extra large
      </Button>
    </div>
  );
}

Icons

ButtonWithIcon places an icon before or after a truncating label. IconButton requires label, which becomes its accessible name; a plain icon-only Button needs aria-label. Pass a positive count to IconButton for a notification badge; counts above 99 display as 99+. Include the count’s meaning in label, for example "Notifications, 2 unread". The badge is decorative and anchored to the top trailing corner, so the icon stays centered. A direct Badge child of an icon-sized Button uses the same placement; give it profile="count" for the same count presentation.

"use client";

import { ButtonWithIcon, IconButton } from "@/siteplane/components/button";
import { ArrowRightIcon, BellIcon, DownloadIcon, PlusIcon } from "lucide-react";

export default function Example() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <ButtonWithIcon icon={<DownloadIcon />} variant="outline">
        Download
      </ButtonWithIcon>
      <ButtonWithIcon icon={<ArrowRightIcon />} iconPosition="end">
        Continue
      </ButtonWithIcon>
      <IconButton icon={<PlusIcon />} label="Create project" />
      <IconButton
        icon={<BellIcon />}
        label="Notifications, 2 unread"
        count={2}
        variant="ghost"
      />
    </div>
  );
}

Avatar buttons

Use profile="avatar" with variant="ghost" and an Avatar child for a profile trigger. This profile inherits the matching square icon size (icon-lg without a provider), fills the control with the portrait, and keeps the interaction surface and keyboard focus ring circular. Hover, press and an open menu or popup tint the portrait through the Button's own interaction layer, at the levels of every ghost button (darker in Light, lighter in Dark). The layer lies over the portrait, so the states also read over a photo. The focus ring uses the shared kit focus style, with the same color, thickness and offset as other buttons, independent of display pixel density, and it shows for keyboard focus only: hovering, pressing or opening a tooltip or menu never draws it. Set an accessible aria-label; compose it with MenuTrigger to open an account menu.

<MenuTrigger
  render={<Button profile="avatar" variant="ghost" aria-label="Open profile" />}
>
  <Avatar>
    <AvatarImage src="/profile.jpg" alt="" />
    <AvatarFallback>AB</AvatarFallback>
  </Avatar>
</MenuTrigger>

Field actions

Use profile="field-action" with shape="square" for an icon-only action inside a field: show or hide a secret, copy, clear, open a picker. It renders the bare icon in a 24px target, with no box and no hover or pressed background. The icon rests in the muted text color and turns to the primary text color on hover, press and while its popup is open. Keyboard focus draws the shared focus ring tight around the icon. A pointer focus shows nothing. In every browser a press keeps focus inside the field: it moves to the action, or stays in the input where the component keeps it there, as Combobox and Autocomplete do. Place it in an InputGroupAddon and set aria-label; the coarse-pointer hit area stays 44px.

<InputGroupAddon align="inline-end">
  <Button
    aria-label="Copy link"
    profile="field-action"
    shape="square"
    onClick={copy}
  >
    <CopyIcon />
  </Button>
</InputGroupAddon>

Bare icon actions

Outside a field, use profile="inline" with profileTone="muted", variant="text" and shape="square" for an icon-only action, such as the options button at the end of a list row. It renders the bare icon with no box and no hover or pressed background. The icon rests in the muted text color and turns to the primary text color on hover, press, keyboard focus and while its menu is open. The square of the control size stays as the target, 36 px at lg, and keyboard focus draws the shared focus ring tight around the icon. Set aria-label. SidebarMenuAction renders this action at the end of a Sidebar row.

<Button
  aria-label="Options for Roadmap"
  profile="inline"
  profileTone="muted"
  shape="square"
  variant="text"
>
  <EllipsisIcon />
</Button>

Disabled and loading states

disabled removes a native button from the tab order; aria-disabled keeps it focusable while blocking activation. loading blocks activation and overlays a spinner without moving the label. The application controls it and can add aria-busy.

"use client";

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

export default function Example() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button disabled>Publish</Button>
      <Button aria-disabled="true">Export report</Button>
      <Button loading aria-busy="true">
        Save changes
      </Button>
    </div>
  );
}

Async action

LoadingButton tracks the promise returned by action and shows loadingText until it settles. This example waits briefly instead of calling a service.

"use client";

import { LoadingButton } from "@/siteplane/components/button";

export default function Example() {
  return (
    <LoadingButton
      action={() =>
        new Promise<void>((resolve) => window.setTimeout(resolve, 1400))
      }
    >
      Save changes
    </LoadingButton>
  );
}

Width and alignment

Use width and align to fit buttons into forms and lists, and multiline for two-line content such as a title with a description.

A Button is never wider than the space it sits in. Its label stays on one line and ends with an ellipsis where the Button meets the edge of that space, and while the label is cut, its title carries the full text; a label that fits shows whole and adds no tooltip. The text stays the Button's accessible name. A multiline Button wraps instead, and an icon-size Button, such as a page number, never cuts its figure.

"use client";

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

export default function Example() {
  return (
    <div className="grid w-full max-w-sm gap-3">
      <Button width="full">Create workspace</Button>
      <Button variant="outline" width="full" align="between">
        <span>Storage</span>
        <span>2 GB</span>
      </Button>
      <Button variant="outline" width="full" align="start" multiline>
        <span className="grid gap-0.5">
          <span>Team workspace</span>
          <span className="font-normal">
            Invite members and share projects.
          </span>
        </span>
      </Button>
      <Button variant="ghost" width="fit">
        Add another
      </Button>
    </div>
  );
}

Use render to compose onto an anchor for navigation. Router links must forward the supplied props and ref to their anchor.

"use client";

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

export default function Example() {
  return (
    <Button render={<a href="#pricing" />} variant="outline">
      View pricing
    </Button>
  );
}

Plain Button defaults to type="button"; with render, the rendered element owns its native type. Use type="submit" for a form action.

Use profile="inline" with variant="link" for actions inside prose. This profile changes only geometry and inherits surrounding text size; hover, pressed, disabled and focus states remain owned by Button. Render an anchor with render={<a href="/activity" />} for navigation. The text of a link Button can be selected and copied like any link, while other variants keep their label from being selected. To copy an address in one click, place a Copy Button beside it.

A link is underlined at rest, in the primary text color, inline in a sentence or on its own, and fades slightly on hover and press. A text Button underlines on hover and press. A link in a sentence keeps its words together and moves to the next line as a whole; only a link longer than a whole line breaks inside it.

Stripe needs details from the business to take payments. Open booking

Read the changelog
"use client";

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

export default function Example() {
  return (
    <div className="grid max-w-sm gap-4 text-sm">
      <p>
        Stripe needs details from the business to take payments.{" "}
        <Button profile="inline" render={<a href="#booking" />} variant="link">
          Open booking
        </Button>
      </p>
      <Button
        className="justify-self-start"
        render={<a href="#changelog" />}
        variant="link"
      >
        Read the changelog
      </Button>
    </div>
  );
}

Use profile="navigation-link" with variant="ghost" for navigation triggers and destinations, such as the links of a header bar. They follow the Sidebar rows. The text rests in the secondary color, hover and press draw a light fill and turn the text to the full foreground, and an open disclosure keeps the full foreground. The current destination, marked with aria-current="page", keeps a persistent fill and the full foreground, the same as the active Sidebar row. The profile keeps the shared control size, focus ring, pressed feedback and disabled behavior. Set aria-current="page" only on the actual destination. Full-width list rows, such as a mobile menu, may use variant="text" with the profile. They rest in the full foreground, hover lightens the text, and the current row shows the same persistent fill.

API reference

Button

Component properties
PropTypeDefaultDescription
variantButton variantdefaultOne of the 13 variants in the variant reference below.
sizexs | sm | md | lg | xl | icon-2xs | icon-xs | icon-sm | icon-md | icon-lg | icon-xlprovider / lgText or square icon size.
loadingbooleanfalseShows the spinner and blocks activation.
shapedefault | square | pilldefaultSquare geometry with the matching inherited or explicit icon size; pill rounds the control fully at every size.
disabledbooleanfalseDisables a native button; a custom element uses aria-disabled and activation guards.
aria-disabledboolean | "true" | "false"unsetBlocks activation without removing an otherwise focusable element from the tab order.
aligncenter | start | betweencenterContent alignment; between distributes content across the button.
widthauto | fit | fullautoNatural layout, fit-content width, or full container width.
multilinebooleanfalseAllows wrapping and content-driven height.
profiledefault | avatar | field-action | inline | navigation-linkdefaultThe documented compositions: avatar, field-action, inline and navigation-link.
profileTonemuted | accent | destructiveunsetInline profile only: muted for a secondary inline action or a bare icon action, accent for a link in the accent text color and destructive for an action that removes something.
renderReactElement | render functionbuttonChanges the rendered element; forwards the composed props.
typebutton | submit | resetbutton without renderNative button behavior.
onClickMouseEventHandlerunsetAction callback, guarded while loading or disabled.

children supplies the content and string className adds styling. Other native attributes, event handlers, and refs follow the rendered element. See Base UI composition for the render contract. Button does not expose Base UI Button's separate API: it is implemented with useRender.

Variant reference

VariantIntended presentation
defaultNeutral action on an opaque surface; the default.
accentPrimary action in the brand color.
inverseInverse surface and foreground.
destructiveFilled destructive action.
outlineOutlined neutral action.
outline-destructiveOutlined destructive action.
ghostQuiet action with a hover surface.
ghost-destructiveQuiet destructive action.
muted-accentAccent text on a tint of the brand color.
frosted-accentTranslucent accent presentation.
glassGlass surface for use over a picture.
linkText with an underline on interaction.
textText-only presentation.

IconButton

count?: number adds a bounded count badge. Zero, negative and non-finite values hide it. Fractional positive counts are rounded up.

Component properties
PropTypeDefaultDescription
iconReactNoderequiredThe icon content.
labelstringrequiredBecomes aria-label; replaces the aria-label prop in this recipe.
sizeicon-xs | icon-sm | icon-md | icon-lg | icon-xlprovider / icon-lgSquare icon size matching the shared control size.

Accepts ButtonStandardProps except children, aria-label, shape and the wider size union. It is always square and accepts no text children.

ButtonWithIcon

Component properties
PropTypeDefaultDescription
iconReactNoderequiredLeading or trailing icon.
childrenReactNoderequiredVisible label, rendered in a truncating span.
iconPositionstart | endstartIcon placement.

Accepts the remaining ButtonStandardProps. Use plain Button with your own content for a multiline icon-and-label composition; this recipe deliberately truncates its label.

LoadingButton

Component properties
PropTypeDefaultDescription
action() => unknown | Promise<unknown>unsetRuns after onClick unless prevented; the recipe tracks its pending state.
childrenReactNoderequiredIdle label.
iconReactNodeunsetOptional idle icon.
iconPositionstart | endstartIdle icon placement.
loadingbooleanfalseForces pending presentation in addition to internal pending state.
loadingTextReactNodeSavingLabel shown beside the spinner while pending.
onClickMouseEventHandler<HTMLButtonElement>unsetCalled before action; preventDefault skips the action.
onError(error: unknown) => voidunsetReceives rejection; render your own useful error feedback.

Accepts the remaining ButtonStandardProps. Pending state disables the button and sets aria-busy. The recipe renders an outer span; use application layout when positioning it. It does not cancel a request, save data, or implement a success notification.

Styling utility and types

buttonVariants({ variant, size, className }) returns the utility classes Button adds beside its stylesheet, such as the touch target, the icon handling and a few variant details. Omitted size defaults to lg; omitted variant defaults to default. The look comes from the kit stylesheet through the attributes Button renders, and the helper does not render the effect layers, resolve provider size, or add disabled/loading guards. Use Button, with render for a link or another element, whenever you need a button's look.

The module exports ButtonProps, ButtonBaseProps, ButtonProfile, ButtonProfileTone, ButtonDefaultProfileProps, InlineButtonProps, ButtonStandardProps, IconButtonProps, ButtonWithIconProps, and LoadingButtonProps. These types describe the same contracts above. The discriminated profile types allow profileTone only with the inline profile.

Read the installed source.

Usage notes

A native button activates with Enter or Space. An anchor keeps link behavior, including Enter activation and browser navigation affordances. Preserve visible focus indicators and provide a meaningful accessible name for every action. Native disabled buttons are skipped by Tab; aria-disabled buttons remain discoverable. Do not use color alone to explain a destructive or unavailable action.