# Button Group

> Attached buttons, inputs and text segments with single-pixel seams, split buttons and vertical stacks.

Category: Buttons. Docs: https://neonkit.heyaadi.com/components/button-group

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/button-group.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/button-group`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"

<ButtonGroup aria-label="Promote">
  <Button variant="solid">Promote</Button>
  <ButtonGroupSeparator />
  <Button variant="solid" size="icon" aria-label="More options">
    <ChevronDown />
  </Button>
</ButtonGroup>
```

## Examples

### Pager and split button

A counter between two buttons, and a solid split button with a menu behind its seam.

```tsx
"use client";

import * as React from "react";
import { ChevronDown, ChevronLeft, ChevronRight } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "@/components/ui/button-group";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

const DEPLOYS = 12;

export default function ButtonGroupDemo() {
  const [index, setIndex] = React.useState(3);
  const [target, setTarget] = React.useState("Production");

  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      <ButtonGroup aria-label="Browse deployments">
        <Button
          variant="ghost"
          size="icon"
          aria-label="Previous deployment"
          disabled={index === 1}
          onClick={() => setIndex((i) => i - 1)}
          className="border border-white/10"
        >
          <ChevronLeft />
        </Button>
        <ButtonGroupText className="min-w-[88px] justify-center tabular-nums text-white/70">
          {index} of {DEPLOYS}
        </ButtonGroupText>
        <Button
          variant="ghost"
          size="icon"
          aria-label="Next deployment"
          disabled={index === DEPLOYS}
          onClick={() => setIndex((i) => i + 1)}
          className="border border-white/10"
        >
          <ChevronRight />
        </Button>
      </ButtonGroup>

      <ButtonGroup aria-label="Promote">
        <Button variant="solid">Promote to {target}</Button>
        <ButtonGroupSeparator />
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="solid" size="icon" aria-label="More promote options" className="w-9">
              <ChevronDown />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuItem onSelect={() => setTarget("Production")}>Promote to Production</DropdownMenuItem>
            <DropdownMenuItem onSelect={() => setTarget("Staging")}>Promote to Staging</DropdownMenuItem>
            <DropdownMenuSeparator />
            <DropdownMenuItem>Schedule for later…</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </ButtonGroup>
    </div>
  );
}
```

### With an input

Input sits in the group like any segment. Its focus glow is lifted over the neighbouring borders.

```tsx
"use client";

import * as React from "react";
import { Search } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import { Input } from "@/components/ui/input";

export default function ButtonGroupWithInput() {
  const [saved, setSaved] = React.useState(false);

  return (
    <div className="flex min-w-0 w-full max-w-md flex-col gap-4">
      <ButtonGroup className="w-full">
        <Input aria-label="Search logs" placeholder="status:500 /checkout" leading={<Search />} />
        <Button variant="outline">Search</Button>
      </ButtonGroup>

      <form
        onSubmit={(event) => {
          event.preventDefault();
          setSaved(true);
        }}
      >
        <ButtonGroup className="w-full">
          <ButtonGroupText>https://</ButtonGroupText>
          <Input
            aria-label="Webhook URL"
            defaultValue="hooks.acme.dev/ci"
            onChange={() => setSaved(false)}
            spellCheck={false}
          />
          <Button type="submit" variant={saved ? "soft" : "outline"}>
            {saved ? "Saved" : "Save"}
          </Button>
        </ButtonGroup>
      </form>
    </div>
  );
}
```

### Toolbar

Groups inside a group stay attached and the outer one spaces them apart.

```tsx
import { Bold, Italic, TextAlignCenter, TextAlignEnd, TextAlignStart, Underline } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";

const quiet = "border border-white/10 text-white/70";

export default function ButtonGroupToolbar() {
  return (
    <ButtonGroup aria-label="Formatting">
      <ButtonGroup aria-label="Text style">
        <Button variant="ghost" size="icon" aria-label="Bold" className={quiet}>
          <Bold />
        </Button>
        <Button variant="ghost" size="icon" aria-label="Italic" className={quiet}>
          <Italic />
        </Button>
        <Button variant="ghost" size="icon" aria-label="Underline" className={quiet}>
          <Underline />
        </Button>
      </ButtonGroup>
      <ButtonGroup aria-label="Alignment">
        <Button variant="ghost" size="icon" aria-label="Align left" className={quiet}>
          <TextAlignStart />
        </Button>
        <Button variant="ghost" size="icon" aria-label="Align center" className={quiet}>
          <TextAlignCenter />
        </Button>
        <Button variant="ghost" size="icon" aria-label="Align right" className={quiet}>
          <TextAlignEnd />
        </Button>
      </ButtonGroup>
    </ButtonGroup>
  );
}
```

### Vertical

orientation="vertical" stacks the segments and squares the inner corners top and bottom.

```tsx
"use client";

import * as React from "react";
import { LocateFixed, Minus, Plus } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group";

export default function ButtonGroupVertical() {
  const [zoom, setZoom] = React.useState(14);

  return (
    <div className="flex items-center gap-5">
      <ButtonGroup orientation="vertical" aria-label="Map zoom">
        <Button variant="outline" size="icon" aria-label="Zoom in" onClick={() => setZoom((z) => Math.min(z + 1, 20))}>
          <Plus />
        </Button>
        <Button variant="outline" size="icon" aria-label="Zoom out" onClick={() => setZoom((z) => Math.max(z - 1, 2))}>
          <Minus />
        </Button>
        <ButtonGroupSeparator />
        <Button variant="outline" size="icon" aria-label="Center on me" onClick={() => setZoom(14)}>
          <LocateFixed />
        </Button>
      </ButtonGroup>
      <p className="text-sm text-white/55">
        Zoom <span className="font-mono tabular-nums text-white/85">{zoom}</span>
      </p>
    </div>
  );
}
```

### Sizes

Size each segment as usual; the group only handles seams and corners, so any size attaches cleanly.

```tsx
import { Minus, Plus } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";

const sizes = [
  { size: "sm", icon: "icon-sm" },
  { size: "md", icon: "icon" },
  { size: "lg", icon: "icon-lg" },
] as const;

export default function ButtonGroupSizes() {
  return (
    <div className="flex flex-col items-start gap-4">
      {sizes.map(({ size, icon }) => (
        <ButtonGroup key={size} aria-label={`Quantity, ${size}`}>
          <Button variant="outline" size={icon} aria-label="Fewer">
            <Minus />
          </Button>
          <ButtonGroupText size={size} className="w-14 justify-center tabular-nums text-white/85">
            2
          </ButtonGroupText>
          <Button variant="outline" size={icon} aria-label="More">
            <Plus />
          </Button>
        </ButtonGroup>
      ))}
    </div>
  );
}
```

## API reference

### ButtonGroup

Renders a div with role="group". Give it an aria-label when the buttons need context.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Direction of the row. |
| `color` | `NeonColor` | `inherited` | Sets --neon for everything inside. |

### ButtonGroupText

A static segment, for a prefix like "https://" or a counter. Accepts native div attributes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Height and padding, matching Button. |

### ButtonGroupSeparator

A one-pixel seam between two segments, for solid buttons whose borders don't show.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"horizontal" \| "vertical"` | `across the group` | The rule's direction. |

## Accessibility

- Neighbours overlap by a pixel, so every seam is a single line.
- The hovered, open or focused segment is raised above the others, so its border and focus ring are never covered.
- Seams and corners are pure CSS on the group's direct children, so it never inspects or clones them; children passed from a Server Component work the same.
- Input and Textarea draw their corners on an inner field box, and the group squares that box too.
- Arrow keys aren't added: it's a group of separate buttons. Use Toggle Group for one-of-many choices.

## Source

`components/ui/button-group.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

type Orientation = "horizontal" | "vertical";
type Size = "sm" | "md" | "lg";

const GroupContext = React.createContext<{ orientation: Orientation } | null>(null);

/*
 * All of the attaching is CSS on direct children, so it works for any element (Button, a menu
 * trigger, Input, your own) and for children a Server Component passes as lazy references,
 * which can't be inspected or cloned.
 * - Neighbours overlap by a pixel, so each seam is one line, not two.
 * - Inner corners are squared. Input and Textarea draw their corners on an inner field box
 *   (the element carrying their `group/field` class), so that box is squared too.
 * - Whatever is hovered, open or focused is lifted above its neighbours, so its border and
 *   focus ring never sit under the next segment.
 */
const layout: Record<Orientation, string> = {
  horizontal: cn(
    "flex-row [&>*+*]:-ml-px",
    "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:last-child)]:rounded-r-none",
    "[&>*:not(:first-child)>[class~='group/field']]:rounded-l-none [&>*:not(:last-child)>[class~='group/field']]:rounded-r-none",
  ),
  vertical: cn(
    "flex-col [&>*+*]:-mt-px",
    "[&>*:not(:first-child)]:rounded-t-none [&>*:not(:last-child)]:rounded-b-none",
    "[&>*:not(:first-child)>[class~='group/field']]:rounded-t-none [&>*:not(:last-child)>[class~='group/field']]:rounded-b-none",
  ),
};

const lift =
  "[&>*]:relative [&>*:hover]:z-10 [&>[data-state=open]]:z-10 [&>[aria-pressed=true]]:z-10 [&>*:focus-visible]:z-30 [&>*:focus-within]:z-30";

// A group of groups is a toolbar: the inner groups stay attached, the outer one spaces them out.
const toolbar = "has-[>[data-slot=button-group]]:gap-2 [&>[data-slot=button-group]]:m-0";

export interface ButtonGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  orientation?: Orientation;
}

const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(
  ({ className, style, color, orientation = "horizontal", children, ...props }, ref) => (
    <GroupContext.Provider value={{ orientation }}>
      <div
        ref={ref}
        role="group"
        data-slot="button-group"
        data-orientation={orientation}
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "isolate flex w-fit items-stretch",
          layout[orientation],
          lift,
          toolbar,
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </GroupContext.Provider>
  ),
);
ButtonGroup.displayName = "ButtonGroup";

const textSizes = {
  sm: "h-8 rounded-md px-2.5 text-xs [&_svg]:size-3.5",
  md: "h-10 rounded-lg px-3 text-sm [&_svg]:size-4",
  lg: "h-12 rounded-xl px-4 text-base [&_svg]:size-[18px]",
} as const;

export interface ButtonGroupTextProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Height and padding, matching Button's sizes. */
  size?: Size;
}

/** A static segment: a prefix like "https://", a unit, or a counter between two buttons. */
const ButtonGroupText = React.forwardRef<HTMLDivElement, ButtonGroupTextProps>(
  ({ className, size = "md", ...props }, ref) => {
    return (
      <div
        ref={ref}
        className={cn(
          "flex shrink-0 select-none items-center gap-2 whitespace-nowrap border border-white/10 bg-white/[0.02] font-medium text-white/55",
          textSizes[size],
          className,
        )}
        {...props}
      />
    );
  },
);
ButtonGroupText.displayName = "ButtonGroupText";

export interface ButtonGroupSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
  /** The rule's direction. Defaults to across the group: vertical in a row, horizontal in a column. */
  orientation?: Orientation;
}

/** A seam between two segments, for split buttons and solid groups where borders don't show. */
const ButtonGroupSeparator = React.forwardRef<HTMLDivElement, ButtonGroupSeparatorProps>(
  ({ className, orientation: orientationProp, ...props }, ref) => {
    const group = React.useContext(GroupContext);
    const orientation =
      orientationProp ?? ((group?.orientation ?? "horizontal") === "horizontal" ? "vertical" : "horizontal");
    return (
      <div
        ref={ref}
        role="separator"
        aria-orientation={orientation}
        className={cn(
          // Sits on top of the shared seam and paints over both segments' edges there.
          "relative z-20 shrink-0 self-stretch bg-[#05060a]",
          "after:absolute after:inset-0 after:bg-[rgb(var(--neon)/0.3)] after:content-['']",
          orientation === "vertical" ? "w-px" : "h-px w-full",
          className,
        )}
        {...props}
      />
    );
  },
);
ButtonGroupSeparator.displayName = "ButtonGroupSeparator";

export { ButtonGroup, ButtonGroupText, ButtonGroupSeparator };
```
