rapidUI

Button Group

Groups related buttons into a single attached control, with optional separators.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/button-group.json

Installs to @/components/ui/button-group.

Manual installation

Install the dependencies:

pnpm add @base-ui/react@^1.8.0 class-variance-authority@^0.7.1

Add the components it builds on: separator.

Copy the source into your project:

components/ui/button-group.tsx
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 { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'

// A filled button paints inside its transparent border (bg-clip-padding), which leaves it a pixel short beside outlined
// buttons in a group; bg-clip-border lets it fill to the group's edge
const buttonGroupVariants = cva(
	"group/button-group flex w-fit items-stretch [&>[data-variant]]:bg-clip-border *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
	{
		variants: {
			orientation: {
				horizontal:
					'*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
				vertical:
					'flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[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 }

Groups related buttons — or an input together with buttons — into one visual control. Adjacent borders and corner radii are merged, and nested groups are spaced apart.

Features

  • Horizontal and vertical orientation
  • ButtonGroupSeparator for split buttons
  • ButtonGroupText for static prefixes/suffixes
  • ButtonGroupText supports the render prop (e.g. to render a Label)
  • Nested ButtonGroups are spaced apart automatically
  • Works with any Button variant and size (including xs and icon sizes), Input, or Select
  • buttonGroupVariants is exported for reuse

Usage

import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "@/components/ui/button-group"
import { Button } from "@/components/ui/button"
import { ChevronDown } from "lucide-react"

<ButtonGroup aria-label="Message actions">
  <Button variant="outline">Reply</Button>
  <Button variant="outline">Forward</Button>
  <Button variant="outline">Archive</Button>
</ButtonGroup>

// Split button
<ButtonGroup>
  <Button>Send decision</Button>
  <ButtonGroupSeparator />
  <Button size="icon" aria-label="More ways to send">
    <ChevronDown />
  </Button>
</ButtonGroup>

Examples

Default

Actions on a message in the case inbox. Give the group an aria-label when its purpose isn’t obvious from the buttons.

Sizes

Buttons keep their own size inside a group; use matching text and icon sizes.

Vertical

orientation="vertical" stacks the buttons, here as zoom controls on a property map.

With separator

A split button: the main action plus a dropdown with alternatives, divided by ButtonGroupSeparator.

With text

ButtonGroupText with render={<Label />} renders a Label as a prefix for an input.

Stepper

A number stepper built from two icon buttons and ButtonGroupText. The buttons use focusableWhenDisabled, so keyboard focus stays on a button when it reaches the limit and becomes disabled.

Nested groups

Nested groups are spaced apart automatically, like the week navigation and Today button in a calendar header.

Disabled

Disable a single button when that action isn't available.

Props

  • ButtonGroup - orientation?: 'horizontal' | 'vertical' (default 'horizontal'), renders role="group"
  • ButtonGroupText - render?: React.ReactElement, the element to render instead of a div
  • ButtonGroupSeparator - props of Separator; orientation defaults to 'vertical'
  • Button
  • Toggle Group
  • Input Group