Skip to content
Dethink Components

ComponentsButtonGroup

ButtonGroup

Keep related buttons together in a row or column.

On this page

Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.

View registry files

Implemented with React and browser APIs, without an additional behavior or rendering library.

Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.

Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.

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.

A considered decision

Attached decision actions with independent states. Review three documents; each remembers your decision.

Design review01 / 03

Ready for your eyes

New onboarding flow

A few considered details. One clear next step.

Choose a decision, or move to the next document.

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

import { useState } from "react";
import { Button, ButtonGroup, IconButton } from "@dethink/components";
import { ArrowRight, Check, MessageSquare, Layers } from "lucide-react";

const documents = [
  "New onboarding flow",
  "Workspace permissions",
  "Billing overview",
];

export function ButtonGroupReviewDesk() {
  const [index, setIndex] = useState(0);
  const [decisions, setDecisions] = useState<Record<number, string>>({});
  const decision = decisions[index];
  return (
    <div className="border-border bg-background mx-auto w-full max-w-lg overflow-hidden rounded-2xl border shadow-sm">
      <div className="border-border flex items-center justify-between gap-3 border-b px-5 py-4">
        <span className="text-muted-foreground flex items-center gap-2 text-xs font-medium">
          <Layers className="size-4" aria-hidden="true" /> Design review
        </span>
        <span className="text-muted-foreground text-xs tabular-nums">
          0{index + 1} / 03
        </span>
      </div>
      <div className="p-5 sm:p-7">
        <div
          aria-hidden="true"
          className="bg-primary/5 border-primary/10 mb-6 flex h-36 items-center justify-center rounded-xl border"
        >
          <div className="flex -space-x-3">
            {["A", "B", "C"].map((letter, i) => (
              <div
                key={letter}
                className={`border-background flex size-16 items-center justify-center rounded-2xl border-4 text-lg font-semibold shadow-sm ${i === index ? "bg-primary text-primary-foreground relative z-10" : "bg-muted text-muted-foreground"}`}
              >
                {letter}
              </div>
            ))}
          </div>
        </div>
        <p className="text-muted-foreground text-[10px] font-medium tracking-[0.16em] uppercase">
          Ready for your eyes
        </p>
        <h3 className="mt-2 text-xl font-semibold tracking-tight">
          {documents[index]}
        </h3>
        <p className="text-muted-foreground mt-2 text-sm leading-6">
          A few considered details. One clear next step.
        </p>
        <div className="mt-6 flex flex-wrap items-center gap-3">
          <ButtonGroup aria-label="Review decision">
            <Button
              size="sm"
              leftIcon={<Check />}
              disabled={decision === "Approved"}
              onClick={() =>
                setDecisions((previous) => ({
                  ...previous,
                  [index]: "Approved",
                }))
              }
            >
              Approve
            </Button>
            <Button
              size="sm"
              variant="outline"
              leftIcon={<MessageSquare />}
              disabled={decision === "Changes requested"}
              onClick={() =>
                setDecisions((previous) => ({
                  ...previous,
                  [index]: "Changes requested",
                }))
              }
            >
              Changes
            </Button>
          </ButtonGroup>
          <IconButton
            size="sm"
            variant="outline"
            aria-label="Next document"
            onClick={() =>
              setIndex((previous) => (previous + 1) % documents.length)
            }
          >
            <ArrowRight />
          </IconButton>
        </div>
        <p role="status" className="text-muted-foreground mt-4 min-h-5 text-xs">
          {decision
            ? `${documents[index]}: ${decision}.`
            : "Choose a decision, or move to the next document."}
        </p>
      </div>
    </div>
  );
}

A closer look

A vertical, icon-only group with real zoom controls, boundary states, and a decorative separator.

Studio canvas
Preview
A closer look, one step at a time.100%
Show sourceexamples/button-group/canvas-controls.tsx
examples/button-group/canvas-controls.tsx
"use client";

import { useState } from "react";
import {
  ButtonGroup,
  ButtonGroupSeparator,
  IconButton,
} from "@dethink/components";
import { Minus, Plus, Maximize2, MousePointer2 } from "lucide-react";

export function ButtonGroupCanvasControls() {
  const [zoom, setZoom] = useState(100);
  return (
    <div className="border-border bg-background mx-auto w-full max-w-lg overflow-hidden rounded-2xl border shadow-sm">
      <div className="border-border flex items-center justify-between gap-3 border-b px-5 py-4">
        <div className="flex items-center gap-2 text-sm font-medium">
          <MousePointer2 className="text-primary size-4" aria-hidden="true" />{" "}
          Studio canvas
        </div>
        <span className="text-muted-foreground text-xs">Preview</span>
      </div>
      <div className="bg-muted/40 relative flex h-72 items-center justify-center overflow-hidden">
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 [background-image:radial-gradient(var(--dt-color-muted-foreground)_1px,transparent_1px)] [background-size:16px_16px] opacity-30"
        />
        <div
          aria-hidden="true"
          data-canvas-preview
          className="border-border bg-background w-44 shrink-0 rounded-xl border p-4 shadow-lg motion-safe:transition-transform motion-safe:duration-200"
          style={{ transform: `scale(${zoom / 100})` }}
        >
          <div className="bg-primary/10 mb-4 flex h-20 items-center justify-center rounded-lg">
            <span className="bg-primary size-10 rounded-full" />
          </div>
          <div className="bg-foreground/70 mb-2 h-2 w-3/4 rounded-full" />
          <div className="bg-muted-foreground/30 h-1.5 w-full rounded-full" />
          <div className="bg-muted-foreground/20 mt-2 h-1.5 w-2/3 rounded-full" />
        </div>
        <ButtonGroup
          aria-label="Canvas zoom controls"
          orientation="vertical"
          className="absolute top-4 right-4 shadow-sm"
        >
          <IconButton
            variant="outline"
            size="sm"
            aria-label="Zoom in"
            disabled={zoom === 150}
            onClick={() => setZoom((value) => Math.min(150, value + 25))}
          >
            <Plus />
          </IconButton>
          <IconButton
            variant="outline"
            size="sm"
            aria-label="Zoom out"
            disabled={zoom === 50}
            onClick={() => setZoom((value) => Math.max(50, value - 25))}
          >
            <Minus />
          </IconButton>
          <ButtonGroupSeparator />
          <IconButton
            variant="outline"
            size="sm"
            aria-label="Reset zoom"
            onClick={() => setZoom(100)}
          >
            <Maximize2 />
          </IconButton>
        </ButtonGroup>
      </div>
      <div className="border-border flex items-center justify-between gap-4 border-t px-5 py-4 text-xs">
        <span className="text-muted-foreground">
          A closer look, one step at a time.
        </span>
        <output
          aria-live="polite"
          aria-label="Canvas zoom"
          className="shrink-0 font-medium tabular-nums"
        >
          {zoom}%
        </output>
      </div>
    </div>
  );
}

Make room for the week

Previous, reset, and next actions in a compact attached group. Every button keeps its own Tab stop.

Team calendar

21 Sept – 27 Sept

Mon
21
Tue
22
Wed
23
Thu
24
Fri
25
Sat
26
Sun
27

Demo schedule · Reset returns to 21 September.

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

import { useState } from "react";
import { Button, ButtonGroup, IconButton } from "@dethink/components";
import { ChevronLeft, ChevronRight, CalendarDays } from "lucide-react";

// A fixed demo week keeps server and client output identical in every time zone.
const referenceMonday = Date.UTC(2026, 8, 21);
const dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

export function ButtonGroupWeekNavigation() {
  const [offset, setOffset] = useState(0);
  const start = referenceMonday + offset * 7 * 86400000;
  const formatter = new Intl.DateTimeFormat("en-GB", {
    day: "numeric",
    month: "short",
    timeZone: "UTC",
  });
  return (
    <div className="border-border bg-background mx-auto w-full max-w-lg overflow-hidden rounded-2xl border shadow-sm">
      <div className="flex flex-wrap items-center justify-between gap-4 p-5">
        <div>
          <p className="text-muted-foreground mb-1 flex items-center gap-2 text-xs">
            <CalendarDays className="size-3.5" aria-hidden="true" /> Team
            calendar
          </p>
          <h3 aria-live="polite" className="text-sm font-semibold">
            {formatter.format(start)} – {formatter.format(start + 6 * 86400000)}
          </h3>
        </div>
        <ButtonGroup aria-label="Week navigation">
          <IconButton
            size="sm"
            variant="outline"
            aria-label="Previous week"
            onClick={() => setOffset((value) => value - 1)}
          >
            <ChevronLeft />
          </IconButton>
          <Button size="sm" variant="outline" onClick={() => setOffset(0)}>
            Reset
          </Button>
          <IconButton
            size="sm"
            variant="outline"
            aria-label="Next week"
            onClick={() => setOffset((value) => value + 1)}
          >
            <ChevronRight />
          </IconButton>
        </ButtonGroup>
      </div>
      <div className="border-border grid grid-cols-7 border-t">
        {dayNames.map((day, index) => (
          <div
            key={day}
            className="border-border min-w-0 border-e px-1 py-5 text-center last:border-e-0"
          >
            <span className="text-muted-foreground text-[10px]">{day}</span>
            <div
              className={`mx-auto mt-2 flex size-7 items-center justify-center rounded-full text-xs font-medium tabular-nums ${offset === 0 && index === 2 ? "bg-primary text-primary-foreground" : "text-foreground"}`}
            >
              {new Date(start + index * 86400000).getUTCDate()}
            </div>
            <div
              aria-hidden="true"
              className={`mx-auto mt-3 size-1 rounded-full ${index === 1 || index === 3 ? "bg-primary" : "bg-transparent"}`}
            />
          </div>
        ))}
      </div>
      <p className="text-muted-foreground border-border border-t px-5 py-3 text-xs">
        Demo schedule · Reset returns to 21 September.
      </p>
    </div>
  );
}

Layouts and states

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"
          className="flex-wrap"
        >
          <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>
  );
}

Keep frequent actions visible and move secondary actions into a menu when space is limited.

Wide group to narrow overflow

The same actions work in either layout, including disabled and destructive actions.

Quarterly report

Export becomes available after approval.

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" },
  { 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="border-border bg-background @container w-full space-y-4 rounded-2xl border p-5 shadow-sm">
      <div
        data-layout="wide"
        className="hidden @min-lg:flex @min-lg:items-center @min-lg:justify-between @min-lg: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}
              size="sm"
              disabled={action.disabled}
              onClick={() => runAction(action)}
              variant={action.destructive ? "destructive" : "outline"}
            >
              {action.label}
            </Button>
          ))}
        </ButtonGroup>
      </div>

      <div
        data-layout="narrow"
        className="flex flex-col items-start gap-3 @min-lg:hidden"
      >
        <p className="min-w-0 truncate text-sm font-medium">Quarterly report</p>
        <ButtonGroup
          aria-label="Quarterly report actions"
          mode="separated"
          className="flex-wrap"
        >
          {narrowPrimaryActions.map((action) => (
            <Button
              key={action.id}
              data-action-id={action.id}
              size="sm"
              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 className="text-muted-foreground text-xs">
        Export becomes available after approval.
      </p>
      <p aria-live="polite" className="text-muted-foreground text-sm">
        {lastActionId
          ? `${headerActions.find((action) => action.id === lastActionId)?.label} selected for Quarterly report.`
          : "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.

Use matching button sizes for a clean attached edge. Keep buttons and decorative separators as direct children. For narrow screens, let separated groups wrap or move secondary actions into a menu; attached groups work best on one line. Give each icon button its own accessible name.

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