Skip to content
txbrendan/ui

Tool Button

beta

A 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. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. 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

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>
  );
}