Button Group
Buttons joined into one control.
import { Div } from "@implementjs/core";
import { BoldIcon, ItalicIcon, UnderlineIcon } from "@implementjs/lucide";
import { Button } from "@/lib/components/ui/button";
import {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
} from "@/lib/components/ui/button-group";
import { Input } from "@/lib/components/ui/input";
export default function ButtonGroupDemo() {
return Div(
{ class: "flex flex-col items-center gap-4" },
ButtonGroup(
Button({ variant: "outline", size: "icon-sm", "aria-label": "Bold" }, BoldIcon()),
Button({ variant: "outline", size: "icon-sm", "aria-label": "Italic" }, ItalicIcon()),
Button({ variant: "outline", size: "icon-sm", "aria-label": "Underline" }, UnderlineIcon()),
),
ButtonGroup(
Button({ variant: "outline", size: "sm" }, "Merge"),
ButtonGroupSeparator(),
Button({ variant: "outline", size: "sm" }, "Squash"),
Button({ variant: "outline", size: "sm" }, "Rebase"),
),
ButtonGroup(
{ class: "w-full max-w-sm" },
ButtonGroupText("https://"),
Input({ placeholder: "example.com", "aria-label": "Site" }),
Button({ variant: "outline" }, "Add"),
),
);
}Installation
npx jsrepo add @implementjs/ui/button-group
jsrepo pulls separator along with it, and installs tailwind-variants.
Copy the file below to src/lib/components/ui/button-group.ts. It imports cn from utils.ts, which belongs at src/lib/utils.ts, and separator from the same directory — copy those in beside it too. Then, on top of @implementjs/core and @implementjs/primitives:
npm install tailwind-variants
import { Div, type Child, type ElementProps } from "@implementjs/core";
import { createComponent } from "@implementjs/primitives";
import { tv, type VariantProps } from "tailwind-variants";
import { Separator } from "./separator";
import { cn } from "@/lib/utils";
export const buttonGroupVariants = tv({
base: [
"flex w-fit items-stretch",
"[&>*]:focus-visible:z-10 [&>*]:focus-visible:relative",
"[&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit",
"[&>input]:flex-1",
"has-[>[data-slot=button-group]]:gap-2",
],
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: { orientation: "horizontal" },
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>["orientation"];
export type ButtonGroupProps = ElementProps<"div"> & VariantProps<typeof buttonGroupVariants>;
export type ButtonGroupTextProps = ElementProps<"div">;
export type ButtonGroupSeparatorProps = ElementProps<"div">;
/**
* Buttons joined into one control. The group squares off the inner corners
* and drops the doubled borders between children, so a row of outline
* buttons reads as a single segmented thing rather than as several.
*
* It styles whatever is inside by position, not by type — a
* [select](/ui/select) trigger or an [input](/ui/input) joins the row on the
* same terms as a button.
*/
export const ButtonGroup = createComponent(function ButtonGroup(
{ class: className, orientation = "horizontal", ...props }: ButtonGroupProps,
...children: Child[]
) {
return Div(
{
role: "group",
...props,
"data-slot": "button-group",
"data-orientation": orientation,
class: cn(buttonGroupVariants({ orientation }), className),
},
...children,
);
});
/** A non-interactive label sitting in the row, styled like a button. */
export const ButtonGroupText = createComponent(function ButtonGroupText(
{ class: className, ...props }: ButtonGroupTextProps,
...children: Child[]
) {
return Div(
{
...props,
"data-slot": "button-group-text",
class: cn(
"flex items-center gap-2 rounded-md border bg-muted px-4 text-sm font-medium shadow-xs",
"[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
),
},
...children,
);
});
/**
* A divider inside the group. The group removes the borders between
* children, so this is how you put a visible line back where you want one.
*/
export const ButtonGroupSeparator = createComponent(function ButtonGroupSeparator({
class: className,
orientation = "vertical",
...props
}: ButtonGroupSeparatorProps & { orientation?: "horizontal" | "vertical" }) {
return Separator({
...props,
orientation,
"data-slot": "button-group-separator",
class: cn("relative !m-0 self-stretch bg-input data-[orientation=vertical]:h-auto", className),
});
});Usage
import { ButtonGroup } from "@/lib/components/ui/button-group";
ButtonGroup(
Button({ variant: "outline", size: "sm" }, "Merge"),
Button({ variant: "outline", size: "sm" }, "Squash"),
Button({ variant: "outline", size: "sm" }, "Rebase"),
);
The group squares off the inner corners and drops the doubled borders between children, so a row of outline buttons reads as one segmented thing rather than as three.
It joins by position, not by type
The rules are written against :first-child and :last-child, so anything in the row joins on the same terms. A select trigger, an input, and a ButtonGroupText prefix all fit:
ButtonGroup(
ButtonGroupText("https://"),
Input({ placeholder: "example.com", "aria-label": "Site" }),
Button({ variant: "outline" }, "Add"),
);
Separators
Because the group removes the borders between children, a visible divider has to go back in on purpose:
ButtonGroup(Button("Merge"), ButtonGroupSeparator(), Button("Squash"));
Vertical
orientation: "vertical" stacks the row and joins the top and bottom edges instead of the left and right.
API Reference
ButtonGroup
Sets role="group". Squares off the inner corners and drops the doubled borders between children, so the row reads as one control. It styles by position rather than by type — a select trigger or an input joins the row on the same terms as a button. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | Which edges are joined. Also set as data-orientation. |
ButtonGroupText
A non-interactive label sitting in the row, styled like a button — a prefix, a unit, a count. Renders a Div; extra props are forwarded onto it.
ButtonGroupSeparator
A divider inside the group. The group removes the borders between children, so this is how a visible line goes back in. Renders a Separator; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "vertical" | Which way the line runs. |