ButtonGroup & Toolbar
Segmented single-choice button set, and a Toolbar container for grouped actions.
import { ButtonGroup, Toolbar, ButtonGroupOption }from "@/components/ui"
Specification
Presents a segmented single-choice set of buttons; Toolbar groups related action controls in a bordered container.
Props (8)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| options* | ButtonGroupOption[] | — | Segments to render; each has value, label and optional disabled (ButtonGroup). |
| value* | string | — | The value of the currently active segment (ButtonGroup). |
| onChange* | (value: string) => void | — | Called with the newly selected segment's value (ButtonGroup). |
| aria-label | string | — | Accessible label for the group; describe what the choice controls (ButtonGroup). |
| className | string | — | Extra classes merged onto the group container (ButtonGroup). |
| Toolbar.children* | ReactNode | — | The controls to lay out inside the toolbar (Toolbar). |
| Toolbar.aria-label | string | — | Accessible label for the toolbar region (Toolbar). |
| Toolbar.className | string | — | Extra classes merged onto the toolbar container (Toolbar). |
Segmented + toolbar
Full source
The complete component, read from
src/components/ui/button-group.tsx.button-group.tsx
"use client";
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface ButtonGroupOption {
value: string;
label: ReactNode;
disabled?: boolean;
}
interface ButtonGroupProps {
options: ButtonGroupOption[];
value: string;
onChange: (value: string) => void;
"aria-label"?: string;
className?: string;
}
export function ButtonGroup({ options, value, onChange, className, ...rest }: ButtonGroupProps) {
return (
<div
role="group"
aria-label={rest["aria-label"]}
className={cn("inline-flex overflow-hidden rounded-lg border border-border", className)}
>
{options.map((opt, i) => {
const on = opt.value === value;
return (
<button
key={opt.value}
type="button"
aria-pressed={on}
disabled={opt.disabled}
onClick={() => onChange(opt.value)}
className={cn(
"px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent",
i > 0 && "border-l border-border",
on ? "bg-accent text-on-accent" : "bg-surface text-text-secondary hover:bg-bg-hover hover:text-text",
opt.disabled && "cursor-not-allowed opacity-50"
)}
>
{opt.label}
</button>
);
})}
</div>
);
}
export function Toolbar({ children, className, "aria-label": ariaLabel }: { children: ReactNode; className?: string; "aria-label"?: string }) {
return (
<div
role="toolbar"
aria-label={ariaLabel}
className={cn("flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface p-2", className)}
>
{children}
</div>
);
}Dependencies
To use ButtonGroup & Toolbar you also need the following. Copy these into your project alongside it.
Recommended folder structure
project structure
src/components/ui/ ├─ button-group.tsx ├─ button.tsx └─ separator.tsx src/hooks/ ├─ use-mounted.ts └─ use-popover-position.ts src/lib/ └─ utils.ts
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/hooks/use-mounted.ts
use-mounted.ts
"use client";
import { useState, useEffect } from "react";
/**
* Returns true once the component has mounted on the client.
* Useful for portals and anything that needs `document` to exist.
*/
export function useMounted(): boolean {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
}src/hooks/use-popover-position.ts
use-popover-position.ts
"use client";
import { useState, useEffect, useCallback, type RefObject, type CSSProperties } from "react";
interface PopoverPositionOptions {
align?: "start" | "center" | "end";
gap?: number;
viewportPadding?: number;
preferredHeight?: number;
matchWidth?: boolean;
minWidth?: number;
}
interface PopoverPosition {
placement: "top" | "bottom";
style: CSSProperties;
}
/**
* Compute a fixed-position popover style anchored to a ref element.
* Opens downward by default; flips upward when there isn't room below
* AND there's more room above. Clamps horizontally so nothing renders
* past the viewport edge. Re-measures on scroll/resize while open.
*/
export function usePopoverPosition(
anchorRef: RefObject<HTMLElement | null>,
open: boolean,
options: PopoverPositionOptions = {}
): PopoverPosition | null {
const {
align = "start",
gap = 4,
viewportPadding = 8,
preferredHeight = 320,
matchWidth = false,
minWidth,
} = options;
const [position, setPosition] = useState<PopoverPosition | null>(null);
const measure = useCallback(() => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const spaceBelow = vh - rect.bottom - viewportPadding;
const spaceAbove = rect.top - viewportPadding;
const placement =
spaceBelow < Math.min(preferredHeight, 160) && spaceAbove > spaceBelow
? "top"
: "bottom";
const maxHeight = Math.max(
120,
Math.min(preferredHeight, placement === "top" ? spaceAbove : spaceBelow)
);
const width = matchWidth ? rect.width : minWidth;
let left = align === "end" && width ? rect.right - width : rect.left;
if (align === "center" && width)
left = rect.left + rect.width / 2 - width / 2;
left = width
? Math.min(Math.max(left, viewportPadding), vw - width - viewportPadding)
: Math.min(Math.max(left, viewportPadding), vw - viewportPadding);
setPosition({
placement,
style: {
position: "fixed",
top: placement === "top" ? undefined : rect.bottom + gap,
bottom: placement === "top" ? vh - rect.top + gap : undefined,
left,
width: matchWidth ? rect.width : undefined,
minWidth: !matchWidth ? minWidth : undefined,
maxHeight,
},
});
}, [anchorRef, align, gap, viewportPadding, preferredHeight, matchWidth, minWidth]);
useEffect(() => {
if (!open) {
setPosition(null);
return;
}
measure();
window.addEventListener("scroll", measure, true);
window.addEventListener("resize", measure);
return () => {
window.removeEventListener("scroll", measure, true);
window.removeEventListener("resize", measure);
};
}, [open, measure]);
return position;
}src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge class names with clsx semantics + Tailwind conflict resolution.
* Prefer this in all components.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Concatenate class names, filtering out falsy values.
* @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
*/
export function cx(...args: (string | false | null | undefined)[]): string {
return args.filter(Boolean).join(" ");
}
/**
* Focus ring style helper.
* @deprecated Focus rings are now handled globally via the
* `:focus-visible` rule in `globals.css` and the `focus-visible:*`
* utilities. This remains only for backwards compatibility.
*/
export function ring(color: string = "var(--color-accent)") {
return { outlineColor: color };
}