Overview

A setting control, not a navigation pattern. Segments are equal-width cells in a radiogroup — ideal for ranges, sort, alignment, density. The spring pill is short (bounce: 0, ~220ms) because this surface fires often.

Tabs switch content panels. Segmented control picks a value. If you need panels that morph, use Menu / Tabs instead.


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 } from "react";import { 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;  /** Unique layoutId when multiple controls share a page */  layoutId?: string;  className?: string;  "aria-label"?: string;};
const SPRING = { type: "spring" as const, bounce: 0, duration: 0.22 };const SPRING_REDUCED = { duration: 0.1 };
/** * 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,  layoutId: layoutIdProp,  className,  "aria-label": ariaLabel = "Options",}: SegmentedControlProps) {  const reduce = useReducedMotion();  const reactId = useId();  const layoutId = layoutIdProp ?? `segment-pill-${reactId}`;  const listRef = useRef<HTMLDivElement>(null);
  const isControlled = valueProp !== undefined;  const [uncontrolled, setUncontrolled] = React.useState(    defaultValue ?? options[0]?.value ?? "",  );  const value = isControlled ? valueProp : uncontrolled;
  const setValue = useCallback(    (next: string) => {      if (!isControlled) setUncontrolled(next);      onValueChange?.(next);    },    [isControlled, onValueChange],  );
  const enabled = options.filter((o) => !o.disabled);  const activeIndex = enabled.findIndex((o) => o.value === value);
  const focusByIndex = (index: number) => {    const clamped = Math.min(Math.max(index, 0), enabled.length - 1);    const opt = enabled[clamped];    if (!opt) return;    setValue(opt.value);    const el = listRef.current?.querySelector<HTMLButtonElement>(      `[data-segment="${opt.value}"]`,    );    el?.focus();  };
  const onKeyDown = (e: React.KeyboardEvent) => {    if (e.key === "ArrowRight" || e.key === "ArrowDown") {      e.preventDefault();      focusByIndex(activeIndex < 0 ? 0 : activeIndex + 1);    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {      e.preventDefault();      focusByIndex(activeIndex < 0 ? 0 : activeIndex - 1);    } else if (e.key === "Home") {      e.preventDefault();      focusByIndex(0);    } else if (e.key === "End") {      e.preventDefault();      focusByIndex(enabled.length - 1);    }  };
  const trackPad = size === "sm" ? "p-0.5" : "p-[3px]";  const btn =    size === "sm"      ? "min-h-7 gap-1 px-2 text-[11px] leading-none"      : "min-h-8 gap-1.5 px-2.5 text-xs leading-none";  const trackRadius = size === "sm" ? "rounded-[9px]" : "rounded-[10px]";  const pillRadius = size === "sm" ? "rounded-[7px]" : "rounded-[8px]";
  return (    <div      ref={listRef}      role="radiogroup"      aria-label={ariaLabel}      onKeyDown={onKeyDown}      className={cn(        "relative inline-flex items-stretch",        equalWidth && "w-full",        trackRadius,        trackPad,        "border border-(--color-rule) bg-(--color-paper-2)/90",        "shadow-[inset_0_1px_1px_rgba(0,0,0,0.04)]",        "dark:shadow-[inset_0_1px_1px_rgba(0,0,0,0.25)]",        className,      )}    >      {options.map((opt) => {        const active = opt.value === value;        return (          <button            key={opt.value}            type="button"            role="radio"            data-segment={opt.value}            aria-checked={active}            disabled={opt.disabled}            tabIndex={active ? 0 : -1}            onClick={() => !opt.disabled && setValue(opt.value)}            className={cn(              "relative z-1 inline-flex items-center justify-center font-medium tracking-tight",              "outline-none select-none",              "transition-[color,transform] duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",              "active:scale-[0.97]",              "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-1",              "disabled:pointer-events-none disabled:opacity-40",              equalWidth && "min-w-0 flex-1",              btn,              pillRadius,              active                ? "text-(--color-ink)"                : "text-(--color-ink-muted) hover:text-(--color-ink-2)",            )}          >            {active ? (              <motion.span                layoutId={layoutId}                className={cn(                  "absolute inset-0 -z-10",                  pillRadius,                  "bg-(--color-paper)",                  "border border-(--color-rule)/80",                  "shadow-[0_1px_2px_rgba(0,0,0,0.06),0_1px_0_rgba(255,255,255,0.55)_inset]",                  "dark:shadow-[0_1px_2px_rgba(0,0,0,0.4),0_1px_0_rgba(255,255,255,0.05)_inset]",                )}                transition={reduce ? SPRING_REDUCED : SPRING}              />            ) : null}            {opt.icon ? (              <span className="relative flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-full [&_svg]:stroke-[1.5]">                {opt.icon}              </span>            ) : null}            <span className="relative truncate">{opt.label}</span>          </button>        );      })}    </div>  );}

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[]YesSegments with value, label, optional icon and disabled.
valuestringNoControlled active value.
defaultValuestringNoUncontrolled initial value.
onValueChange(value: string) => voidNoFires when the active segment changes.
size"sm" | "md"No"md"Control density — denser than tabs by default.
equalWidthbooleanNotrueEqual-width cells filling the track (classic segmented look).
layoutIdstringNoOverride the shared pill layoutId when multiple controls share a page.
classNamestringNoOptional class names on the track.

Best Practices

  1. Use for filters and settings; use Tabs / Menu when switching content panels.
  2. Prefer 2–4 segments; denser choices belong in a select or menu.
  3. Pass a unique layoutId when rendering more than one control on the same route.
  4. Keep press feedback at scale(0.97) / ~100ms — this fires often.
  5. Under reduced motion, the pill still moves with a short duration (no bounce).