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.
npx shadcn add https://design.helpmarq.com /r/segmented-control-fling.jsonregistry/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("