Expanding button group

Icon buttons in a compact group: the one you point at or focus grows to reveal its label while its neighbours slide aside, and an action confirms in place.

import { ExpandingButtonGroup } from "@sagui/ui";
Markdown
Loading demo

When to use

  • A compact action bar on a message, file, or card where space is tight but labels should be one glance away.
  • Toolbars where actions finish in place and deserve a confirmation without a toast, such as Archive or Copy link.

When not to use

  • Use toggle-group or segmented-control when the buttons select a value instead of running an action.
  • Use plain labelled buttons when there are only one or two actions and space allows; hiding labels then costs clarity.
  • Use a dropdown menu for more than about six actions, or for actions that need descriptions.

Installation

Install the package and import the styles once. The installation guide covers the Tailwind setup.

$ npm install @sagui/ui

Usage

example.tsx
import { Archive, Clock3, Forward, Reply, Trash2 } from "lucide-react";
import { ExpandingButtonGroup } from "@sagui/ui";

export function MessageActions({ message }: { message: Message }) {
  return (
    <ExpandingButtonGroup
      label="Message actions"
      defaultExpanded="reply"
      items={[
        { id: "reply", label: "Reply", icon: <Reply />, onSelect: () => openReply(message.id) },
        { id: "forward", label: "Forward", icon: <Forward />, onSelect: () => openForward(message.id) },
        { id: "archive", label: "Archive", doneLabel: "Archived", icon: <Archive />, onSelect: () => archive(message.id) },
        { id: "snooze", label: "Snooze", doneLabel: "Snoozed", icon: <Clock3 />, onSelect: () => snooze(message.id, "tomorrow-8am") },
        { id: "delete", label: "Delete", doneLabel: "Deleted", tone: "danger", icon: <Trash2 />, onSelect: () => moveToTrash(message.id) },
      ]}
    />
  );
}

Examples

Small

Loading demo

Choosing the resting action

One action is always expanded. defaultExpanded picks which one, and the group returns to it when the pointer and focus leave.

Loading demo

API reference

ExpandingButtonGroup

Icon buttons in a compact group where one action always shows its label. The one you point at or focus takes the label over while its neighbours slide aside, and an action confirms in place.

Prop Type Default Description
items (required) { id: string; label: string; icon: ReactNode; onSelect?: () => void | boolean | Promise<void | boolean>; doneLabel?: string; tone?: "neutral" | "danger"; disabled?: boolean }[] – The actions, in order. label is shown when the button expands and is always its accessible name. With doneLabel, a successful onSelect turns the icon into a check and the label into that word, such as "Archived". Return false or reject to skip it; a promise keeps the button busy until it settles.
label (required) string – Accessible name of the toolbar, such as "Message actions".
size "sm" | "md" "md" Button height of 28 or 36px, with 14 or 16px icons.
defaultExpanded string | null First enabled action Id of the action whose label shows at rest, usually the primary one. One action is always expanded: pointing at or focusing another moves the expansion there, and the group returns to this one when the pointer and focus leave.
onAction (id: string) => void – Called with the item id whenever an action runs, before the item's own onSelect.
confirmDuration number 1400 How long the confirmation stays, in milliseconds.
className string – Extra class on the toolbar.
style CSSProperties – Inline styles on the toolbar.

Keyboard interactions

Keys Action
Tab Moves into the toolbar onto one action (the last focused, else the resting one) and out again.
Arrow leftorArrow right Moves focus to the previous or next action, wrapping at the ends. The focused action expands to show its label.
HomeorEnd Moves focus to the first or last action.
EnterorSpace Runs the focused action.

Accessibility

  • The root is a toolbar named by label, with one tab stop and roving arrow key focus.
  • Each button keeps its full label as its accessible name while collapsed; the visible label and icon are hidden from assistive technology so nothing is read twice.
  • Disabled actions use aria-disabled instead of disabled, so they stay focusable and still reveal their label, but do nothing when activated.
  • A confirmation is announced through a polite live region, such as "Archived". The same word is announced again on a repeat action.
  • A busy action (a promise from onSelect) sets aria-busy until it settles.
  • The expansion is the first focus indicator and the focused button also draws an inset focus ring. Only keyboard focus expands, so a mouse click does not leave a label open after the pointer leaves.
  • Confirmation is never color alone: the icon becomes a check and the label changes to a past tense word.

Motion

  • Each button's width springs on the morph spring between its icon size and icon plus measured label. Neighbours slide aside in normal flow and the group's own width follows, so nothing resizes in a single frame.
  • The label slot always reserves the longer of label and doneLabel, so the confirmation never changes the width. The server render already shows the resting action at that exact width, so hydration moves nothing.
  • Anchor the group at its start edge (not centred) and reserve its widest width in the layout, so expansion only moves buttons inside the group.
  • The icon keeps the same left inset in both states; the label slides out from behind it with a short fade and blur, and tucks back faster than it came.
  • A confirmation draws a check where the icon was and lifts the old word out while the new one rises in, while the width springs between the two words.
  • Hover only follows real pointer movement, and moving to a neighbour needs the pointer 4px inside it, so a button that slides under a resting pointer never takes over and a pointer resting on a boundary never flips between two actions.
  • Pressing shrinks the icon slightly; the button box never scales, because its width is the animation.
  • With reduced motion, labels and widths change at once with a quick fade and the check appears without drawing.

Responsive behavior

  • Five medium actions with one label open take about 260 to 280px, depending on the longest label. Reserve that width in narrow layouts rather than letting the group squeeze content.
  • Touch has no hover: the first tap expands an action and the second runs it. A tap outside the group closes a label opened by touch.
  • Buttons are 36px tall at md and 28px at sm; use md where touch matters.

Performance

  • Each label is measured from invisible copies in its own slot (and again if its font or text changes) with a ResizeObserver; width is a Motion value, so React renders only when the expanded action changes.
  • Width animation causes layout in the group only; keep the group out of large reflowing containers.

Notes

  • Use it for a row of three to six related actions on one object, where icons are recognisable but labels help: message, file and card actions.
  • Give every action a short verb label and a matching lucide icon. Use doneLabel only for actions that finish at once (Archive, Snooze, Copy link); leave it out for actions that open something (Reply, Rename).
  • Mark at most one destructive action with tone="danger". It needs no confirm dialog when the surrounding UI offers Undo.
  • One action is always expanded. Put the primary action first, or set defaultExpanded to it, so it reads as a labelled button at rest.
  • This is an action group. For choosing one or several values use toggle-group or segmented-control.
  • On touch the first tap reveals the label and a second tap runs it; the resting action runs on the first tap.
  • Place it start aligned in a row that reserves its widest width, so the expansion never moves surrounding content.

Also in buttons