component — actions
Button Group
Joins a row or column of buttons and inputs into one connected, segmented control by collapsing shared borders and squaring inner radii.
- action
- group
- toolbar
- segmented
- layout
05 examples
Horizontal
The default — buttons connect into a single segmented control.
Vertical
Stack the same group with `orientation="vertical"`.
Icon controls
Square icon buttons make a tidy pager / stepper.
With text addon
Pair buttons with a flush, non-interactive label via ButtonGroupText.
Active selection
Promote the chosen segment with the primary intent.
source
button-group.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import type { VariantProps } from "tailwind-variants"
import { cn, tv } from "@/lib/utils"
/**
* ButtonGroup — quebi design system
*
* Joins a row (or column) of buttons / inputs into a single connected unit:
* shared borders are collapsed and the inner radii squared off so the children
* read as one segmented control. Self-contained — drop any buttons inside.
*
* Use `ButtonGroupText` for non-interactive labels or addons that sit flush
* with the buttons (e.g. units, prefixes).
*/
const buttonGroupStyles = tv({
base: [
"flex w-fit items-stretch",
// keep the active child above its neighbours so its border/ring isn't clipped
"*:hover:relative *:hover:z-10",
// `focus-within` rather than `focus-visible`, because a child of this group
// is not always the focusable thing in it. A `NumberField` dropped in here
// is a whole field — label, control wrapper, message — and the input that
// takes focus is two levels down, so `*:focus-visible` never matched and the
// ring drawn around that control was painted *under* the next segment, which
// is precisely what this line exists to prevent. `:focus-within` matches the
// child whether it is focused itself or merely holds the focus.
"*:focus-within:relative *:focus-within:z-10",
// nested groups get breathing room
"has-[>[data-slot=button-group]]:gap-2",
// text inputs flex to fill
"[&>input]:flex-1",
],
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-s-none [&>*:not(:first-child)]:-ml-px [&>*:not(:last-child)]:rounded-e-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:-mt-px [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
})
export interface ButtonGroupProps
extends React.ComponentProps<"div">,
VariantProps<typeof buttonGroupStyles> {}
export function ButtonGroup({ className, orientation, ...props }: ButtonGroupProps) {
return (
// biome-ignore lint/a11y/useSemanticElements: no semantic HTML element for button group role
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupStyles({ orientation }), className)}
{...props}
/>
)
}
export function ButtonGroupText({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="button-group-text"
className={cn(
"flex items-center gap-2 whitespace-nowrap",
"rounded-(--q-radius-control) border border-quebi-hairline px-4",
"font-sans text-sm font-medium text-quebi-fg-muted",
"*:data-[slot=icon]:pointer-events-none *:data-[slot=icon]:shrink-0",
"[&_[data-slot=icon]:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}