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,  useEffect,  useLayoutEffect,  useRef,} from "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;  /** @deprecated Unused — kept for demo API compatibility */  layoutId?: string;  className?: string;  "aria-label"?: string;};
/** * 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: _layoutId,  className,  "aria-label": ariaLabel = "Options",}: SegmentedControlProps) {  void _layoutId;  const listRef = useRef<HTMLDivElement>(null);  const pillRef = useRef<HTMLSpanElement>(null);  const animatePill = useRef(false);
  const isControlled = valueProp !== undefined;  const [uncontrolled, setUncontrolled] = React.useState(    defaultValue ?? options[0]?.value ?? "",  );  const value = isControlled ? valueProp : uncontrolled;
  const setValue = useCallback(    (next: string, animate = false) => {      animatePill.current = animate;      if (!isControlled) setUncontrolled(next);      onValueChange?.(next);    },    [isControlled, onValueChange],  );
  const enabled = options.filter((o) => !o.disabled);  const activeIndex = enabled.findIndex((o) => o.value === value);
  const moveTo = useCallback((tab: HTMLElement | null, animate: boolean) => {    const pill = pillRef.current;    if (!pill || !tab) return;    if (!animate) {      const prev = pill.style.transition;      pill.style.transition = "none";      pill.style.transform = `translateX(${tab.offsetLeft}px)`;      pill.style.width = `${tab.offsetWidth}px`;      void pill.offsetWidth;      pill.style.transition = prev;    } else {      pill.style.transform = `translateX(${tab.offsetLeft}px)`;      pill.style.width = `${tab.offsetWidth}px`;    }  }, []);
  const activeTabEl = useCallback(() => {    const bar = listRef.current;    if (!bar) return null;    return (      bar.querySelector<HTMLElement>('.t-tab[aria-selected="true"]') ??      bar.querySelector<HTMLElement>(".t-tab")    );  }, []);
  useLayoutEffect(() => {    moveTo(activeTabEl(), animatePill.current);    animatePill.current = false;  }, [value, options, size, equalWidth, moveTo, activeTabEl]);
  useEffect(() => {    const onResize = () => moveTo(activeTabEl(), false);    window.addEventListener("resize", onResize);    return () => window.removeEventListener("resize", onResize);  }, [moveTo, activeTabEl]);
  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, true);    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(        "t-tabs 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,      )}      style={        {          display: "flex",          "--tabs-bar-bg": "transparent",          "--tabs-pill-bg": "var(--color-paper)",          "--tabs-text-muted": "var(--color-ink-muted)",          "--tabs-text-active": "var(--color-ink)",        } as React.CSSProperties      }    >      <span        ref={pillRef}        className={cn(          "t-tabs-pill",          pillRadius,          "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]",        )}        style={{          top: size === "sm" ? 2 : 3,          height: size === "sm" ? 28 : 30,          background: "var(--color-paper)",        }}        aria-hidden="true"      />      {options.map((opt) => {        const active = opt.value === value;        return (          <button            key={opt.value}            type="button"            role="radio"            data-segment={opt.value}            aria-checked={active}            aria-selected={active}            disabled={opt.disabled}            tabIndex={active ? 0 : -1}            onClick={() => {              if (opt.disabled) return;              setValue(opt.value, true);            }}            className={cn(              "t-tab relative z-1 inline-flex items-center justify-center font-medium tracking-tight",              "outline-none select-none",              "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)",            )}            style={{              height: "auto",              background: "transparent",              borderRadius: undefined,            }}          >            {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).