Skip to contentVibraUI
Inputs & filters

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-group

Needs the @vibra registry in your components.json — set it up once.

Examples

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.

With a separator

Filled buttons have no border to share, so ButtonGroupSeparator draws the join.

Vertical

orientation="vertical" shares tops and bottoms — a map's zoom stack.

Sizes

The group takes the size of its buttons, and their corner at both ends.

Icon only

Undo and redo, each named, with its chord in the tooltip and in aria-keyshortcuts.

Split button

The action most people want, with the others a menu away.

With a count

A figure beside the button as ButtonGroupText, read out as the button's description.

With a text addon

A fixed prefix, the part you own, and CopyButton for the whole address.

With an input

The field stretches and the button submits; an address that is not one is named under the field.

With a select

A column, a title and add: the board's quick add as one control.

With a sender

Reply as yourself or as the team's inbox, the choice and the send side by side.

Previous and next

Stepping through months. At the current month, Next stays focusable and says why it is off.

Busy

While one export runs the whole group waits, and the file it made is named on the status line.

Nested groups

Groups inside a group sit apart instead of joining, and wrap on a phone.

Right to left

The same actions in Arabic. The joins sit on the inline axis, so corners and shared hairlines mirror.

Props

PropTypeDefaultDescription
orientation"horizontal" | "vertical""horizontal"Which edges are shared: the sides of a row, or the tops and bottoms of a column.

Dependencies

Source

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 { 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,
}