Buttons

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"

export type ButtonGroupOrientation = "horizontal" | "vertical"
export type ButtonGroupSize = "sm" | "md" | "lg"
export type ButtonGroupVariant = "default" | "outline" | "ghost"

/** How long a refusal stays in the live region before it is wiped. */
const ANNOUNCE_MS = 4000

// A layout effect on the server logs a warning and can never run; the fallback

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/button-group.json

Prompt

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-px instead 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-10 on the child plus isolate on 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, never disabled — 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.

On This Page