Skip to content
Dethink Components

ComponentsButtonGroup

ButtonGroup

Keep related buttons together in a row or column.

On this page

Use this component from the local workspace package. Follow the setup guide first. A public npm package and registry are not available yet.

View registry files

Import the component into your page or component file.

Usage
import {
  Button,
  ButtonGroup,
  ButtonGroupSeparator,
} from "@dethink/components";

Attached and separated groups share visual structure, never interaction state.

Action groups

Horizontal, vertical, mixed-state, icon-only, and separator compositions using the same two-prop layout contract.

Attached document actions

Separated state-aware actions

Vertical actions with a decorative separator

Show sourceexamples/button-group/basic.tsx
examples/button-group/basic.tsx
"use client";

import {
  Button,
  ButtonGroup,
  ButtonGroupSeparator,
  IconButton,
} from "@dethink/components";
import { MoreHorizontal } from "lucide-react";

export function ButtonGroupBasic() {
  return (
    <div className="space-y-8">
      <div className="space-y-3">
        <p className="text-muted-foreground text-sm font-medium">
          Attached document actions
        </p>
        <ButtonGroup aria-label="Document actions">
          <Button variant="outline">Preview</Button>
          <Button variant="outline">Share</Button>
          <IconButton aria-label="More document actions" variant="outline">
            <MoreHorizontal />
          </IconButton>
        </ButtonGroup>
      </div>

      <div className="space-y-3">
        <p className="text-muted-foreground text-sm font-medium">
          Separated state-aware actions
        </p>
        <ButtonGroup aria-label="Publishing actions" mode="separated">
          <Button>Publish</Button>
          <Button disabled variant="outline">
            Schedule
          </Button>
          <Button loading variant="outline">
            Syncing
          </Button>
        </ButtonGroup>
      </div>

      <div className="space-y-3">
        <p className="text-muted-foreground text-sm font-medium">
          Vertical actions with a decorative separator
        </p>
        <ButtonGroup aria-label="Record actions" orientation="vertical">
          <Button variant="outline">Duplicate</Button>
          <ButtonGroupSeparator />
          <Button variant="outline">Archive</Button>
          <Button variant="destructive">Delete</Button>
        </ButtonGroup>
      </div>
    </div>
  );
}

The product declares one container threshold and keeps action ownership stable across representations.

Wide group to narrow overflow

The narrow representation is the no-container-query fallback. At the declared container threshold, the same action definitions render in ButtonGroup; IDs, labels, disabled rules, destructive meaning, and handlers are preserved.

Quarterly report

Choose an available action.

Show sourceexamples/button-group/responsive-action-handoff.tsx
examples/button-group/responsive-action-handoff.tsx
"use client";

import { useState } from "react";
import {
  Button,
  ButtonGroup,
  DropdownButton,
  DropdownMenuItem,
} from "@dethink/components";

type HeaderActionId = "preview" | "share" | "export" | "archive";

interface HeaderAction {
  destructive?: boolean;
  disabled?: boolean;
  id: HeaderActionId;
  label: string;
}

const headerActions: HeaderAction[] = [
  { id: "preview", label: "Preview" },
  { id: "share", label: "Share" },
  { disabled: true, id: "export", label: "Export pending approval" },
  { destructive: true, id: "archive", label: "Archive" },
];

const alwaysVisibleIds = new Set<HeaderActionId>(["preview", "share"]);

export function ButtonGroupResponsiveActionHandoff() {
  const [lastActionId, setLastActionId] = useState<HeaderActionId>();
  const wideActions = headerActions;
  const narrowPrimaryActions = headerActions.filter((action) =>
    alwaysVisibleIds.has(action.id),
  );
  const narrowMenuActions = headerActions.filter(
    (action) => !alwaysVisibleIds.has(action.id),
  );
  const runAction = (action: HeaderAction) => setLastActionId(action.id);

  return (
    <div className="@container space-y-4">
      <div
        data-layout="wide"
        className="hidden @min-3xl:flex @min-3xl:items-center @min-3xl:justify-between @min-3xl:gap-4"
      >
        <p className="text-sm font-medium">Quarterly report</p>
        <ButtonGroup aria-label="Quarterly report actions">
          {wideActions.map((action) => (
            <Button
              key={action.id}
              data-action-id={action.id}
              disabled={action.disabled}
              onClick={() => runAction(action)}
              variant={action.destructive ? "destructive" : "outline"}
            >
              {action.label}
            </Button>
          ))}
        </ButtonGroup>
      </div>

      <div
        data-layout="narrow"
        className="flex items-center justify-between gap-3 @min-3xl:hidden"
      >
        <p className="min-w-0 truncate text-sm font-medium">Quarterly report</p>
        <ButtonGroup aria-label="Quarterly report actions" mode="separated">
          {narrowPrimaryActions.map((action) => (
            <Button
              key={action.id}
              data-action-id={action.id}
              disabled={action.disabled}
              onClick={() => runAction(action)}
              variant="outline"
            >
              {action.label}
            </Button>
          ))}
          <DropdownButton
            aria-label="More quarterly report actions"
            label="More"
            size="sm"
          >
            {narrowMenuActions.map((action) => (
              <DropdownMenuItem
                key={action.id}
                data-action-id={action.id}
                destructive={action.destructive}
                disabled={action.disabled}
                onAction={() => runAction(action)}
              >
                {action.label}
              </DropdownMenuItem>
            ))}
          </DropdownButton>
        </ButtonGroup>
      </div>

      <p aria-live="polite" className="text-muted-foreground text-sm">
        {lastActionId
          ? `Application handled action ID: ${lastActionId}`
          : "Choose an available action."}
      </p>
    </div>
  );
}

This is an application recipe, not automatic component behavior. ButtonGroup never measures children, infers priority, hides actions, installs a ResizeObserver, or becomes a Toolbar. Choose the threshold and the always-visible action IDs from product requirements.

ButtonGroup is deliberately narrower than the components it is often confused with.

ButtonGroup
Use for a small set of related independent actions. Every enabled control stays in the normal Tab order; arrow keys do nothing special.
ToggleGroup
Use when controls represent single or multiple pressed selections and the selected value belongs to the group.
Toolbar
Use for a command surface that requires toolbar semantics, roving focus, and arrow-key navigation.
DropdownButton
Use when a visible action opens a menu, or when a primary action and its alternatives form a split button.
InputGroup
Use when controls are attached to an input and participate in a field's labelling, validation, or editing workflow.

Label the relationship; leave each action's behavior native.

Supply aria-label or aria-labelledby on every group. Icon-only children still need their own accessible names.

Tab and Shift+Tab visit each enabled child in document order. Enter and Space activate the focused native button. ButtonGroup does not add arrow-key navigation, selection values, pressed state, shared loading, child measurement, or automatic overflow.

ButtonGroup props
PropWhat it doesDefault
mode"attached" | "separated"Merge adjacent borders and logical corners, or retain a density-token gap between actions."attached"
orientation"horizontal" | "vertical"Set the visual flow. Each child remains in normal document Tab order in either orientation."horizontal"
aria-label / aria-labelledbystringGive the role=group container a concise accessible name that describes the relationship between its actions.Not set
childrenReactNodeIndependent Button, IconButton, native button, or compatible action elements. ButtonGroup does not clone them.Not set
classNamestringCompose placement and sizing utilities. Keep the internal axis aligned with orientation; responsive action handoff belongs to the surrounding product layout.Not set
ButtonGroupSeparator props
PropWhat it doesDefault
classNamestringCompose classes onto the decorative token-backed rule. Its axis is derived from the surrounding group.Not set