Segmented Control Fling

Control

Segmented control with a visibly grabbable pill (grip dots, hover lift) — fling it, it coasts on real release velocity, rubber-bands off the ends, and snaps into the nearest detent.

Install
npx shadcn add https://design.helpmarq.com/r/segmented-control-fling.json
Source
registry/core/segmented-control-fling/component.tsx
"use client";

import { useEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// FlingSegment — segmented control whose selection pill is grabbable: drag
// and release it anywhere along the track and it coasts on real release
// velocity under exponential friction, rubber-bands off the track edges, and
// captures into the nearest segment detent with one small overshoot. Click
// and keyboard remain instant no-drag paths (critically damped glide across
// both x and width — segments may differ in width). Pure DOM: the pill is an
// absolutely-positioned node driven by OFFSET transforms relative to its
// layout slot on a direct-DOM rAF loop — zero React state on the hot path,
// the loop sleeps at a velocity epsilon, pauses offscreen, and a 1 s
// forced-settle deadline guarantees physics never jitters forever. All ink
// is token-relative CSS (background/surface/border/foreground/accent), so
// both themes restyle live with nothing to re-derive.
// ---------------------------------------------------------------------------

const FRICTION = 4.5; // exponential coast friction, s^-1
const CAPTURE_V = 300; // px/s — below this the nearest detent captures
const DETENT_K = 280; // detent capture spring, s^-2
const DETENT_ZETA = 0.8; // one small overshoot
const EDGE_K = 320; // edge rubber-band spring, s^-2
const EDGE_ZETA = 0.9;
const GLIDE_K = 220; // click/keyboard glide — critically damped, ~260 ms
const GLIDE_ZETA = 1.0;
const RUBBER = 0.35; // beyond-track displacement scale
const SETTLE_MS = 1000; // forced-settle deadline
const V_WINDOW = 80; // ms of pointer samples averaged into release velocity
const INTRO_DELAY_MS = 2500; // one-shot intro fling — a beat after first paint

export interface FlingSegmentOption {
  value: string;
  label: string;
}

const DEFAULT_OPTIONS: FlingSegmentOption[] = [
  { value: "list", label: "List" },
  { value: "board", label: "Board" },
  { value: "timeline", label: "Timeline" },
];

export function FlingSegment({
  options = DEFAULT_OPTIONS,
  value,
  defaultValue,
  onValueChange,
  introFling = false,
  className = "",
  "aria-label": ariaLabel = "View",
}: {
  options?: FlingSegmentOption[];
  /** controlled value; omit for uncontrolled */
  value?: string;
  defaultValue?: string;
  /** fires once per selection change — on detent commit, never per-frame */
  onValueChange?: (value: string) => void;
  /**
   * opt-in one-shot self-demo: a beat after mount the pill is flung to the
   * far segment, coasts, and captures into the detent. Plays exactly once,
   * never loops, is skipped under reduced motion, and is permanently
   * cancelled by any pointer or keyboard interaction.
   */
  introFling?: boolean;
  className?: string;
  "aria-label"?: string;
}) {
  const trackRef = useRef<HTMLDivElement>(null);
  const pillRef = useRef<HTMLSpanElement>(null);
  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const engineRef = useRef<{
    goTo: (index: number, animate: boolean) => void;
  } | null>(null);
  const firstRef = useRef(true);

  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(
    defaultValue ?? options[0]?.value ?? ""
  );
  const selectedValue = isControlled ? value : internal;
  let selectedIndex = options.findIndex((o) => o.value === selectedValue);
  if (selectedIndex < 0) selectedIndex = 0;

  const selectedRef = useRef(selectedIndex);
  selectedRef.current = selectedIndex;
  const optsLenRef = useRef(options.length);
  optsLenRef.current = options.length;
  const introRef = useRef(introFling);
  introRef.current = introFling;
  const introDoneRef = useRef(false); // survives option changes — plays once ever

  // instant commit path shared by click, keyboard, and the physics engine's
  // detent settle — fires onValueChange exactly once per selection change
  const setIndex = (i: number) => {
    const opt = options[i];
    if (!opt) return;
    if (!isControlled) setInternal(opt.value);
    if (opt.value !== selectedValue) onValueChange?.(opt.value);
  };
  const commitRef = useRef<(i: number) => void>(() => {});
  commitRef.current = setIndex;

  const optKey = options.map((o) => o.value).join("");

  useEffect(() => {
    const track = trackRef.current;
    const pill = pillRef.current;
    if (!track || !pill) return;
    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    // -- hot-path state: locals only, never React state --------------------
    let segX: number[] = []; // offsetLeft per segment (track-relative)
    let segW: number[] = []; // offsetWidth per segment
    let minX = 0;
    let maxX = 0;
    let sized = false;
    let x = 0; // pill offset within the track's layout, px
    let v = 0; // px/s
    let w = 0; // pill width, px
    let wv = 0;
    let mode: 0 | 1 | 2 | 3 | 4 = 0; // idle | drag | coast | detent | glide
    let targetI = selectedRef.current;
    let deadline = 0;
    let raf = 0;
    let last = 0;
    let visible = true;

    // drag bookkeeping
    let pointerId = -1;
    let downX = 0;
    let basePillX = 0;
    let pointerX = 0;
    let samples: { t: number; x: number }[] = [];

    const render = () => {
      // beyond-track displacement renders scaled ×RUBBER (rubber band); the
      // physical x stays unscaled so the edge spring sees true displacement
      let rx = x;
      if (rx < minX) rx = minX + (rx - minX) * RUBBER;
      else if (rx > maxX) rx = maxX + (rx - maxX) * RUBBER;
      pill.style.width = `${w.toFixed(2)}px`;
      pill.style.transform = `translateX(${rx.toFixed(2)}px)`;
    };

    const nearest = () => {
      const center = x + w / 2;
      let best = 0;
      let bd = Infinity;
      for (let i = 0; i < segX.length; i++) {
        const d = Math.abs((segX[i] ?? 0) + (segW[i] ?? 0) / 2 - center);
        if (d < bd) {
          bd = d;
          best = i;
        }
      }
      return best;
    };

    const measure = () => {
      const btns: HTMLButtonElement[] = [];
      for (let i = 0; i < optsLenRef.current; i++) {
        const b = btnRefs.current[i];
        if (b) btns.push(b);
      }
      const rect = track.getBoundingClientRect();
      const first = btns[0];
      if (!first || rect.width < 8 || rect.height < 8) {
        sized = false; // zero-size guard — no physics into nothing
        pill.style.opacity = "0";
        return;
      }
      segX = btns.map((b) => b.offsetLeft);
      segW = btns.map((b) => b.offsetWidth);
      minX = segX[0] ?? 0;
      maxX = segX[segX.length - 1] ?? 0;
      pill.style.top = `${first.offsetTop}px`;
      pill.style.height = `${first.offsetHeight}px`;
      pill.style.opacity = "1";
      sized = true;
      if (mode === 0) {
        targetI = Math.min(targetI, segX.length - 1);
        x = segX[targetI] ?? 0;
        w = segW[targetI] ?? 0;
        v = wv = 0;
        render();
      }
    };

    const settle = () => {
      mode = 0;
      render();
      track.style.cursor = "";
      commitRef.current(targetI);
    };

    const loop = (now: number) => {
      raf = 0;
      const dt = last ? Math.min(0.032, (now - last) / 1000) : 1 / 60;
      last = now;
      if (!sized || mode === 0) return;
      let running = true;

      if (mode === 1) {
        // drag: pill tracks the pointer 1:1; width springs toward whichever
        // segment is currently nearest so unequal widths morph mid-drag
        x = basePillX + (pointerX - downX);
        const tw = segW[nearest()] ?? w;
        const c = 2 * DETENT_ZETA * Math.sqrt(DETENT_K);
        wv += (-DETENT_K * (w - tw) - c * wv) * dt;
        w += wv * dt;
      } else if (mode === 2) {
        if (x < minX || x > maxX) {
          // overshot an end — rubber-band spring hauls it back
          const edge = x < minX ? minX : maxX;
          const c = 2 * EDGE_ZETA * Math.sqrt(EDGE_K);
          v += (-EDGE_K * (x - edge) - c * v) * dt;
          x += v * dt;
          if (x >= minX && x <= maxX && Math.abs(v) < CAPTURE_V) {
            mode = 3;
            targetI = nearest();
          }
        } else {
          v *= Math.exp(-FRICTION * dt);
          x += v * dt;
          if (Math.abs(v) < CAPTURE_V) {
            mode = 3;
            targetI = nearest();
          }
        }
        const tw = segW[nearest()] ?? w;
        const c = 2 * DETENT_ZETA * Math.sqrt(DETENT_K);
        wv += (-DETENT_K * (w - tw) - c * wv) * dt;
        w += wv * dt;
      } else {
        // detent capture (one small overshoot) or click/keyboard glide
        const k = mode === 3 ? DETENT_K : GLIDE_K;
        const z = mode === 3 ? DETENT_ZETA : GLIDE_ZETA;
        const tx = segX[targetI] ?? 0;
        const tw = segW[targetI] ?? w;
        const c = 2 * z * Math.sqrt(k);
        v += (-k * (x - tx) - c * v) * dt;
        x += v * dt;
        wv += (-k * (w - tw) - c * wv) * dt;
        w += wv * dt;
        if (
          Math.abs(x - tx) < 0.3 &&
          Math.abs(v) < 8 &&
          Math.abs(w - tw) < 0.3 &&
          Math.abs(wv) < 8
        ) {
          x = tx;
          w = tw;
          v = wv = 0;
          running = false; // velocity-epsilon sleep
        }
      }

      // forced-settle deadline — physics never jitters forever
      if (mode !== 1 && now > deadline) {
        if (mode === 2) targetI = nearest();
        x = segX[targetI] ?? x;
        w = segW[targetI] ?? w;
        v = wv = 0;
        running = false;
      }

      if (!running) {
        settle();
        return;
      }
      render();
      if (visible && !document.hidden) raf = requestAnimationFrame(loop);
    };

    const wake = () => {
      if (!raf && !reduced && sized && mode !== 0 && visible && !document.hidden) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    const goTo = (i: number, animate: boolean) => {
      if (!sized) {
        targetI = i;
        return;
      }
      if (mode === 1) return; // user owns the pill mid-drag
      targetI = Math.max(0, Math.min(i, segX.length - 1));
      if (reduced || !animate) {
        x = segX[targetI] ?? 0;
        w = segW[targetI] ?? 0;
        v = wv = 0;
        mode = 0;
        render();
        return;
      }
      if (mode === 0 && Math.abs(x - (segX[targetI] ?? 0)) < 0.5) return;
      mode = 4;
      deadline = performance.now() + SETTLE_MS;
      wake();
    };

    const finishDrag = (releaseV: number) => {
      pointerId = -1;
      if (mode !== 1) return;
      track.style.cursor = "";
      if (reduced) {
        // no coast — release selects the nearest segment instantly
        targetI = nearest();
        x = segX[targetI] ?? x;
        w = segW[targetI] ?? w;
        v = wv = 0;
        mode = 0;
        render();
        commitRef.current(targetI);
        return;
      }
      v = releaseV;
      x = basePillX + (pointerX - downX);
      mode = 2;
      deadline = performance.now() + SETTLE_MS;
      wake();
    };

    // -- one-shot intro fling (opt-in via introFling) -----------------------
    // scripted self-demo: the pill gets flung toward the far segment, coasts
    // on friction, nudges the end rubber band, and captures into the detent —
    // so a first-time viewer instantly sees the mechanic. Runs once shortly
    // after mount, never loops, skipped under reduced motion, permanently
    // cancelled by any pointer or keyboard interaction.
    let introTimer = 0;
    const cancelIntro = () => {
      introDoneRef.current = true;
      if (introTimer) {
        window.clearTimeout(introTimer);
        introTimer = 0;
      }
    };
    if (introRef.current && !reduced && !introDoneRef.current) {
      introTimer = window.setTimeout(() => {
        introTimer = 0;
        if (introDoneRef.current || !sized || mode !== 0) return;
        introDoneRef.current = true;
        const to = selectedRef.current === segX.length - 1 ? 0 : segX.length - 1;
        const d = (segX[to] ?? 0) - x;
        if (Math.abs(d) < 4) return;
        // release velocity sized so friction bleeds it to capture speed just
        // past the target — a visible coast plus one rubber-band nudge
        v = Math.sign(d) * (CAPTURE_V + FRICTION * Math.abs(d) + 60);
        mode = 2;
        deadline = performance.now() + SETTLE_MS;
        wake();
      }, INTRO_DELAY_MS);
    }

    const onDown = (e: PointerEvent) => {
      cancelIntro(); // any real interaction retires the self-demo for good
      if (e.button !== 0 || !sized || mode === 1) return;
      // is the press inside the pill's rendered span? (track coords; the 1px
      // track border is absorbed by the ±2px tolerance)
      const px = e.clientX - track.getBoundingClientRect().left;
      let rx = x;
      if (rx < minX) rx = minX + (rx - minX) * RUBBER;
      else if (rx > maxX) rx = maxX + (rx - maxX) * RUBBER;
      if (px < rx - 2 || px > rx + w + 2) return; // segment click path instead
      pointerId = e.pointerId;
      downX = e.clientX;
      pointerX = e.clientX;
      basePillX = x;
      v = wv = 0;
      samples = [{ t: e.timeStamp, x: e.clientX }];
      mode = 1; // catch the pill — even mid-flight
      track.style.cursor = "grabbing";
      try {
        track.setPointerCapture(e.pointerId);
      } catch {
        /* pointer already gone */
      }
      if (reduced) render();
      else wake();
    };

    const onMove = (e: PointerEvent) => {
      if (mode !== 1 || e.pointerId !== pointerId) return;
      pointerX = e.clientX;
      samples.push({ t: e.timeStamp, x: e.clientX });
      while (samples.length > 1 && e.timeStamp - (samples[0]?.t ?? 0) > 120)
        samples.shift();
      if (reduced) {
        x = Math.min(maxX, Math.max(minX, basePillX + (pointerX - downX)));
        w = segW[nearest()] ?? w;
        render();
      }
    };

    const onUp = (e: PointerEvent) => {
      if (e.pointerId !== pointerId) return;
      // release velocity = mean of pointer samples from the last 80 ms
      const cutoff = e.timeStamp - V_WINDOW;
      let i0 = 0;
      while (i0 < samples.length - 1 && (samples[i0]?.t ?? 0) < cutoff) i0++;
      const a = samples[i0];
      const b = samples[samples.length - 1];
      let rv = 0;
      if (a && b && b.t - a.t > 8) rv = ((b.x - a.x) / (b.t - a.t)) * 1000;
      finishDrag(rv);
    };

    const onCancel = (e: PointerEvent) => {
      if (e.pointerId !== pointerId) return;
      finishDrag(0);
    };

    measure();
    engineRef.current = { goTo };

    // -- observers ----------------------------------------------------------
    const ro = new ResizeObserver(measure);
    ro.observe(track);
    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible) wake();
      else {
        cancelAnimationFrame(raf);
        raf = 0; // offscreen — loop pauses, resumes on re-entry
      }
    });
    io.observe(track);
    const onVis = () => {
      if (!document.hidden) wake();
    };
    document.addEventListener("visibilitychange", onVis);
    track.addEventListener("keydown", cancelIntro, true);
    track.addEventListener("pointerdown", onDown);
    track.addEventListener("pointermove", onMove);
    track.addEventListener("pointerup", onUp);
    track.addEventListener("pointercancel", onCancel);

    return () => {
      cancelAnimationFrame(raf);
      if (introTimer) window.clearTimeout(introTimer); // teardown ≠ played
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      track.removeEventListener("keydown", cancelIntro, true);
      track.removeEventListener("pointerdown", onDown);
      track.removeEventListener("pointermove", onMove);
      track.removeEventListener("pointerup", onUp);
      track.removeEventListener("pointercancel", onCancel);
      track.style.cursor = "";
      engineRef.current = null;
    };
  }, [optKey]);

  // selection changes glide the pill; drag commits land here already parked
  // at the detent, so the glide is a no-op for them
  useEffect(() => {
    if (firstRef.current) {
      firstRef.current = false;
      return;
    }
    engineRef.current?.goTo(selectedIndex, true);
  }, [selectedIndex, optKey]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    const n = options.length;
    if (n === 0) return;
    let next = -1;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        next = (selectedIndex + 1) % n;
        break;
      case "ArrowLeft":
      case "ArrowUp":
        next = (selectedIndex - 1 + n) % n;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = n - 1;
        break;
      case " ":
      case "Enter":
        next = selectedIndex; // commit focused
        break;
      default:
        return;
    }
    e.preventDefault();
    setIndex(next);
    btnRefs.current[next]?.focus();
  };

  return (
    <div
      ref={trackRef}
      role="radiogroup"
      aria-label={ariaLabel}
      onKeyDown={onKeyDown}
      className={`group/fling relative inline-flex touch-pan-y select-none items-center rounded-sm border border-border bg-foreground/[0.04] p-1 ${className}`}
    >
      <span
        ref={pillRef}
        aria-hidden
        className="absolute left-0 top-0 rounded-[5px] border border-border bg-background opacity-0 shadow-[0_1px_2px_rgba(0,0,0,0.12)] transition-shadow duration-150 will-change-transform group-has-[button[aria-checked=true]:hover]/fling:shadow-[0_3px_8px_rgba(0,0,0,0.24)]"
      >
        {/* grip affordance — six dots hugging the pill's right edge say
            "this is a handle"; they brighten when the pill is hovered */}
        <span className="absolute right-[5px] top-1/2 grid -translate-y-1/2 grid-cols-2 gap-[3px]">
          {[0, 1, 2, 3, 4, 5].map((i) => (
            <span
              key={i}
              className="h-[2px] w-[2px] rounded-full bg-foreground/25 transition-colors duration-150 group-has-[button[aria-checked=true]:hover]/fling:bg-foreground/50"
            />
          ))}
        </span>
      </span>
      {options.map((opt, i) => {
        const selected = i === selectedIndex;
        return (
          <button
            key={opt.value}
            ref={(el) => {
              btnRefs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={selected}
            tabIndex={selected ? 0 : -1}
            onClick={() => setIndex(i)}
            className={`relative rounded-[5px] px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent ${
              selected
                ? "cursor-grab text-foreground active:cursor-grabbing"
                : "cursor-pointer text-muted hover:text-foreground"
            }`}
          >
            {opt.label}
          </button>
        );
      })}
    </div>
  );
}
Use when

Segmented control with a visibly grabbable pill (grip dots, hover lift) — fling it, it coasts on real release velocity, rubber-bands off the ends, and snaps into the nearest detent. Optional one-shot intro fling demos the mechanic on first view.

Build spec

Segmented control whose selection pill is grabbable: drag and release it anywhere along the track and it coasts on real release velocity (mean of pointer samples from the last 80 ms) under exponential friction v*=exp(-4.5*dt), rubber-bands off the track edges (beyond-track displacement rendered scaled 0.35, spring back k=320 zeta=0.9), and below 300 px/s captures into the nearest segment detent via a spring k=280 zeta=0.8 with one small overshoot. Click and keyboard remain instant no-drag paths: measure the target segment's width first (segments may differ) and glide both x and width with a critically damped spring k=220 zeta=1, ~260 ms. Pure DOM — the pill is an absolutely-positioned node driven by offset transforms relative to its layout slot on a direct-DOM rAF loop, zero React state on the hot path; a 1 s forced-settle deadline guarantees physics never jitters forever; the loop sleeps at a velocity epsilon and pauses offscreen via IntersectionObserver. role=radiogroup of role=radio buttons: ArrowLeft/Right/Up/Down move selection, Home/End jump to first/last, Space/Enter commit the focused segment, focus ring uses the accent token, and onValueChange fires once on detent commit, never per-frame. Reduced motion: the pill repositions instantly and drag release selects the nearest segment with no coast. Grabbability is advertised visually: the pill carries a six-dot grip affordance at its right edge and lifts (deeper shadow, brighter dots) when the selected segment is hovered, with cursor-grab/grabbing. Optional introFling prop plays a one-shot scripted self-demo ~2.5 s after mount — the pill is flung to the far segment, coasts, nudges the end rubber band, and captures into the detent; it never loops, is skipped under reduced motion, and is permanently cancelled by any pointer or keyboard interaction. All ink is token-relative CSS — no canvas.

Tags
controlformsegmentedphysicsdrag