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.
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.jsonSource
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);
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) 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) {
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;
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 () => {
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;}
}
`;
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
| Prop | Type | Default | Description |
|---|---|---|---|
| items | StarchShearItem[] | — | — |
| value? | number | — | Controlled active index. Omit for uncontrolled (see defaultValue). |
| defaultValue? | number | 0 | Initial active index when uncontrolled. Default 0. |
| onValueChange? | (index: number) => void | — | — |
| label? | string | "Film strip" | Accessible name for the listbox. Default "Film strip". |
| className? | string | — | — |