A Radix toggle-group where a single active indicator slides and morphs between segments instead of each segment restyling.
npx shadcn@latest add @paragon/segmented-control"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 SegmentedControlContextValue {
value: string;
layoutId: string;
isStatic: boolean;
register: (value: string) => () => void;
}
const SegmentedControlContext =
React.createContext<SegmentedControlContextValue | null>(null);
function useSegmentedControl() {
const context = React.useContext(SegmentedControlContext);
if (!context) {
throw new Error(
"SegmentedControlItem must be used within a SegmentedControl",
);
}
return context;
}
export interface SegmentedControlProps
extends Omit<
React.ComponentProps<typeof ToggleGroupPrimitive.Root>,
"type" | "value" | "defaultValue" | "onValueChange"
> {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Disables the sliding indicator motion; selection snaps instantly. */
static?: boolean;
}
/**
* A single-select toggle group where one active pill slides and morphs
* between segments (shared-element via motion layoutId) instead of each
* segment restyling in place. The spring is interruptible: clicking fast
* mid-slide retargets from the pill's current position and velocity.
*
* Exactly one segment is always selected — clicking the active segment
* never deselects, and an uncontrolled control with no defaultValue
* adopts its first segment.
*/
export function SegmentedControl({
className,
value: valueProp,
defaultValue,
onValueChange,
static: isStatic = false,
children,
...props
}: SegmentedControlProps) {
const layoutId = React.useId();
const [itemValues, setItemValues] = React.useState<string[]>([]);
const [internalValue, setInternalValue] = React.useState(defaultValue);
const value = valueProp ?? internalValue ?? itemValues[0] ?? "";
const register = React.useCallback((itemValue: string) => {
setItemValues((prev) =>
prev.includes(itemValue) ? prev : [...prev, itemValue],
);
return () => {
setItemValues((prev) => prev.filter((v) => v !== itemValue));
};
}, []);
const contextValue = React.useMemo(
() => ({ value, layoutId, isStatic, register }),
[value, layoutId, isStatic, register],
);
return (
<ToggleGroupPrimitive.Root
type="single"
data-slot="segmented-control"
value={value}
onValueChange={(next: string) => {
// A segmented control always has a selection — ignore deselects.
if (!next || next === value) return;
setInternalValue(next);
onValueChange?.(next);
}}
className={cn(
"inline-flex w-fit items-center rounded-lg bg-muted p-1",
className,
)}
{...props}
>
<SegmentedControlContext.Provider value={contextValue}>
{children}
</SegmentedControlContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
export interface SegmentedControlItemProps
extends React.ComponentProps<typeof ToggleGroupPrimitive.Item> {
value: string;
}
export function SegmentedControlItem({
className,
children,
value,
...props
}: SegmentedControlItemProps) {
const { value: activeValue, layoutId, isStatic, register } =
useSegmentedControl();
const reducedMotion = useReducedMotion();
const active = activeValue === value;
React.useEffect(() => register(value), [register, value]);
return (
<ToggleGroupPrimitive.Item
data-slot="segmented-control-item"
value={value}
className={cn(
// after: extends the hit area to 40px — segments sit flush
// horizontally, so only the vertical axis needs help.
"relative inline-flex h-8 items-center justify-center rounded-sm px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[color] duration-150 ease-out select-none after:absolute after:inset-x-0 after:-inset-y-1 hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:text-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
>
{active && (
<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-sm bg-background shadow-border dark:bg-foreground/10"
/>
)}
<span className="relative z-10 inline-flex items-center gap-1.5 [&>svg:first-child]:-ml-0.5">
{children}
</span>
</ToggleGroupPrimitive.Item>
);
}