Tool Button
betaA small button in a glass toolbar, with its label and key taught in a tooltip. A press can leave its result in place, with Undo and Retry.
Preview
toggles
actions
text
tone
result
fails
states
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/tool-button
Use it for
- Small toolbars: the quick actions on a row, an editor's formatting bar, a bar of bulk actions.
- Put ToolButtons and ToolDividers inside a Toolbar. The toolbar is one Tab stop, and the arrow keys move between its buttons.
- A ToolButton alone, outside a Toolbar, is a single quiet button with the same tooltip and result, like Copy on a code block.
- Give every button a label. It names the button and fills its tooltip, so an icon button is never unnamed.
- Give shortcut the key the app listens for, like R. The tooltip teaches it. ActionKeys wires single keys for you.
- Give result to a press that changes something, like a reminder. The button says what happened, offers Undo with onUndo, and commits when the window ends.
- Return a promise from onClick to show a spinner until it settles. A failure turns the button into Retry.
- A change with no button, like a dragged row, calls undoable(result) to join the page's ⌘Z stack. A Toaster calls setToastSlot, and then takes the Undo or Retry of a button that leaves.
- Use pressed for a toggle, like Bold, and keepFocus so a press leaves the text selection alone.
Avoid it for
- A page's main action, like Save. Use Button.
- A toast that repeats what the button already says. The button carries the result.
- More than a handful of buttons in a row's quick actions. Keep two to four, and put the rest in a Menu.
Props
- Toolbar
Root. The glass bar. role=toolbar, one Tab stop, and arrow keys between its buttons. Name it with aria-label.
ComponentProps<"div"> & { orientation?: "horizontal" | "vertical"; "aria-label"?: string }
- ToolButton
Part. One button in the toolbar.
component
- ToolDivider
Part. The thin line between groups of buttons.
component
- label
ToolButton. Required. What it does, like Reply. It names the button and fills its tooltip.
string
- icon
ToolButton. The icon, like a lucide icon. With no icon, the label shows as text.
ReactNode
- text
ToolButton. Shows the label beside the icon.
boolean · default false
- shortcut
ToolButton. The key as the tooltip teaches it, like R or ⌘B. A single key also sets aria-keyshortcuts and data-shortcut, which ActionKeys reads.
string
- pressed
ToolButton. Makes it a toggle. Sets aria-pressed.
boolean
- tone
ToolButton. Color role. Use danger for a destructive action.
"neutral" | "danger" · default "neutral"
- keepFocus
ToolButton. A press leaves focus and the text selection where they are, as an editor's toolbar needs.
boolean · default false
- tooltip
ToolButton. Shows the tooltip.
boolean · default true
- loading
ToolButton. Shows a spinner and blocks presses. The button stays focusable.
boolean · default false
- disabled
ToolButton. Blocks presses. The button stays focusable, so its tooltip still shows.
boolean · default false
- onClick
ToolButton. Runs on press. Return a promise to show a spinner until it settles. A rejection turns the button into Retry.
(event?: MouseEvent) => void | Promise<unknown>
- result
ToolButton. What the press leaves behind. The button shows result.label with a check, offers Undo while the window lasts if onUndo is set, then commits and stays as result.doneLabel, a record that can't be pressed.
ActionResult
- ActionResult
Type. seconds is the Undo window, default 8. onCommit runs when it ends, or when the button leaves the page mid-window. A rejected onCommit turns the button into Retry. toast goes to the toast slot, only for what the button can't show.
{ label: string; doneLabel?: string; say?: string; failed?: string; onUndo?: () => void; undone?: string; onCommit?: () => void | Promise<unknown>; seconds?: number; toast?: string }
- useActionResult
Hook. The same press-to-result flow for a control of your own, on the same ⌘Z stack. Call press from its click, spread handlers on it, and render message in a polite live region. Pass live: 'page' for a control that may leave before it is heard, like one in a peek, and the page's one region says it instead.
({ label, onPress, result, onSlip?, live? }) => { phase, armed, undoShown, telling, choice, failText, press, handlers, message }
- undoable
Puts a change with no control, like a saved inline edit, on the page's ⌘Z stack for its window. restart gives it the whole window again.
(result: ActionResult) => { undo, restart }
- setToastSlot
For a Toaster. A button that leaves mid-window hands it its Undo, and one that leaves showing Retry hands it Retry. With no slot, it commits as before.
(slot: ((toast: ResultToast) => () => void) | null) => void
- toolbarVariants
The toolbar's glass look, for a bar of your own.
() => string
- className
Toolbar, ToolButton, or ToolDivider. Extra classes. Later classes win.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| Toolbar | ComponentProps<"div"> & { orientation?: "horizontal" | "vertical"; "aria-label"?: string } | - | Root. The glass bar. role=toolbar, one Tab stop, and arrow keys between its buttons. Name it with aria-label. |
| ToolButton | component | - | Part. One button in the toolbar. |
| ToolDivider | component | - | Part. The thin line between groups of buttons. |
| label | string | - | ToolButton. Required. What it does, like Reply. It names the button and fills its tooltip. |
| icon | ReactNode | - | ToolButton. The icon, like a lucide icon. With no icon, the label shows as text. |
| text | boolean | false | ToolButton. Shows the label beside the icon. |
| shortcut | string | - | ToolButton. The key as the tooltip teaches it, like R or ⌘B. A single key also sets aria-keyshortcuts and data-shortcut, which ActionKeys reads. |
| pressed | boolean | - | ToolButton. Makes it a toggle. Sets aria-pressed. |
| tone | "neutral" | "danger" | "neutral" | ToolButton. Color role. Use danger for a destructive action. |
| keepFocus | boolean | false | ToolButton. A press leaves focus and the text selection where they are, as an editor's toolbar needs. |
| tooltip | boolean | true | ToolButton. Shows the tooltip. |
| loading | boolean | false | ToolButton. Shows a spinner and blocks presses. The button stays focusable. |
| disabled | boolean | false | ToolButton. Blocks presses. The button stays focusable, so its tooltip still shows. |
| onClick | (event?: MouseEvent) => void | Promise<unknown> | - | ToolButton. Runs on press. Return a promise to show a spinner until it settles. A rejection turns the button into Retry. |
| result | ActionResult | - | ToolButton. What the press leaves behind. The button shows result.label with a check, offers Undo while the window lasts if onUndo is set, then commits and stays as result.doneLabel, a record that can't be pressed. |
| ActionResult | { label: string; doneLabel?: string; say?: string; failed?: string; onUndo?: () => void; undone?: string; onCommit?: () => void | Promise<unknown>; seconds?: number; toast?: string } | - | Type. seconds is the Undo window, default 8. onCommit runs when it ends, or when the button leaves the page mid-window. A rejected onCommit turns the button into Retry. toast goes to the toast slot, only for what the button can't show. |
| useActionResult | ({ label, onPress, result, onSlip?, live? }) => { phase, armed, undoShown, telling, choice, failText, press, handlers, message } | - | Hook. The same press-to-result flow for a control of your own, on the same ⌘Z stack. Call press from its click, spread handlers on it, and render message in a polite live region. Pass live: 'page' for a control that may leave before it is heard, like one in a peek, and the page's one region says it instead. |
| undoable | (result: ActionResult) => { undo, restart } | - | Puts a change with no control, like a saved inline edit, on the page's ⌘Z stack for its window. restart gives it the whole window again. |
| setToastSlot | (slot: ((toast: ResultToast) => () => void) | null) => void | - | For a Toaster. A button that leaves mid-window hands it its Undo, and one that leaves showing Retry hands it Retry. With no slot, it commits as before. |
| toolbarVariants | () => string | - | The toolbar's glass look, for a bar of your own. |
| className | string | - | Toolbar, ToolButton, or ToolDivider. Extra classes. Later classes win. |
Accessibility
- Toolbar has role=toolbar. It is one Tab stop, and the arrow keys move between its buttons, with Home and End to the ends. Give it an aria-label.
- Every ToolButton is named by its label, as aria-label. The type requires a label.
- A single-key shortcut sets aria-keyshortcuts. The tooltip shows the key, but only to sighted mouse and keyboard users.
- The button's name follows its state: the result once pressed, '… Press to undo.' while Undo is on offer, the record once done, and 'Retry: …' after a failure.
- The page's one polite live region says the result, Undone, or what failed, once. So it is still heard after a hover bar hides the button or its row leaves, and while a modal dialog is open.
- After a mouse press, Undo arms only once the pointer leaves. After a key or touch press it arms in 1.2 seconds, and a touch press then shows Undo. So a double press never undoes. The window holds while an armed button is pointed at or has keyboard focus.
- Disabled, loading, and done buttons stay focusable, so their tooltips still show.
- ⌘Z or Ctrl+Z undoes the newest change still in its window, whatever shows it: a button, a peek, or undoable(). In a text field it stays the field's own undo.
Examples
A formatting toolbar
import { Bold, Italic, Link } from "lucide-react";
import { useState } from "react";
function FormatBar() {
const [bold, setBold] = useState(false);
return (
<Toolbar aria-label="Formatting">
<ToolButton icon={<Bold />} label="Bold" shortcut="⌘B" aria-keyshortcuts="Meta+B" pressed={bold} keepFocus onClick={() => setBold(!bold)} />
<ToolButton icon={<Italic />} label="Italic" shortcut="⌘I" aria-keyshortcuts="Meta+I" keepFocus />
<ToolDivider />
<ToolButton icon={<Link />} label="Add link" keepFocus />
</Toolbar>
);
}A press that leaves its result, with Undo
import { BellRing } from "lucide-react";
function RemindButton({ send, cancel }: { send: () => Promise<void>; cancel: () => void }) {
return (
<Toolbar aria-label="Person">
<ToolButton
icon={<BellRing />}
label="Remind"
text
shortcut="R"
result={{ label: "Sent", doneLabel: "Reminded", say: "Reminder sent to Tom Hale", onUndo: cancel, onCommit: send }}
/>
</Toolbar>
);
}