Skip to content

Button

The action trigger: six variants, eight sizes and at most one filled indigo button per view.

Status
Stable
Level
Atom
Category
Actions
Adoption
Not used yet
import { Button } from "@oration/canon/components/button";
packages/canon/src/components/button.tsx

Schedule payment run

212 invoices to 48 suppliers, Friday, Oct 2 at 2:00 PM CT.

import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";export function Hero() {    return (        <div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-popover text-left shadow-lg">            <div className="flex flex-col gap-1 p-4">                <p className="text-base leading-none font-medium text-foreground">                    Schedule payment run                </p>                <p className="text-sm text-muted-foreground">                    212 invoices to 48 suppliers, Friday, Oct 2 at 2:00 PM CT.                </p>            </div>            <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3">                <Button type="button" variant="ghost">                    Cancel                </Button>                <Button type="button" variant="outline">                    Save draft                </Button>                <Button                    type="button"                    onClick={() =>                        toast.add({                            type: "success",                            title: "Payment run scheduled",                            description: "Friday, Oct 2 at 2:00 PM CT.",                        })                    }                >                    Schedule run                </Button>            </div>        </div>    );}

Usage#

Button starts an action: it saves, sends, opens, runs or removes something. It is the most used component in the suite, and the fill is rationed. Most buttons on a screen are outline, secondary or ghost; the filled indigo button is saved for the one action the view exists for. Buttons that go somewhere keep link semantics through render, and icon-only buttons always carry a name.

When to use

  • To commit or start an action in place: Save, Send, Run test, Approve and send.
  • For the primary action of a dialog, sheet, form or page, filled and placed last in its row.
  • For secondary and tertiary actions beside it, as outline, secondary or ghost.
  • For a styled link that looks like an action, such as Open workspace, by rendering a Link through render.
  • As a square icon button in toolbars and row actions, with an aria-label and a tooltip.

When not to use

  • To navigate inside running text. Use a plain underlined link.
  • To show a value such as a stage or tier. Values are tags, not controls. Use Tag
  • For an icon-only action that needs a tooltip and a pressed state. Reach for the ready-made version. Use Icon action
  • For an on and off setting that applies immediately. Use Switch
  • For choosing one of two to five modes of a view. Use Segmented control
  • For a delete that cannot be undone and should not be one click. Use Hold button

The One Filled Button Rule

Each view has at most one filled indigo button, the action the view exists for. Everything else is outline, secondary, ghost, link or a destructive tint. In a stack of proposals, only the expanded one shows its filled button.

Destructive is a tint

Destructive buttons are a 10% Signal Red tint with red text, never a solid red fill. Loss is communicated by the words and by a confirmation, not by alarm color.

Anatomy#

  1. Container. A 32px box with 10px corners and a transparent 1px border that becomes the focus edge. bg-clip-padding keeps fills inside it.
  2. Leading icon. Optional, 16px, marked data-icon="inline-start" so the left padding tightens by 2px.
  3. Label. Control type: 14px at weight 500, one line, verb first, sentence case.
  4. Trailing icon. Optional, marked data-icon="inline-end", for disclosure chevrons and outbound arrows.
  5. Focus ring. A 3px Focus Indigo ring at 40% plus an indigo border, only for keyboard focus.

Examples#

Variants

Primary for the one action the view exists for, outline and secondary beside it, ghost for low-emphasis actions, the red tint for destructive ones and link for inline actions.

import { Button } from "@oration/canon/components/button";import { Trash2Icon } from "lucide-react";export function Variants() {    return (        <>            <Button type="button">Approve and send</Button>            <Button type="button" variant="outline">                Edit draft            </Button>            <Button type="button" variant="secondary">                Add note            </Button>            <Button type="button" variant="ghost">                Skip            </Button>            <Button type="button" variant="destructive">                <Trash2Icon data-icon="inline-start" aria-hidden="true" />                Delete supplier            </Button>            <Button type="button" variant="link">                View remittance            </Button>        </>    );}

Sizes

24, 28, 32 and 36px. Default (32px) is the control height across the suite; sm (28px) is for toolbars and dense rows; lg is for touch-first layouts.

import { Button } from "@oration/canon/components/button";export function Sizes() {    return (        <>            <Button type="button" variant="outline" size="xs">                Extra small            </Button>            <Button type="button" variant="outline" size="sm">                Small            </Button>            <Button type="button" variant="outline">                Default            </Button>            <Button type="button" variant="outline" size="lg">                Large            </Button>        </>    );}

With icons

Mark the icon with data-icon="inline-start" or "inline-end" so the padding on that side tightens by 2px and the label stays optically centered.

import { Button } from "@oration/canon/components/button";import { ChevronDownIcon, DownloadIcon, PlusIcon, SendIcon } from "lucide-react";export function WithIcons() {    return (        <>            <Button type="button">                <SendIcon data-icon="inline-start" aria-hidden="true" />                Send invite            </Button>            <Button type="button" variant="outline">                <DownloadIcon data-icon="inline-start" aria-hidden="true" />                Export CSV            </Button>            <Button type="button" variant="outline">                Stage                <ChevronDownIcon data-icon="inline-end" aria-hidden="true" />            </Button>            <Button type="button" variant="ghost" size="sm">                <PlusIcon data-icon="inline-start" aria-hidden="true" />                Add filter            </Button>        </>    );}

Icon only

Square sizes for toolbars and row actions. Each one names its action with aria-label and repeats it in a tooltip.

import { Button } from "@oration/canon/components/button";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { DownloadIcon, MoreHorizontalIcon, PlusIcon } from "lucide-react";export function IconOnly() {    const actions = [        { label: "Add supplier", icon: PlusIcon, size: "icon" as const },        {            label: "Download statement",            icon: DownloadIcon,            size: "icon-sm" as const,        },        {            label: "More actions",            icon: MoreHorizontalIcon,            size: "icon-xs" as const,        },    ];    return (        <>            {actions.map((action) => (                <Tooltip key={action.label}>                    <TooltipTrigger                        render={                            <Button                                type="button"                                variant="outline"                                size={action.size}                                aria-label={action.label}                            />                        }                    >                        <action.icon aria-hidden="true" />                    </TooltipTrigger>                    <TooltipContent>{action.label}</TooltipContent>                </Tooltip>            ))}        </>    );}

States#

RestHoverFocusPressedDisabled
Primary
outline
ghost
destructive
import { Button } from "@oration/canon/components/button";import { cn } from "@oration/canon/lib/utils";export function StatesMatrix() {    const variants = ["default", "outline", "ghost", "destructive"] as const;    return (        <div className="grid w-full min-w-0 grid-cols-[5.5rem_repeat(5,minmax(0,1fr))] items-center gap-x-2 gap-y-3 overflow-x-auto">            <span />            {states.map((state) => (                <span                    key={state}                    className="text-center text-xs text-muted-foreground"                >                    {state}                </span>            ))}            {variants.map((variant) => (                <div key={variant} className="contents">                    <span className="text-13 text-muted-foreground capitalize">                        {variant === "default" ? "Primary" : variant}                    </span>                    {states.map((state) => (                        <div key={state} className="flex justify-center">                            <Button                                type="button"                                variant={variant}                                tabIndex={-1}                                disabled={state === "Disabled"}                                className={cn(                                    "pointer-events-none",                                    forced[variant]?.[state],                                )}                            >                                Save                            </Button>                        </div>                    ))}                </div>            ))}        </div>    );}
States
StateTreatment
RestThe variant's fill. Outline adds the control shadow; primary adds its inset highlight.
HoverPrimary darkens by mixing 9% black (8% white in dark). Outline and ghost fill Well Gray. Secondary mixes 5% ink. Destructive deepens to a 20% tint. 150ms on the house ease-out.
Focus visibleIndigo border and a 3px ring at 40%. Destructive uses a red ring at 20%. Pointer focus draws nothing.
PressedScales to 0.96 while held, only when motion is allowed.
ExpandedWhen it opens a menu or popover (aria-expanded), outline and ghost keep the hover fill while the popup is open.
Disabled50% opacity and no pointer events. Prefer explaining why nearby over disabling silently.
InvalidWith aria-invalid, a red border and a 3px red ring at 20%, for buttons that act as form controls.
PendingUse Pending button: the label fades in place and a spinner sits on top, so the width never jumps.

Behavior#

  • Renders a native <button> through Base UI, so Enter and Space activate it and disabled removes it from the tab order.
  • Base UI renders native buttons with type="button", so a Button inside a form never submits it by accident. Pass type="submit" on the one button that should.
  • render={<Link href="…" />} renders an anchor with the button's look while keeping link semantics: it is announced as a link and opens in a new tab with ⌘-click.
  • Inside a Button group the corners step down to 8px so the group reads as one concentric shape.
  • Icons without an explicit size-* class are sized for you: 16px by default, 14px at sm, 12px at xs.
  • The press scale is behind motion-safe:, so reduced-motion users get the color change only.

Do and don't#

Do. Give the view one filled button for its main action, last in the row, with the rest outline or ghost.
Don't. Fill every button in a footer. When everything is primary, nothing is.
Do. Draw destructive actions as the red tint and name exactly what goes away.
Don't. Use a solid red fill. It shouts, and it breaks the tint convention every other destructive action follows.
Do. Start labels with a verb and name the object: Schedule run, Add supplier, Export CSV.
Don't. Use vague labels such as OK, Submit, Yes or Click here.

Content#

  • Verb first, sentence case, no trailing punctuation: Send invite, not Invite Sent! or SEND.
  • Name the object when the verb alone is ambiguous: Delete supplier rather than Delete.
  • Keep labels to one to three words. If it needs a sentence, the sentence belongs beside the button.
  • Pair a confirming button with its dialog title: a dialog titled Delete Northwind Freight? confirms with Delete supplier.
  • Use Cancel to back out of a change and Close to dismiss something that changed nothing.
  • Don't put counts in labels unless they change the action: Approve 12 invoices is useful, Save (3) is not.

Accessibility#

  • Icon-only buttons need an aria-label that names the action (Add supplier, not Plus) and a tooltip with the same words.
  • Mark decorative icons aria-hidden="true"; the label already names the action.
  • Don't disable a submit button to signal invalid input. Keep it enabled and show the errors on submit, so screen reader users learn why.
  • Hit areas are at least 24px (the xs size) and 32px on touch surfaces; toolbars on touch use the default size.
  • Links rendered as buttons stay links. Don't put onClick navigation on a real button.
  • Toggle buttons expose state with aria-pressed; buttons that open popups expose aria-expanded.
Keyboard interactions
KeysAction
TabMoves focus to the button. Disabled buttons are skipped unless focusableWhenDisabled is set.
EnterActivates the button.
SpaceActivates the button on release.

Design tokens#

Design tokens
TokenUsed for
--primaryFilled background of the default variant; link text
--primary-foregroundLabel and icons on the filled variant
--secondarySecondary background
--mutedHover fill of outline and ghost
--borderOutline stroke (light)
--inputOutline stroke and 30% fill (dark)
--destructiveDestructive text and its 10% and 20% tints
--ringFocus border and 3px ring at 40%
shadow-xsThe control shadow on outline buttons
--radius-lg10px corners; 8px at sm and xs

API reference#

Button

The button, built on Base UI Button. Also exported: buttonVariants, for styling another element as a button.

Other props spread onto Base UI Button (native <button> attributes).

Props of Button
PropTypeDefaultDescription
variant"default" | "outline" | "secondary" | "ghost" | "destructive" | "link""default"Visual weight. default is the filled indigo button: one per view.
size"xs" | "sm" | "default" | "lg" | "icon-xs" | "icon-sm" | "icon" | "icon-lg""default"Height: 24, 28, 32 or 36px. icon-* sizes are square. sm is for toolbars.
renderReactElement | (props, state) => ReactElementNo defaultRender as another element, such as <Link href />. Non-button elements keep their own semantics.
disabledbooleanfalseDims to 50% and blocks interaction.
focusableWhenDisabledbooleanfalseKeeps a disabled button in the tab order so its tooltip can explain why.
type"button" | "submit" | "reset""button"Base UI defaults native buttons to "button". Pass "submit" on a form's submit button.
classNamestringNo defaultMerged after the variant classes, so it can override them.