Buttons

Segmented Control

A Radix toggle-group where a single active indicator slides and morphs between segments instead of each segment restyling.

Install

npx shadcn@latest add @paragon/segmented-control

segmented-control.tsx

"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>
  );
}