Skip to main content

ns-ui

Starch Shear

Film-strip thumbnail scrubber made of a shear-thickening material: drag slowly and each thumbnail visibly lags the one before it down the chain like a liquid; flick fast and the strip instantly locks into one rigid slab that moves and stops as a single piece.

Usage

registry/core/starch-shear/demo.tsx
"use client";

import { useState } from "react";
import { StarchShear, type StarchShearItem } from "./component";

const ITEMS: StarchShearItem[] = Array.from({ length: 12 }, (_, i) => ({
  id: `frame-${i}`,
  label: `Frame ${i + 1}`,
  caption: `0:${String((i + 1) * 4).padStart(2, "0")}`,
}));

export default function StarchShearDemo() {
  const [index, setIndex] = useState(1);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">ns-ui / starch-shear</p>

      <div className="w-full max-w-lg rounded-[12px] border border-border bg-background p-5">
        <div className="mb-4 flex items-center justify-between gap-3">
          <span className="text-sm text-foreground">Reel_master.mov</span>
          <span className="font-mono text-xs tabular-nums text-ns-muted">
            Frame {index + 1} / {ITEMS.length}
          </span>
        </div>

        <StarchShear items={ITEMS} value={index} onValueChange={setIndex} label="Reel frames" />
      </div>

      <p className="max-w-md text-center text-xs text-ns-muted">
        Drag slowly and the strip flows, each frame lagging its neighbor. Flick
        it and the strip locks rigid, moving as one piece.
      </p>
    </div>
  );
}

Component

registry/core/starch-shear/component.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";

// ---------------------------------------------------------------------------
// StarchShear — a film-strip thumbnail scrubber made of a shear-thickening
// material. Thumbnails are laid out in a fixed track; a single "drive" value
// (the raw, always-instantaneous scroll target driven by the pointer or a
// keyboard jump) is chased down the strip through a chain of overdamped
// couplings, one per neighbor, node[0] -> drive, node[i] -> node[i-1].
//
// The coupling rate itself is not constant: a fast-attack / slow-release
// envelope tracks |d(drive)/dt| (rendered scroll speed) and maps it to the
// chain's relaxation rate. Low envelope -> low rate -> each node visibly
// trails the one before it, a cascading lag down the strip (the liquid
// regime). High envelope -> high rate -> every node closes its gap inside
// a frame or two, so the whole strip reads as one rigid slab (the locked
// regime). There is no spring/overshoot — an overdamped, monotonic settle
// reads as fluid, not bouncy, which is the point of a "liquid" material.
//
// The chain is pure visual settle. Selection (aria-selected / the accent
// ring) is derived straight from `drive` every frame (nearest-to-viewport-
// center), independent of how far the visual chain has caught up — dragging
// commits ahead of the strip visually finishing its flow, exactly like a
// viscous material catching up to where it was already struck.
//
// Keyboard is the deliberate example of both regimes with no pointer at
// all: ArrowLeft/ArrowRight step one item and force the chain into the
// soft/fluid rate for that settle (a visible one-item cascade); Home/End
// jump to either end and force the chain into the stiff/locked rate (the
// whole strip leaps as one piece). A short click (no meaningful drag
// distance) on a thumbnail selects it directly through the same soft path.
//
// At rest every node also carries a *static* ±SAG_PX translateY, peaking at
// the strip's center and easing to zero at both ends — a fixed sine sag,
// never animated — which is the tell that the material is fluid even when
// nothing is being dragged. prefers-reduced-motion removes the chain, the
// envelope, and the sag entirely: drive is applied to every node directly,
// every frame, i.e. plain instant scrolling.
// ---------------------------------------------------------------------------

export interface StarchShearItem {
  id: string;
  label: string;
  caption?: string;
}

export interface StarchShearProps {
  items: StarchShearItem[];
  /** Controlled active index. Omit for uncontrolled (see defaultValue). */
  value?: number;
  /** Initial active index when uncontrolled. Default 0. */
  defaultValue?: number;
  onValueChange?: (index: number) => void;
  /** Accessible name for the listbox. Default "Film strip". */
  label?: string;
  className?: string;
}

const ITEM_W = 88;
const ITEM_H = 68;
const GAP = 8;
const STEP = ITEM_W + GAP;
const SAG_PX = 2;

const V_REF = 1.4; // px/ms envelope value that maps to full lock
const ATTACK_RATE = 0.09; // per-ms, fast: envelope closes to a rising speed almost immediately
const RELEASE_RATE = 0.0022; // per-ms, slow: envelope bleeds off a falling speed gradually
const LAMBDA_SOFT = 0.006; // per-ms chain relaxation rate at zero shear (visible cascade)
const LAMBDA_STIFF = 0.9; // per-ms chain relaxation rate at full shear (locked slab)
const EPS_PX = 0.05;
const EPS_V = 0.01;
const CLICK_SLOP = 6; // px of pointer travel below which a release counts as a select-click
const MAX_DT = 48; // ms, clamp to avoid a huge jump after a background tab
const IDLE_AMP_PX = 1.6; // idle wobble fed into node[0]'s chase target, not into `drive` itself
const IDLE_PERIOD_MS = 7000; // one full breathe cycle

function clamp(v: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, v));
}

export function StarchShear({
  items,
  value: controlledValue,
  defaultValue = 0,
  onValueChange,
  label = "Film strip",
  className = "",
}: StarchShearProps) {
  const uid = useId().replace(/:/g, "");
  const n = items.length;
  const clampIdx = useCallback((i: number) => clamp(i, 0, Math.max(0, n - 1)), [n]);

  const [internalIndex, setInternalIndex] = useState(() => clamp(defaultValue, 0, Math.max(0, n - 1)));
  const activeIndex = clampIdx(controlledValue ?? internalIndex);

  const listboxRef = useRef<HTMLDivElement | null>(null);
  const itemRefs = useRef<Array<HTMLDivElement | null>>([]);

  const driveRef = useRef(0);
  const lastDriveRef = useRef(0);
  const renderedRef = useRef<number[]>([]);
  const envelopeRef = useRef(0);
  const lastTimeRef = useRef(0);
  const runningRef = useRef(false);
  const rafRef = useRef<number | null>(null);
  // The frame loop re-arms itself for as long as there is motion to settle, so
  // without this it outlives the component: `writeItem` would keep writing
  // transforms to detached nodes (pinning the subtree alive through
  // `itemRefs`) and `commit` would keep calling `onValueChange` on a dead
  // component, 60 times a second, for the life of the page. Checked before
  // every re-arm and set by the mount effect's cleanup.
  const disposedRef = useRef(false);

  const draggingRef = useRef(false);
  const pointerIdRef = useRef<number | null>(null);
  const pointerStartXRef = useRef(0);
  const pointerStartDriveRef = useRef(0);
  const pointerTravelRef = useRef(0);

  const viewportWidthRef = useRef(0);
  const reducedRef = useRef(false);
  const lastCommittedRef = useRef(activeIndex);
  const initializedRef = useRef(false);

  const trackWidth = n > 0 ? n * STEP - GAP : 0;

  // ±SAG_PX: -SAG_PX at both ends, +SAG_PX at the center — a literal 2px
  // peak-to-peak sag toward the middle of the strip, not a 0..2px ramp.
  const sagFor = useMemo(() => {
    return (i: number) => (n > 1 ? SAG_PX * (2 * Math.sin((Math.PI * i) / (n - 1)) - 1) : 0);
  }, [n]);

  const commit = useCallback(
    (idx: number) => {
      lastCommittedRef.current = idx;
      onValueChange?.(idx);
      if (controlledValue === undefined) setInternalIndex(idx);
    },
    [controlledValue, onValueChange]
  );

  const maxDrive = useCallback(() => Math.max(0, trackWidth - viewportWidthRef.current), [trackWidth]);

  const indexToDrive = useCallback(
    (idx: number) => {
      const center = idx * STEP + ITEM_W / 2;
      const vw = viewportWidthRef.current;
      return clamp(center - vw / 2, 0, maxDrive());
    },
    [maxDrive]
  );

  const driveToIndex = useCallback(
    (drive: number) => {
      const vw = viewportWidthRef.current;
      const centerX = drive + vw / 2;
      return clampIdx(Math.round((centerX - ITEM_W / 2) / STEP));
    },
    [clampIdx]
  );

  const writeItem = useCallback((i: number, x: number, y: number) => {
    const el = itemRefs.current[i];
    if (!el) return;
    el.style.transform = `translateX(${(-x).toFixed(2)}px) translateY(${y.toFixed(2)}px)`;
  }, []);

  const wake = useCallback(() => {
    if (runningRef.current || disposedRef.current) return;
    runningRef.current = true;
    lastTimeRef.current = performance.now();
    rafRef.current = requestAnimationFrame(frameRef.current);
  }, []);

  // frameRef indirection lets `frame` close over the latest callbacks
  // (indexToDrive/driveToIndex/writeItem/commit) without re-subscribing the
  // rAF loop itself, and without listing a mutually-recursive function in
  // its own dependency array.
  const frameRef = useRef<(now: number) => void>(() => {});

  useEffect(() => {
    frameRef.current = (now: number) => {
      const dt = Math.min(MAX_DT, Math.max(0, now - lastTimeRef.current));
      lastTimeRef.current = now;
      const drive = driveRef.current;
      const arr = renderedRef.current;

      let maxDelta = 0;

      if (reducedRef.current) {
        for (let i = 0; i < n; i++) {
          arr[i] = drive;
          writeItem(i, drive, 0);
        }
        envelopeRef.current = 0;
      } else {
        const instV = dt > 0 ? Math.abs(drive - lastDriveRef.current) / dt : 0;
        const env = envelopeRef.current;
        envelopeRef.current =
          instV > env
            ? env + (instV - env) * (1 - Math.exp(-ATTACK_RATE * dt))
            : env + (instV - env) * (1 - Math.exp(-RELEASE_RATE * dt));

        const t = clamp(envelopeRef.current / V_REF, 0, 1);
        const lambda = LAMBDA_SOFT + (LAMBDA_STIFF - LAMBDA_SOFT) * t;
        const alpha = 1 - Math.exp(-lambda * dt);

        // At rest (no drag, envelope decayed to ~0) node[0] chases a slow
        // sine offset instead of `drive` exactly — the same overdamped
        // coupling then carries a gentle lag down the chain, unprompted.
        // `drive`/`lastDriveRef` and the committed index below are never
        // touched by this: the wobble is purely a visual settle target, so
        // selection can't drift while idle.
        const idleWobble = draggingRef.current
          ? 0
          : IDLE_AMP_PX * Math.sin((now * 2 * Math.PI) / IDLE_PERIOD_MS);
        let prev = drive + idleWobble;
        for (let i = 0; i < n; i++) {
          const gap = prev - arr[i];
          arr[i] += gap * alpha;
          maxDelta = Math.max(maxDelta, Math.abs(prev - arr[i]));
          prev = arr[i];
          writeItem(i, arr[i], sagFor(i));
        }
      }

      lastDriveRef.current = drive;

      const idx = driveToIndex(drive);
      if (idx !== lastCommittedRef.current) commit(idx);

      // Reduced motion never has anything left to chase (arr already equals
      // drive above), so it's the only case allowed to actually stop the
      // loop. Otherwise the idle wobble means there's always something to
      // settle toward next frame — ambient motion keeps the loop alive
      // continuously, gated only by tab visibility for cost.
      const settled = reducedRef.current && maxDelta < EPS_PX && envelopeRef.current < EPS_V;
      if (!settled && !document.hidden && !disposedRef.current) {
        rafRef.current = requestAnimationFrame(frameRef.current);
      } else {
        runningRef.current = false;
      }
    };
  }, [n, sagFor, writeItem, driveToIndex, commit]);

  const jumpTo = useCallback(
    (idx: number, regime: "soft" | "stiff") => {
      const target = indexToDrive(clampIdx(idx));
      driveRef.current = target;
      lastDriveRef.current = target;
      envelopeRef.current = regime === "soft" ? 0 : V_REF * 2;
      commit(clampIdx(idx));
      wake();
    },
    [indexToDrive, clampIdx, commit, wake]
  );

  // Mount: measure viewport, seed drive/rendered arrays at the initial
  // index with no animation, and keep re-measuring on resize.
  useEffect(() => {
    const el = listboxRef.current;
    if (!el) return;

    disposedRef.current = false;
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    reducedRef.current = mq.matches;
    const onMotionChange = () => {
      reducedRef.current = mq.matches;
      if (!mq.matches) wake();
    };
    mq.addEventListener("change", onMotionChange);

    const seed = () => {
      const w = el.getBoundingClientRect().width;
      if (w <= 0) return;
      viewportWidthRef.current = w;
      const drive = indexToDrive(activeIndex);
      driveRef.current = drive;
      lastDriveRef.current = drive;
      lastCommittedRef.current = activeIndex;
      const arr = new Array(n).fill(drive);
      renderedRef.current = arr;
      for (let i = 0; i < n; i++) writeItem(i, drive, reducedRef.current ? 0 : sagFor(i));
      initializedRef.current = true;
      if (!reducedRef.current) wake(); // idle wobble starts immediately at rest
    };
    seed();

    const ro = new ResizeObserver(() => {
      const w = el.getBoundingClientRect().width;
      if (w <= 0) return;
      viewportWidthRef.current = w;
      driveRef.current = clamp(driveRef.current, 0, maxDrive());
      wake();
    });
    ro.observe(el);

    // the frame loop stops scheduling itself while the tab is hidden (see
    // frameRef above); this is what starts it back up.
    const onVisibility = () => {
      if (!document.hidden && !reducedRef.current) wake();
    };
    document.addEventListener("visibilitychange", onVisibility);

    return () => {
      disposedRef.current = true;
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
      rafRef.current = null;
      runningRef.current = false;
      mq.removeEventListener("change", onMotionChange);
      document.removeEventListener("visibilitychange", onVisibility);
      ro.disconnect();
    };
    // seeded once per item-count change; controlled `value` sync handled below
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [n]);

  // Controlled `value` changes from outside a drag/keypress: locked regime,
  // same as Home/End — a caller setting the index isn't a scrub gesture.
  useEffect(() => {
    if (controlledValue === undefined) return;
    if (!initializedRef.current) return;
    if (draggingRef.current) return;
    const idx = clampIdx(controlledValue);
    if (idx === lastCommittedRef.current) return;
    jumpTo(idx, "stiff");
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [controlledValue]);

  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    const el = listboxRef.current;
    if (!el || n === 0) return;
    try {
      el.setPointerCapture(e.pointerId);
    } catch {
      // synthetic pointerId (e.g. a demo-dispatched PointerEvent) matches no
      // live pointer outside the autoplay driver's shim; nothing to do.
    }
    pointerIdRef.current = e.pointerId;
    draggingRef.current = true;
    pointerTravelRef.current = 0;
    pointerStartXRef.current = e.clientX;
    pointerStartDriveRef.current = driveRef.current;
    wake();
  };

  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!draggingRef.current || pointerIdRef.current !== e.pointerId) return;
    const dx = e.clientX - pointerStartXRef.current;
    pointerTravelRef.current = Math.max(pointerTravelRef.current, Math.abs(dx));
    driveRef.current = clamp(pointerStartDriveRef.current - dx, 0, maxDrive());
    wake();
  };

  const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (pointerIdRef.current !== e.pointerId) return;
    draggingRef.current = false;
    pointerIdRef.current = null;
    try {
      listboxRef.current?.releasePointerCapture(e.pointerId);
    } catch {
      // pointer already released/invalid; nothing to clean up
    }
    if (pointerTravelRef.current < CLICK_SLOP && listboxRef.current) {
      const rect = listboxRef.current.getBoundingClientRect();
      const x = clamp(e.clientX - rect.left, 0, rect.width);
      const idx = clampIdx(Math.floor((x + driveRef.current) / STEP));
      jumpTo(idx, "soft");
    } else {
      wake();
    }
  };

  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (n === 0) return;
    switch (e.key) {
      case "ArrowLeft":
      case "ArrowUp":
        e.preventDefault();
        jumpTo(activeIndex - 1, "soft");
        break;
      case "ArrowRight":
      case "ArrowDown":
        e.preventDefault();
        jumpTo(activeIndex + 1, "soft");
        break;
      case "Home":
        e.preventDefault();
        jumpTo(0, "stiff");
        break;
      case "End":
        e.preventDefault();
        jumpTo(n - 1, "stiff");
        break;
      default:
        break;
    }
  };

  const optionId = (i: number) => `ns-shear-opt-${uid}-${i}`;

  return (
    <div className={`relative ${className}`}>
      <style>{CSS}</style>
      <div
        ref={listboxRef}
        role="listbox"
        tabIndex={0}
        aria-label={label}
        aria-activedescendant={n > 0 ? optionId(activeIndex) : undefined}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onKeyDown={onKeyDown}
        className="ns-shear-viewport relative block w-full cursor-grab touch-pan-y select-none overflow-hidden rounded-[12px] border border-border bg-background focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground/60 active:cursor-grabbing"
        style={{ height: ITEM_H + 16 }}
      >
        <div role="presentation" className="relative h-full" style={{ width: trackWidth }}>
          {items.map((item, i) => {
            const selected = i === activeIndex;
            return (
              <div
                key={item.id}
                id={optionId(i)}
                ref={(el) => {
                  itemRefs.current[i] = el;
                }}
                role="option"
                aria-selected={selected}
                aria-label={item.label}
                className="ns-shear-item absolute top-2 flex flex-col items-center justify-center gap-1 overflow-hidden rounded-[6px] border border-border bg-background transition-colors duration-150 hover:border-foreground/30 motion-reduce:transition-none"
                style={{
                  left: i * STEP,
                  width: ITEM_W,
                  height: ITEM_H,
                  boxShadow: selected ? "0 0 0 2px var(--ns-accent)" : undefined,
                }}
              >
                <svg
                  aria-hidden="true"
                  viewBox="0 0 24 16"
                  className="h-6 w-9 opacity-40"
                  style={{ color: "var(--ns-muted)" }}
                >
                  {/* viewfinder corners, not a mountain silhouette: the
                      earlier two-peak path filled solid between its ends
                      (single closed polygon, valley short of the baseline)
                      and read as a stray play-triangle rather than a photo
                      placeholder. Four open corner brackets can't be
                      mistaken for anything but "this is a frame". */}
                  <path
                    d="M2 2 L2 6 M2 2 L7 2 M22 2 L22 6 M22 2 L17 2 M2 14 L2 10 M2 14 L7 14 M22 14 L22 10 M22 14 L17 14"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={1.5}
                    strokeLinecap="round"
                  />
                </svg>
                {item.caption ? (
                  <span className="font-mono text-[9px] tabular-nums text-ns-muted">{item.caption}</span>
                ) : null}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

const CSS = `
.ns-shear-item{will-change:transform;}
@media (prefers-reduced-motion: reduce){
  .ns-shear-item{transition:none !important;}
}
`;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/starch-shear.tsx, so point the import there.

Use when a thumbnail/frame strip where the coupling between neighboring thumbnails should itself read intent from input rate — slow scrubbing visibly cascades a lag down the chain, a fast flick locks every thumbnail into one rigid transform, and there is no snap-to-detent at any speed (position is continuous, only the visual settle differs). Reach for scrubber-film-strip instead for a playback-position slider with fixed claw physics (a discrete perforation-snap regime below a speed threshold, a continuous glide above it) and buffered-range semantics; reach for segmented-control-fling for a small fixed set of exclusive options with fling-and-detent selection. starch-shear's regime split is driven by a live fast-attack/slow-release envelope over drag velocity, not a fixed threshold with two hard-coded behaviors, and its unit of physics is the coupling between adjacent thumbnails, not one playhead or one pill.

Install

npx shadcn add https://design.helpmarq.com/r/starch-shear.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.

Build spec

Build <StarchShear items value? defaultValue? onValueChange? label? className?> where `items: { id, label, caption? }[]` is the ordered set of thumbnails, `value`/`defaultValue` is the active index (controlled/uncontrolled, defaulting to 0), `onValueChange(index)` fires whenever the active index commits, and `label` names the listbox (default "Film strip"). STRUCTURE: an outer relative wrapper contains one role="listbox" div (tabIndex 0, aria-label from `label`, aria-activedescendant pointing at the active option's id) that is a fixed-height, overflow-hidden viewport; inside it a track div sized to items.length * (88+8) - 8 px holds each thumbnail as an absolutely-positioned role="option" div (aria-selected, aria-label from item.label, left: index * 96, width 88, height 68, rounded-[6px] border border-border bg-background) at its rest slot — the option never moves through layout, only through an imperative transform written on its own ref. MECHANISM — the drive value: a single ref (`driveRef`), the raw px scroll target, is always instantaneous — set directly and synchronously from the pointer position during a drag (1:1, no rounding, no easing), or jumped directly to a computed target on a keyboard step or a controlled `value` change. It is never itself animated; it is the thing every thumbnail's visual position is chasing. MECHANISM — the chain: one requestAnimationFrame loop, direct-DOM writes only (no per-frame React state), advances an array of per-thumbnail rendered positions. Each frame it first updates a velocity envelope from |drive_now - drive_prev| / dt with a fast attack (envelope jumps up almost immediately when the instantaneous speed exceeds it) and a slow release (envelope bleeds down gradually when speed drops below it) — a classic asymmetric envelope follower. The envelope, normalized against a reference speed and clamped to [0,1], sets a relaxation rate (lambda) that interpolates between a very low value (soft/fluid: each node closes only a small fraction of its gap per frame, so a lag is visibly still cascading down the row several frames after the pointer stops) and a very high value (stiff/locked: every node closes essentially its whole gap within a frame or two, so the whole strip reads as one rigid transform even though each node is still computed independently). The chain itself is a follow-the-leader relaxation, not a spring: thumbnail 0 relaxes toward `drive` at the current rate, thumbnail 1 relaxes toward thumbnail 0's just-updated position at the same rate, and so on down the row — each node chases the one immediately before it, so the lag compounds with distance from the point of contact. This is a deliberately overdamped, monotonic settle (no overshoot/bounce) because a liquid flows into place, it does not spring into place. SELECTION: aria-selected / the active option's persistent 2px var(--ns-accent) box-shadow ring (the component's only accent use) is derived straight from `drive` every frame via nearest-thumbnail-to-viewport-center, independent of how far the visual chain has caught up — the committed selection can lead the visible settle, exactly like a viscous material registering a hit before it finishes flowing into its new shape. POINTER: pointerdown on the listbox begins a drag (setPointerCapture, records start x and the current drive); pointermove writes drive = clamp(startDrive - dx, 0, maxDrive) every sample; pointerup/pointercancel ends the drag, and if total travel stayed under 6px (a tap, not a scrub) the thumbnail under that point is selected directly through the same jump path used by keyboard, forced into the soft/fluid regime for its settle. KEYBOARD: ArrowLeft/ArrowUp and ArrowRight/ArrowDown step the active index by one and force the chain's envelope to its minimum for that jump — the settle is always a visible one-item cascade, regardless of any residual velocity from a prior drag — while Home and End jump to the first/last thumbnail and force the envelope to its maximum, so the whole strip visibly leaps as one locked piece; a controlled `value` change from outside a drag is treated the same as Home/End (locked), since a caller setting the index directly is not a scrub gesture. REST STATE: every thumbnail also carries a static translateY sag — SAG_PX * (2*sin(pi * index / (n-1)) - 1), a literal -2px at both ends of the strip rising to +2px at the center — applied unconditionally alongside the chain's translateX, never animated on its own and never driven by velocity; it is the constant visual tell that the material is fluid even when nothing is being dragged. IDLE MOTION: with nothing being dragged, node[0]'s chase target is a slow ±1.6px sine offset (7s period) instead of the exact drive value — the real `drive` and the committed selection never see it, only the visual settle target does — so the same overdamped coupling that answers a real drag carries a gentle, continuously cascading lag down the strip at rest, unprompted, with no risk of the selection drifting. TOKENS: all ink is var(--border)/var(--foreground)/var(--ns-muted)/var(--background); var(--ns-accent) appears only on the active thumbnail's ring; no gradients. REDUCED MOTION: prefers-reduced-motion removes the chain, the envelope, the sag and the idle wobble entirely — every thumbnail's transform is written to the current drive value directly, every frame, with translateY always 0, which reads as plain instant scrolling with a still, non-cascading strip. Zero dependencies; DOM + CSS only, no canvas, no SVG beyond a small decorative corner-bracket glyph per thumbnail (aria-hidden), open strokes rather than a filled silhouette so it can't misread as a play icon. DEMO: a 12-item strip in a card frame with a caption line explaining the two regimes; fully interactive, no synthetic input of its own — the landing-page card's own self-demonstration is driven by the site's shared autoplay descriptor below, not by anything baked into the demo.

Props

PropTypeDefaultDescription
itemsStarchShearItem[]——
value?number—Controlled active index. Omit for uncontrolled (see defaultValue).
defaultValue?number0Initial active index when uncontrolled. Default 0.
onValueChange?(index: number) => void——
label?string"Film strip"Accessible name for the listbox. Default "Film strip".
className?string——