Duck Button Group
Shared geometry for a cluster of buttons that act: one seam instead of two borders, outer corners on the ends only, and a choice between three tab stops and one.
ActionsClient componentclass-variance-authority
Preview
100%
Both clusters cost one Tab. Drop toolbar for a plain named group when the buttons sit inside a form and every one of them should have its own stop.
Installation
The CLI writes the source into your project and pulls in whatever it depends on.
$
pnpm dlx shadcn@latest add @duck/duck-button-groupPulls in @duck/theme. Already installed items are skipped.
Usage
import { DuckButtonGroup } from "@/components/ui/duck-button-group"The file lands in components/ui/duck-button-group.tsx and belongs to you from that point on. Edit it in place rather than wrapping it.
Props
Everything not listed here is forwarded to the underlying element.
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | "horizontal" | "vertical" | "horizontal" | Which way the cluster runs. It also picks the seam edge, which corners the ends keep, and — for a toolbar — which two arrow keys move focus. |
| joined | boolean | true | Collapse the shared edges so the cluster reads as one control with dividers. False leaves each child its own border and a gap-2 between them; override the gap with a gap-* class. |
| toolbar | boolean | false | role="toolbar" plus a roving tabindex: one tab stop for the whole cluster, arrows inside it, Home and End to the ends, both wrapping. Off, it is a role="group" and every button keeps its own stop. |
| aria-label / aria-labelledby | string | - | One of the two is required by the types. A group or a toolbar with no name is announced as an unnamed container. |
| className | string | - | Merged over the group. A rounded-* class here changes the cluster's outer corners — the end children inherit the group's radius rather than carrying their own. |
Rules
What keeps this component from turning into noise.
- Joined is for buttons doing one job — zoom in, zoom out, reset. Unjoined is for separate actions that merely travel together; a fused Save and Delete reads as one control and invites the wrong press.
- role="group" is the default because three buttons are expected to cost three Tabs. Set toolbar when the cluster is the screen's controls rather than part of a form: an icon-only canvas cluster, or anything past about four buttons.
- No size prop. Children carry their own size — QuackButton size="icon" for a canvas cluster — and the group only shares geometry.
- Style the group, not the children. The [&>*] rules reach a plain button or an anchor exactly as they reach a QuackButton, so nothing has to be a duck component to sit in here.
- Never put overflow-hidden on the group. The overlap means a middle child's focus ring is already sitting over its neighbour; clipping it is the bug this component exists to avoid.
- A toolbar button that disables itself while focused takes the focus with it. Clamp the action instead — arrows skip disabled items, so a permanently disabled one is fine and a self-disabling one is not.
