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 filesImplemented 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.
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.
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
"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.
Show sourceexamples/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
Demo schedule · Reset returns to 21 September.
Show sourceexamples/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
"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
Quarterly report
Export becomes available after approval.
Choose an available action.
Show sourceexamples/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.
| Prop | What it does | Default |
|---|---|---|
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-labelledbystring | Give the role=group container a concise accessible name that describes the relationship between its actions. | Not set |
childrenReactNode | Independent Button, IconButton, native button, or compatible action elements. ButtonGroup does not clone them. | Not set |
classNamestring | Compose placement and sizing utilities. Keep the internal axis aligned with orientation; responsive action handoff belongs to the surrounding product layout. | Not set |
| Prop | What it does | Default |
|---|---|---|
classNamestring | Compose classes onto the decorative token-backed rule. Its axis is derived from the surrounding group. | Not set |