Mobile & Consumer

Segmented Toggle Pill

An iOS-style segmented control with a solid filled pill that slides between segments on a muted track.

Install

npx shadcn@latest add @paragon/segmented-toggle-pill

segmented-toggle-pill.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 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>
  );
}