Buttons joined into one control, with shared seams, separators, text addons, nesting and vertical stacks.
import {
IconArchive,
IconClock,
IconDotsVertical,
IconFlag,
} from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
export function ButtonGroupDemo() {
return (
<ButtonGroup aria-label="Message actions">
<Button variant="outline">
<IconArchive data-icon="inline-start" />
<span className="max-sm:sr-only">Archive</span>
</Button>
<Button variant="outline">
<IconFlag data-icon="inline-start" />
<span className="max-sm:sr-only">Report</span>
</Button>
<Button variant="outline">
<IconClock data-icon="inline-start" />
<span className="max-sm:sr-only">Snooze</span>
</Button>
<Button variant="outline" size="icon" aria-label="More actions">
<IconDotsVertical />
</Button>
</ButtonGroup>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/button-group.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/button-group.tsx "use client" import * as React from "react" 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 "cn" import { Separator, type SeparatorProps } from "@/components/ui/separator" const buttonGroupVariants = cva( "group/button-group flex w-fit max-w-full min-w-0 items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*:focus-within]:relative [&>*:focus-within]:z-10 [&>[aria-invalid=true]:not(:focus-within)]:relative [&>[aria-invalid=true]:not(:focus-within)]:z-1 [&>[data-slot=button]:active]:translate-none! [&>[data-slot=button]:active]:scale-none! [&>[data-slot=input]]:min-w-0 [&>[data-slot=input]]:flex-1 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:min-w-0 [&>input]:flex-1", { variants: { orientation: { horizontal: "flex-row [&:not(:has(>[data-slot=button-group]))>:not([data-slot=button-status],[data-base-ui-focus-guard],span[aria-owns],input[aria-hidden=true],select[aria-hidden=true]):has(~:not([data-slot=button-status],[data-base-ui-focus-guard],span[aria-owns],input[aria-hidden=true],select[aria-hidden=true]))]:rounded-e-none [&:not(:has(>[data-slot=button-group]))>:not([data-slot=button-status],[data-base-ui-focus-guard],span[aria-owns],input[aria-hidden=true],select[aria-hidden=true])~:not([data-slot=button-status],[data-base-ui-focus-guard],span[aria-owns],input[aria-hidden=true],select[aria-hidden=true])]:-ms-(--hairline) [&:not(:has(>[data-slot=button-group]))>:not([data-slot=button-status],[data-base-ui-focus-guard],span[aria-owns],input[aria-hidden=true],select[aria-hidden=true])~:not([data-slot=button-status],[data-base-ui-focus-guard],span[aria-owns],input[aria-hidden=true],select[aria-hidden=true])]:rounded-s-none", vertical: "flex-col [&:not(:has(>[data-slot=button-group]))>:not([data-slot=button-status],[data-base-ui-focus-guard],span[aria-owns],input[aria-hidden=true],select[aria-hidden=true]):has(~:not([data-slot=button-status],[data-base-ui-focus-guard],span[aria-owns],input[aria-hidden=true],select[aria-hidden=true]))]:rounded-b-none [&:not(:has(>[data-slot=button-group]))>:not([data-slot=button-status],[data-base-ui-focus-guard],span[aria-owns],input[aria-hidden=true],select[aria-hidden=true])~:not([data-slot=button-status],[data-base-ui-focus-guard],span[aria-owns],input[aria-hidden=true],select[aria-hidden=true])]:-mt-(--hairline) [&:not(:has(>[data-slot=button-group]))>:not([data-slot=button-status],[data-base-ui-focus-guard],span[aria-owns],input[aria-hidden=true],select[aria-hidden=true])~:not([data-slot=button-status],[data-base-ui-focus-guard],span[aria-owns],input[aria-hidden=true],select[aria-hidden=true])]:rounded-t-none", }, }, defaultVariants: { orientation: "horizontal", }, } ) type ButtonGroupOrientation = NonNullable< VariantProps<typeof buttonGroupVariants>["orientation"] > type ButtonGroupProps = useRender.ComponentProps<"div"> & { orientation?: ButtonGroupOrientation } const ButtonGroupContext = React.createContext<ButtonGroupOrientation>("horizontal") function dataAttributes(slot: string, extra?: Record<string, string>) { return { "data-slot": slot, ...extra } as Record<string, string> } function ButtonGroup({ className, orientation = "horizontal", render, ...props }: ButtonGroupProps) { const element = useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { role: "group", className: cn(buttonGroupVariants({ orientation }), className), }, props, dataAttributes("button-group", { "data-orientation": orientation }) ), }) return ( <ButtonGroupContext.Provider value={orientation}> {element} </ButtonGroupContext.Provider> ) } function ButtonGroupText({ className, render, ...props }: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn( "flex max-w-full min-w-0 shrink-0 items-center gap-2 rounded-md bg-muted px-2.5 text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-border dark:inset-ring-input forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className ), }, props, dataAttributes("button-group-text") ), }) } type ButtonGroupSeparatorProps = Omit<SeparatorProps, "align" | "children"> function mergeClassName<State>( base: string, className: string | ((state: State) => string | undefined) | undefined ) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } function ButtonGroupSeparator({ className, orientation, ...props }: ButtonGroupSeparatorProps) { const groupOrientation = React.useContext(ButtonGroupContext) const resolvedOrientation = orientation ?? (groupOrientation === "horizontal" ? "vertical" : "horizontal") return ( <Separator data-slot="button-group-separator" orientation={resolvedOrientation} className={mergeClassName( resolvedOrientation === "horizontal" ? "relative z-1 mx-px w-auto self-stretch bg-input" : "relative z-1 my-px bg-input", className )} {...props} /> ) } export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants, } export type { ButtonGroupOrientation, ButtonGroupProps, ButtonGroupSeparatorProps, }components/ui/separator.tsx "use client" import * as React from "react" import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" import { cn } from "cn" type SeparatorAlign = "start" | "center" | "end" type SeparatorOrientation = NonNullable<SeparatorPrimitive.Props["orientation"]> type SeparatorProps = SeparatorPrimitive.Props & { decorative?: boolean align?: SeparatorAlign } const lineClassName: Record<SeparatorOrientation, string> = { horizontal: "h-(--hairline) w-full shrink-0 bg-border forced-colors:bg-canvas-text", vertical: "w-(--hairline) shrink-0 self-stretch bg-border forced-colors:bg-canvas-text", } const labelledClassName: Record<SeparatorOrientation, string> = { horizontal: "flex w-full shrink-0 items-center gap-3 text-xs text-muted-foreground before:h-(--hairline) before:min-w-4 before:flex-1 before:bg-border after:h-(--hairline) after:min-w-4 after:flex-1 after:bg-border data-[align=end]:after:hidden data-[align=start]:before:hidden", vertical: "flex shrink-0 flex-col items-center gap-3 self-stretch text-xs text-muted-foreground before:min-h-4 before:w-(--hairline) before:flex-1 before:bg-border after:min-h-4 after:w-(--hairline) after:flex-1 after:bg-border data-[align=end]:after:hidden data-[align=start]:before:hidden", } const labelAlignClassName: Record<SeparatorAlign, string> = { start: "text-start", center: "text-center", end: "text-end", } function mergeClassName<State>( base: string, className: string | ((state: State) => string | undefined) | undefined ) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } function hasContent(children: React.ReactNode) { return React.Children.toArray(children).some( (child) => typeof child !== "string" || child.trim() !== "" ) } function Separator({ className, orientation = "horizontal", decorative = false, align = "center", children, ...props }: SeparatorProps) { const labelled = hasContent(children) const semantics = labelled ? { role: undefined, "aria-orientation": undefined } : decorative ? { role: "none", "aria-orientation": undefined } : {} return ( <SeparatorPrimitive data-slot="separator" data-content={labelled ? "" : undefined} data-align={labelled ? align : undefined} orientation={orientation} className={mergeClassName( (labelled ? labelledClassName : lineClassName)[ orientation === "vertical" ? "vertical" : "horizontal" ], className )} {...semantics} {...props} > {labelled ? ( <span data-slot="separator-label" className={cn( "flex max-w-full min-w-0 items-center gap-1.5 text-pretty wrap-anywhere [&>svg]:size-3.5 [&>svg]:shrink-0", labelAlignClassName[align] )} > {children} </span> ) : null} </SeparatorPrimitive> ) } export { Separator } export type { SeparatorAlign, SeparatorProps } - 4
Update the import paths to match your project setup.
import {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
} from "@/components/ui/button-group"<ButtonGroup aria-label="Text formatting">
<Button variant="outline">Bold</Button>
<Button variant="outline">Italic</Button>
<Button variant="outline">Underline</Button>
</ButtonGroup>Button group joins the buttons you already use. Install <Button /> as well.
ButtonGroup
├── Button
├── ButtonGroupSeparator
├── ButtonGroupText
└── ButtonGroupSizes#
Set size on each button. Joined corners stay square at every size, and the outer corners keep the button’s radius.
import { IconBold, IconItalic, IconUnderline } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
const sizes = ["xs", "sm", "default", "lg"] as const
export function ButtonGroupSizes() {
return (
<div className="flex flex-col items-center gap-3">
{sizes.map((size) => (
<ButtonGroup key={size} aria-label="Formatting">
<Button variant="outline" size={size}>
<IconBold data-icon="inline-start" />
Bold
</Button>
<Button variant="outline" size={size}>
<IconItalic data-icon="inline-start" />
Italic
</Button>
<Button variant="outline" size={size}>
<IconUnderline data-icon="inline-start" />
Underline
</Button>
</ButtonGroup>
))}
</div>
)
}Split button#
Filled buttons sit flush, so add a <ButtonGroupSeparator /> between them. A dropdown trigger can be any segment.
import { IconChevronDown, IconGitBranch } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ButtonGroupSplit() {
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<ButtonGroup aria-label="Deploy">
<Button>
<IconGitBranch data-icon="inline-start" />
Deploy main
</Button>
<ButtonGroupSeparator />
<DropdownMenu>
<DropdownMenuTrigger
render={<Button size="icon" aria-label="Choose branch" />}
>
<IconChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem>Deploy staging</DropdownMenuItem>
<DropdownMenuItem>Deploy preview</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
<ButtonGroup aria-label="Delete">
<Button variant="destructive">Delete</Button>
<ButtonGroupSeparator />
<Button variant="destructive" size="icon" aria-label="More">
<IconChevronDown />
</Button>
</ButtonGroup>
</div>
)
}Separators#
Separators between icon buttons, with a toggle that reports its pressed state.
"use client"
import * as React from "react"
import {
IconPlayerPause,
IconPlayerPlay,
IconPlayerSkipBack,
IconPlayerSkipForward,
} from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"
export function ButtonGroupPlayer() {
const [playing, setPlaying] = React.useState(false)
return (
<ButtonGroup aria-label="Playback">
<Button variant="secondary" size="icon" aria-label="Previous track">
<IconPlayerSkipBack />
</Button>
<ButtonGroupSeparator />
<Button
variant="secondary"
size="icon"
aria-label={playing ? "Pause" : "Play"}
aria-pressed={playing}
onClick={() => setPlaying(!playing)}
>
{playing ? <IconPlayerPause /> : <IconPlayerPlay />}
</Button>
<ButtonGroupSeparator />
<Button variant="secondary" size="icon" aria-label="Next track">
<IconPlayerSkipForward />
</Button>
</ButtonGroup>
)
}Feedback inside a group#
When a button’s label changes width during loading, success or error, the segments after it slide along instead of jumping.
"use client"
import * as React from "react"
import { IconChevronDown } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"
function wait(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
async function fail(ms: number) {
await wait(ms)
throw new Error("Request failed")
}
export function ButtonGroupFeedback() {
const [shouldFail, setShouldFail] = React.useState(false)
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<ButtonGroup aria-label="Save changes">
<Button
feedback
loadingLabel="Saving changes"
successLabel="All changes saved"
errorLabel="Couldn't save"
onClick={() => (shouldFail ? fail(900) : wait(900))}
>
Save
</Button>
<ButtonGroupSeparator />
<Button size="icon" aria-label="More save options">
<IconChevronDown />
</Button>
</ButtonGroup>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={shouldFail}
onChange={(event) => setShouldFail(event.target.checked)}
/>
Fail next save
</label>
</div>
)
}Vertical#
orientation="vertical" stacks the segments. Separators turn horizontal automatically.
100%
"use client"
import * as React from "react"
import { IconMinus, IconPlus } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"
export function ButtonGroupVertical() {
const [zoom, setZoom] = React.useState(100)
return (
<div className="flex flex-wrap items-start justify-center gap-8">
<div className="flex items-center gap-4">
<ButtonGroup orientation="vertical" aria-label="Zoom">
<Button
variant="outline"
size="icon"
aria-label="Zoom in"
disabled={zoom >= 200}
onClick={() => setZoom((value) => Math.min(200, value + 25))}
>
<IconPlus />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Zoom out"
disabled={zoom <= 25}
onClick={() => setZoom((value) => Math.max(25, value - 25))}
>
<IconMinus />
</Button>
</ButtonGroup>
<p className="text-sm text-muted-foreground tabular-nums">{zoom}%</p>
</div>
<ButtonGroup orientation="vertical" aria-label="Sort">
<Button variant="outline">Newest first</Button>
<Button variant="outline">Oldest first</Button>
<Button variant="outline">Most discussed</Button>
</ButtonGroup>
<ButtonGroup orientation="vertical" aria-label="Position">
<Button variant="secondary">Top</Button>
<ButtonGroupSeparator />
<Button variant="secondary">Bottom</Button>
</ButtonGroup>
</div>
)
}Nested groups#
Groups inside a group become separate clusters with a gap between them, each joined on its own.
"use client"
import * as React from "react"
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
const pages = [1, 2, 3, 4]
export function ButtonGroupNested() {
const [page, setPage] = React.useState(1)
return (
<ButtonGroup aria-label="Pagination">
<ButtonGroup>
<Button
variant="outline"
size="icon"
aria-label="Previous page"
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
<IconArrowLeft className="rtl:-scale-x-100" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Next page"
disabled={page === pages.length}
onClick={() => setPage(page + 1)}
>
<IconArrowRight className="rtl:-scale-x-100" />
</Button>
</ButtonGroup>
<ButtonGroup>
{pages.map((value) => (
<Button
key={value}
variant={value === page ? "secondary" : "outline"}
size="icon"
aria-current={value === page ? "page" : undefined}
onClick={() => setPage(value)}
>
{value}
</Button>
))}
</ButtonGroup>
</ButtonGroup>
)
}With text and an input#
<ButtonGroupText /> adds a label or prefix. Render it as a <label> to name the input. The input stretches to fill the row.
"use client"
import * as React from "react"
import { IconCopy, IconLink } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"
export function ButtonGroupInput() {
const [value, setValue] = React.useState("hextaui.com/docs/button-group")
return (
<ButtonGroup className="w-full max-w-md" aria-label="Share link">
<ButtonGroupText render={<label htmlFor="share-url" />}>
<IconLink />
https://
</ButtonGroupText>
<input
id="share-url"
value={value}
onChange={(event) => setValue(event.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm transition-shadow outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden dark:bg-input/30 pointer-coarse:text-touch"
/>
<Button
variant="outline"
feedback
successLabel="Copied"
errorLabel="Blocked"
onClick={() => navigator.clipboard.writeText(`https://${value}`)}
>
<IconCopy data-icon="inline-start" />
Copy
</Button>
</ButtonGroup>
)
}Disabled and invalid#
Disable or mark a single segment with aria-invalid. An invalid segment rises above its neighbours so its red ring is never covered.
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
export function ButtonGroupDisabled() {
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<ButtonGroup aria-label="Post actions">
<Button variant="outline">Edit</Button>
<Button variant="outline" disabled>
Publish
</Button>
<Button variant="outline">Share</Button>
</ButtonGroup>
<ButtonGroup aria-label="Date range">
<Button variant="outline">Start</Button>
<Button variant="outline" aria-invalid>
Missing date
</Button>
<Button variant="outline">End</Button>
</ButtonGroup>
</div>
)
}Full width#
Give the group a width and each button flex-1 to split the space evenly.
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
export function ButtonGroupFullWidth() {
return (
<ButtonGroup className="w-full max-w-md" aria-label="Range">
<Button variant="outline" className="flex-1">
Day
</Button>
<Button variant="outline" className="flex-1">
Week
</Button>
<Button variant="outline" className="flex-1">
Month
</Button>
</ButtonGroup>
)
}As a fieldset#
Use render to output a <fieldset> inside forms.
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
export function ButtonGroupFieldset() {
return (
<ButtonGroup render={<fieldset />} aria-label="Reply">
<Button variant="outline">Reply</Button>
<Button variant="outline">Reply all</Button>
<Button variant="outline">Forward</Button>
</ButtonGroup>
)
}Right to left#
Joined corners, separators and nested clusters mirror in right-to-left layouts.
"use client"
import * as React from "react"
import {
IconArchive,
IconArrowLeft,
IconArrowRight,
IconChevronDown,
IconDotsVertical,
} from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"
const pages = [1, 2, 3]
export function ButtonGroupRtl() {
const [page, setPage] = React.useState(1)
return (
<div dir="rtl" className="flex flex-col items-center gap-3">
<ButtonGroup aria-label="إجراءات">
<Button variant="outline">
<IconArchive data-icon="inline-start" />
أرشفة
</Button>
<Button variant="outline">إبلاغ</Button>
<Button variant="outline" size="icon" aria-label="المزيد">
<IconDotsVertical />
</Button>
</ButtonGroup>
<ButtonGroup aria-label="نشر">
<Button>نشر الآن</Button>
<ButtonGroupSeparator />
<Button size="icon" aria-label="خيارات">
<IconChevronDown />
</Button>
</ButtonGroup>
<ButtonGroup aria-label="الصفحات">
<ButtonGroup>
<Button
variant="outline"
size="icon"
aria-label="السابق"
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
<IconArrowLeft className="rtl:-scale-x-100" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="التالي"
disabled={page === pages.length}
onClick={() => setPage(page + 1)}
>
<IconArrowRight className="rtl:-scale-x-100" />
</Button>
</ButtonGroup>
<ButtonGroup>
{pages.map((value) => (
<Button
key={value}
variant={value === page ? "secondary" : "outline"}
size="icon"
aria-current={value === page ? "page" : undefined}
onClick={() => setPage(value)}
>
{value}
</Button>
))}
</ButtonGroup>
</ButtonGroup>
</div>
)
}| Key | Action |
|---|---|
| Tab | Moves through the segments in order. The focused segment rises so its ring and border are never covered. |
| EnterSpace | Activates the focused button. |
- The group has
role="group". Give it anaria-labelthat describes the set, like “Message actions”. - Icon-only segments need their own
aria-label. - Separators are exposed as separators with the right orientation. Add
decorativewhen the line is only visual.
Every part renders a <div> and accepts its attributes.
| Prop | Type | Default |
|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="button-group" | Target groups in CSS. |
data-orientation | The current orientation. |
| Prop | Type | Default |
|---|---|---|
orientationDefaults to the opposite of the group’s orientation. | "horizontal" | "vertical" | – |
decorativeHide the line from screen readers. | boolean | false |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="button-group-separator" | Target separators in CSS. |
data-orientation | The resolved orientation. |
| Prop | Type | Default |
|---|---|---|
renderRender a <label> to name an input in the group. | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="button-group-text" | Target text segments in CSS. |