Button group
A few related buttons in one frame, joined on shared seams. Each button stays its own Tab stop. For a long row of commands that should be one stop, use Toolbar.
Loading the live example…
"use client"
import { useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import { ButtonGroup, ButtonGroupText } from "@/components/keyline/ui/button-group"
const ORDERS = [
{ id: "DM-4102", client: "Scranton Business Park", paper: "40 cases, 20 lb copy", rep: "Dwight Schrute" },
{ id: "DM-4087", client: "Lackawanna County", paper: "12 cases, cardstock", rep: "Jim Halpert" },
{ id: "DM-4120", client: "Wilkes-Barre Library", paper: "6 cases, recycled bond", rep: "Phyllis Vance" },
{ id: "DM-4131", client: "Blue Cross of Pennsylvania", paper: "25 cases, letterhead", rep: "Stanley Hudson" },
{ id: "DM-4136", client: "Harper Collins, Scranton", paper: "8 cases, legal pads", rep: "Andy Bernard" },
]
export function OrderReview() {
const [i, setI] = useState(0)
const order = ORDERS[i]
return (
<div className="grid gap-4">
<ButtonGroup aria-label="Today’s orders" className="justify-self-start">
<Button layout="cell" disabled={i === 0} onClick={() => setI(i - 1)}>
Previous
</Button>
<ButtonGroupText aria-live="polite">
{i + 1} of {ORDERS.length}
</ButtonGroupText>
<Button layout="cell" disabled={i === ORDERS.length - 1} onClick={() => setI(i + 1)}>
Next
</Button>
</ButtonGroup>
<dl className="m-0 grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-[14px]">
<dt className="text-kl-ink-3">Order</dt>
<dd className="m-0 font-kl-mono">{order.id}</dd>
<dt className="text-kl-ink-3">Client</dt>
<dd className="m-0">{order.client}</dd>
<dt className="text-kl-ink-3">Paper</dt>
<dd className="m-0">{order.paper}</dd>
<dt className="text-kl-ink-3">Sales rep</dt>
<dd className="m-0">{order.rep}</dd>
</dl>
</div>
)
}Installation
npx shadcn@latest add @keyline-ui/button-groupRun it where your components.json is. The first time, map the @keyline-ui registry: one command, in Installation.
Usage
Children are cells: a Button with layout="cell", a ButtonGroupText, or a menu trigger rendering a cell button.
import { Button } from "@/components/keyline/ui/button"
import { ButtonGroup, ButtonGroupText } from "@/components/keyline/ui/button-group"<ButtonGroup aria-label="Today’s orders">
<Button layout="cell">Previous</Button>
<ButtonGroupText>Order 1 of 5</ButtonGroupText>
<Button layout="cell">Next</Button>
</ButtonGroup>Examples
Text and separator
ButtonGroupText holds a value between steppers. ButtonGroupSeparator draws a heavier seam where one run of cells ends and the next begins.
Loading the live example…
"use client"
import { useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "@/components/keyline/ui/button-group"
export function ReportZoom() {
const [zoom, setZoom] = useState(100)
return (
<div className="grid gap-4">
<ButtonGroup aria-label="Zoom the quarterly sales report" className="justify-self-start">
<Button layout="cell" size="icon" aria-label="Zoom out" disabled={zoom <= 50} onClick={() => setZoom(zoom - 25)}>
−
</Button>
<ButtonGroupText className="min-w-[7ch] justify-center tabular-nums">
<bdi>{zoom}%</bdi>
</ButtonGroupText>
<Button layout="cell" size="icon" aria-label="Zoom in" disabled={zoom >= 200} onClick={() => setZoom(zoom + 25)}>
+
</Button>
<ButtonGroupSeparator />
<Button layout="cell" disabled={zoom === 100} onClick={() => setZoom(100)}>
Actual size
</Button>
</ButtonGroup>
<p role="status" className="m-0 text-[14px] text-kl-ink-2">
Q3 sales report, Scranton branch · shown at {zoom}%
</p>
</div>
)
}With a menu
A MenuTrigger that renders a cell button joins the group; its menu docks to that cell.
Loading the live example…
"use client"
import { ChevronDown } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import { ButtonGroup } from "@/components/keyline/ui/button-group"
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@/components/keyline/ui/menu"
export function TrackingLink() {
const [sent, setSent] = useState<string | null>(null)
return (
<div className="grid gap-4">
<ButtonGroup aria-label="Tracking link for DM-4087" className="justify-self-start">
<Button layout="cell" onClick={() => setSent("Link copied: dundermifflin.com/track/DM-4087")}>
Copy tracking link
</Button>
<Menu>
<MenuTrigger render={<Button layout="cell" aria-label="Send the tracking link" />}>
Send
<ChevronDown size={16} aria-hidden="true" />
</MenuTrigger>
<MenuContent align="end">
<MenuItem onClick={() => setSent("Emailed to the Lackawanna County clerk.")}>Email the client</MenuItem>
<MenuItem onClick={() => setSent("Texted to Jim Halpert.")}>Text the sales rep</MenuItem>
<MenuItem onClick={() => setSent("Printed at reception. Pam has it.")}>Print for reception</MenuItem>
</MenuContent>
</Menu>
</ButtonGroup>
<p role="status" className="m-0 min-h-5 text-[14px] text-kl-ink-2">
{sent ?? "Order DM-4087 left the warehouse at 10:15."}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | Default "horizontal" | Vertical stacks the cells on horizontal seams. |
aria-label | string | Default — | The group is role="group"; name it for what the buttons act on. |
Parts: ButtonGroup, ButtonGroupText, ButtonGroupSeparator. Other div props pass through.