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.
Usage
registry/core/segmented-control-fling/demo.tsx"use client";
import { useState } from "react";
import { FlingSegment } from "./component";
type Status = "Todo" | "In progress" | "Done";
interface Task {
id: string;
title: string;
status: Status;
owner: string;
due: string;
start: number; // day offset within the sprint window (0-9)
span: number; // days
}
const TASKS: Task[] = [
{
id: "NS-224",
title: "Reduced-motion audit for registry demos",
status: "Done",
owner: "NS",
due: "Jul 17",
start: 0,
span: 3,
},
{
id: "NS-231",
title: "Detent capture tuning for trackpad flings",
status: "In progress",
owner: "KM",
due: "Jul 21",
start: 1,
span: 5,
},
{
id: "NS-219",
title: "Pointer-capture edge cases on iPad",
status: "In progress",
owner: "JL",
due: "Jul 23",
start: 3,
span: 5,
},
{
id: "NS-228",
title: "Radiogroup keyboard pass on all controls",
status: "Todo",
owner: "AR",
due: "Jul 24",
start: 5,
span: 4,
},
{
id: "NS-215",
title: "Publish segmented-control docs page",
status: "Todo",
owner: "NS",
due: "Jul 28",
start: 7,
span: 3,
},
];
const COLUMNS: Status[] = ["Todo", "In progress", "Done"];
function StatusDot({ status }: { status: Status }) {
return (
<span
aria-hidden
className={`inline-block h-2 w-2 shrink-0 rounded-full ${
status === "Done"
? "bg-foreground"
: status === "In progress"
? "bg-foreground/50"
: "border border-foreground/40 bg-transparent"
}`}
/>
);
}
function Avatar({ initials }: { initials: string }) {
return (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border bg-background font-mono text-[10px] text-ns-muted">
{initials}
</span>
);
}
function ListView() {
return (
<ul className="divide-y divide-border">
{TASKS.map((t) => (
<li
key={t.id}
className="flex items-center gap-4 px-5 py-3 transition-colors hover:bg-foreground/[0.03]"
>
<StatusDot status={t.status} />
<span className="w-16 shrink-0 font-mono text-xs text-ns-muted">
{t.id}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{t.title}
</span>
<span className="hidden shrink-0 font-mono text-xs text-ns-muted sm:block">
{t.due}
</span>
<Avatar initials={t.owner} />
</li>
))}
</ul>
);
}
function BoardView() {
return (
<div className="grid grid-cols-1 gap-4 p-5 sm:grid-cols-3">
{COLUMNS.map((col) => {
const cards = TASKS.filter((t) => t.status === col);
return (
<div key={col} className="min-w-0">
<div className="mb-3 flex items-center gap-2">
<StatusDot status={col} />
<span className="font-mono text-xs uppercase tracking-wider text-ns-muted">
{col}
</span>
<span className="ml-auto font-mono text-xs text-ns-muted">
{cards.length}
</span>
</div>
<div className="flex flex-col gap-2">
{cards.map((t) => (
<div
key={t.id}
className="rounded-sm border border-border bg-background p-3 transition-colors hover:border-foreground/25"
>
<p className="font-mono text-[11px] text-ns-muted">{t.id}</p>
<p className="mt-1 text-sm leading-snug text-foreground">
{t.title}
</p>
<div className="mt-3 flex items-center justify-between">
<span className="font-mono text-[11px] text-ns-muted">
{t.due}
</span>
<Avatar initials={t.owner} />
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
function TimelineView() {
return (
<div className="p-5">
<div className="mb-2 flex">
<span className="w-16 shrink-0" />
<div className="flex flex-1 justify-between font-mono text-[10px] text-ns-muted">
<span>Jul 14</span>
<span>Jul 16</span>
<span>Jul 18</span>
<span>Jul 20</span>
<span>Jul 23</span>
</div>
</div>
<div className="flex flex-col gap-2">
{TASKS.map((t) => (
<div key={t.id} className="flex items-center">
<span className="w-16 shrink-0 font-mono text-xs text-ns-muted">
{t.id}
</span>
<div className="relative h-7 flex-1 overflow-hidden rounded-sm [background-image:repeating-linear-gradient(to_right,var(--border)_0_1px,transparent_1px_10%)]">
<div
className={`absolute top-1 bottom-1 flex items-center gap-2 overflow-hidden rounded-sm border border-border px-2 ${
t.status === "Done" ? "bg-foreground/15" : "bg-background"
}`}
style={{
left: `${t.start * 10}%`,
width: `${t.span * 10}%`,
}}
>
<StatusDot status={t.status} />
<span className="truncate text-xs text-foreground">
{t.title}
</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}
export default function FlingSegmentDemo() {
const [view, setView] = useState("list");
const inProgress = TASKS.filter((t) => t.status === "In progress").length;
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
<p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
ns-ui / segmented-control-fling — grab the pill, fling it
</p>
<div className="w-full max-w-2xl rounded-md border border-border bg-surface">
<div className="flex flex-wrap items-center gap-4 border-b border-border px-5 py-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-foreground">
Sprint 24 — Interaction polish
</h2>
<p className="mt-0.5 font-mono text-xs text-ns-muted">
{TASKS.length} tasks · {inProgress} in progress
</p>
</div>
<button
type="button"
className="rounded-sm border border-border px-3 py-1.5 text-xs font-medium text-ns-muted transition-colors hover:border-foreground/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ns-accent"
>
Filter
</button>
<FlingSegment
aria-label="Task view"
introFling
options={[
{ value: "list", label: "List" },
{ value: "board", label: "Board" },
{ value: "timeline", label: "Timeline" },
]}
value={view}
onValueChange={setView}
/>
</div>
<div className="min-h-[300px]">
{view === "list" && <ListView />}
{view === "board" && <BoardView />}
{view === "timeline" && <TimelineView />}
</div>
<div className="flex items-center justify-between border-t border-border px-5 py-3">
<p className="font-mono text-xs text-ns-muted">
view / {view} · updated 2 min ago
</p>
<p className="font-mono text-xs text-ns-muted">sprint ends Jul 25</p>
</div>
</div>
<p className="max-w-md text-center text-xs text-ns-muted">
Click a segment and it switches instantly. Or grab the pill (the raised
tab with the grip dots), throw it, and it coasts, bounces off the ends,
and snaps into the nearest segment. Arrow keys work too.
</p>
</div>
);
}Component
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("