Button group

Related actions joined into one surface with hairline dividers: a hover highlight glides between segments, the pressed one answers in place, and an attached menu can close the row.

pnpm dlx shadcn@latest add @uiarc/button-group
Live · keyboard ready
  • A small set of related actions on the same object, shown together in a header, toolbar or card footer.
  • Compact tool clusters such as zoom out, the current level and zoom in, or stacked map controls.
  • A primary action row, such as Reply, Reply all and Forward on a message.
  • Use toggle-group or segmented-control when the segments are options that stay selected.
  • Use split-button when there is one main action and a few variants of it.
  • Use separate buttons when the actions are unrelated or one of them is much more important than the rest.
  • Use tabs for switching between views.

Installation

Add Button group with the shadcn CLI, or copy the source by hand.

pnpm dlx shadcn@latest add @uiarc/button-group

Adds the component and its local dependencies, and installs motion, @radix-ui/react-dropdown-menu, lucide-react. First time? Add the @uiarc registry to components.json, or use the full URL:

example.tsx
import { Archive, Check, CopyPlus, Link, Pin, Trash2 } from "lucide-react";import { ButtonGroup } from "@/components/arc/button-group/button-group"; export function DocumentActions({ doc }: { doc: Doc }) {  const [copied, setCopied] = useState(false);  return (    <ButtonGroup      label="Document actions"      items={[        {          id: "share",          label: copied ? "Copied" : "Share",          reserve: ["Share", "Copied"],          icon: copied ? <Check /> : <Link />,          onSelect: async () => {            await navigator.clipboard.writeText(doc.url);            setCopied(true);            setTimeout(() => setCopied(false), 1800);          },        },        { id: "duplicate", label: "Duplicate", icon: <CopyPlus />, onSelect: () => duplicate(doc.id) },        { id: "archive", label: "Archive", icon: <Archive />, onSelect: () => archive(doc.id) },      ]}      menu={{        label: "More actions",        items: [          { id: "pin", label: "Pin to sidebar", icon: <Pin />, onSelect: () => pin(doc.id) },          { id: "delete", label: "Delete", icon: <Trash2 />, destructive: true, onSelect: () => remove(doc.id) },        ],      }}    />  );}

ButtonGroup

Related actions joined into one surface with hairline dividers: a hover highlight glides between segments, the pressed one answers in place, and an attached menu can close the row.

PropTypeDefaultDescription
itemsRequired{ id: string; label: string; reserve?: string[]; icon?: ReactNode; iconOnly?: boolean; content?: ReactNode; onSelect?: () => void; href?: string; disabled?: boolean }[]–The segments in order. label is the visible text and the accessible name; changing it right after a press ("Share" to "Copied") crossfades in place and is announced. reserve lists every other label the segment can show, so it is sized to the widest and never resizes; a label nobody reserved springs the width instead. iconOnly shows only the icon and moves the label to aria-label and the tooltip. content replaces the visible label with a live value, such as a zoom level. href renders a link.
labelRequiredstring–Accessible name of the group, such as "Document actions".
menu{ label: string; items: { id: string; label: string; icon?: ReactNode; onSelect?: () => void; href?: string; disabled?: boolean; destructive?: boolean }[] }–Adds a trailing chevron segment that opens more actions. menu.label names the chevron, such as "More actions". The menu aligns to the group's end edge (start edge when vertical).
variant"outline" | "solid""outline"outline sits on a neutral surface with a quiet border. solid takes the primary button's fill, for the main actions of a view.
size"sm" | "md""md"Matches the small and medium button heights.
orientation"horizontal" | "vertical""horizontal"Vertical stacks the segments, such as zoom controls on a canvas; arrow up and down then move between them.
collapseLabelsbooleantrueWhen a horizontal row no longer fits its container, segments with an icon drop their label and keep the icon (the label moves to aria-label and the tooltip). Labels return once the container is wide enough again.
disabledbooleanfalseDisables every segment and the menu.
classNamestring–Extra class on the group.
styleCSSProperties–Inline styles on the group.
TaborShift+Tab
Moves into, through and out of the group. Every segment is a tab stop, so the group behaves like the buttons it replaces.
Arrow rightorArrow left
Also moves between segments in a horizontal group, wrapping at the ends (mirrored in right to left layouts).
Arrow downorArrow up
Moves between segments in a vertical group. On the chevron segment, Arrow down opens the menu instead.
HomeorEnd
Moves to the first or last segment.
EnterorSpace
Activates the focused segment. Link segments follow the link with Enter.
EnterorSpaceorArrow down
On the chevron segment: opens the menu with the first item highlighted.
Arrow keysorHomeorEnd
In the menu: moves between items, looping at the ends; disabled items are skipped.
Escape
Closes the menu and returns focus to the chevron.
Tab (menu open)
Closes the menu and moves on, since the menu is non-modal.
  • The root is a div with role="group" named by label. Segments are native buttons (or links with href), so they keep their own roles and activation keys.
  • Disabled segments use aria-disabled instead of the disabled attribute, so they stay focusable and are read as dimmed; their onSelect never runs. A disabled whole group uses the disabled attribute.
  • Icon-only segments, segments with content and collapsed segments take their label as aria-label and as a tooltip; icons are hidden from assistive technology.
  • When a segment's label changes within a second of its press, such as "Share" to "Copied", a polite live region in the group announces the new label once; the quiet return to "Share" is not announced.
  • The chevron segment is a Radix dropdown trigger named by menu.label, with aria-expanded and aria-haspopup. The menu manages focus, typeahead, Escape and outside clicks, and returns focus to the chevron. It is non-modal, so opening it never locks scroll or shifts the page.
  • Keyboard focus moves the same highlight the pointer does; there is no focus ring, following the library's rule. A mouse click focuses without the highlight, so nothing stays lit after the pointer leaves.
  • State is never carried by color alone: confirmations change the label and icon, and disabled segments are dimmed and announced.
  • One highlight lives under all segments. Moving the pointer or keyboard focus to another segment carries it there on the snappy spring; arriving from outside the group it fades in where it lands, so it only travels between segments.
  • The hairline dividers on either side of the highlighted segment fade out, so the highlight reads as one soft shape inside the joined surface.
  • A press dips the segment's content to 0.97 (0.9 for icon-only) and springs back, while the highlight under it deepens. The group and its neighbours never move. The chevron anchors the menu, so it answers with the deeper highlight only.
  • On touch, the highlight appears under the finger while pressed and fades on release.
  • Nothing in the group moves or resizes on hover, focus, press, menu open or a label change: every reserved label sits invisibly in the segment, so a confirmation crossfades inside a fixed box. The new label rises in from a soft blur while the old one lifts away.
  • A label that was not reserved never snaps either: the segment springs to its width on the morph spring, and the highlight stays locked to it while it resizes.
  • The highlight is measured at sub-pixel precision, so it sits exactly on its segment at rest.
  • The menu grows from the group's edge with a short offset and scale and leaves faster than it arrives; the chevron turns over while it is open.
  • With reduced motion, the highlight jumps between segments, the press dip and chevron turn are removed, and labels and the menu crossfade.
  • A horizontal group never exceeds its container: when it would, segments with an icon drop their label (collapseLabels), and anything still too wide scrolls sideways without a visible scrollbar. At 320px the document example collapses to icons with its names kept as tooltips and aria-labels.
  • The vertical orientation suits narrow rails and canvas corners; segments stretch to the widest one.
  • Segments are 34px tall at sm and 42px at md inside the border, matching the button heights. The highlight follows the pointer only for mouse and pen; touch gets it while pressed.
  • The highlight is one element moved by transforms and size motion values; segments never re-render on hover beyond a state update in the group.
  • Two ResizeObservers per group keep the highlight on its segment and decide when labels collapse; each label slot has its own observer for the width morph.
  • The menu renders only while open.

Notes for AI

Give your coding assistant the Markdown reference instead of screenshots.

  • Choose it for two to five related actions on one object, such as a document's Share, Duplicate and Archive, or zoom controls. Put rarely used and destructive actions in menu.
  • It is an action group: every segment does something now. For choosing one or more options that stay pressed, use toggle-group or segmented-control instead.
  • Give confirmations by changing an item's label and icon ("Share" to "Copied") and resetting it after a moment, and list every label in reserve so the row never resizes. Keep confirmation labels close in length to the resting label, since the segment takes the widest.
  • Use iconOnly only for actions with universally understood icons (zoom, alignment, playback). The label still names the action for screen readers and the tooltip.
  • Use the solid variant for the main actions of a view, at most once per screen; outline for everything else.

The full library index for assistants is at /llms.txt.