{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "starch-shear",
  "title": "Starch Shear",
  "description": "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.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/starch-shear/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// StarchShear — a film-strip thumbnail scrubber made of a shear-thickening\n// material. Thumbnails are laid out in a fixed track; a single \"drive\" value\n// (the raw, always-instantaneous scroll target driven by the pointer or a\n// keyboard jump) is chased down the strip through a chain of overdamped\n// couplings, one per neighbor, node[0] -> drive, node[i] -> node[i-1].\n//\n// The coupling rate itself is not constant: a fast-attack / slow-release\n// envelope tracks |d(drive)/dt| (rendered scroll speed) and maps it to the\n// chain's relaxation rate. Low envelope -> low rate -> each node visibly\n// trails the one before it, a cascading lag down the strip (the liquid\n// regime). High envelope -> high rate -> every node closes its gap inside\n// a frame or two, so the whole strip reads as one rigid slab (the locked\n// regime). There is no spring/overshoot — an overdamped, monotonic settle\n// reads as fluid, not bouncy, which is the point of a \"liquid\" material.\n//\n// The chain is pure visual settle. Selection (aria-selected / the accent\n// ring) is derived straight from `drive` every frame (nearest-to-viewport-\n// center), independent of how far the visual chain has caught up — dragging\n// commits ahead of the strip visually finishing its flow, exactly like a\n// viscous material catching up to where it was already struck.\n//\n// Keyboard is the deliberate example of both regimes with no pointer at\n// all: ArrowLeft/ArrowRight step one item and force the chain into the\n// soft/fluid rate for that settle (a visible one-item cascade); Home/End\n// jump to either end and force the chain into the stiff/locked rate (the\n// whole strip leaps as one piece). A short click (no meaningful drag\n// distance) on a thumbnail selects it directly through the same soft path.\n//\n// At rest every node also carries a *static* ±SAG_PX translateY, peaking at\n// the strip's center and easing to zero at both ends — a fixed sine sag,\n// never animated — which is the tell that the material is fluid even when\n// nothing is being dragged. prefers-reduced-motion removes the chain, the\n// envelope, and the sag entirely: drive is applied to every node directly,\n// every frame, i.e. plain instant scrolling.\n// ---------------------------------------------------------------------------\n\nexport interface StarchShearItem {\n  id: string;\n  label: string;\n  caption?: string;\n}\n\nexport interface StarchShearProps {\n  items: StarchShearItem[];\n  /** Controlled active index. Omit for uncontrolled (see defaultValue). */\n  value?: number;\n  /** Initial active index when uncontrolled. Default 0. */\n  defaultValue?: number;\n  onValueChange?: (index: number) => void;\n  /** Accessible name for the listbox. Default \"Film strip\". */\n  label?: string;\n  className?: string;\n}\n\nconst ITEM_W = 88;\nconst ITEM_H = 68;\nconst GAP = 8;\nconst STEP = ITEM_W + GAP;\nconst SAG_PX = 2;\n\nconst V_REF = 1.4; // px/ms envelope value that maps to full lock\nconst ATTACK_RATE = 0.09; // per-ms, fast: envelope closes to a rising speed almost immediately\nconst RELEASE_RATE = 0.0022; // per-ms, slow: envelope bleeds off a falling speed gradually\nconst LAMBDA_SOFT = 0.006; // per-ms chain relaxation rate at zero shear (visible cascade)\nconst LAMBDA_STIFF = 0.9; // per-ms chain relaxation rate at full shear (locked slab)\nconst EPS_PX = 0.05;\nconst EPS_V = 0.01;\nconst CLICK_SLOP = 6; // px of pointer travel below which a release counts as a select-click\nconst MAX_DT = 48; // ms, clamp to avoid a huge jump after a background tab\nconst IDLE_AMP_PX = 1.6; // idle wobble fed into node[0]'s chase target, not into `drive` itself\nconst IDLE_PERIOD_MS = 7000; // one full breathe cycle\n\nfunction clamp(v: number, min: number, max: number): number {\n  return Math.min(max, Math.max(min, v));\n}\n\nexport function StarchShear({\n  items,\n  value: controlledValue,\n  defaultValue = 0,\n  onValueChange,\n  label = \"Film strip\",\n  className = \"\",\n}: StarchShearProps) {\n  const uid = useId().replace(/:/g, \"\");\n  const n = items.length;\n  const clampIdx = useCallback((i: number) => clamp(i, 0, Math.max(0, n - 1)), [n]);\n\n  const [internalIndex, setInternalIndex] = useState(() => clamp(defaultValue, 0, Math.max(0, n - 1)));\n  const activeIndex = clampIdx(controlledValue ?? internalIndex);\n\n  const listboxRef = useRef<HTMLDivElement | null>(null);\n  const itemRefs = useRef<Array<HTMLDivElement | null>>([]);\n\n  const driveRef = useRef(0);\n  const lastDriveRef = useRef(0);\n  const renderedRef = useRef<number[]>([]);\n  const envelopeRef = useRef(0);\n  const lastTimeRef = useRef(0);\n  const runningRef = useRef(false);\n  const rafRef = useRef<number | null>(null);\n\n  const draggingRef = useRef(false);\n  const pointerIdRef = useRef<number | null>(null);\n  const pointerStartXRef = useRef(0);\n  const pointerStartDriveRef = useRef(0);\n  const pointerTravelRef = useRef(0);\n\n  const viewportWidthRef = useRef(0);\n  const reducedRef = useRef(false);\n  const lastCommittedRef = useRef(activeIndex);\n  const initializedRef = useRef(false);\n\n  const trackWidth = n > 0 ? n * STEP - GAP : 0;\n\n  // ±SAG_PX: -SAG_PX at both ends, +SAG_PX at the center — a literal 2px\n  // peak-to-peak sag toward the middle of the strip, not a 0..2px ramp.\n  const sagFor = useMemo(() => {\n    return (i: number) => (n > 1 ? SAG_PX * (2 * Math.sin((Math.PI * i) / (n - 1)) - 1) : 0);\n  }, [n]);\n\n  const commit = useCallback(\n    (idx: number) => {\n      lastCommittedRef.current = idx;\n      onValueChange?.(idx);\n      if (controlledValue === undefined) setInternalIndex(idx);\n    },\n    [controlledValue, onValueChange]\n  );\n\n  const maxDrive = useCallback(() => Math.max(0, trackWidth - viewportWidthRef.current), [trackWidth]);\n\n  const indexToDrive = useCallback(\n    (idx: number) => {\n      const center = idx * STEP + ITEM_W / 2;\n      const vw = viewportWidthRef.current;\n      return clamp(center - vw / 2, 0, maxDrive());\n    },\n    [maxDrive]\n  );\n\n  const driveToIndex = useCallback(\n    (drive: number) => {\n      const vw = viewportWidthRef.current;\n      const centerX = drive + vw / 2;\n      return clampIdx(Math.round((centerX - ITEM_W / 2) / STEP));\n    },\n    [clampIdx]\n  );\n\n  const writeItem = useCallback((i: number, x: number, y: number) => {\n    const el = itemRefs.current[i];\n    if (!el) return;\n    el.style.transform = `translateX(${(-x).toFixed(2)}px) translateY(${y.toFixed(2)}px)`;\n  }, []);\n\n  const wake = useCallback(() => {\n    if (runningRef.current) return;\n    runningRef.current = true;\n    lastTimeRef.current = performance.now();\n    rafRef.current = requestAnimationFrame(frameRef.current);\n  }, []);\n\n  // frameRef indirection lets `frame` close over the latest callbacks\n  // (indexToDrive/driveToIndex/writeItem/commit) without re-subscribing the\n  // rAF loop itself, and without listing a mutually-recursive function in\n  // its own dependency array.\n  const frameRef = useRef<(now: number) => void>(() => {});\n\n  useEffect(() => {\n    frameRef.current = (now: number) => {\n      const dt = Math.min(MAX_DT, Math.max(0, now - lastTimeRef.current));\n      lastTimeRef.current = now;\n      const drive = driveRef.current;\n      const arr = renderedRef.current;\n\n      let maxDelta = 0;\n\n      if (reducedRef.current) {\n        for (let i = 0; i < n; i++) {\n          arr[i] = drive;\n          writeItem(i, drive, 0);\n        }\n        envelopeRef.current = 0;\n      } else {\n        const instV = dt > 0 ? Math.abs(drive - lastDriveRef.current) / dt : 0;\n        const env = envelopeRef.current;\n        envelopeRef.current =\n          instV > env\n            ? env + (instV - env) * (1 - Math.exp(-ATTACK_RATE * dt))\n            : env + (instV - env) * (1 - Math.exp(-RELEASE_RATE * dt));\n\n        const t = clamp(envelopeRef.current / V_REF, 0, 1);\n        const lambda = LAMBDA_SOFT + (LAMBDA_STIFF - LAMBDA_SOFT) * t;\n        const alpha = 1 - Math.exp(-lambda * dt);\n\n        // At rest (no drag, envelope decayed to ~0) node[0] chases a slow\n        // sine offset instead of `drive` exactly — the same overdamped\n        // coupling then carries a gentle lag down the chain, unprompted.\n        // `drive`/`lastDriveRef` and the committed index below are never\n        // touched by this: the wobble is purely a visual settle target, so\n        // selection can't drift while idle.\n        const idleWobble = draggingRef.current\n          ? 0\n          : IDLE_AMP_PX * Math.sin((now * 2 * Math.PI) / IDLE_PERIOD_MS);\n        let prev = drive + idleWobble;\n        for (let i = 0; i < n; i++) {\n          const gap = prev - arr[i];\n          arr[i] += gap * alpha;\n          maxDelta = Math.max(maxDelta, Math.abs(prev - arr[i]));\n          prev = arr[i];\n          writeItem(i, arr[i], sagFor(i));\n        }\n      }\n\n      lastDriveRef.current = drive;\n\n      const idx = driveToIndex(drive);\n      if (idx !== lastCommittedRef.current) commit(idx);\n\n      // Reduced motion never has anything left to chase (arr already equals\n      // drive above), so it's the only case allowed to actually stop the\n      // loop. Otherwise the idle wobble means there's always something to\n      // settle toward next frame — ambient motion keeps the loop alive\n      // continuously, gated only by tab visibility for cost.\n      const settled = reducedRef.current && maxDelta < EPS_PX && envelopeRef.current < EPS_V;\n      if (!settled && !document.hidden) {\n        rafRef.current = requestAnimationFrame(frameRef.current);\n      } else {\n        runningRef.current = false;\n      }\n    };\n  }, [n, sagFor, writeItem, driveToIndex, commit]);\n\n  const jumpTo = useCallback(\n    (idx: number, regime: \"soft\" | \"stiff\") => {\n      const target = indexToDrive(clampIdx(idx));\n      driveRef.current = target;\n      lastDriveRef.current = target;\n      envelopeRef.current = regime === \"soft\" ? 0 : V_REF * 2;\n      commit(clampIdx(idx));\n      wake();\n    },\n    [indexToDrive, clampIdx, commit, wake]\n  );\n\n  // Mount: measure viewport, seed drive/rendered arrays at the initial\n  // index with no animation, and keep re-measuring on resize.\n  useEffect(() => {\n    const el = listboxRef.current;\n    if (!el) return;\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n    const onMotionChange = () => {\n      reducedRef.current = mq.matches;\n      if (!mq.matches) wake();\n    };\n    mq.addEventListener(\"change\", onMotionChange);\n\n    const seed = () => {\n      const w = el.getBoundingClientRect().width;\n      if (w <= 0) return;\n      viewportWidthRef.current = w;\n      const drive = indexToDrive(activeIndex);\n      driveRef.current = drive;\n      lastDriveRef.current = drive;\n      lastCommittedRef.current = activeIndex;\n      const arr = new Array(n).fill(drive);\n      renderedRef.current = arr;\n      for (let i = 0; i < n; i++) writeItem(i, drive, reducedRef.current ? 0 : sagFor(i));\n      initializedRef.current = true;\n      if (!reducedRef.current) wake(); // idle wobble starts immediately at rest\n    };\n    seed();\n\n    const ro = new ResizeObserver(() => {\n      const w = el.getBoundingClientRect().width;\n      if (w <= 0) return;\n      viewportWidthRef.current = w;\n      driveRef.current = clamp(driveRef.current, 0, maxDrive());\n      wake();\n    });\n    ro.observe(el);\n\n    // the frame loop stops scheduling itself while the tab is hidden (see\n    // frameRef above); this is what starts it back up.\n    const onVisibility = () => {\n      if (!document.hidden && !reducedRef.current) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      mq.removeEventListener(\"change\", onMotionChange);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      ro.disconnect();\n    };\n    // seeded once per item-count change; controlled `value` sync handled below\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [n]);\n\n  // Controlled `value` changes from outside a drag/keypress: locked regime,\n  // same as Home/End — a caller setting the index isn't a scrub gesture.\n  useEffect(() => {\n    if (controlledValue === undefined) return;\n    if (!initializedRef.current) return;\n    if (draggingRef.current) return;\n    const idx = clampIdx(controlledValue);\n    if (idx === lastCommittedRef.current) return;\n    jumpTo(idx, \"stiff\");\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [controlledValue]);\n\n  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const el = listboxRef.current;\n    if (!el || n === 0) return;\n    try {\n      el.setPointerCapture(e.pointerId);\n    } catch {\n      // synthetic pointerId (e.g. a demo-dispatched PointerEvent) matches no\n      // live pointer outside the autoplay driver's shim; nothing to do.\n    }\n    pointerIdRef.current = e.pointerId;\n    draggingRef.current = true;\n    pointerTravelRef.current = 0;\n    pointerStartXRef.current = e.clientX;\n    pointerStartDriveRef.current = driveRef.current;\n    wake();\n  };\n\n  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (!draggingRef.current || pointerIdRef.current !== e.pointerId) return;\n    const dx = e.clientX - pointerStartXRef.current;\n    pointerTravelRef.current = Math.max(pointerTravelRef.current, Math.abs(dx));\n    driveRef.current = clamp(pointerStartDriveRef.current - dx, 0, maxDrive());\n    wake();\n  };\n\n  const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (pointerIdRef.current !== e.pointerId) return;\n    draggingRef.current = false;\n    pointerIdRef.current = null;\n    try {\n      listboxRef.current?.releasePointerCapture(e.pointerId);\n    } catch {\n      // pointer already released/invalid; nothing to clean up\n    }\n    if (pointerTravelRef.current < CLICK_SLOP && listboxRef.current) {\n      const rect = listboxRef.current.getBoundingClientRect();\n      const x = clamp(e.clientX - rect.left, 0, rect.width);\n      const idx = clampIdx(Math.floor((x + driveRef.current) / STEP));\n      jumpTo(idx, \"soft\");\n    } else {\n      wake();\n    }\n  };\n\n  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (n === 0) return;\n    switch (e.key) {\n      case \"ArrowLeft\":\n      case \"ArrowUp\":\n        e.preventDefault();\n        jumpTo(activeIndex - 1, \"soft\");\n        break;\n      case \"ArrowRight\":\n      case \"ArrowDown\":\n        e.preventDefault();\n        jumpTo(activeIndex + 1, \"soft\");\n        break;\n      case \"Home\":\n        e.preventDefault();\n        jumpTo(0, \"stiff\");\n        break;\n      case \"End\":\n        e.preventDefault();\n        jumpTo(n - 1, \"stiff\");\n        break;\n      default:\n        break;\n    }\n  };\n\n  const optionId = (i: number) => `ns-shear-opt-${uid}-${i}`;\n\n  return (\n    <div className={`relative ${className}`}>\n      <style>{CSS}</style>\n      <div\n        ref={listboxRef}\n        role=\"listbox\"\n        tabIndex={0}\n        aria-label={label}\n        aria-activedescendant={n > 0 ? optionId(activeIndex) : undefined}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        onKeyDown={onKeyDown}\n        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\"\n        style={{ height: ITEM_H + 16 }}\n      >\n        <div role=\"presentation\" className=\"relative h-full\" style={{ width: trackWidth }}>\n          {items.map((item, i) => {\n            const selected = i === activeIndex;\n            return (\n              <div\n                key={item.id}\n                id={optionId(i)}\n                ref={(el) => {\n                  itemRefs.current[i] = el;\n                }}\n                role=\"option\"\n                aria-selected={selected}\n                aria-label={item.label}\n                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\"\n                style={{\n                  left: i * STEP,\n                  width: ITEM_W,\n                  height: ITEM_H,\n                  boxShadow: selected ? \"0 0 0 2px var(--ns-accent)\" : undefined,\n                }}\n              >\n                <svg\n                  aria-hidden=\"true\"\n                  viewBox=\"0 0 24 16\"\n                  className=\"h-6 w-9 opacity-40\"\n                  style={{ color: \"var(--ns-muted)\" }}\n                >\n                  {/* viewfinder corners, not a mountain silhouette: the\n                      earlier two-peak path filled solid between its ends\n                      (single closed polygon, valley short of the baseline)\n                      and read as a stray play-triangle rather than a photo\n                      placeholder. Four open corner brackets can't be\n                      mistaken for anything but \"this is a frame\". */}\n                  <path\n                    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\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth={1.5}\n                    strokeLinecap=\"round\"\n                  />\n                </svg>\n                {item.caption ? (\n                  <span className=\"font-mono text-[9px] tabular-nums text-ns-muted\">{item.caption}</span>\n                ) : null}\n              </div>\n            );\n          })}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-shear-item{will-change:transform;}\n@media (prefers-reduced-motion: reduce){\n  .ns-shear-item{transition:none !important;}\n}\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/starch-shear.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "listbox",
      "media",
      "carousel",
      "drag",
      "keyboard",
      "accessibility",
      "physics"
    ],
    "instruction": "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."
  },
  "type": "registry:ui"
}