Skip to main content

ns-ui

Segmented Control Fling

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.

Use when a small horizontal set of exclusive views where the pill itself should be grabbable and coast to the nearest detent on release, while click and keyboard stay instant no-drag paths; pick radio-group-pin instead when the options stack vertically and their order carries meaning, or view-toggle-rails when switching also has to rearrange the content behind it.

Install

npx shadcn add https://design.helpmarq.com/r/segmented-control-fling.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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",
}: {
  /** the segments */
  options?: FlingSegmentOption[];
  /** controlled value; omit for uncontrolled */
  value?: string;
  /** uncontrolled initial value */
  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;
  /** extra classes merged onto the rendered root element */
  className?: string;
  /** accessible name for the control. Default "View". */
  "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-ns-accent ${
              selected
                ? "cursor-grab text-foreground active:cursor-grabbing"
                : "cursor-pointer text-ns-muted hover:text-foreground"
            }`}
          >
            {opt.label}
          </button>
        );
      })}
    </div>
  );
}
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.

Props

PropTypeDefaultDescription
options?FlingSegmentOption[]DEFAULT_OPTIONSthe segments
value?stringcontrolled value; omit for uncontrolled
defaultValue?stringuncontrolled initial value
onValueChange?(value: string) => voidfires once per selection change — on detent commit, never per-frame
introFling?booleanfalseopt-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.
className?stringextra classes merged onto the rendered root element
aria-label?string"View"accessible name for the control. Default "View".