Button
The kit's button: the accent fill, an ink second primary, and a tooltip with keycaps built in.
Vibra fills the default button in the accent with an ink-mixed hover (shadcn fades it to 80%) and an inset shadow on press, adds an ink variant for a second primary in a region that has already spent its accent (it reads apart from the default fill only where the accent is a colour, since the default palette's accent is an ink itself), keeps outline on the card plane with the hairline in both modes, and focuses with a solid 2px accent outline instead of a translucent ring. It dims an aria-disabled button the way it dims a disabled one, so one kept in the tab order with focusableWhenDisabled still reads as unavailable, and it tightens the padding beside an icon on the inline axis, so the icon keeps it in a right-to-left page. buttonVariants sets each class group once per variant and size, so a link wearing it draws exactly the Button — outline's hairline included — with no class merge; the rendered button names both axes as data-variant and data-size. tooltip and shortcut wrap it in a tooltip that prints the chord as keycaps, and announce the chord through aria-keyshortcuts in ARIA's key names.
Install
npx shadcn@latest add @vibra/buttonNeeds the @vibra registry in your components.json — set it up once.
Examples
import { DownloadIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
export default function ButtonDemo() {
return (
<div className="flex max-w-lg flex-wrap items-center justify-center gap-2">
<Button>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
New report
</Button>
<Button variant="outline">
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Export CSV
</Button>
<Button variant="secondary">Duplicate</Button>
<Button variant="ghost">Cancel</Button>
<Button variant="destructive">
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
Delete
</Button>
<Button variant="outline" size="icon" aria-label="Search reports" tooltip="Search reports" shortcut="mod+k">
<SearchIcon aria-hidden="true" />
</Button>
</div>
)
}Sizes
Four heights, each with the icon size that matches it, so a toolbar can mix labelled and icon-only buttons in one row.
import { DownloadIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
// Each text size beside the icon size that shares its height, so a toolbar can
// mix the two without a row going ragged.
const SIZES = [
{ size: "xs", icon: "icon-xs", height: 24 },
{ size: "sm", icon: "icon-sm", height: 28 },
{ size: "default", icon: "icon", height: 32 },
{ size: "lg", icon: "icon-lg", height: 36 },
] as const
export default function ButtonSizes() {
return (
<dl className="grid grid-cols-[auto_auto] items-center gap-x-6 gap-y-2.5">
{SIZES.map(({ size, icon, height }) => (
<div key={size} className="contents">
<dt className="font-mono text-xs text-muted-foreground">
{size} · {height}px
</dt>
<dd className="flex items-center gap-1.5">
<Button variant="outline" size={size}>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Export CSV
</Button>
<Button variant="outline" size={icon} aria-label="Export CSV">
<DownloadIcon aria-hidden="true" />
</Button>
</dd>
</div>
))}
</dl>
)
}With an icon
Mark the icon data-icon="inline-start" or "inline-end", and the padding on its side tightens so icon and label sit centred together.
import { ArrowRightIcon, UserPlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
export default function ButtonWithIcon() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
{/* data-icon names the side the icon sits on, and the padding on that
side tightens so icon and label read as one centred unit. */}
<Button variant="outline">
<UserPlusIcon data-icon="inline-start" aria-hidden="true" />
Invite teammate
</Button>
<Button>
Continue to billing
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" className="rtl:rotate-180" />
</Button>
</div>
)
}Icon only
Every icon-only button carries an aria-label; the tooltip shows the same name to the pointer, with the key when there is one.
import { EllipsisIcon, ExpandIcon, ImageDownIcon, RefreshCwIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
// A chart card's own toolbar: ghost icon buttons, each named for a screen
// reader and shown by name to the pointer, two with the key that fires them.
export default function ButtonIcon() {
return (
<div className="flex w-full max-w-sm items-center justify-between gap-3 panel py-1.5 ps-4 pe-1.5">
<p className="text-sm font-semibold">Weekly revenue</p>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon-sm" aria-label="Refresh" tooltip="Refresh" shortcut="r">
<RefreshCwIcon aria-hidden="true" />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Download as PNG" tooltip="Download as PNG">
<ImageDownIcon aria-hidden="true" />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Full screen" tooltip="Full screen" shortcut="f">
<ExpandIcon aria-hidden="true" />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="More chart actions" tooltip="More actions">
<EllipsisIcon aria-hidden="true" />
</Button>
</div>
</div>
)
}Link
An action in the middle of a sentence. Underlined in running text, so it stands apart by more than its colour.
import { Button } from "@/components/ui/button"
// An action inside a sentence: it opens the card form rather than going
// anywhere, so it is a button that reads as a link. In running text it keeps
// its underline, so it is told apart from the words around it by more than
// colour.
export default function ButtonLink() {
return (
<p className="max-w-sm text-sm text-pretty text-muted-foreground">
The card ending 0187 expires in May, before the next invoice runs.{" "}
<Button variant="link" className="h-auto p-0 underline">
Update payment method
</Button>
</p>
)
}As a link
When it goes somewhere, it is an anchor wearing buttonVariants — a new tab says so to a screen reader.
import { ExternalLinkIcon, FileTextIcon } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
// Somewhere to go is a link, whatever it looks like: an anchor wearing the
// button's classes keeps link semantics, middle-click and "open in new tab".
export default function ButtonAsLink() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<a href="/billing/invoices/INV-2041" className={buttonVariants()}>
<FileTextIcon data-icon="inline-start" aria-hidden="true" />
Open invoice
</a>
<a
href="https://status.northwind.example"
target="_blank"
rel="noreferrer"
className={buttonVariants({ variant: "outline" })}
>
Status page
<ExternalLinkIcon data-icon="inline-end" aria-hidden="true" />{" "}
<span className="sr-only">(opens in a new tab)</span>
</a>
</div>
)
}Full width
Stacked options in a narrow column, with the accent on the way most people come in.
import { FingerprintIcon, KeyRoundIcon, MailIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
// A sign-in card's options, each the width of the column: one accent fill
// for the way most people come in, the others outlined beneath it.
export default function ButtonFullWidth() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<p className="pb-1 text-sm font-medium">Sign in to Northwind Analytics</p>
<Button className="w-full">
<MailIcon data-icon="inline-start" aria-hidden="true" />
Continue with email
</Button>
<Button variant="outline" className="w-full">
<KeyRoundIcon data-icon="inline-start" aria-hidden="true" />
Continue with SSO
</Button>
<Button variant="outline" className="w-full">
<FingerprintIcon data-icon="inline-start" aria-hidden="true" />
Use a passkey
</Button>
</div>
)
}Ink beside a coloured accent
Two primaries in one region: the recommended plan spends the accent, the other's call to action is ink. The pair only differs where the accent is a colour, so data-preset scopes Alpine to the region; on the default palette the two fills are the same ink.
import { Button } from "@/components/ui/button"
const PLANS = [
{ name: "Pro", price: "$24 a seat", note: "Recommended for your team of 12", recommended: true },
{ name: "Business", price: "$48 a seat", note: "SSO, audit log and a named contact", recommended: false },
]
// Two primaries in one region: the plan we recommend spends the accent, and
// the other plan's call to action, a real choice and so still a fill, is ink.
// The pair only reads apart where the accent is a colour. On the default
// palette the accent is an ink too, and the two fills look the same. So this
// region wears a coloured palette: data-preset scopes Alpine's cobalt to the
// wrapper while the page keeps its own. The palette's stylesheet has to be
// installed for the attribute to take.
export default function ButtonPalette() {
return (
<ul data-preset="alpine" className="flex w-full max-w-md flex-col divide-y divide-border panel text-foreground">
{PLANS.map((plan) => (
<li key={plan.name} className="flex items-center justify-between gap-4 px-4 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<p className="text-sm font-medium">
{plan.name} <span className="font-normal text-muted-foreground tabular-nums">· {plan.price}</span>
</p>
<p className="text-xs text-muted-foreground">{plan.note}</p>
</div>
{plan.recommended ? (
<Button className="shrink-0">Upgrade to Pro</Button>
) : (
<Button variant="ink" className="shrink-0">
Talk to sales
</Button>
)}
</li>
))}
</ul>
)
}Destructive
The danger tone on its tint, for the one action that cannot be taken back. Pair it with ConfirmDialog, which asks first.
import { Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
// The danger tone on its own tint, not a filled alarm: the one irreversible
// action in the settings reads as serious without shouting over the page.
export default function ButtonDestructive() {
return (
<div className="flex w-full max-w-lg flex-col gap-3 panel p-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Delete this workspace</p>
<p className="text-sm text-muted-foreground">
Removes 14 dashboards, 3 integrations and every scheduled export. This can’t be undone.
</p>
</div>
<Button variant="destructive" className="self-start sm:self-center">
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
Delete workspace
</Button>
</div>
)
}Loading
A spinner in the icon's place, aria-busy, and focusableWhenDisabled so a keyboard user keeps the focus they pressed with. AsyncButton runs this off a promise.
"use client"
import * as React from "react"
import { SendIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
export default function ButtonLoading() {
const [state, setState] = React.useState<"idle" | "sending" | "sent">("idle")
const sending = state === "sending"
React.useEffect(() => {
if (!sending) return
const timer = window.setTimeout(() => setState("sent"), 1200)
return () => window.clearTimeout(timer)
}, [sending])
return (
<div className="flex flex-col items-center gap-3">
{/* focusableWhenDisabled: a natively disabled button drops the focus
it was pressed with, and a keyboard user lands back on the page. */}
<Button
disabled={sending}
focusableWhenDisabled
aria-busy={sending || undefined}
onClick={() => setState("sending")}
>
{sending ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<SendIcon data-icon="inline-start" aria-hidden="true" />
)}
{sending ? "Sending…" : "Send invoice"}
</Button>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{state === "sent" ? "INV-2041 is on its way to Blue Harbor Logistics." : null}
</p>
</div>
)
}Disabled, with a reason
Disabled but focusable, and described by the line that says why. Select the invoices and both wake up.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
const REASON = "button-disabled-reason"
export default function ButtonDisabled() {
const [selected, setSelected] = React.useState(false)
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex items-center gap-3">
<Checkbox
id="button-disabled-select"
checked={selected}
onCheckedChange={(checked) => setSelected(checked === true)}
/>
<Label htmlFor="button-disabled-select">Select all 12 overdue invoices</Label>
</div>
{/* Disabled but focusable: a keyboard user reaches each button, hears it
is unavailable, and hears why from the line it points at. */}
<div className="flex flex-wrap gap-2">
<Button variant="outline" disabled={!selected} focusableWhenDisabled aria-describedby={REASON}>
Send reminders
</Button>
<Button variant="outline" disabled={!selected} focusableWhenDisabled aria-describedby={REASON}>
Export CSV
</Button>
</div>
<p id={REASON} aria-live="polite" className="text-xs text-muted-foreground">
{selected ? "12 invoices selected." : "Select at least one invoice to send reminders or export."}
</p>
</div>
)
}After an error
The failure is an alert beside the way out of it; the success is a status line. The button stays mounted, so focus stays where it was.
"use client"
import * as React from "react"
import { RotateCwIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Spinner } from "@/components/ui/spinner"
export default function ButtonRetry() {
const [state, setState] = React.useState<"failed" | "retrying" | "synced">("failed")
const retrying = state === "retrying"
React.useEffect(() => {
if (!retrying) return
const timer = window.setTimeout(() => setState("synced"), 1200)
return () => window.clearTimeout(timer)
}, [retrying])
return (
<div className="flex w-full max-w-md flex-col gap-3">
{/* The refusal and the success never share a slot: the alert goes when
the retry lands, and the status line has been there all along. */}
{state === "synced" ? null : (
<Callout variant="danger" role="alert" title="Invoice sync failed at 09:42">
The billing provider timed out after 30 seconds. Nothing was changed.
</Callout>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
{/* The button outlives the error, so the focus it was pressed with
stays put when the alert goes. */}
<Button
variant="outline"
disabled={retrying}
focusableWhenDisabled
aria-busy={retrying || undefined}
onClick={() => setState("retrying")}
>
{retrying ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<RotateCwIcon data-icon="inline-start" aria-hidden="true" />
)}
{retrying ? "Retrying…" : state === "synced" ? "Sync now" : "Retry sync"}
</Button>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{state === "synced" ? "Synced just now. 1,204 invoices are up to date." : null}
</p>
</div>
</div>
)
}With a shortcut
Keycaps in the label, hidden from the accessible name, and the chord in aria-keyshortcuts instead.
import { SearchIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
// The keycaps sit in the label, where the eye already is, and stay out of the
// accessible name: aria-keyshortcuts announces the chord instead, in the key
// names ARIA reads — ⌘K on a Mac and Ctrl+K everywhere else.
export default function ButtonShortcut() {
return (
<Button
variant="outline"
aria-keyshortcuts="Meta+K Control+K"
className="w-full max-w-xs justify-start font-normal text-muted-foreground"
>
<SearchIcon data-icon="inline-start" aria-hidden="true" />
Search reports and customers
<KbdShortcut keys="mod+k" size="sm" aria-hidden="true" className="ms-auto" />
</Button>
)
}Bound to a key
shortcut prints and announces the chord; matchesHotkey binds it, from the same string, so the three never disagree. The chord fires inside the field, where a bare key would be typed.
"use client"
import * as React from "react"
import { SendIcon } from "lucide-react"
import { matchesHotkey } from "@/lib/hotkeys"
import { Button } from "@/components/ui/button"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
const SEND = "mod+enter"
// shortcut prints the chord and announces it; binding it is the page's job.
// One string feeds all three (the keycaps, aria-keyshortcuts and
// matchesHotkey), so they cannot disagree. A chord fires inside the field,
// where a bare key would be typed, and a plain Enter still starts a new line.
export default function ButtonHotkey() {
const field = React.useRef<HTMLTextAreaElement>(null)
const [reply, setReply] = React.useState("Paid in full this morning. Closing the ticket.")
const [sent, setSent] = React.useState<string | null>(null)
function send() {
// Focus goes back to the box either way, ready for the next reply.
field.current?.focus()
if (!reply.trim()) return
setSent("Reply sent to Blue Harbor Logistics.")
setReply("")
}
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="button-hotkey-reply">Reply to Blue Harbor Logistics</Label>
<Textarea
ref={field}
id="button-hotkey-reply"
value={reply}
onChange={(event) => setReply(event.target.value)}
onKeyDown={(event) => {
if (!matchesHotkey(event.nativeEvent, SEND)) return
event.preventDefault()
send()
}}
/>
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<KbdShortcut keys={SEND} size="sm" /> sends from the box
</p>
<Button tooltip="Send reply" shortcut={SEND} onClick={send}>
<SendIcon data-icon="inline-start" aria-hidden="true" className="rtl:-scale-x-100" />
Send
</Button>
</div>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{sent}
</p>
</div>
)
}With a count
A Badge inside the label, with a word for a screen reader so the number is not read out bare.
import { MessageSquareIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
// The count is part of the button's name, with the word that says what it
// counts, so "Approvals 4" is not read out as a bare number. The spaces are
// written out: inline parts join with none, and the name would otherwise be
// "Approvals4waiting". A space between flex items draws nothing.
export default function ButtonWithCount() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<Button variant="outline">
Approvals{" "}
<Badge variant="secondary" className="tabular-nums">
4
</Badge>{" "}
<span className="sr-only">waiting</span>
</Button>
<Button variant="ghost">
<MessageSquareIcon data-icon="inline-start" aria-hidden="true" />
Comments{" "}
<Badge variant="secondary" className="tabular-nums">
12
</Badge>{" "}
<span className="sr-only">unread</span>
</Button>
</div>
)
}With a dot
An ink mark on an icon button: a count, or just something new. The name says what the mark shows.
import { BellIcon, InboxIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
// A decorative mark is ink, not the accent. The number the dot shows — or the
// fact of something new — is in the button's name, since the mark itself is
// hidden from a screen reader. The marks sit on the inline end, so they cross
// to the left corner in a right-to-left page.
export default function ButtonNotification() {
return (
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" aria-label="Notifications, 3 unread" tooltip="Notifications" className="relative">
<BellIcon aria-hidden="true" />
<span
aria-hidden="true"
className="absolute -top-0.5 -end-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-2xs font-medium text-background tabular-nums ring-2 ring-background"
>
3
</span>
</Button>
<Button variant="ghost" size="icon" aria-label="Inbox, new messages" tooltip="Inbox" className="relative">
<InboxIcon aria-hidden="true" />
<span aria-hidden="true" className="absolute top-1.5 end-1.5 size-2 rounded-full bg-foreground ring-2 ring-background" />
</Button>
</div>
)
}With an avatar
An assignee picker: the face and the name in the trigger, the teammates in a menu.
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const TEAM = ["Hiro Tanaka", "Saoirse Byrne", "Joaquín Ortega"]
// Decoration beside a printed name: hidden whole, or its initials would join
// the name while the picture loads ("HT Hiro Tanaka").
function Face({ name }: { name: string }) {
return (
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback>{getInitials(name)}</AvatarFallback>
</Avatar>
)
}
export default function ButtonWithAvatar() {
const [assignee, setAssignee] = React.useState(TEAM[0])
return (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">Assignee</span>
<DropdownMenu>
{/* The name the reader hears says which field this is. */}
<DropdownMenuTrigger
render={<Button variant="outline" className="ps-1" aria-label={`Assignee: ${assignee}`} />}
>
<Face name={assignee} />
{assignee}
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuGroup>
<DropdownMenuLabel>Assign INV-2041 follow-up to</DropdownMenuLabel>
<DropdownMenuRadioGroup value={assignee} onValueChange={(value) => setAssignee(String(value))}>
{TEAM.map((name) => (
<DropdownMenuRadioItem key={name} value={name}>
<Face name={name} />
{name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}Long label
Truncate in a span when the row has one line to give, or let it wrap when the label is the content.
import { FileDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
const VIEW = "Q3 revenue by region, EMEA and APAC"
// A saved view's name is the user's, so its length is too. In a one-line row
// the label truncates inside a span — the full name is still the button's
// name, and the title gives it to the pointer; where the label is the content,
// it wraps instead.
export default function ButtonLongLabel() {
return (
<div className="flex w-full max-w-60 flex-col gap-2 panel p-3">
<Button variant="outline" className="w-full justify-start" title={`Export “${VIEW}”`}>
<FileDownIcon data-icon="inline-start" aria-hidden="true" />
<span className="truncate">Export “{VIEW}”</span>
</Button>
<Button variant="outline" className="h-auto w-full justify-start py-1.5 text-start whitespace-normal">
<FileDownIcon data-icon="inline-start" aria-hidden="true" className="self-start mt-0.5" />
<span>Export “{VIEW}”</span>
</Button>
</div>
)
}Opens a menu
The trigger keeps its pressed plane while the menu is open, and the chevron turns over on the motion tokens.
import { ChevronDownIcon, DownloadIcon, FileSpreadsheetIcon, FileTextIcon, SheetIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
// Open, the trigger holds the pressed plane (aria-expanded) and its chevron
// turns over on the motion tokens, so it stops under reduced motion. A menu
// trigger does not dip a pixel on press the way a plain button does.
export default function ButtonMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Export
<ChevronDownIcon
data-icon="inline-end"
aria-hidden="true"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-aria-expanded/button:rotate-180"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>Q3 revenue by region</DropdownMenuLabel>
<DropdownMenuItem>
<SheetIcon aria-hidden="true" />
CSV, for a spreadsheet
</DropdownMenuItem>
<DropdownMenuItem>
<FileSpreadsheetIcon aria-hidden="true" />
XLSX workbook
</DropdownMenuItem>
<DropdownMenuItem>
<FileTextIcon aria-hidden="true" />
PDF, as the board sees it
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}Form actions
The primary goes last on a wide screen and first on a phone, where the buttons stack full-width.
import { Button } from "@/components/ui/button"
// One accent fill per footer, and the order flips with the width: last on a
// wide screen, where the eye ends a row, and first on a phone, where the
// buttons stack full-width and the top one is nearest the thumb.
export default function ButtonFormActions() {
return (
<div className="flex w-full max-w-lg flex-col gap-3 panel p-4">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Publish “Q3 board report”?</p>
<p className="text-sm text-muted-foreground">Its 6 subscribers get an email.</p>
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="ghost">Discard changes</Button>
<Button variant="outline">Save draft</Button>
<Button>Publish report</Button>
</div>
</div>
)
}In a table row
The xs sizes for a dense row, each button naming the row it acts on.
import { XIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
const INVITES = [
{ email: "kwame.larsen@northwind.example", sent: "Sent 3 days ago" },
{ email: "nadia.costa@northwind.example", sent: "Sent yesterday" },
]
// A dense row takes the xs size. Every row has a "Resend", so each one names
// its own invite for a screen reader, which lists buttons out of context.
export default function ButtonRowActions() {
return (
<ul className="flex w-full max-w-md flex-col divide-y divide-border panel">
{INVITES.map((invite) => (
<li key={invite.email} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex min-w-0 flex-col">
<p className="truncate text-sm font-medium">{invite.email}</p>
<p className="text-xs text-muted-foreground">{invite.sent}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button variant="outline" size="xs">
Resend{" "}
<span className="sr-only">the invite to {invite.email}</span>
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label={`Revoke the invite to ${invite.email}`}
tooltip="Revoke invite"
>
<XIcon aria-hidden="true" />
</Button>
</div>
</li>
))}
</ul>
)
}Stacked icon and label
Shortcut tiles: flex-col and h-auto turn a button into a tile that wraps its label.
import { ChartColumnIcon, ImportIcon, ReceiptIcon, UserRoundPlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
const ACTIONS = [
{ label: "New invoice", icon: ReceiptIcon },
{ label: "Add customer", icon: UserRoundPlusIcon },
{ label: "Import CSV", icon: ImportIcon },
{ label: "New report", icon: ChartColumnIcon },
]
// A home page's shortcuts: the icon over the label, so four sit in a row of
// tiles and wrap to two by two on a phone. h-auto and flex-col are all it
// takes; the label wraps if a translation runs long.
export default function ButtonQuickActions() {
return (
<div className="grid w-full max-w-md grid-cols-2 gap-2 sm:grid-cols-4">
{ACTIONS.map(({ label, icon: Icon }) => (
<Button key={label} variant="outline" className="h-auto flex-col gap-1.5 px-2 py-3 whitespace-normal">
<Icon aria-hidden="true" className="size-5 text-muted-foreground" />
{label}
</Button>
))}
</div>
)
}Label on wide screens
Below 640px the label goes sr-only and the button squares around its icon; its name stays the same.
import { DownloadIcon, PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
// A page header's actions give up their words below 640px and keep their
// names: the label turns sr-only rather than hidden, so a screen reader still
// hears "New invoice" on a phone, and the button squares up around its icon.
export default function ButtonResponsive() {
return (
<div className="flex w-full max-w-md items-center justify-between gap-3">
<p className="text-base font-semibold">Invoices</p>
<div className="flex items-center gap-2">
<Button variant="outline" className="max-sm:w-8 max-sm:px-0">
<DownloadIcon aria-hidden="true" />
<span className="max-sm:sr-only">Export CSV</span>
</Button>
<Button className="max-sm:w-8 max-sm:px-0">
<PlusIcon aria-hidden="true" />
<span className="max-sm:sr-only">New invoice</span>
</Button>
</div>
</div>
)
}Right to left
The same pager in Arabic: the arrows turn with rtl:rotate-180, and the padding follows each icon to its side.
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
function Pager({ previous, next }: { previous: string; next: string }) {
return (
<div className="flex items-center gap-3">
<Button variant="outline">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" className="rtl:rotate-180" />
{previous}
</Button>
<span className="font-mono text-xs text-muted-foreground">INV-2041</span>
<Button variant="outline">
{next}
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" className="rtl:rotate-180" />
</Button>
</div>
)
}
// The same pager in English and in Arabic. The arrows turn with the reading
// direction (rtl:rotate-180), and the tighter padding follows each icon to its
// side of the button, because data-icon names the inline start and end rather
// than left and right.
export default function ButtonRtl() {
return (
<div className="flex flex-col items-center gap-4">
<Pager previous="Previous" next="Next" />
<div dir="rtl" lang="ar">
<Pager previous="السابق" next="التالي" />
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "ink" | "outline" | "secondary" | "ghost" | "destructive" | "link" | "default" | default is the accent fill; ink the near-ink fill for a second primary when the accent is coloured, identical to default on the default palette; outline a hairline on the card plane; destructive the danger tone on its tint. |
| size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | "default" | 32px by default; xs and sm (and their icon sizes) turn the smaller md corner. |
| tooltip | React.ReactNode | — | What the button does, shown on hover and focus; an icon-only button still needs its aria-label. |
| shortcut | string | — | The chord that fires it, e.g. "mod+k": printed as keycaps in the tooltip and announced through aria-keyshortcuts as "Meta+K Control+K". Binding the key stays yours. |
| focusableWhenDisabled | boolean | false | Keeps a disabled button in the tab order — aria-disabled instead of the attribute — so a keyboard user can reach it and the reason it points at with aria-describedby. It dims all the same, and a press does nothing. |
Dependencies
Source
import * as React from "react"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
const buttonVariants = cva(
// rounded-lg, an explicit transition list (a button that animates `all`
// animates its own width as its label changes), and the one focus treatment:
// a solid 2px outline in the accent, which reads ≥3:1 on every plane, where
// the old ring-3 halo measured 1.98:1 on white. The dimming answers
// aria-disabled as well as :disabled — focusableWhenDisabled keeps a button
// in the tab order by trading the attribute for aria-disabled, and without
// it that button looked as pressable as any. Pointer events stay on for it:
// a button that is aria-disabled on purpose may still explain itself when
// pressed.
//
// Every class group is set once per variant and size: the corner, the type
// size and the icon size live with the sizes, the border colour with the
// variants. What cva returns raw is then already what a merge would leave,
// so a link wearing buttonVariants() draws exactly the Button — with a
// stylesheet deciding between two border colours or two type sizes, an
// outline link lost its hairline and a sm one its 13px register.
"group/button inline-flex shrink-0 items-center justify-center border bg-clip-padding font-medium whitespace-nowrap transition-[background-color,border-color,color,box-shadow,translate] duration-(--duration-fast) ease-(--ease-standard) focus-ring select-none active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-disabled:opacity-50 aria-invalid:border-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
// Role one of six: the accent is a filled button. The hover mixes ink
// into the brand rather than fading it — an alpha hover on a solid fill
// shows the page through the button — and the press adds an inset
// shadow so the pixel it moves reads as a press rather than a jump.
default:
"border-transparent bg-brand text-brand-foreground hover:bg-[color-mix(in_oklch,var(--brand),var(--foreground)_10%)] active:shadow-[inset_0_1px_2px_oklch(0_0_0_/_10%)]",
// The near-ink primary Vibra shipped before this look, kept as a
// variant for the second button in a region that already spent its one
// accent fill.
ink: "border-transparent bg-foreground text-background hover:bg-[color-mix(in_oklch,var(--foreground),var(--background)_12%)] active:shadow-[inset_0_1px_2px_oklch(0_0_0_/_10%)]",
// A hairline, not the field boundary: a button is not an input, and
// the heavier line made every outline button read as a control to
// type in.
outline:
"bg-card border-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"border-transparent hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
// The tone on its own opaque tint, which reads 4.98:1 light and 6.35
// dark. Deepening the tint on hover would move the background towards
// the text, so the hover moves the border — for which the base already
// reserves room — and the fill holds.
destructive: "border-transparent bg-danger-muted text-danger hover:border-danger",
link: "border-transparent text-brand underline-offset-4 hover:underline",
},
size: {
// The radius follows the height: the 32px sizes turn the lg corner
// (10px), the smaller ones the md corner (8px), so a small button is
// not a capsule. The tighter padding beside an icon is set on the
// inline axis (ps/pe), so it follows the icon to the right-hand side
// of a right-to-left page instead of staying on the left.
default:
"h-8 gap-1.5 rounded-lg px-2.5 text-sm has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2 [&_svg:not([class*='size-'])]:size-4",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
// The label register, 13px — not the 12.8px a 0.8rem step lands on.
sm: "h-7 gap-1 rounded-md px-2.5 text-label has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 rounded-lg px-2.5 text-sm has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2 [&_svg:not([class*='size-'])]:size-4",
icon: "size-8 rounded-lg text-sm [&_svg:not([class*='size-'])]:size-4",
"icon-xs": "size-6 rounded-md text-sm [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-md text-sm [&_svg:not([class*='size-'])]:size-4",
"icon-lg": "size-9 rounded-lg text-sm [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
// aria-keyshortcuts takes the UI Events names of the keys — Meta, Control,
// Alt, Shift, Escape — with the modifiers first. The kit writes chords its own
// way ("mod+k"), and "mod" is two chords: Meta+K on a Mac, Control+K
// everywhere else. Both are listed, which the attribute allows, so nothing has
// to guess the platform on the server.
const ARIA_MODIFIERS: Record<string, string> = {
cmd: "Meta",
command: "Meta",
meta: "Meta",
ctrl: "Control",
control: "Control",
alt: "Alt",
opt: "Alt",
option: "Alt",
shift: "Shift",
}
const ARIA_KEYS: Record<string, string> = {
esc: "Escape",
escape: "Escape",
enter: "Enter",
return: "Enter",
tab: "Tab",
space: "Space",
backspace: "Backspace",
delete: "Delete",
del: "Delete",
up: "ArrowUp",
arrowup: "ArrowUp",
down: "ArrowDown",
arrowdown: "ArrowDown",
left: "ArrowLeft",
arrowleft: "ArrowLeft",
right: "ArrowRight",
arrowright: "ArrowRight",
pageup: "PageUp",
pagedown: "PageDown",
home: "Home",
end: "End",
}
function ariaKeyshortcuts(shortcut: string): string {
const tokens = shortcut
.split("+")
.map((token) => token.trim())
.filter(Boolean)
const chord = (mod: string) => {
const modifiers: string[] = []
const keys: string[] = []
for (const token of tokens) {
const name = token.toLowerCase()
if (name === "mod") modifiers.push(mod)
else if (ARIA_MODIFIERS[name]) modifiers.push(ARIA_MODIFIERS[name])
else keys.push(ARIA_KEYS[name] ?? token[0].toUpperCase() + token.slice(1))
}
return [...modifiers, ...keys].join("+")
}
return tokens.some((token) => token.toLowerCase() === "mod")
? `${chord("Meta")} ${chord("Control")}`
: chord("Meta")
}
export type ButtonProps = ButtonPrimitive.Props &
VariantProps<typeof buttonVariants> & {
/**
* What the button does, in a few words. An icon-only button needs one —
* and its `aria-label` is still what a screen reader hears; the tooltip is
* for the pointer.
*/
tooltip?: React.ReactNode
/**
* The chord that fires it, e.g. "mod+k". Printed as keycaps in the tooltip
* and announced through `aria-keyshortcuts`; binding the key itself stays
* the caller's job.
*/
shortcut?: string
}
function Button({
className,
variant = "default",
size = "default",
tooltip,
shortcut,
...props
}: ButtonProps) {
const button = (
<ButtonPrimitive
data-slot="button"
// Both axes, always, as CONVENTIONS asks of an enumerated one: a button
// group reads data-variant to give an outline button a field's line.
data-variant={variant}
data-size={size}
// The chord is announced whether or not a tooltip prints it, so a
// keyboard user hears it without hovering.
aria-keyshortcuts={shortcut ? ariaKeyshortcuts(shortcut) : undefined}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
if (!tooltip && !shortcut) return button
return (
<Tooltip>
<TooltipTrigger render={button} />
<TooltipContent className="flex items-center gap-2">
{tooltip}
{shortcut ? <KbdShortcut keys={shortcut} size="sm" className="opacity-80" /> : null}
</TooltipContent>
</Tooltip>
)
}
export { Button, buttonVariants }