Button group
Buttons, inputs and select triggers joined into one control with shared edges.
shadcn's base-nova button group with its joins rewritten: it squares only the corners two segments share and drops the border one of them would double, on the inline axis. So a group of small buttons keeps their corner at both ends — shadcn's restores a fixed rounded-lg on the last — a right-to-left page mirrors it, and a screen-reader-only sibling such as CopyButton's status line is not counted as a segment. Joined to a field, an outline button and a text addon draw the field's --input line instead of the hairline, so the control has one edge. What it joins is Vibra's: the buttons, inputs and select triggers inside take the kit's look.
Install
npx shadcn@latest add @vibra/button-groupNeeds the @vibra registry in your components.json — set it up once.
Examples
import { ChevronDownIcon, MinusIcon, PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "@/components/ui/button-group"
export default function ButtonGroupDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-6">
<ButtonGroup aria-label="Zoom">
<Button variant="outline" size="icon-sm" aria-label="Zoom out">
<MinusIcon aria-hidden="true" />
</Button>
<ButtonGroupText className="text-label tabular-nums">100%</ButtonGroupText>
<Button variant="outline" size="icon-sm" aria-label="Zoom in">
<PlusIcon aria-hidden="true" />
</Button>
</ButtonGroup>
<ButtonGroup aria-label="Export">
<Button size="sm">Export report</Button>
<ButtonGroupSeparator />
<Button size="icon-sm" aria-label="More export formats">
<ChevronDownIcon aria-hidden="true" />
</Button>
</ButtonGroup>
</div>
)
}Actions
A record's actions as one control: the shared edges collapse to one hairline, and the group's name says what they act on.
import { ArchiveIcon, CopyIcon, PencilIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
// An invoice's own actions, joined into one control: the shared edges collapse
// to a single hairline, and only the outer corners stay round. The group is
// named, so a screen reader announces what the three buttons are for.
export default function ButtonGroupActions() {
return (
<ButtonGroup aria-label="Invoice INV-2041">
<Button variant="outline">
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Edit
</Button>
<Button variant="outline">
<CopyIcon data-icon="inline-start" aria-hidden="true" />
Duplicate
</Button>
<Button variant="outline">
<ArchiveIcon data-icon="inline-start" aria-hidden="true" />
Archive
</Button>
</ButtonGroup>
)
}With a separator
Filled buttons have no border to share, so ButtonGroupSeparator draws the join.
import { CodeIcon, LinkIcon, MailIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"
// Filled buttons have no border to share, so a separator draws the join —
// in the field boundary's grey, which clears 3:1 on the fill.
export default function ButtonGroupWithSeparator() {
return (
<ButtonGroup aria-label="Share Q3 board report">
<Button variant="secondary">
<LinkIcon data-icon="inline-start" aria-hidden="true" />
Copy link
</Button>
<ButtonGroupSeparator />
<Button variant="secondary">
<MailIcon data-icon="inline-start" aria-hidden="true" />
Email
</Button>
<ButtonGroupSeparator />
<Button variant="secondary">
<CodeIcon data-icon="inline-start" aria-hidden="true" />
Embed
</Button>
</ButtonGroup>
)
}Vertical
orientation="vertical" shares tops and bottoms — a map's zoom stack.
import { LocateFixedIcon, MinusIcon, PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
// The zoom stack in the corner of an orders-by-region map: stacked, the group
// shares tops and bottoms instead of sides.
export default function ButtonGroupVertical() {
return (
<ButtonGroup orientation="vertical" aria-label="Map zoom">
<Button variant="outline" size="icon-sm" aria-label="Zoom in" tooltip="Zoom in">
<PlusIcon aria-hidden="true" />
</Button>
<Button variant="outline" size="icon-sm" aria-label="Zoom out" tooltip="Zoom out">
<MinusIcon aria-hidden="true" />
</Button>
<Button variant="outline" size="icon-sm" aria-label="Fit to orders" tooltip="Fit to orders">
<LocateFixedIcon aria-hidden="true" />
</Button>
</ButtonGroup>
)
}Sizes
The group takes the size of its buttons, and their corner at both ends.
import { DownloadIcon, UploadIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
const SIZES = [
{ size: "xs", height: 24 },
{ size: "sm", height: 28 },
{ size: "default", height: 32 },
] as const
// The group has no size of its own: size the buttons and it follows, outer
// corners included — the smaller sizes keep their tighter corner at both ends.
export default function ButtonGroupSizes() {
return (
<dl className="grid grid-cols-[auto_auto] items-center gap-x-4 gap-y-3">
{SIZES.map(({ size, height }) => (
<div key={size} className="contents">
<dt className="font-mono text-xs text-muted-foreground">
{size} · {height}px
</dt>
<dd>
<ButtonGroup aria-label={`Customers, ${size}`}>
<Button variant="outline" size={size}>
<UploadIcon data-icon="inline-start" aria-hidden="true" />
Import
</Button>
<Button variant="outline" size={size}>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Export
</Button>
</ButtonGroup>
</dd>
</div>
))}
</dl>
)
}Icon only
Undo and redo, each named, with its chord in the tooltip and in aria-keyshortcuts.
import { Redo2Icon, Undo2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
// The dashboard editor's history. Icon-only, so each button is named, shows
// its name and chord on hover and focus, and announces the chord — ⌘Z on a
// Mac, Ctrl+Z elsewhere. The arrows mirror in a right-to-left page.
export default function ButtonGroupIcons() {
return (
<ButtonGroup aria-label="Edit history">
<Button variant="outline" size="icon" aria-label="Undo" tooltip="Undo" shortcut="mod+z">
<Undo2Icon aria-hidden="true" className="rtl:-scale-x-100" />
</Button>
<Button variant="outline" size="icon" aria-label="Redo" tooltip="Redo" shortcut="shift+mod+z">
<Redo2Icon aria-hidden="true" className="rtl:-scale-x-100" />
</Button>
</ButtonGroup>
)
}Split button
The action most people want, with the others a menu away.
import { CalendarClockIcon, ChevronDownIcon, FileDownIcon, MailIcon, SendIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
// The action most people want, with the rest a menu away. Outline buttons
// share their border, so the split needs no separator; the menu root renders
// no element, so the trigger is the group's own last segment.
export default function ButtonGroupSplit() {
return (
<ButtonGroup aria-label="Send INV-2041">
<Button variant="outline">
<SendIcon data-icon="inline-start" aria-hidden="true" />
Send invoice
</Button>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="icon" aria-label="More ways to send" />}>
<ChevronDownIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuItem>
<CalendarClockIcon aria-hidden="true" />
Schedule send…
</DropdownMenuItem>
<DropdownMenuItem>
<MailIcon aria-hidden="true" />
Send a test to me
</DropdownMenuItem>
<DropdownMenuItem>
<FileDownIcon aria-hidden="true" />
Download PDF
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
)
}With a count
A figure beside the button as ButtonGroupText, read out as the button's description.
import { MessageSquareIcon, PaperclipIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"
// An invoice's activity: the button opens the thread, and the figure beside
// it is text the button is described by, so a screen reader hears
// "Comments, 12" rather than a lone number after it.
export default function ButtonGroupCount() {
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<ButtonGroup>
<Button variant="outline" aria-describedby="button-group-count-comments">
<MessageSquareIcon data-icon="inline-start" aria-hidden="true" />
Comments
</Button>
<ButtonGroupText id="button-group-count-comments" className="font-normal tabular-nums">
12
</ButtonGroupText>
</ButtonGroup>
<ButtonGroup>
<Button variant="outline" aria-describedby="button-group-count-files">
<PaperclipIcon data-icon="inline-start" aria-hidden="true" />
Attachments
</Button>
<ButtonGroupText id="button-group-count-files" className="font-normal tabular-nums">
3
</ButtonGroupText>
</ButtonGroup>
</div>
)
}With a text addon
A fixed prefix, the part you own, and CopyButton for the whole address.
"use client"
import * as React from "react"
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"
import { CopyButton } from "@/components/ui/copy-button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
const PREFIX = "northwind.example/r/"
// A shared report's address: the fixed part is text in the group, the part
// you own is the field, and the copy button takes the whole address. The copy
// button's status line is a screen-reader-only sibling in the group, and the
// join skips it, so the button keeps its outer corners.
export default function ButtonGroupWithText() {
const [slug, setSlug] = React.useState("q3-board-report")
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="button-group-slug">Public link</Label>
<ButtonGroup className="w-full">
<ButtonGroupText className="font-normal text-muted-foreground">{PREFIX}</ButtonGroupText>
<Input
id="button-group-slug"
value={slug}
spellCheck={false}
onChange={(event) => setSlug(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"))}
className="min-w-0 font-mono"
/>
<CopyButton variant="outline" value={`https://${PREFIX}${slug}`} label="Copy link" />
</ButtonGroup>
</div>
)
}With an input
The field stretches and the button submits; an address that is not one is named under the field.
"use client"
import * as React from "react"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
// A field and its button joined: the input stretches, and the error names
// the field it belongs to, with the success on a status line of its own.
export default function ButtonGroupInput() {
const [error, setError] = React.useState<string | null>(null)
const [sent, setSent] = React.useState<string | null>(null)
function invite(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const field = event.currentTarget.elements.namedItem("email") as HTMLInputElement
const email = field.value.trim()
if (!isEmail(email)) {
setSent(null)
setError("Enter a full email address, like maren@northwind.example.")
return
}
setError(null)
setSent(email)
field.value = ""
}
return (
<form noValidate onSubmit={invite} className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="button-group-invite">Invite a teammate</Label>
<ButtonGroup className="w-full">
<Input
id="button-group-invite"
name="email"
type="email"
autoComplete="off"
placeholder="name@northwind.example"
aria-invalid={error ? true : undefined}
aria-describedby={error ? "button-group-invite-error" : undefined}
/>
<Button type="submit" variant="outline">
Send invite
</Button>
</ButtonGroup>
<FieldError id="button-group-invite-error" className="text-xs">
{error}
</FieldError>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{sent ? `Invite sent to ${sent}.` : null}
</p>
</form>
)
}With a select
A column, a title and add: the board's quick add as one control.
"use client"
import * as React from "react"
import { PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const COLUMNS = [
{ value: "todo", label: "To do" },
{ value: "doing", label: "In progress" },
{ value: "review", label: "In review" },
]
// The board's quick add: which column, what it is, and add — three controls
// read as one, the select trigger and the field sharing their edges with the
// button.
export default function ButtonGroupSelect() {
const [column, setColumn] = React.useState("todo")
const [added, setAdded] = React.useState<string | null>(null)
function add(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const field = event.currentTarget.elements.namedItem("title") as HTMLInputElement
const title = field.value.trim()
if (!title) return field.focus()
const label = COLUMNS.find((option) => option.value === column)?.label
setAdded(`Added “${title}” to ${label}.`)
field.value = ""
}
return (
<form onSubmit={add} className="flex w-full max-w-md flex-col gap-2">
<ButtonGroup className="w-full" aria-label="Add a task">
<Select items={COLUMNS} value={column} onValueChange={(value) => setColumn(String(value))}>
<SelectTrigger aria-label="Column" className="w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLUMNS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input name="title" aria-label="Task title" placeholder="Chase INV-2041" className="min-w-0" />
<Button type="submit" variant="outline" size="icon" aria-label="Add task" tooltip="Add task">
<PlusIcon aria-hidden="true" />
</Button>
</ButtonGroup>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{added}
</p>
</form>
)
}With a sender
Reply as yourself or as the team's inbox, the choice and the send side by side.
"use client"
import { HeadsetIcon, SendIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const SENDERS = [
{ value: "maren", label: "Maren Kovač" },
{ value: "team", label: "Northwind Support" },
]
// Decoration beside the name it stands for, so it is hidden whole.
function Sender({ value, label }: { value: string; label: string }) {
return (
<>
<Avatar size="sm" aria-hidden="true">
{value === "team" ? null : <AvatarImage src={avatarFor(label)} alt="" />}
<AvatarFallback>
{value === "team" ? <HeadsetIcon className="size-3.5" /> : getInitials(label)}
</AvatarFallback>
</Avatar>
{label}
</>
)
}
// A support reply goes out as you or as the team's shared inbox; the choice
// and the send sit together as one control at the foot of the thread.
export default function ButtonGroupSendAs() {
return (
<ButtonGroup aria-label="Send the reply to Blue Harbor Logistics">
<Select items={SENDERS} defaultValue="maren">
<SelectTrigger aria-label="Reply as" className="ps-1.5">
<SelectValue>
{(value: string) => {
const sender = SENDERS.find((option) => option.value === value) ?? SENDERS[0]
return <Sender {...sender} />
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{SENDERS.map((sender) => (
<SelectItem key={sender.value} value={sender.value}>
<Sender {...sender} />
</SelectItem>
))}
</SelectContent>
</Select>
<Button>
<SendIcon data-icon="inline-start" aria-hidden="true" className="rtl:-scale-x-100" />
Send
</Button>
</ButtonGroup>
)
}Previous and next
Stepping through months. At the current month, Next stays focusable and says why it is off.
"use client"
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"
const MONTH = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" })
function monthsBack(offset: number) {
const date = new Date(REFERENCE_DATE)
date.setUTCDate(1)
date.setUTCMonth(date.getUTCMonth() - offset)
return MONTH.format(date)
}
// Stepping through a report's months. The current month has nothing after it,
// so "Next month" stays focusable and points at the line that says why.
export default function ButtonGroupPeriod() {
const [offset, setOffset] = React.useState(0)
const atLatest = offset === 0
return (
<div className="flex flex-col items-center gap-2">
<ButtonGroup aria-label="Reporting month">
<Button variant="outline" size="icon" aria-label="Previous month" onClick={() => setOffset(offset + 1)}>
<ChevronLeftIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
<ButtonGroupText aria-live="polite" className="min-w-36 justify-center font-normal tabular-nums">
{monthsBack(offset)}
</ButtonGroupText>
<Button
variant="outline"
size="icon"
aria-label="Next month"
disabled={atLatest}
focusableWhenDisabled
aria-describedby={atLatest ? "button-group-period-latest" : undefined}
onClick={() => setOffset(offset - 1)}
>
<ChevronRightIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
</ButtonGroup>
<p id="button-group-period-latest" className="min-h-4 text-xs text-muted-foreground">
{atLatest ? "This is the current month." : null}
</p>
</div>
)
}Busy
While one export runs the whole group waits, and the file it made is named on the status line.
"use client"
import * as React from "react"
import { FileSpreadsheetIcon, SheetIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"
import { Spinner } from "@/components/ui/spinner"
const FORMATS = [
{ value: "csv", label: "CSV", icon: SheetIcon },
{ value: "xlsx", label: "XLSX", icon: FileSpreadsheetIcon },
] as const
type Format = (typeof FORMATS)[number]["value"]
// One export at a time: while a segment works, the whole group waits — every
// segment disabled but focusable, the busy one spinning — and the file it made
// is named on the status line.
export default function ButtonGroupLoading() {
const [busy, setBusy] = React.useState<Format | null>(null)
const [ready, setReady] = React.useState<Format | null>(null)
React.useEffect(() => {
if (!busy) return
const timer = window.setTimeout(() => {
setReady(busy)
setBusy(null)
}, 1200)
return () => window.clearTimeout(timer)
}, [busy])
return (
<div className="flex flex-col items-center gap-2">
<ButtonGroup aria-label="Export September invoices" aria-busy={busy ? true : undefined}>
<ButtonGroupText className="font-normal text-muted-foreground">Export</ButtonGroupText>
{FORMATS.map(({ value, label, icon: Icon }) => (
<Button
key={value}
variant="outline"
disabled={busy !== null}
focusableWhenDisabled
onClick={() => {
setReady(null)
setBusy(value)
}}
>
{busy === value ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<Icon data-icon="inline-start" aria-hidden="true" />
)}
{busy === value ? "Exporting…" : label}
</Button>
))}
</ButtonGroup>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{ready ? `invoices-2026-09.${ready} is ready to download.` : null}
</p>
</div>
)
}Nested groups
Groups inside a group sit apart instead of joining, and wrap on a phone.
import { ChevronDownIcon, ChevronUpIcon, EllipsisIcon, FileDownIcon, SendIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
// An invoice header's toolbar: groups inside a group, which spaces them apart
// instead of joining them — moving between invoices, acting on this one, and
// the rest in a menu. It wraps rather than overflowing on a phone.
export default function ButtonGroupNested() {
return (
<ButtonGroup aria-label="Invoice INV-2041" className="flex-wrap justify-center">
<ButtonGroup aria-label="Move between invoices">
<Button variant="outline" size="icon" aria-label="Previous invoice" tooltip="Previous invoice" shortcut="k">
<ChevronUpIcon aria-hidden="true" />
</Button>
<Button variant="outline" size="icon" aria-label="Next invoice" tooltip="Next invoice" shortcut="j">
<ChevronDownIcon aria-hidden="true" />
</Button>
</ButtonGroup>
<ButtonGroup aria-label="This invoice">
<Button variant="outline">
<FileDownIcon data-icon="inline-start" aria-hidden="true" />
Download
</Button>
<Button variant="outline">
<SendIcon data-icon="inline-start" aria-hidden="true" />
Send
</Button>
</ButtonGroup>
<ButtonGroup>
<Button variant="outline" size="icon" aria-label="More invoice actions" tooltip="More actions">
<EllipsisIcon aria-hidden="true" />
</Button>
</ButtonGroup>
</ButtonGroup>
)
}Right to left
The same actions in Arabic. The joins sit on the inline axis, so corners and shared hairlines mirror.
import { ArchiveIcon, CopyIcon, PencilIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
const LABELS = {
en: { group: "Invoice INV-2041", edit: "Edit", duplicate: "Duplicate", archive: "Archive" },
ar: { group: "الفاتورة INV-2041", edit: "تعديل", duplicate: "تكرار", archive: "أرشفة" },
}
function Actions({ labels }: { labels: (typeof LABELS)["en"] }) {
return (
<ButtonGroup aria-label={labels.group}>
<Button variant="outline">
<PencilIcon data-icon="inline-start" aria-hidden="true" />
{labels.edit}
</Button>
<Button variant="outline">
<CopyIcon data-icon="inline-start" aria-hidden="true" />
{labels.duplicate}
</Button>
<Button variant="outline">
<ArchiveIcon data-icon="inline-start" aria-hidden="true" />
{labels.archive}
</Button>
</ButtonGroup>
)
}
// The same group in English and in Arabic. The group joins its segments on
// the inline axis, so in a right-to-left page the first segment keeps its
// round corners on the right and the shared hairlines stay single.
export default function ButtonGroupRtl() {
return (
<div className="flex flex-col items-center gap-4">
<Actions labels={LABELS.en} />
<div dir="rtl" lang="ar">
<Actions labels={LABELS.ar} />
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | "horizontal" | "vertical" | "horizontal" | Which edges are shared: the sides of a row, or the tops and bottoms of a column. |
Dependencies
Source
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
// A segment is any child with a data-slot. The group squares only the corners
// two segments share and drops the border one of them would double; the
// outer corners are the children's own, so a group of sm buttons keeps the md
// corner at both ends instead of turning a fixed rounded-lg back on at the
// last. The joins sit on the inline axis (e/s), so a right-to-left page
// mirrors them. A screen-reader-only sibling — CopyButton's status line — is
// not a segment, or the button before it would lose its outer corners.
//
// Joined to a field, an outline button and a text addon draw the field's line
// (--input) rather than the hairline, so the control has one edge: Vibra's
// field boundary is darker than --border, where upstream the two are one
// value. A filled button keeps its own edge.
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[>input,>textarea,>[data-slot=select-trigger]]:[&>[data-variant=outline]]:border-input has-[>input,>textarea,>[data-slot=select-trigger]]:[&>[data-slot=button-group-text]]:border-input",
{
variants: {
orientation: {
horizontal:
"[&>[data-slot]:has(~[data-slot]:not(.sr-only))]:rounded-e-none [&>[data-slot]:not(.sr-only)~[data-slot]]:rounded-s-none [&>[data-slot]:not(.sr-only)~[data-slot]]:border-s-0",
vertical:
"flex-col [&>[data-slot]:has(~[data-slot]:not(.sr-only))]:rounded-b-none [&>[data-slot]:not(.sr-only)~[data-slot]]:rounded-t-none [&>[data-slot]:not(.sr-only)~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
},
props
),
render,
state: {
slot: "button-group-text",
},
})
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}