Button
The action trigger: six variants, eight sizes and at most one filled indigo button per view.
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
Linkthroughrender. - As a square icon button in toolbars and row actions, with an
aria-labeland 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
Destructive is a tint
Anatomy#
- Container. A 32px box with 10px corners and a transparent 1px border that becomes the focus edge.
bg-clip-paddingkeeps fills inside it. - Leading icon. Optional, 16px, marked
data-icon="inline-start"so the left padding tightens by 2px. - Label. Control type: 14px at weight 500, one line, verb first, sentence case.
- Trailing icon. Optional, marked
data-icon="inline-end", for disclosure chevrons and outbound arrows. - 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> ))} </> );}As a link
When the action goes somewhere, render a Link. It looks like a button and is announced, opened and middle-clicked like a link.
import { Button } from "@oration/canon/components/button";import { ArrowUpRightIcon } from "lucide-react";import Link from "next/link";export function AsLink() { return ( <> <Button variant="outline" render={<Link href="/design/components/tag" />} > Read the Tag guidance </Button> <Button variant="ghost" render={<Link href="/" />}> Open workspace <ArrowUpRightIcon data-icon="inline-end" aria-hidden="true" /> </Button> </> );}States#
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> );}| State | Treatment |
|---|---|
| Rest | The variant's fill. Outline adds the control shadow; primary adds its inset highlight. |
| Hover | Primary 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 visible | Indigo border and a 3px ring at 40%. Destructive uses a red ring at 20%. Pointer focus draws nothing. |
| Pressed | Scales to 0.96 while held, only when motion is allowed. |
| Expanded | When it opens a menu or popover (aria-expanded), outline and ghost keep the hover fill while the popup is open. |
| Disabled | 50% opacity and no pointer events. Prefer explaining why nearby over disabling silently. |
| Invalid | With aria-invalid, a red border and a 3px red ring at 20%, for buttons that act as form controls. |
| Pending | Use 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 anddisabledremoves it from the tab order. - Base UI renders native buttons with
type="button", so a Button inside a form never submits it by accident. Passtype="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 atsm, 12px atxs. - The press scale is behind
motion-safe:, so reduced-motion users get the color change only.
Do and don't#
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-labelthat 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
xssize) and 32px on touch surfaces; toolbars on touch use the default size. - Links rendered as buttons stay links. Don't put
onClicknavigation on a real button. - Toggle buttons expose state with
aria-pressed; buttons that open popups exposearia-expanded.
| Keys | Action |
|---|---|
| Tab | Moves focus to the button. Disabled buttons are skipped unless focusableWhenDisabled is set. |
| Enter | Activates the button. |
| Space | Activates the button on release. |
Design tokens#
| Token | Used for |
|---|---|
--primary | Filled background of the default variant; link text |
--primary-foreground | Label and icons on the filled variant |
--secondary | Secondary background |
--muted | Hover fill of outline and ghost |
--border | Outline stroke (light) |
--input | Outline stroke and 30% fill (dark) |
--destructive | Destructive text and its 10% and 20% tints |
--ring | Focus border and 3px ring at 40% |
shadow-xs | The control shadow on outline buttons |
--radius-lg | 10px 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).
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
render | ReactElement | (props, state) => ReactElement | No default | Render as another element, such as <Link href />. Non-button elements keep their own semantics. |
disabled | boolean | false | Dims to 50% and blocks interaction. |
focusableWhenDisabled | boolean | false | Keeps 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. |
className | string | No default | Merged after the variant classes, so it can override them. |