New: Activity Terrain +12 more

Button group

Buttons joined into one control, with shared seams, separators, text addons, nesting and vertical stacks.

12 examplesDepends on@base-ui/reactclass-variance-authoritycnFrom HextaUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/button-group.json
Install manually
  1. Install the dependencies:

    npm install @base-ui/react class-variance-authority cn
  2. It builds on these, so copy them first:

  3. Copy this into components/ui/button-group.tsx:

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

Examples

Disabled and invalid

Feedback inside a group

As a fieldset

Full width

With text and an input

Nested groups

Separators

Right to left

Sizes

Split button

Vertical

Feedback

Questions, bugs or ideas for Button group? Post them here, or reply to someone else. Sign in with GitHub to comment.