Skip to content

Segmented Control

An iOS-style segmented control with a draggable thumb: grab it and drag across segments, it stretches with its own velocity, grows under your finger and settles with a touch of overshoot. Icons, sizes, two thumb styles, light and dark. No dependencies.

DOM + CSS0 deps1 file · 15 KB TSXreduced-motion aware

Freeminimalplayful
Preview

Props

Show icons
No runtime dependencies
"use client";

import { useEffect, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, PointerEvent, ReactNode } from "react";

const DEFAULT_OPTIONS = ["Day", "Week", "Month"];
const SAFE_COLOR = /^(#[0-9a-f]{3,8}|[a-z]{3,20})$/i;

// Springs, [stiffness, damping]. SETTLE has ~5% overshoot, FOLLOW trails a finger without wobble.
const SETTLE: [number, number] = [420, 28];
const FOLLOW: [number, number] = [900, 52];
const PRESS: [number, number] = [600, 34];
// How far the thumb may poke past the inner edge of the track (the track has 4px padding).
const EDGE = 3;

const SIZES = {
  sm: { h: "30px", fs: "13px", px: "12px" },
  md: { h: "36px", fs: "14px", px: "16px" },
  lg: { h: "44px", fs: "16px", px: "20px" },
} as const;

const TRACK = {
  light: { bg: "#f5f5f5", border: "#e5e5e5", inset: "inset 0 1px 2px rgba(0,0,0,0.06)", text: "#737373", hover: "#262626" },
  dark: { bg: "rgba(255,255,255,0.06)", border: "rgba(255,255,255,0.1)", inset: "inset 0 1px 3px rgba(0,0,0,0.5)", text: "rgba(255,255,255,0.55)", hover: "#ffffff" },
} as const;

type Engine = {
  go: (i: number, snap: boolean) => void;
  down: (px: number) => boolean;
  move: (px: number) => boolean;
  up: (commit: boolean) => number;
};

/**
 * An iOS-style segmented control. One thumb rides the track on a spring with a touch of overshoot,
 * stretches and flattens with its own velocity, grows slightly under your finger, and can be
 * dragged: release it and it settles on the nearest segment (a flick carries it further).
 * Labels flip colour exactly where the thumb passes. Radiogroup semantics, roving tabindex,
 * arrow keys / Home / End. Hand-written springs, no dependencies.
 */
export function SegmentedControl({
  options = DEFAULT_OPTIONS,
  defaultIndex = 0,
  onChange,
  icons,
  size = "md",
  shape = "pill",
  variant = "accent",
  accent = "#ef4444",
  accentText = "#ffffff",
  tone = "light",
  disabled = false,
  className = "",
  ariaLabel = "Segmented control",
}: {
  options?: string[];
  defaultIndex?: number;
  onChange?: (index: number) => void;
  icons?: ReactNode[];
  size?: "sm" | "md" | "lg";
  shape?: "pill" | "rounded";
  variant?: "accent" | "neutral";
  accent?: string;
  accentText?: string;
  tone?: "light" | "dark";
  disabled?: boolean;
  className?: string;
  ariaLabel?: string;
}) {
  const count = options.length;
  const [picked, setPicked] = useState(defaultIndex);
  const active = Math.max(0, Math.min(picked, count - 1));
  const dark = tone === "dark";
  const t = TRACK[dark ? "dark" : "light"];
  const dims = SIZES[size === "sm" || size === "lg" ? size : "md"];
  const pill = shape === "pill";
  const accentColor = SAFE_COLOR.test(accent) ? accent : "#ef4444";
  const accentInk = SAFE_COLOR.test(accentText) ? accentText : "#ffffff";
  const neutral = variant === "neutral";
  const thumbInk = neutral ? (dark ? "#0a0a0a" : "#171717") : accentInk;
  const thumbBg = neutral ? (dark ? "#f4f4f5" : "#ffffff") : accentColor;
  const thumbShadow = neutral
    ? dark
      ? "0 1px 2px rgba(0,0,0,0.5), 0 8px 18px -6px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.9)"
      : "0 0 0 0.5px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.12), 0 6px 14px -4px rgba(0,0,0,0.16)"
    : "0 6px 16px -6px color-mix(in srgb, " + accentColor + " 70%, transparent), 0 1px 2px color-mix(in srgb, " + accentColor + " 40%, transparent), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -1px 0 rgba(0,0,0,0.12)";
  const thumbGloss = neutral ? "none" : "linear-gradient(180deg, rgba(255,255,255,0.24), rgba(255,255,255,0) 60%)";
  const round = pill ? "rounded-full" : "rounded-lg";
  const roundFocus = pill ? "focus-visible:!rounded-full" : "focus-visible:!rounded-lg";

  const innerRef = useRef<HTMLDivElement>(null);
  const thumbRef = useRef<HTMLDivElement>(null);
  const overRef = useRef<HTMLDivElement>(null);
  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const engine = useRef<Engine | null>(null);
  const activeRef = useRef(active);
  const onChangeRef = useRef(onChange);
  const draggedRef = useRef(false);
  useEffect(() => {
    activeRef.current = active;
    onChangeRef.current = onChange;
  });

  // The engine: one rAF loop that sleeps at rest. Everything it touches is written straight to the DOM.
  useEffect(() => {
    const inner = innerRef.current;
    const thumb = thumbRef.current;
    const over = overRef.current;
    if (!inner || !thumb || !over || count === 0) return;
    let centers: number[] = [];
    let segW = 0;
    let top = 0;
    let h = 0;
    let boxW = 0;
    let boxH = 0;
    let x = 0;
    let v = 0;
    let tx = 0;
    let k = SETTLE[0];
    let c = SETTLE[1];
    let p = 0;
    let pv = 0;
    let pt = 0;
    let armed = false;
    let dragging = false;
    let startPx = 0;
    let grab = 0;
    let near = -1;
    let shown = false;
    let raf = 0;
    let last = 0;
    const reduced = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const measure = () => {
      const list = btnRefs.current.slice(0, count);
      if (list.length < count || list.some((b) => !b)) return false;
      centers = list.map((b) => (b ? b.offsetLeft + b.offsetWidth / 2 : 0));
      const first = list[0];
      if (!first) return false;
      segW = first.offsetWidth;
      top = first.offsetTop;
      h = first.offsetHeight;
      boxW = inner.offsetWidth;
      boxH = inner.offsetHeight;
      return segW > 0;
    };
    const nearest = (px: number) => {
      let best = 0;
      let bd = Infinity;
      centers.forEach((cx, i) => {
        const d = Math.abs(cx - px);
        if (d < bd) {
          bd = d;
          best = i;
        }
      });
      return best;
    };
    const write = () => {
      const s = reduced() ? 0 : Math.min(1, Math.abs(v) / 1800);
      const grow = 1 + 0.05 * p;
      const w = segW * (1 + 0.16 * s) * grow;
      const ht = h * (1 - 0.09 * s) * grow;
      const left = Math.max(-EDGE, Math.min(boxW + EDGE - w, x - w / 2));
      const y = top + (h - ht) / 2;
      const r = pill ? ht / 2 : Math.min(8, ht / 2);
      thumb.style.transform = "translate3d(" + left.toFixed(2) + "px," + y.toFixed(2) + "px,0)";
      thumb.style.width = w.toFixed(2) + "px";
      thumb.style.height = ht.toFixed(2) + "px";
      thumb.style.borderRadius = r.toFixed(2) + "px";
      over.style.clipPath =
        "inset(" + y.toFixed(2) + "px " + (boxW - left - w).toFixed(2) + "px " + (boxH - y - ht).toFixed(2) + "px " + left.toFixed(2) + "px round " + r.toFixed(2) + "px)";
      if (!shown) {
        shown = true;
        thumb.style.visibility = "visible";
      }
    };
    const tick = (now: number) => {
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      const n = Math.max(1, Math.ceil(dt / 0.004));
      const sub = dt / n;
      for (let s = 0; s < n; s++) {
        v += (-k * (x - tx) - c * v) * sub;
        x += v * sub;
        pv += (-PRESS[0] * (p - pt) - PRESS[1] * pv) * sub;
        p += pv * sub;
      }
      const rest = !dragging && Math.abs(x - tx) < 0.02 && Math.abs(v) < 0.05 && Math.abs(p - pt) < 0.001 && Math.abs(pv) < 0.01;
      if (rest) {
        x = tx;
        v = 0;
        p = pt;
        pv = 0;
        write();
        raf = 0;
        return;
      }
      write();
      raf = requestAnimationFrame(tick);
    };
    const wake = () => {
      if (!raf) {
        last = performance.now();
        raf = requestAnimationFrame(tick);
      }
    };
    const settleTo = (i: number) => {
      tx = centers[i] ?? tx;
      k = SETTLE[0];
      c = SETTLE[1];
      wake();
    };

    engine.current = {
      go(i, snap) {
        if (!measure() || dragging) return;
        tx = centers[i] ?? tx;
        if (snap || !shown || reduced()) {
          x = tx;
          v = 0;
          write();
          return;
        }
        settleTo(i);
      },
      down(px) {
        if (!measure() || Math.abs(px - x) > segW / 2) return false;
        armed = true;
        dragging = false;
        startPx = px;
        grab = px - x;
        if (!reduced()) {
          pt = 1;
          wake();
        }
        return true;
      },
      move(px) {
        if (!armed) return false;
        let started = false;
        if (!dragging && Math.abs(px - startPx) > 3) {
          dragging = true;
          started = true;
          near = nearest(x);
          k = FOLLOW[0];
          c = FOLLOW[1];
        }
        if (dragging) {
          const lo = centers[0] ?? 0;
          const hi = centers[count - 1] ?? 0;
          let target = px - grab;
          if (target < lo) target = lo - (lo - target) * 0.25;
          else if (target > hi) target = hi + (target - hi) * 0.25;
          tx = target;
          if (reduced()) {
            x = tx;
            write();
          } else wake();
          const nn = nearest(tx);
          if (nn !== near) {
            near = nn;
            if (typeof navigator.vibrate === "function") navigator.vibrate(4);
          }
        }
        return started;
      },
      up(commit) {
        if (!armed) return -1;
        armed = false;
        pt = 0;
        const was = dragging;
        dragging = false;
        if (!was) {
          wake();
          return -1;
        }
        const idx = commit ? nearest(x + v * 0.1) : activeRef.current;
        settleTo(idx);
        return idx;
      },
    };
    engine.current.go(activeRef.current, true);
    const ro = new ResizeObserver(() => engine.current?.go(activeRef.current, true));
    ro.observe(inner);
    for (const b of btnRefs.current) if (b) ro.observe(b);
    const fonts = document.fonts;
    if (fonts) void fonts.ready.then(() => engine.current?.go(activeRef.current, true));
    return () => {
      ro.disconnect();
      if (raf) cancelAnimationFrame(raf);
      engine.current = null;
    };
  }, [count, pill]);

  useEffect(() => {
    engine.current?.go(active, false);
  }, [active]);

  const choose = (i: number) => {
    if (disabled || i === active) return;
    setPicked(i);
    onChangeRef.current?.(i);
  };

  const layoutX = (e: PointerEvent<HTMLDivElement>) => {
    const el = e.currentTarget;
    const r = el.getBoundingClientRect();
    return (e.clientX - r.left) * (el.offsetWidth / (r.width || 1));
  };
  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
    if (disabled || (e.pointerType === "mouse" && e.button !== 0)) return;
    draggedRef.current = false;
    engine.current?.down(layoutX(e));
  };
  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
    const started = engine.current?.move(layoutX(e));
    if (started) {
      draggedRef.current = true;
      try {
        e.currentTarget.setPointerCapture(e.pointerId);
      } catch {
        /* the pointer may already be gone */
      }
    }
  };
  const finish = (e: PointerEvent<HTMLDivElement>, commit: boolean) => {
    const idx = engine.current?.up(commit) ?? -1;
    try {
      e.currentTarget.releasePointerCapture(e.pointerId);
    } catch {
      /* not captured */
    }
    if (idx >= 0) choose(idx);
    // The click that follows a drag must not re-select the segment under the pointer.
    setTimeout(() => {
      draggedRef.current = false;
    }, 0);
  };

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (disabled) return;
    const key = e.key;
    let next = -1;
    if (key === "ArrowRight" || key === "ArrowDown") next = (active + 1) % count;
    else if (key === "ArrowLeft" || key === "ArrowUp") next = (active - 1 + count) % count;
    else if (key === "Home") next = 0;
    else if (key === "End") next = count - 1;
    if (next < 0) return;
    e.preventDefault();
    choose(next);
    btnRefs.current[next]?.focus();
  };

  const labelBox =
    "flex h-[var(--scx-h)] items-center justify-center gap-[0.5em] whitespace-nowrap px-[var(--scx-px)] text-[length:var(--scx-fs)] font-semibold leading-none";
  const iconBox = "inline-flex h-[1.15em] w-[1.15em] shrink-0 items-center justify-center [&>svg]:h-full [&>svg]:w-full";

  const rootStyle = {
    "--scx-h": "var(--sc-h, " + dims.h + ")",
    "--scx-fs": "var(--sc-fs, " + dims.fs + ")",
    "--scx-px": "var(--sc-px, " + dims.px + ")",
    "--scx-text": t.text,
    "--scx-hover": t.hover,
    background: t.bg,
    borderColor: t.border,
    boxShadow: t.inset,
    opacity: disabled ? 0.5 : 1,
    WebkitTapHighlightColor: "transparent",
  } as CSSProperties;

  return (
    <div
      role="radiogroup"
      aria-label={ariaLabel}
      aria-disabled={disabled || undefined}
      onKeyDown={onKeyDown}
      className={"inline-flex select-none border p-1 " + (pill ? "rounded-full" : "rounded-xl") + " " + className}
      style={rootStyle}
    >
      <div
        ref={innerRef}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={(e) => finish(e, true)}
        onPointerCancel={(e) => finish(e, false)}
        className="relative grid min-w-0 flex-1 auto-cols-fr grid-flow-col"
        style={{ touchAction: "pan-y" }}
      >
        {options.map((option, i) => {
          const on = i === active;
          return (
            <button
              key={option + "\u0001" + i}
              ref={(el) => {
                btnRefs.current[i] = el;
              }}
              type="button"
              role="radio"
              aria-checked={on}
              disabled={disabled}
              tabIndex={on ? 0 : -1}
              onClick={() => {
                if (!draggedRef.current) choose(i);
              }}
              className={
                "group min-w-0 cursor-pointer border-0 bg-transparent p-0 text-[color:var(--scx-text)] transition-colors duration-200 hover:text-[color:var(--scx-hover)] focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-sky-500 disabled:cursor-not-allowed motion-reduce:transition-none " +
                round +
                " " +
                roundFocus
              }
            >
              <span className={labelBox + " transition-transform duration-150 ease-out group-active:scale-[0.96] motion-reduce:transition-none"}>
                {icons?.[i] ? <span className={iconBox}>{icons[i]}</span> : null}
                {option}
              </span>
            </button>
          );
        })}
        <div
          ref={thumbRef}
          aria-hidden
          className="pointer-events-none absolute left-0 top-0"
          style={{ visibility: "hidden", willChange: "transform, width, height", backgroundColor: thumbBg, backgroundImage: thumbGloss, boxShadow: thumbShadow }}
        />
        <div
          ref={overRef}
          aria-hidden
          className="pointer-events-none absolute inset-0 grid auto-cols-fr grid-flow-col"
          style={{ color: thumbInk, clipPath: "inset(0 100% 0 0)" }}
        >
          {options.map((option, i) => (
            <span key={option + "\u0001" + i} className={labelBox}>
              {icons?.[i] ? <span className={iconBox}>{icons[i]}</span> : null}
              {option}
            </span>
          ))}
        </div>
      </div>
    </div>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

One thumb rides the track. Click a segment and it travels on an underdamped spring (stiffness 420, damping 28) that overshoots by about five percent and settles. Grab the thumb and it grows slightly under your finger, follows the pointer on a stiffer spring, rubber-bands past either end, and stretches along its direction of travel in proportion to its speed while it flattens a little in height. Let go and it snaps to the nearest segment, and a flick carries it one segment further. Labels flip colour exactly where the thumb passes: a clipped copy of the labels in the thumb colour is revealed through the thumb’s own rectangle, so text is never stretched with the squish. All motion runs in a single requestAnimationFrame loop that sleeps at rest and writes straight to the DOM. Segments are equal width, as on iOS. Props: options, defaultIndex and onChange as before; optional icons, size (sm, md, lg), shape (pill or rounded), variant (accent or neutral), accent and accentText colours, tone (light or dark), disabled, className and ariaLabel. Sizes are CSS variables (--sc-h, --sc-fs, --sc-px) you can override from a className. It defaults to the light track and red thumb it always had, so it works on a white page unchanged.

Curator’s note

Rebuilt in October around the part that makes an iOS segmented control feel physical: the thumb is something you can pick up. It no longer needs Framer Motion; the springs are hand-written.

Pairs well with

Pro