An iOS-style segmented control with a solid filled pill that slides between segments on a muted track.
npx shadcn@latest add @paragon/segmented-toggle-pill"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 PillContextValue {
value: string;
layoutId: string;
isStatic: boolean;
size: "sm" | "default";
register: (value: string) => () => void;
}
const PillContext = React.createContext<PillContextValue | null>(null);
function usePillContext() {
const ctx = React.useContext(PillContext);
if (!ctx) {
throw new Error(
"SegmentedTogglePillItem must be used within a SegmentedTogglePill",
);
}
return ctx;
}
export interface SegmentedTogglePillProps
extends Omit<
React.ComponentProps<typeof ToggleGroupPrimitive.Root>,
"type" | "value" | "defaultValue" | "onValueChange"
> {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
size?: "sm" | "default";
/** Disables the sliding pill; selection snaps. */
static?: boolean;
}
/**
* An iOS-style segmented control. Unlike the surface-inset SegmentedControl,
* the active indicator here is a solid filled pill on a muted track, and the
* selected label flips to the primary foreground. The pill slides between
* segments via a shared layoutId spring; clicking fast retargets mid-slide.
* Exactly one segment is always selected.
*/
export function SegmentedTogglePill({
className,
value: valueProp,
defaultValue,
onValueChange,
size = "default",
static: isStatic = false,
children,
...props
}: SegmentedTogglePillProps) {
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, size, register }),
[value, layoutId, isStatic, size, register],
);
return (
<ToggleGroupPrimitive.Root
type="single"
data-slot="segmented-toggle-pill"
value={value}
onValueChange={(next: string) => {
if (!next || next === value) return;
setInternalValue(next);
onValueChange?.(next);
}}
className={cn(
"inline-flex w-fit items-center rounded-full bg-muted p-0.5",
className,
)}
{...props}
>
<PillContext.Provider value={contextValue}>
{children}
</PillContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
export interface SegmentedTogglePillItemProps
extends React.ComponentProps<typeof ToggleGroupPrimitive.Item> {
value: string;
}
export function SegmentedTogglePillItem({
className,
children,
value,
...props
}: SegmentedTogglePillItemProps) {
const { value: activeValue, layoutId, isStatic, size, register } =
usePillContext();
const reduced = useReducedMotion();
const active = activeValue === value;
React.useEffect(() => register(value), [register, value]);
return (
<ToggleGroupPrimitive.Item
data-slot="segmented-toggle-pill-item"
value={value}
className={cn(
"relative inline-flex items-center justify-center rounded-full font-medium whitespace-nowrap text-muted-foreground transition-[color] duration-150 ease-out outline-none select-none after:absolute after:inset-x-0 after:-inset-y-1 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
size === "sm" ? "h-7 px-3 text-[13px]" : "h-8 px-4 text-sm",
className,
)}
{...props}
>
{active && (
<motion.span
layoutId={layoutId}
aria-hidden
initial={false}
transition={
isStatic || reduced
? { duration: 0 }
: { type: "spring", duration: 0.3, bounce: 0.1 }
}
className="pointer-events-none absolute inset-0 rounded-full bg-primary shadow-border"
/>
)}
<span className="relative z-10 inline-flex items-center gap-1.5 [&>svg:first-child]:-ml-0.5">
{children}
</span>
</ToggleGroupPrimitive.Item>
);
}