Button Group
A container that groups buttons (and optional text or separators) with shared styling and orientation.
Preview
import {
ChevronLeftIcon,
DotsHorizontalIcon,
} from "@radix-ui/react-icons";
import {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
} from "@/components/ui/ButtonGroup";
import { Button } from "@/components/ui/Button";
export function ButtonGroupDemo() {
return (
<div className="flex flex-wrap gap-2">
<ButtonGroup aria-label="Go back">
<Button variant="pill" aria-label="Go back">
<ChevronLeftIcon className="icon" />
</Button>
</ButtonGroup>
<ButtonGroup aria-label="Archive and report">
<Button variant="pill">Archive</Button>
<Button variant="pill">Report</Button>
</ButtonGroup>
<ButtonGroup aria-label="Snooze">
<Button variant="pill">Snooze</Button>
<Button variant="pill" aria-label="More options">
<DotsHorizontalIcon className="icon" />
</Button>
</ButtonGroup>
</div>
);
}Installation
Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces
pnpm dlx shadcn@latest add @aura/button-groupManual
Install the following dependencies:
pnpm install @radix-ui/react-slot class-variance-authorityCopy and paste the class names utility into your utils/class-names.ts file.
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy and paste the separator component into your components/ui/Separator.tsx file.
"use client"
import * as React from "react"
import {Separator as SeparatorPrimitive} from "radix-ui"
import { cn } from "@/utils/class-names"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-gray-a6 shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }Copy and paste the ButtonGroup component into your components/ui/ButtonGroup.tsx file.
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils/class-names"
import { Button, type ButtonProps } from "@/components/ui/Button"
import { Separator } from "@/components/ui/Separator"
/**
* `default` maps child buttons to `md` / `icon-md` (52px).
* `sm` maps them to `sm` / `icon-sm` (39px). `icon` is the same 39px control.
*
* Size is cloned onto direct `Button` and `ButtonGroupText` children.
* `Button` stays free of `createContext`, which React's server build does not export.
*/
type ButtonGroupSize = "default" | "sm"
const buttonGroupVariants = cva(
"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-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md 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",
},
}
)
const groupSizeWarnings = new Set<string>()
function warnOnce(message: string) {
if (process.env.NODE_ENV === "production") return
if (groupSizeWarnings.has(message)) return
groupSizeWarnings.add(message)
console.warn(message)
}
function visibleContent(props: {
label?: React.ReactNode
children?: React.ReactNode
}) {
const hasLabel = props.label != null && props.label !== false
const hasChildren = props.children != null && props.children !== false
if (hasLabel && hasChildren) return props.children
return hasChildren ? props.children : props.label
}
function isIconOnlyContent(node: React.ReactNode): boolean {
const items = React.Children.toArray(node).filter(
(child) => child !== null && child !== undefined && child !== false && child !== "",
)
if (items.length === 0) return false
return items.every((child) => {
if (typeof child === "string" || typeof child === "number") return false
if (!React.isValidElement(child)) return false
if (typeof child.type === "string" && (child.type === "svg" || child.type === "img")) {
return true
}
const className = (child.props as { className?: unknown }).className
return typeof className === "string" && className.split(/\s+/).includes("icon")
})
}
function applyGroupSize(
children: React.ReactNode,
size: ButtonGroupSize,
): React.ReactNode {
return React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return child
if (child.type === React.Fragment) {
const fragmentProps = child.props as { children?: React.ReactNode }
return React.cloneElement(
child,
{},
applyGroupSize(fragmentProps.children, size),
)
}
if (child.type === ButtonGroupText) {
const textProps = child.props as { size?: ButtonGroupSize }
if (textProps.size != null) return child
return React.cloneElement(child, { size })
}
if (child.type === Button) {
const buttonProps = child.props as ButtonProps
if (buttonProps.size != null) return child
const iconOnly = isIconOnlyContent(
buttonProps.asChild ? null : visibleContent(buttonProps),
)
const nextSize = iconOnly
? size === "sm"
? "icon-sm"
: "icon-md"
: size === "sm"
? "sm"
: "md"
if (iconOnly && !buttonProps["aria-label"] && !buttonProps["aria-labelledby"]) {
warnOnce(
"Button: an icon-only button in a ButtonGroup needs an aria-label.",
)
}
return React.cloneElement(child, { size: nextSize })
}
return child
})
}
function ButtonGroup({
className,
orientation = "horizontal",
size = "default",
children,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof buttonGroupVariants> & {
size?: ButtonGroupSize
}) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
data-size={size}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
>
{applyGroupSize(children, size)}
</div>
)
}
function ButtonGroupText({
className,
asChild = false,
size,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
size?: ButtonGroupSize
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"flex items-center gap-0.5 rounded-sm border border-gray-6 bg-gray-2 px-2 font-medium [&_svg]:pointer-events-none",
size === "sm" ? "h-3" : "h-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative !m-0 self-stretch bg-gray-6 data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
export type { ButtonGroupSize }Usage
size is "default" or "sm". Default passes md (52px) and icon-md to direct child buttons that omit size. Small passes sm (39px) and icon-sm. An explicit size on a button is kept. ButtonGroupText is h-4 or h-3 to match.
size="icon" and size="icon-sm" are the 39px icon button. size="icon-md" is 52px. Icon-only buttons need an aria-label.
On a narrow screen, put groups in flex flex-wrap gap-2 so they wrap.
Joined fill buttons keep an accent-10 edge between them. Inner corners are square; the outer corners keep --aura-button-radius.
Small
export const Small = () => (
<div className="flex flex-wrap gap-2">
<ButtonGroup size="sm" aria-label="Archive and report">
<Button variant="pill">Archive</Button>
<Button variant="pill">Report</Button>
</ButtonGroup>
<ButtonGroup size="sm" aria-label="Snooze">
<Button variant="pill">Snooze</Button>
<Button variant="pill" aria-label="More options">
<DotsHorizontalIcon className="icon" />
</Button>
</ButtonGroup>
</div>
)Button icon
A label button paired with a three-dots icon button, at the default size and at small.
export const ButtonIcon = () => (
<div className="flex flex-wrap gap-2">
<ButtonGroup aria-label="Snooze">
<Button variant="pill">Snooze</Button>
<Button variant="pill" aria-label="More options">
<DotsHorizontalIcon className="icon" />
</Button>
</ButtonGroup>
<ButtonGroup size="sm" aria-label="Snooze small">
<Button variant="pill">Snooze</Button>
<Button variant="pill" aria-label="More options">
<DotsHorizontalIcon className="icon" />
</Button>
</ButtonGroup>
</div>
)Vertical
export const Vertical = () => (
<div className="flex flex-wrap gap-2">
<ButtonGroup orientation="vertical" aria-label="Vertical button group">
<Button variant="fill">One</Button>
<Button variant="fill">Two</Button>
<Button variant="fill">Three</Button>
</ButtonGroup>
<ButtonGroup
orientation="vertical"
size="sm"
aria-label="Small vertical button group"
>
<Button variant="fill">One</Button>
<Button variant="fill">Two</Button>
</ButtonGroup>
</div>
)Text
export const Text = () => (
<div className="flex flex-wrap gap-2">
<ButtonGroup aria-label="Amount">
<ButtonGroupText>Amount</ButtonGroupText>
<ButtonGroupSeparator />
<Button variant="pill">$100</Button>
</ButtonGroup>
<ButtonGroup size="sm" aria-label="Small amount">
<ButtonGroupText>Amount</ButtonGroupText>
<ButtonGroupSeparator />
<Button variant="pill">$100</Button>
</ButtonGroup>
</div>
)