Inputs
Button
Buttons trigger an action or, given an href, navigate like a link. Six variants cover everything from the one primary action on a screen to quiet toolbar icons, in three heights that line up with every other control (32, 36 and 40px). Loading, icons and full width are built in, so you never fight the base classes.
<div className="flex flex-wrap items-center justify-center gap-3"> <Button variant="primary">Publish</Button> <Button variant="secondary">Preview</Button> <Button variant="outline">Duplicate</Button> <Button variant="soft">Share</Button> <Button variant="ghost">Cancel</Button> <Button variant="danger">Delete</Button></div>Usage
import { Button } from "@/components/ui/button"; export function Example() { return <Button variant="primary">Create invoice</Button>;}Examples
Variants
Use one primary button per view. Secondary is the default; soft and ghost suit dense UIs, danger is for destructive actions only.
<div className="flex flex-wrap items-center justify-center gap-3"> <Button variant="primary">Publish</Button> <Button variant="secondary">Preview</Button> <Button variant="outline">Duplicate</Button> <Button variant="soft">Share</Button> <Button variant="ghost">Cancel</Button> <Button variant="danger">Delete</Button></div>Sizes
sm, md and lg match the input heights. The icon sizes are square and need an aria-label.
<div className="flex flex-wrap items-center justify-center gap-3"> <Button variant="primary" size="sm">Small</Button> <Button variant="primary" size="md">Medium</Button> <Button variant="primary" size="lg">Large</Button> <Button size="icon-sm" aria-label="Settings"><Settings2 strokeWidth={1.75} /></Button> <Button size="icon" aria-label="Settings"><Settings2 strokeWidth={1.75} /></Button> <Button size="icon-lg" aria-label="Settings"><Settings2 strokeWidth={1.75} /></Button></div>With icons
Pass icons through iconLeft and iconRight; padding on that side tightens automatically.
<div className="flex flex-wrap items-center justify-center gap-3"> <Button variant="primary" iconLeft={<Plus strokeWidth={1.75} />}>New project</Button> <Button iconLeft={<Download strokeWidth={1.75} />}>Export CSV</Button> <Button variant="ghost" iconRight={<ArrowRight strokeWidth={1.75} />}>View report</Button></div>Loading
loading swaps the leading icon for a spinner, disables the button and sets aria-busy. The label stays, so the width doesn't jump.
"use client"; import { useState } from "react";import { Check, Send } from "lucide-react";import { Button } from "@/components/ui/button"; export function SaveButtonDemo() { const [state, setState] = useState<"idle" | "saving" | "saved">("idle"); function save() { setState("saving"); setTimeout(() => setState("saved"), 1400); setTimeout(() => setState("idle"), 3200); } return ( <div className="flex flex-wrap items-center justify-center gap-3"> <Button variant="primary" loading={state === "saving"} iconLeft={state === "saved" ? <Check strokeWidth={1.75} /> : <Send strokeWidth={1.75} />} onClick={save} > {state === "saving" ? "Sending invoice…" : state === "saved" ? "Invoice sent" : "Send invoice"} </Button> <Button variant="secondary" loading> Syncing </Button> </div> );}Button group
ButtonGroup joins related actions into one control. Give it an aria-label.
<div className="flex flex-wrap items-center justify-center gap-3"> <ButtonGroup aria-label="Text style"> <Button size="icon" aria-label="Bold"><Bold strokeWidth={1.75} /></Button> <Button size="icon" aria-label="Italic"><Italic strokeWidth={1.75} /></Button> <Button size="icon" aria-label="Underline"><Underline strokeWidth={1.75} /></Button> </ButtonGroup> <ButtonGroup aria-label="Date range"> <Button>Day</Button> <Button>Week</Button> <Button>Month</Button> </ButtonGroup></div>In context
A destructive confirmation footer: full-width on phones, natural width from sm up, disabled state included.
Delete “Fernhill Studio”?
The workspace, 12 projects and 48 invoices will be removed for everyone. This can't be undone.
<div className="mx-auto w-full max-w-lg rounded-ui-lg border border-line bg-surface p-5 shadow-ui"> <p className="text-heading">Delete “Fernhill Studio”?</p> <p className="mt-1 text-[13px] leading-5 text-muted"> The workspace, 12 projects and 48 invoices will be removed for everyone. This can't be undone. </p> <div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> <Button variant="ghost" className="max-sm:w-full">Keep workspace</Button> <Button variant="outline" disabled className="max-sm:w-full">Archive instead</Button> <Button variant="danger" iconLeft={<Trash2 strokeWidth={1.75} />} className="max-sm:w-full"> Delete workspace </Button> </div></div>Props
Button
variant"primary" | "secondary" | "outline" | "ghost" | "soft" | "danger"default: "secondary"Visual weight. Keep one primary per view.size"sm" | "md" | "lg" | "icon-sm" | "icon" | "icon-lg"default: "md"Height 32 / 36 / 40px. Icon sizes are square.loadingbooleandefault: falseShows a spinner, disables the button and sets aria-busy.iconLeftReactNodedefault: —Icon before the label. Replaced by the spinner while loading.iconRightReactNodedefault: —Icon after the label, e.g. an arrow or chevron.fullWidthbooleandefault: falseStretch to the container width.hrefstringdefault: —Render a Next.js Link with button styling instead of a <button>.type"button" | "submit" | "reset"default: "button"Native button type. Defaults to button so forms don't submit by accident....propsButtonHTMLAttributesdefault: —Any other <button> attribute (onClick, disabled, aria-*).
ButtonGroup
orientation"horizontal" | "vertical"default: "horizontal"Direction the buttons are joined in.aria-labelrequiredstringdefault: —Describes the group for screen readers.childrenrequiredReactNodedefault: —Buttons (or anything with matching borders).
Accessibility
- Renders a native <button type="button">, so Enter and Space activate it and it never submits a form unless you pass type="submit".
- Icon-only buttons have no visible text: always pass an aria-label ("Settings", "Remove file").
- loading sets aria-busy and disables the button so a second click can't send twice; keep the label meaningful while it spins.
- Focus shows a 3px ring in the accent color on keyboard focus only (focus-visible), the same ring every Plinth control uses.
- ButtonGroup renders role="group"; give it an aria-label so the shared purpose is announced.
- Danger is never the only cue: pair it with explicit copy such as "Delete workspace".