Attached segmented buttons with single and multiple select modes, roving tabindex, and a selection tint that slides between segments.
npx shadcn@latest add @paragon/button-group"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
interface ButtonGroupContextValue {
type: "single" | "multiple";
value: string;
layoutId: string;
orientation: "horizontal" | "vertical";
isStatic: boolean;
}
const ButtonGroupContext = React.createContext<ButtonGroupContextValue | null>(
null,
);
function useButtonGroup() {
const context = React.useContext(ButtonGroupContext);
if (!context) {
throw new Error("ButtonGroupItem must be used within a ButtonGroup");
}
return context;
}
interface ButtonGroupBaseProps
extends Omit<React.ComponentProps<"div">, "defaultValue" | "dir"> {
orientation?: "horizontal" | "vertical";
disabled?: boolean;
/** Disables the sliding tint; selection snaps instantly. */
static?: boolean;
}
interface ButtonGroupSingleProps extends ButtonGroupBaseProps {
type?: "single";
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
}
interface ButtonGroupMultipleProps extends ButtonGroupBaseProps {
type: "multiple";
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
}
export type ButtonGroupProps =
| ButtonGroupSingleProps
| ButtonGroupMultipleProps;
/**
* Segmented, attached buttons on a single card surface with hairline
* dividers. Single mode slides one selection tint between segments
* (interruptible shared-element spring); multiple mode fades a tint per
* pressed segment. One tab stop — Radix's roving tabindex moves focus
* with the arrow keys.
*/
export function ButtonGroup(props: ButtonGroupProps) {
const {
type = "single",
orientation = "horizontal",
static: isStatic = false,
className,
children,
disabled,
value: _value,
defaultValue: _defaultValue,
onValueChange: _onValueChange,
...rest
} = props;
const layoutId = React.useId();
// Mirror of the current single-mode value so the sliding tint knows its
// segment even when Radix owns the (uncontrolled) state.
const [internalValue, setInternalValue] = React.useState(
props.type !== "multiple" && typeof props.defaultValue === "string"
? props.defaultValue
: "",
);
const singleValue =
props.type !== "multiple" && typeof props.value === "string"
? props.value
: internalValue;
const contextValue = React.useMemo<ButtonGroupContextValue>(
() => ({
type,
value: type === "single" ? singleValue : "",
layoutId,
orientation,
isStatic,
}),
[type, singleValue, layoutId, orientation, isStatic],
);
const rootClassName = cn(
"inline-flex w-fit items-stretch rounded-lg bg-card shadow-border",
orientation === "vertical"
? "flex-col divide-y divide-border"
: "divide-x divide-border",
className,
);
const content = (
<ButtonGroupContext.Provider value={contextValue}>
{children}
</ButtonGroupContext.Provider>
);
if (props.type === "multiple") {
return (
<ToggleGroupPrimitive.Root
type="multiple"
data-slot="button-group"
orientation={orientation}
disabled={disabled}
value={props.value}
defaultValue={props.defaultValue}
onValueChange={props.onValueChange}
className={rootClassName}
{...rest}
>
{content}
</ToggleGroupPrimitive.Root>
);
}
return (
<ToggleGroupPrimitive.Root
type="single"
data-slot="button-group"
orientation={orientation}
disabled={disabled}
value={props.value}
defaultValue={props.defaultValue}
onValueChange={(next: string) => {
setInternalValue(next);
props.onValueChange?.(next);
}}
className={rootClassName}
{...rest}
>
{content}
</ToggleGroupPrimitive.Root>
);
}
export interface ButtonGroupItemProps
extends React.ComponentProps<typeof ToggleGroupPrimitive.Item> {
value: string;
}
export function ButtonGroupItem({
className,
children,
value,
...props
}: ButtonGroupItemProps) {
const {
type,
value: activeValue,
layoutId,
orientation,
isStatic,
} = useButtonGroup();
const reducedMotion = useReducedMotion();
const showSlidingTint = type === "single" && activeValue === value;
return (
<ToggleGroupPrimitive.Item
data-slot="button-group-item"
value={value}
className={cn(
"group/bgi relative inline-flex h-9 items-center justify-center px-3 text-sm font-medium whitespace-nowrap text-muted-foreground outline-none select-none",
"transition-[color,background-color] duration-150 ease-out",
// Vertical hit-area extension to 40px; segments sit flush sideways.
"after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2",
"hover:text-foreground focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"disabled:pointer-events-none disabled:opacity-50",
"data-[state=on]:text-foreground",
orientation === "vertical"
? "first:rounded-t-[inherit] last:rounded-b-[inherit]"
: "first:rounded-l-[inherit] last:rounded-r-[inherit]",
// Multiple mode: each pressed segment tints in place.
type === "multiple" && "data-[state=on]:bg-accent",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
>
{showSlidingTint && (
<motion.span
layoutId={layoutId}
aria-hidden
initial={false}
transition={
isStatic || reducedMotion
? { duration: 0 }
: { type: "spring", duration: 0.35, bounce: 0 }
}
className="pointer-events-none absolute inset-0 rounded-[inherit] bg-accent"
/>
)}
{/* Press feedback on the content only, so attached seams never wobble. */}
<span
className={cn(
"relative z-10 inline-flex items-center gap-2",
!isStatic &&
"transition-[scale] duration-150 ease-out group-active/bgi:scale-[0.97]",
)}
>
{children}
</span>
</ToggleGroupPrimitive.Item>
);
}