Component
Button Group
A joined row of related buttons. Inner buttons share borders (side radius removed, borders overlapped by 1px) so the group reads as one control. Pass children for full control, or the `items` prop for a quick group.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/buttons/button-group/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Buttons/button-group React/Components/Buttons/button-group import type { HTMLAttributes, ReactNode } from "react";
/* DevSnips React — ButtonGroup
* Joined row of related buttons. Inner buttons lose their side radius and
* overlap borders by 1px so the group reads as one control.
*/
export type ButtonSize = "xs" | "sm" | "md" | "lg" | "xl";
export type GroupVariant = "outline" | "solid" | "secondary" | "ghost";
export interface ButtonGroupItem {
id?: string;
label: ReactNode;
icon?: string;
active?: boolean;
onClick?: () => void;
}
export interface ButtonGroupProps extends HTMLAttributes<HTMLDivElement> {
items?: ButtonGroupItem[];
variant?: GroupVariant;
size?: ButtonSize;
/** Accessible group name (rendered as aria-label). */
label?: string;
}
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
const SIZES: Record<ButtonSize, string> = {
xs: "h-7 gap-1 px-2 text-xs [&_svg]:size-[14px]",
sm: "h-8 gap-1.5 px-3 text-xs [&_svg]:size-[14px]",
md: "h-9 gap-2 px-3.5 text-[13px] [&_svg]:size-4",
lg: "h-10 gap-2 px-4 text-[13px] [&_svg]:size-[18px]",
xl: "h-11 gap-2 px-5 text-sm [&_svg]:size-5",
};
const VARIANTS: Record<GroupVariant, string> = {
outline: "border-[var(--ds-color-border-strong)] bg-transparent text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)] active:bg-[var(--ds-color-surface-active)]",
solid: "border-transparent bg-[var(--ds-color-primary)] text-[var(--ds-color-primary-foreground)] hover:bg-[color-mix(in_srgb,var(--ds-color-primary)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-primary)_80%,#000)]",
secondary: "border-[var(--ds-color-border)] bg-[var(--ds-color-secondary)] text-[var(--ds-color-secondary-foreground)] hover:bg-[var(--ds-color-surface-active)] active:bg-[var(--ds-color-surface-active)]",
ghost: "border-transparent bg-transparent text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)] active:bg-[var(--ds-color-surface-active)]",
};
const BASE_BTN =
"inline-flex select-none items-center justify-center whitespace-nowrap rounded-[var(--ds-radius-sm)] border font-medium leading-none " +
"transition-colors duration-150 ease-out motion-reduce:transition-none " +
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] " +
"disabled:pointer-events-none disabled:opacity-50";
export function ButtonGroup({
items,
children,
variant = "outline",
size = "md",
label,
className,
...rest
}: ButtonGroupProps) {
return (
<div role="group" aria-label={label} className={cx("inline-flex", className)} {...rest}>
{items
? items.map((it, i) => (
<button
key={it.id ?? i}
type="button"
aria-pressed={it.active || undefined}
onClick={it.onClick}
className={cx(
BASE_BTN,
VARIANTS[variant],
SIZES[size],
"rounded-none border-r-0",
i === 0 ? "rounded-l-[var(--ds-radius-sm)]" : "-ml-px",
i === items.length - 1 && "rounded-r-[var(--ds-radius-sm)] border-r",
it.active && "bg-[var(--ds-color-surface-active)]",
)}
>
{it.icon ? <Icon name={it.icon} className="shrink-0" /> : null}
<span>{it.label}</span>
</button>
))
: children}
</div>
);
}
export default ButtonGroup; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const SIZES = {
xs: "h-7 gap-1 px-2 text-xs [&_svg]:size-[14px]",
sm: "h-8 gap-1.5 px-3 text-xs [&_svg]:size-[14px]",
md: "h-9 gap-2 px-3.5 text-[13px] [&_svg]:size-4",
lg: "h-10 gap-2 px-4 text-[13px] [&_svg]:size-[18px]",
xl: "h-11 gap-2 px-5 text-sm [&_svg]:size-5"
};
const VARIANTS = {
outline: "border-[var(--ds-color-border-strong)] bg-transparent text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)] active:bg-[var(--ds-color-surface-active)]",
solid: "border-transparent bg-[var(--ds-color-primary)] text-[var(--ds-color-primary-foreground)] hover:bg-[color-mix(in_srgb,var(--ds-color-primary)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-primary)_80%,#000)]",
secondary: "border-[var(--ds-color-border)] bg-[var(--ds-color-secondary)] text-[var(--ds-color-secondary-foreground)] hover:bg-[var(--ds-color-surface-active)] active:bg-[var(--ds-color-surface-active)]",
ghost: "border-transparent bg-transparent text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)] active:bg-[var(--ds-color-surface-active)]"
};
const BASE_BTN = "inline-flex select-none items-center justify-center whitespace-nowrap rounded-[var(--ds-radius-sm)] border font-medium leading-none transition-colors duration-150 ease-out motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50";
export function ButtonGroup({
items,
children,
variant = "outline",
size = "md",
label,
className,
...rest
}) {
return <div role="group" aria-label={label} className={cx("inline-flex", className)} {...rest}>
{items ? items.map((it, i) => <button
key={it.id ?? i}
type="button"
aria-pressed={it.active || undefined}
onClick={it.onClick}
className={cx(
BASE_BTN,
VARIANTS[variant],
SIZES[size],
"rounded-none border-r-0",
i === 0 ? "rounded-l-[var(--ds-radius-sm)]" : "-ml-px",
i === items.length - 1 && "rounded-r-[var(--ds-radius-sm)] border-r",
it.active && "bg-[var(--ds-color-surface-active)]"
)}
>
{it.icon ? <Icon name={it.icon} className="shrink-0" /> : null}
<span>{it.label}</span>
</button>) : children}
</div>;
}
export default ButtonGroup; # Button Group
A joined row of related buttons. Inner buttons share borders (side radius removed, borders overlapped by 1px) so the group reads as one control. Pass children for full control, or the `items` prop for a quick group.
## Installation
This component requires **React** and **Tailwind CSS**. Drop `code.tsx` (or `code.jsx` for JavaScript projects) into your project. Tailwind utility classes are included directly in the component, so no separate CSS file is required.
The component consumes the DevSnips semantic design tokens through Tailwind arbitrary values (for example `bg-[var(--ds-color-primary)]`). Define the `--ds-*` tokens once in your theme — see [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the full token spec.
## Usage
```tsx
<ButtonGroup label="Text alignment" items={[{id:"l",label:"Left"},{id:"c",label:"Center",active:true},{id:"r",label:"Right"}}] />
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
## Props
| Prop | Type | Default |
|---|---|---|
| `items` | `Array<{ id?: string; label: ReactNode; icon?: string; active?: boolean; onClick?: () => void }>` | — |
| `children` | `ReactNode` | — (renders children directly when no `items`) |
| `variant` | `outline \| solid \| secondary \| ghost` | `outline` |
| `size` | `ButtonSize` | `md` |
| `label` | `string` | — (group `aria-label`) |
Plus all native `HTMLAttributes<HTMLDivElement>`.
## Variants
outline (default) · solid · secondary · ghost. Children render with their own variant; `items` use the shared `variant`.
## Sizes
xs (28px) · sm (32px) · **md (36px, default)** · lg (40px) · xl (44px). Horizontal padding scales 8 → 20px; icons scale 14 → 20px.
## States
default · hover · active · focus-visible · selected (`active` → `aria-pressed` + `surface-active`) · disabled (reduced opacity on each child).
## Accessibility
Renders a `role="group"` container with `aria-label`. Each item is a native `<button>` with `aria-pressed` when `active`. Focus-visible ring on each child.
## Styling
Tailwind classes are included directly in the component and consume the DevSnips semantic design tokens (`--ds-*`) via arbitrary values. The button themes with the surface automatically in light and dark mode. No component-specific CSS file is needed.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This button uses the semantic color, radius, and motion tokens; define them once in your project theme and every button in the family stays in sync.
## Notes
For mutually exclusive single-choice control, prefer SegmentedButton (radiogroup semantics). ButtonGroup is a loose toolbar row. 89 lines UTF-8 · LF · Spaces: 2
Continue browsing