Overview

A compact either-or control. The selected segment's raised pill springs to the new choice and squeezes slightly while you press it, a faint wash follows the pointer across the other segments, and thin dividers appear only between resting segments, so the track stays clean. Icons give a small pop when chosen. It's a radio group underneath: arrow keys move and wrap, Home and End jump, and only the selected segment is in the tab order.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/segmented-control.json

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/segmented-control.tsx
tsx
"use client";
import React, { useCallback, useId, useRef, useState } from "react";import { LayoutGroup, motion, useReducedMotion } from "motion/react";import { cn } from "@/lib/utils";
export type SegmentedOption = {  value: string;  label: string;  /** Optional leading icon — keep small; this is a control, not a nav tab. */  icon?: React.ReactNode;  disabled?: boolean;};
export type SegmentedControlProps = {  options: SegmentedOption[];  value?: string;  defaultValue?: string;  onValueChange?: (value: string) => void;  size?: "sm" | "md";  /**   * Stretch to fill the parent and give every segment equal width.   * Default true — equal cells are what separates this from free-width tabs.   */  equalWidth?: boolean;  className?: string;  "aria-label"?: string;};
const PILL_SPRING = { type: "spring" as const, bounce: 0.18, duration: 0.45 };
/** * Compact mutually-exclusive *choice* control (radiogroup). * Use for filters, ranges, alignment — not for switching content panels (use Tabs). */export function SegmentedControl({  options,  value: valueProp,  defaultValue,  onValueChange,  size = "md",  equalWidth = true,  className,  "aria-label": ariaLabel = "Options",}: SegmentedControlProps) {  const reduce = !!useReducedMotion();  const uid = useId();  const listRef = useRef<HTMLDivElement>(null);  const isControlled = valueProp !== undefined;  const [uncontrolled, setUncontrolled] = useState(defaultValue ?? options[0]?.value ?? "");  const value = isControlled ? valueProp : uncontrolled;  const [hovered, setHovered] = useState<string | null>(null);  const [pressed, setPressed] = useState<string | null>(null);
  const setValue = useCallback(    (next: string) => {      if (!isControlled) setUncontrolled(next);      if (next !== value) onValueChange?.(next);    },    [isControlled, onValueChange, value],  );
  const enabled = options.filter((o) => !o.disabled);  // Only an enabled option can be selected. A controlled value that points at  // a disabled option leaves nothing selected; the group stays reachable  // through the first enabled segment.  const selectedValue = enabled.some((o) => o.value === value) ? value : null;  const tabStop = selectedValue ?? enabled[0]?.value ?? null;  const activeIndex = enabled.findIndex((o) => o.value === selectedValue);
  const focusByIndex = (index: number) => {    const opt = enabled[Math.min(Math.max(index, 0), enabled.length - 1)];    if (!opt) return;    setValue(opt.value);    listRef.current?.querySelector<HTMLButtonElement>(`[data-segment="${CSS.escape(opt.value)}"]`)?.focus();  };
  const onKeyDown = (e: React.KeyboardEvent) => {    const map: Record<string, number> = {      ArrowRight: activeIndex + 1,      ArrowDown: activeIndex + 1,      ArrowLeft: activeIndex - 1,      ArrowUp: activeIndex - 1,      Home: 0,      End: enabled.length - 1,    };    if (!(e.key in map)) return;    e.preventDefault();    // Arrows wrap around, like native radio groups.    let next = activeIndex < 0 ? 0 : map[e.key];    if (next >= enabled.length) next = 0;    if (next < 0) next = enabled.length - 1;    focusByIndex(next);  };
  const sm = size === "sm";  const radius = sm ? 8 : 10;
  return (    <LayoutGroup id={uid}>      <div        ref={listRef}        role="radiogroup"        aria-label={ariaLabel}        onKeyDown={onKeyDown}        onPointerLeave={() => setHovered(null)}        className={cn(          "relative isolate flex items-stretch",          equalWidth ? "w-full" : "w-fit max-w-full",          sm ? "gap-0.5 rounded-[10px] p-0.5" : "gap-0.5 rounded-[13px] p-[3px]",          "bg-(--color-paper-2) shadow-[inset_0_0_0_1px_var(--color-rule),inset_0_1px_2px_rgba(0,0,0,0.04)]",          className,        )}      >        {options.map((opt, i) => {          const active = opt.value === selectedValue;          const isHovered = hovered === opt.value && !active && !opt.disabled;          // A thin divider between two resting segments, hidden next to the pill.          const prev = options[i - 1];          const showDivider =            i > 0 && !active && prev?.value !== selectedValue && hovered !== opt.value && hovered !== prev?.value;          return (            <button              key={opt.value}              type="button"              role="radio"              data-segment={opt.value}              aria-checked={active}              disabled={opt.disabled}              tabIndex={opt.value === tabStop ? 0 : -1}              onClick={() => !opt.disabled && setValue(opt.value)}              onPointerEnter={() => setHovered(opt.value)}              onPointerDown={() => setPressed(opt.value)}              onPointerUp={() => setPressed(null)}              onPointerCancel={() => setPressed(null)}              onPointerLeave={() => setPressed(null)}              className={cn(                "relative inline-flex items-center justify-center font-medium whitespace-nowrap outline-none select-none",                "transition-colors duration-200",                "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-1 focus-visible:ring-offset-(--color-paper-2)",                "disabled:cursor-not-allowed disabled:opacity-40",                equalWidth && "min-w-0 flex-1",                sm ? "h-7 gap-1 px-2.5 text-[12px]" : "h-8 gap-1.5 px-3 text-[13px]",                active ? "text-(--color-ink)" : "text-(--color-neutral) hover:text-(--color-ink-2)",              )}              style={{ borderRadius: radius }}            >              <span                aria-hidden                className={cn(                  "absolute top-1/2 -left-px h-3.5 w-px -translate-y-1/2 bg-(--color-rule) transition-opacity duration-200",                  showDivider ? "opacity-100" : "opacity-0",                )}              />              {isHovered ? (                <motion.span                  layoutId="hover"                  aria-hidden                  transition={reduce ? { duration: 0 } : PILL_SPRING}                  className="absolute inset-0 -z-10 bg-(--color-ink)/[0.045]"                  style={{ borderRadius: radius }}                />              ) : null}              {active ? (                <motion.span                  layoutId="pill"                  aria-hidden                  transition={reduce ? { duration: 0 } : PILL_SPRING}                  animate={{ scale: pressed === opt.value && !reduce ? 0.96 : 1 }}                  className={cn(                    "absolute inset-0 -z-10 bg-(--color-paper)",                    "shadow-[0_0_0_0.5px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.08),0_2px_6px_-2px_rgba(0,0,0,0.08)]",                    "dark:shadow-[0_0_0_0.5px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.5)]",                  )}                  style={{ borderRadius: radius }}                />              ) : null}              {opt.icon ? (                <motion.span                  aria-hidden                  initial={false}                  animate={active && !reduce ? { scale: [0.8, 1.12, 1] } : { scale: 1 }}                  transition={{ duration: 0.35, ease: [0.23, 1, 0.32, 1] }}                  className={cn("flex shrink-0 items-center justify-center [&_svg]:size-full [&_svg]:stroke-[1.75]", sm ? "size-3.5" : "size-4")}                >                  {opt.icon}                </motion.span>              ) : null}              <span className="truncate">{opt.label}</span>            </button>          );        })}      </div>    </LayoutGroup>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { SegmentedControl } from "@/components/ui/segmented-control";
<SegmentedControl  aria-label="Time range"  value={range}  onValueChange={setRange}  options={[    { value: "day", label: "Day" },    { value: "week", label: "Week" },    { value: "month", label: "Month" },  ]}/>

Props

PropTypeRequiredDefaultDescription
optionsSegmentedOption[]Yes—Segments with value, label, and optional icon and disabled.
value / defaultValuestringNo—Controlled or initial value. Defaults to the first option.
onValueChange(value: string) => voidNo—Fires when the selection changes.
size"sm" | "md"No"md"Track height: 32px for sm, 38px for md.
equalWidthbooleanNotrueFill the parent with equal-width segments. False fits the segments to their labels.
aria-label / classNamestringNo—Accessible name of the radio group, and classes for the track.

Best Practices

  1. Use it for choices and settings (range, alignment, view mode), not for switching content panels; use Animated Tabs for that.
  2. Keep it to two to five short labels.
  3. Something is always selected. If "none" is a valid state, use a Filter Bar instead.
  4. Reduced motion moves the pill instantly and keeps the colour change.