Skip to main content
Gremorie

Button Group

Cluster of buttons (or selects) joined by a shared border, with optional separators and label text.

Overview

ButtonGroup joins multiple Button siblings into a single visual unit by neutralizing inner borders and rounding only the first and last child. Use it for related actions that share a context: copy / paste / cut, alignment, view modes, pagination.

It also accepts Select triggers and ButtonGroupText so you can mix labels and controls in the same border-shared row. ButtonGroupSeparator adds a hairline between groups when you need a visual beat.

Preview

'use client';import { Button, ButtonGroup, ButtonGroupSeparator } from '@gremorie/rx-forms';export function ButtonGroupPreview() {  return (    <ButtonGroup>      <Button variant="outline">Copy</Button>      <ButtonGroupSeparator />      <Button variant="outline">Paste</Button>      <ButtonGroupSeparator />      <Button variant="outline">Cut</Button>    </ButtonGroup>  );}

Anatomy

ButtonGroup                  role="group" flex wrapper that fuses its children
├─ ButtonGroupText           non-interactive text/label segment (muted addon)
└─ ButtonGroupSeparator      thin Radix Separator between segments

Installation

bash npx gremorie@latest add rx-button-group

bash pnpm dlx gremorie@latest add rx-button-group

bash yarn dlx gremorie@latest add rx-button-group

bash bunx --bun gremorie@latest add rx-button-group

Usage

import {
  Button,
  ButtonGroup,
  ButtonGroupSeparator,
} from "@gremorie/rx-forms";

export function Example() {
  return (
    <ButtonGroup>
      <Button variant="outline">Copy</Button>
      <ButtonGroupSeparator />
      <Button variant="outline">Paste</Button>
    </ButtonGroup>
  );
}

The Angular edition of this component ships from source today (see the workbench for the side-by-side); its registry entry is coming next.

API

<ButtonGroup>

PropTypeDefaultDescription
orientation"horizontal" | "vertical""horizontal"Lays children side by side or stacked. Borders adapt: horizontal strips border-l, vertical strips border-t.

Extends all React.ComponentProps<"div">. Always rendered with role="group" and data-slot="button-group".

<ButtonGroupSeparator>

PropTypeDefaultDescription
orientation"horizontal" | "vertical""vertical"Radix Separator direction. Defaults to a vertical 1px line for horizontal groups.

Wraps @radix-ui/react-separator. Decorative (forced decorative flag), so it does not announce to screen readers.

<ButtonGroupText>

PropTypeDefaultDescription
asChildbooleanfalseRender as Slot.Root so the first child receives the muted/bordered surface. Use for non-interactive labels (e.g. "of 12", a unit).

Extends all React.ComponentProps<"div">.

Composition

  1. <ButtonGroup> strips inner borders and rounds only the first / last child via CSS sibling selectors.
  2. Children can be: <Button>, <Select> triggers (when wrapped in SelectTrigger), or <ButtonGroupText> for labels.
  3. <ButtonGroupSeparator> inserts a visible 1px divider between children that share a border.
  4. <ButtonGroupText> is the right element for static labels inside the cluster - never use a raw <span> or <div> since the group expects its children to participate in border merging.

Action rows and size pairing

An action row is any horizontal cluster of controls: a toolbar, a card header with a select and an icon button, a filter bar. Two layouts exist, and one sizing rule governs both.

Grouped vs gapped

LayoutWhenHow
<ButtonGroup>The actions form ONE unit: segmented modes, copy/paste/cut, pagination. Borders merge, the cluster reads as a single control.Wrap the children in ButtonGroup; see Composition above.
Gapped rowThe actions are independent: a theme select next to a dark-mode toggle, a search input next to a submit. Each control keeps its own border.<div className="flex items-center gap-2">

The sizing rule

Adjacent controls in a row must share the same height step. Pick the step, then use the matching size variant on every control:

HeightButtonIcon ButtonSelectInput
24px (h-6)size="xs"size="icon-xs"--
32px (h-8)size="sm"size="icon-sm"size="sm"className="h-8" (no variant yet)
36px (h-9)defaultsize="icon"defaultdefault
40px (h-10)size="lg"size="icon-lg"--

Never override a control's height with a className when a size variant exists. The variant owns the height (Select applies it via data-[size=...] selectors), so a manual h-8 on a default-size trigger loses to the variant's h-9 and the row misaligns by 4px.

// Wrong: manual height fights the size variant and loses
<div className="flex items-center gap-2">
  <SelectTrigger className="h-8 w-36">...</SelectTrigger>  {/* renders 36px */}
  <Button variant="outline" size="icon-sm">...</Button>     {/* renders 32px */}
</div>

// Right: same step, matching variants
<div className="flex items-center gap-2">
  <SelectTrigger size="sm" className="w-36">...</SelectTrigger>  {/* 32px */}
  <Button variant="outline" size="icon-sm">...</Button>          {/* 32px */}
</div>

The same contract applies in the Angular edition: the size inputs mirror these variants one to one, so a mixed React and Angular surface stays aligned by construction.

Variations

Three actions with separators

Use for related commands. Separators communicate that each action is distinct, even though they share a visual surface.

<ButtonGroup>
  <Button variant="outline">Copy</Button>
  <ButtonGroupSeparator />
  <Button variant="outline">Paste</Button>
  <ButtonGroupSeparator />
  <Button variant="outline">Cut</Button>
</ButtonGroup>

Vertical orientation

Switch orientation to stack actions in a compact column - useful for image editors, video players, and any tool palette.

'use client';import { Button, ButtonGroup } from '@gremorie/rx-forms';import { Bold, Italic, Underline } from 'lucide-react';export function ButtonGroupVerticalPreview() {  return (    <ButtonGroup orientation="vertical">      <Button size="icon" variant="outline" aria-label="Bold">        <Bold />      </Button>      <Button size="icon" variant="outline" aria-label="Italic">        <Italic />      </Button>      <Button size="icon" variant="outline" aria-label="Underline">        <Underline />      </Button>    </ButtonGroup>  );}

Text label addon

ButtonGroupText renders a non-interactive label that shares the group's border, so prefixes and units merge seamlessly with the buttons.

https://
'use client';import { Button, ButtonGroup, ButtonGroupText } from '@gremorie/rx-forms';export function ButtonGroupTextPreview() {  return (    <ButtonGroup>      <ButtonGroupText>https://</ButtonGroupText>      <Button variant="outline">gremorie.com</Button>    </ButtonGroup>  );}

Pagination with label text

Combine ButtonGroupText with arrow buttons. The text stays non-interactive but inherits the group's surface.

import { ChevronLeft, ChevronRight } from 'lucide-react';

<ButtonGroup>
  <Button variant="outline" size="icon" aria-label="Previous page">
    <ChevronLeft />
  </Button>
  <ButtonGroupText>Page 3 of 12</ButtonGroupText>
  <Button variant="outline" size="icon" aria-label="Next page">
    <ChevronRight />
  </Button>
</ButtonGroup>;

Accessibility

  • Group semantics: the wrapper renders role="group". Provide aria-label (or aria-labelledby) so screen readers announce what the cluster represents.
  • Keyboard: each child remains an independent focusable element. Tab and Shift+Tab move between them; there is no roving tabindex (use ToggleGroup if you want that).
  • Focus: focused child elevates above its siblings (z-10) so the focus ring is never clipped by the adjacent button's border.
  • Separators: rendered as decorative so they do not appear in the accessibility tree.
  • Button - the building block
  • Toggle Group - same visual idea but with coordinated aria-pressed state and roving tabindex
  • Input Group - the input + addon equivalent
  • Select - allowed inside ButtonGroup when you need a dropdown trigger in the cluster

On this page