Button Group
A welded row or column of independent actions — 1px seams, outer corners only, an unclipped focus ring, and a measured unweld when the row stops fitting.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/button-group.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ButtonGroup" component using
class-variance-authority (cva). No animation library, no headless UI library:
the group is a flex container, a React context and one measurement pass.
Contract
- ButtonGroup: forwardRef<HTMLDivElement, ButtonGroupProps>, ButtonGroupProps
extends React.HTMLAttributes<HTMLDivElement> with
{ orientation?: "horizontal" | "vertical" (default "horizontal"),
size?: "sm" | "md" | "lg" (default "md"),
variant?: "default" | "outline" | "ghost" (default "default"),
attached?: boolean (default true),
label?: string (default "Actions") }.
Rest props and the ref land on the root div; className is merged with cn().
- ButtonGroupItem: forwardRef<HTMLButtonElement, ButtonGroupItemProps>,
ButtonGroupItemProps extends
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "disabled"> with
{ variant?, size? (both fall back to the group's), iconOnly?: boolean,
disabled?: boolean, disabledReason?: string }. type defaults to "button".
- A context carries { orientation, size, variant, announce } down. Export a
useButtonGroup() hook that returns it or null, so a consumer can build their
own member; every consumer of it must have a standalone fallback.
- The group styles ANY child, not just ButtonGroupItem — a shadcn <Button>, an
<a>, a <select> trigger. ButtonGroupItem exists only for the two things CSS
cannot do: inheriting the skin and reporting a refusal.
Behavior
- Semantics: role="group" plus an accessible name (label -> aria-label, skipped
when the consumer passes aria-labelledby). That name is the only thing that
tells a screen-reader user these buttons belong together; a nameless group is
announced as nothing at all.
- This is deliberately NOT a composite widget. Every child keeps its own tab
stop and its own activation. Do not add roving tabindex, do not add arrow
keys: WAI-ARIA reserves that pattern for toolbar / radiogroup / tablist,
where one control holds one value. These are independent commands, and
hiding half of them behind an arrow key nobody knows to press is a
regression, not a feature.
- Keyboard map: Tab / Shift+Tab move between members in DOM order; Enter and
Space activate the focused member (native <button> behaviour, no key handler
at all). There is nothing else to learn, which is the point.
- Unavailable members get aria-disabled + a handler guard, NEVER the native
disabled attribute. The browser blurs a node the instant it is disabled, so
an Undo that disables itself on the last press drops the keyboard user on
<body>. aria-disabled keeps it focusable, hoverable and announced.
Do not set pointer-events: none either, or hover and focus die with it.
- The guard runs preventDefault() (covers a type="submit" member) and
stopPropagation() (covers a delegated ancestor handler), then announces the
refusal through a polite live region owned by the group and returns without
calling the consumer's onClick.
- disabledReason is folded into the accessible NAME, not into a title: append
it to aria-label when one exists, otherwise render a trailing sr-only span so
the name computed from the contents grows instead of being replaced. That is
how a screen-reader user hears *why* on focus, before pressing anything.
- Live region: a single sr-only role="status" node. Blank it, then set the
message inside a requestAnimationFrame — a screen reader stays silent on
unchanged text, so two presses of the same refused button would otherwise be
announced once. Wipe it after ~4s. The refusal string is built from the
closure (string children, else aria-label), never read back from the DOM,
which would pick up the sr-only reason and say it twice.
- Overflow: the row is flex-wrap, and a measurement pass decides whether the
welded look survives. Compare every child's getBoundingClientRect().top to
the first child's, with 1px of slack for sub-pixel rounding; if anything sits
on a second line, unweld the whole group — drop the negative margins, restore
every corner and add a gap. Half-rounded corners and a second line that
starts one pixel outside the container are the alternative.
This never oscillates and needs no hysteresis constant: the detached layout
is always the wider one, so a group that wrapped stays wrapped, and a
detached group that fits on one line still fits once welded.
- Run that pass in a layout effect (isomorphic — fall back to useEffect where
document is undefined) so the group is never painted welded for one frame
inside a container it was always too wide for.
- Re-run it from a ResizeObserver on the root AND on every child. The root
catches the container resizing; the children catch a label changing while the
root — width: fit-content, already capped at the container — keeps exactly
the same box and would never notify. Also re-run once on document.fonts.ready,
because web fonts land after the first measurement and move every label.
- Always schedule the pass through requestAnimationFrame; writing state
straight from the observer callback trips "ResizeObserver loop completed with
undelivered notifications" once the new layout feeds back into the observed
box. Cleanup: cancel the frame, disconnect the observer, clear the
announcement timeout and its frame, and set an `alive` flag that both the
scheduler and the measure function check — fonts.ready can settle after
unmount.
- A vertical group cannot wrap, so skip the whole pass for it, and skip it when
attached is false.
Rendering & styling
- Semantic tokens only. default: bg-primary / text-primary-foreground, hover
bg-primary/90; outline: border-border + bg-background, hover bg-accent /
text-accent-foreground; ghost: transparent with text-muted-foreground and the
same accent hover. Divider between welded neighbours:
border-primary-foreground/25 for the solid skin, border-border for the other
two. focus-visible:ring-2 ring-ring with ring-offset-1
ring-offset-background. aria-disabled:opacity-50 +
aria-disabled:cursor-not-allowed, and neutralise the hover styles for it (a
`disabled:` variant would never match — there is no disabled attribute).
- The seam is painted from the PARENT with child-agnostic selectors, so a
foreign child gets it too: [&>*:not(:first-child)]:-ml-px plus
[&>*:not(:first-child)]:rounded-l-none and
[&>*:not(:last-child)]:rounded-r-none (…-mt-px / rounded-t-none /
rounded-b-none when vertical). Two facts make this robust: the negative
margin lands the neighbour ON the previous border so the shared edge is 1px
rather than 2px, and `.parent > *:not(:first-child)` outranks a child's own
`.rounded-lg` on specificity, so the child does not need to cooperate.
- Give every member a 1px border on all four sides — border-transparent for the
solid and ghost skins — so the box maths is identical across variants and the
divider only has to recolour one edge.
- Focus ring: siblings paint in DOM order, so the next button's background
would cover the previous button's ring. [&>*:focus-visible]:relative +
[&>*:focus-visible]:z-10 lifts only the focused child; the root gets
`isolate` so that z-index cannot escape into the page. Every member is also
position:relative in its own right, because z-index does nothing on a static
box.
- Root: isolate flex w-fit max-w-full items-stretch. w-fit is what lets the
group be as wide as its actions yet never wider than its container, which is
the precondition for wrapping at the container edge instead of overflowing.
- Set data-slot="button-group" on the root so shadcn's own
in-data-[slot=button-group] radius rules apply to small Buttons dropped
inside, and expose data-attached / data-orientation for consumer styling.
- Motion is limited to transition-colors with motion-reduce:transition-none.
Nothing about the seam, the unweld, the keyboard or the announcement depends
on animation.
Customization levers
- variant is the skin axis and it also picks the divider colour — add one cva
entry plus one DIVIDER entry to ship a fourth (e.g. destructive) and nothing
else branches.
- size sm|md|lg drives height, padding and glyph size; the icon-only square
widths are three compound variants. Keep one size per group — mixing them
leaves a visible step in the seam.
- attached={false} keeps the accessible grouping and drops the seam; it renders
exactly what the overflow pass falls back to, so use it when you want spaced
buttons that are still announced as one group.
- orientation="vertical" for a stacked control (canvas side rails, list
reordering); the measurement pass switches itself off there.
- Full-bleed row: add className="w-full [&>*]:flex-1" to make members share the
width equally — w-full beats the base w-fit through tailwind-merge.
- iconOnly + aria-label for a compact glyph bar; pair it with your own tooltip
layer if the icons are not self-evident.
- disabledReason is the accessibility lever: prefer "Undo (nothing left to
undo)" over silently greying a button out.
- Any focusable element can be a member — drop in a shadcn <Button>, a link
styled as a button, or a dropdown trigger, and the seam still lands.Concepts
- Welded, not merged — the group only collapses the seam: each member keeps its own tab stop, its own activation and its own accessible name. That is the line between this and a segmented control, where one control holds one value and arrow keys are mandatory.
- 1px overlap — the neighbour is pulled onto the previous border with
-ml-pxinstead of dropping its own, so two 1px borders occupy one 1px seam. Removing a border instead makes every member after the first a pixel narrower; leaving both makes the seam twice as thick as the outer edge. - Unclipped ring — siblings paint in DOM order, so without lifting the focused member its ring is quietly covered by the next button's background.
focus-visible:relative z-10on the child plusisolateon the group is the whole fix, and it costs nothing when nothing is focused. - Measured unweld — the fallback for a narrow container is not a scrollbar and not a breakpoint: the group compares its children's line positions and, the moment one lands on a second row, becomes a wrapped set of standalone buttons. Because the detached layout is always the wider one, the decision can never flip-flop.
aria-disabled, neverdisabled— the native attribute blurs its own element, so a button that becomes unavailable because you pressed it throws the keyboard user back to<body>. The ARIA state keeps the member focusable and announced; the handler is what refuses to run.- Refusal is a message, not a silence — the reason rides in the accessible name so it is heard on focus, and a polite
role="status"repeats it if the press happens anyway; blanking the region for a frame first is what lets the same refusal be announced twice.
Record Button
A record control with a real idle → recording → processing → done state machine, drift-free elapsed timer, and an optional hold-to-record gesture with slide-away cancel.
Toggle Group
A row or column of independent aria-pressed toggles — one tab stop per option, optional min/max limits that refuse out loud, and per-item disabled reasons.