{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "playhead-reel",
  "type": "registry:ui",
  "title": "Playhead Reel",
  "description": "A whole archive in one thin strip, with a playhead that walks along it as you scroll. Whatever's under the playhead opens into a loose collage, and the ruler below is a scrubber you can drag.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/playhead-reel.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  cancelFrame,\n  frame,\n  useMotionValue,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * PLAYHEAD REEL STORYBOARD   (driven by scroll progress, not time)\n *\n *  rest    every item sits small in one strip across the middle,\n *          in groups with a gap between them. A ruler underneath\n *          has a tick per item and a label per group\n *    0%    the section pins; the playhead stands on the first item\n *  0→100%  the playhead walks left → right along the ruler while\n *          the strip slides the other way under it, so the\n *          playhead is also a progress bar for the whole reel\n *  bloom   items near the playhead open out of the strip: they\n *          grow to about half the lane, spread apart and drift\n *          above or below the line into a loose collage, then\n *          shrink back as they pass. Later items lie on top,\n *          like prints dealt left to right\n *  skim    scroll fast and the collage folds flat into the strip,\n *          so you can see where you're going; slow down and it\n *          opens again\n *  edges   near either end the collage leans inward, so the item\n *          under the playhead never hangs off the stage\n *  point   hover an open item to lift it off the pile; click any\n *          item to glide it under the playhead\n *  scrub   drag along the ruler, or focus the handle and use the\n *          arrow keys (Shift or Page Up/Down jump a group).\n *          Group labels jump to their group\n *  reduce  no smoothing and no folding; jumps are instant\n *\n *  Every item is one element moved by a transform written once a\n *  frame, so scrolling never renders React.\n * ───────────────────────────────────────────────────────── */\n\nconst REEL = {\n  thumbHeight: 40, // px, an item's height in the strip\n  gap: 6, // px between items in a group\n  groupGap: 40, // px between groups\n  bloom: 0.46, // height of an open item, as a fraction of the lane\n  maxWidth: 0.6, // widest an open item gets, as a fraction of the stage\n  sizes: [0.8, 1.08], // open items vary between these sizes\n  lifts: [0.3, 1], // and drift this much of their room off the line\n  room: 0.6, // most room an item gets on each side, as a fraction of its open height\n  reach: 2.5, // roughly how many items either side of the playhead open\n  overlap: 0.55, // open neighbors sit this fraction of an item's width apart\n  maxSpread: 0.25, // cap on how far the collage pushes the strip aside, as a fraction of the stage\n  dim: 0.5, // opacity of strip items away from the playhead\n  skim: [10, 36], // items per second where the collage starts to fold, and is flat\n  settle: 140, // ms without scrolling before a folded collage opens again\n  pad: [16, 32], // px between the ruler and the stage edge, narrow and wide\n  edge: 12, // px kept between an open item and the lane's edges\n  ruler: 48, // px, height of the band under the lane\n  track: 14, // px from the top of the ruler to its line\n  glide: 1200, // ms a keyboard step remembers its target while the scroll catches up\n};\n\nconst SCROLL_SPRING = { stiffness: 170, damping: 32, mass: 0.4 };\nconst BLOOM_SPRING = { stiffness: 180, damping: 26 };\n\nexport interface PlayheadReelItem {\n  /** Image URL. Leave it out and pass `content` to show your own markup instead. */\n  src?: string;\n  alt?: string;\n  /** Width ÷ height. Sets the item's width in the strip and the shape it opens to. */\n  aspect?: number;\n  /** Rendered in place of an image, at the item's open size, then scaled with it. */\n  content?: React.ReactNode;\n}\n\nexport interface PlayheadReelGroup {\n  /** Short name on the ruler, like \"v2.4\", \"2019\" or \"Lisbon\". */\n  label: string;\n  items: readonly PlayheadReelItem[];\n}\n\nexport interface PlayheadReelActive {\n  /** Index of the group under the playhead. */\n  group: number;\n  /** Index of the item under the playhead, within its group. */\n  item: number;\n}\n\nexport interface PlayheadReelProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  groups: readonly PlayheadReelGroup[];\n  /** Height of an item in the strip, in px. */\n  thumbHeight?: number;\n  /** Pixels of scroll it takes to move the playhead one item along. */\n  scrollPerItem?: number;\n  /** Roughly how many items on each side of the playhead open up. */\n  reach?: number;\n  /** Fold the collage flat while scrolling fast, and open it again on slowing down. */\n  skim?: boolean;\n  /**\n   * Scroll container to track. Defaults to the window. The container must be\n   * positioned (e.g. `relative`), or scroll progress is measured from the page.\n   */\n  containerRef?: React.RefObject<HTMLElement | null>;\n  /** Classes for the pinned stage. Set its height and text color here. */\n  stageClassName?: string;\n  /** Classes for every item's frame, which defaults to rounded corners and a soft shadow. */\n  itemClassName?: string;\n  /** Pinned above the reel, in the flow: titles, counters, links. */\n  header?: React.ReactNode;\n  /** Pinned below the ruler, in the flow. */\n  footer?: React.ReactNode;\n  /** Called with the group and item under the playhead whenever they change. */\n  onActiveChange?: (active: PlayheadReelActive) => void;\n}\n\ntype Slot = {\n  group: number;\n  index: number;\n  aspect: number;\n  /** Center along the strip at rest, in px. */\n  center: number;\n  /** Open-size factor. */\n  size: number;\n  /** Where it drifts when open: −1 is the top of its room, 1 the bottom. */\n  lift: number;\n};\n\ntype Strip = {\n  slots: Slot[];\n  /** First item of each group that has any, in order. */\n  starts: { group: number; slot: number }[];\n  first: number;\n  length: number;\n  /** Average center-to-center distance, gaps included. */\n  pitch: number;\n};\n\ntype Geometry = {\n  width: number;\n  lane: number;\n  /** Where the playhead starts, and how far it walks. */\n  x0: number;\n  travel: number;\n  /** Open height and vertical room of each item. */\n  open: number[];\n  room: number[];\n  radius: number;\n  spread: number;\n  /** Half the width of each item when open. */\n  half: number[];\n};\n\nexport function PlayheadReel({\n  groups,\n  thumbHeight = REEL.thumbHeight,\n  scrollPerItem = 80,\n  reach = REEL.reach,\n  skim = true,\n  containerRef,\n  stageClassName,\n  itemClassName,\n  header,\n  footer,\n  onActiveChange,\n  className,\n  ...props\n}: PlayheadReelProps) {\n  const sectionRef = React.useRef<HTMLElement>(null);\n  const deckRef = React.useRef<HTMLDivElement>(null);\n  const playheadRef = React.useRef<HTMLDivElement>(null);\n  const handleRef = React.useRef<HTMLDivElement>(null);\n  const fillRef = React.useRef<HTMLDivElement>(null);\n  const itemEls = React.useRef<(HTMLDivElement | null)[]>([]);\n  const activeRef = React.useRef(0);\n  const pendingRef = React.useRef<{ slot: number; until: number } | null>(null);\n  const reduceMotion = useReducedMotion() ?? false;\n\n  const strip = React.useMemo(() => layoutStrip(groups, thumbHeight), [groups, thumbHeight]);\n  const deck = useElementSize(deckRef);\n  const geometry = React.useMemo(\n    () => measureReel(strip, deck.width, deck.height - REEL.ruler, thumbHeight, reach),\n    [strip, deck.width, deck.height, thumbHeight, reach],\n  );\n  const near = useNearViewport(sectionRef);\n  const [active, setActive] = React.useState(0);\n\n  const { scrollYProgress } = useScroll({\n    target: sectionRef,\n    container: containerRef,\n    offset: [\"start start\", \"end end\"],\n  });\n  const smoothProgress = useSpring(scrollYProgress, SCROLL_SPRING);\n  const progress = reduceMotion ? scrollYProgress : smoothProgress;\n  const bloomTarget = useMotionValue(1);\n  const bloom = useSpring(bloomTarget, BLOOM_SPRING);\n\n  const report = React.useEffectEvent((slot: number) => {\n    setActive(slot);\n    const { group, index } = strip.slots[slot];\n    onActiveChange?.({ group, item: index });\n  });\n\n  const setItemEl = React.useCallback((index: number, el: HTMLDivElement | null) => {\n    itemEls.current[index] = el;\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const g = geometry;\n    if (!g) return;\n    const { slots, first, length } = strip;\n    const skimming = skim && !reduceMotion;\n    if (!skimming) bloomTarget.set(1);\n    let settle: ReturnType<typeof setTimeout> | undefined;\n\n    const paint = () => {\n      const p = clamp01(progress.get());\n      const b = bloom.get();\n      const focus = first + p * length;\n      const x = g.x0 + p * g.travel;\n      // Lean the collage inward near the ends, so the item under the playhead stays on stage.\n      const reachOut = halfAt(slots, g.half, focus) * b + REEL.edge;\n      const lean = Math.max(0, reachOut - x) - Math.max(0, x + reachOut - g.width);\n\n      for (let i = 0; i < slots.length; i++) {\n        const el = itemEls.current[i];\n        if (!el) continue;\n        const slot = slots[i];\n        const d = slot.center - focus;\n        const closeness = Math.exp(-(d * d) / (g.radius * g.radius));\n        const open = b * closeness;\n        const cx = x + lean + d + g.spread * b * Math.tanh(d / g.radius);\n        const height = thumbHeight + (g.open[i] - thumbHeight) * open;\n        const half = (height * slot.aspect) / 2;\n        if (cx + half < 0 || cx - half > g.width) {\n          el.style.visibility = \"hidden\";\n          continue;\n        }\n        const cy = slot.lift * g.room[i] * open;\n        const w = g.open[i] * slot.aspect;\n        el.style.visibility = \"visible\";\n        // Only the strip dims; anything opening up is fully opaque, so overlaps stay clean.\n        el.style.opacity = String(REEL.dim + (1 - REEL.dim) * Math.min(1, closeness * 3));\n        el.style.transform = `translate3d(${cx - w / 2}px, ${cy - g.open[i] / 2}px, 0) scale(${height / g.open[i]})`;\n      }\n\n      const playhead = `translate3d(${x}px, 0, 0)`;\n      if (playheadRef.current) playheadRef.current.style.transform = playhead;\n      if (handleRef.current) handleRef.current.style.transform = playhead;\n      if (fillRef.current) fillRef.current.style.transform = `scaleX(${g.travel ? (x - g.x0) / g.travel : 0})`;\n\n      const current = nearestSlot(slots, focus);\n      const pending = pendingRef.current;\n      if (pending && (pending.slot === current || performance.now() > pending.until)) {\n        pendingRef.current = null;\n      }\n      if (current !== activeRef.current) {\n        activeRef.current = current;\n        report(current);\n      }\n    };\n\n    const schedule = () => frame.render(paint);\n    const onProgress = () => {\n      if (skimming && strip.pitch > 0) {\n        const speed = (Math.abs(progress.getVelocity()) * length) / strip.pitch;\n        bloomTarget.set(1 - smoothstep(REEL.skim[0], REEL.skim[1], speed));\n        clearTimeout(settle);\n        settle = setTimeout(() => bloomTarget.set(1), REEL.settle);\n      }\n      schedule();\n    };\n\n    paint();\n    const stops = [progress.on(\"change\", onProgress), bloom.on(\"change\", schedule)];\n    return () => {\n      stops.forEach((stop) => stop());\n      cancelFrame(paint);\n      clearTimeout(settle);\n    };\n  }, [bloom, bloomTarget, geometry, progress, reduceMotion, skim, strip, thumbHeight]);\n\n  const scrollToProgress = (p: number, smooth: boolean) => {\n    const section = sectionRef.current;\n    if (!section) return;\n    const container = containerRef?.current ?? null;\n    const viewport = container ? container.clientHeight : window.innerHeight;\n    const range = Math.max(0, section.offsetHeight - viewport);\n    const origin = container ? container.getBoundingClientRect().top + container.clientTop : 0;\n    const scrolled = container ? container.scrollTop : window.scrollY;\n    const top = scrolled + section.getBoundingClientRect().top - origin + clamp01(p) * range;\n    (container ?? window).scrollTo({ top, behavior: smooth && !reduceMotion ? \"smooth\" : \"instant\" });\n  };\n\n  const glideTo = (slot: number) => {\n    const target = Math.min(Math.max(slot, 0), strip.slots.length - 1);\n    if (target < 0) return;\n    pendingRef.current = { slot: target, until: performance.now() + REEL.glide };\n    scrollToProgress(strip.length ? (strip.slots[target].center - strip.first) / strip.length : 0, true);\n  };\n\n  const scrubTo = (clientX: number) => {\n    const el = deckRef.current;\n    if (!el || !geometry?.travel) return;\n    const x = clientX - el.getBoundingClientRect().left;\n    pendingRef.current = null;\n    scrollToProgress((x - geometry.x0) / geometry.travel, false);\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent) => {\n    const { slots, starts } = strip;\n    if (!slots.length) return;\n    const from = pendingRef.current?.slot ?? activeRef.current;\n    const at = starts.findLastIndex((start) => start.slot <= from);\n    const nextGroup = () => starts[at + 1]?.slot ?? slots.length - 1;\n    const previousGroup = () =>\n      starts[at].slot < from ? starts[at].slot : (starts[at - 1]?.slot ?? 0);\n\n    let to: number | null = null;\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") {\n      to = event.shiftKey ? nextGroup() : from + 1;\n    } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") {\n      to = event.shiftKey ? previousGroup() : from - 1;\n    } else if (event.key === \"PageDown\") to = nextGroup();\n    else if (event.key === \"PageUp\") to = previousGroup();\n    else if (event.key === \"Home\") to = 0;\n    else if (event.key === \"End\") to = slots.length - 1;\n    if (to === null) return;\n    event.preventDefault();\n    glideTo(to);\n  };\n\n  const activeSlot = strip.slots[active];\n  const activeGroup = activeSlot ? groups[activeSlot.group] : undefined;\n  const lane = geometry?.lane ?? 0;\n  const tickX = (slot: number) =>\n    geometry\n      ? geometry.x0 +\n        (strip.length ? (strip.slots[slot].center - strip.first) / strip.length : 0) * geometry.travel\n      : 0;\n\n  return (\n    <section ref={sectionRef} className={cn(\"relative\", className)} {...props}>\n      <div className={cn(\"sticky top-0 flex h-dvh w-full flex-col overflow-hidden\", stageClassName)}>\n        {header}\n        <div\n          ref={deckRef}\n          className=\"relative min-h-0 flex-1\"\n          // Hidden until measured, so items don't flash in a pile at the corner.\n          style={{ visibility: geometry ? undefined : \"hidden\" }}\n        >\n          <div\n            className=\"absolute inset-x-0 top-0 isolate\"\n            style={{ height: lane }}\n            onClick={(event) => {\n              const item = (event.target as Element).closest<HTMLElement>(\"[data-reel-item]\");\n              if (item) glideTo(Number(item.dataset.reelItem));\n            }}\n          >\n            {geometry ? (\n              <ReelItems\n                groups={groups}\n                strip={strip}\n                open={geometry.open}\n                load={near}\n                className={itemClassName}\n                setItemEl={setItemEl}\n              />\n            ) : null}\n          </div>\n\n          <div\n            ref={playheadRef}\n            aria-hidden\n            className=\"pointer-events-none absolute top-0 left-0 z-10 w-px bg-current will-change-transform\"\n            style={{ height: lane + REEL.track }}\n          >\n            <span className=\"absolute top-0 left-1/2 size-[5px] -translate-x-1/2 rounded-full bg-current\" />\n          </div>\n\n          {geometry ? (\n            <div className=\"absolute inset-x-0 bottom-0\" style={{ height: REEL.ruler }}>\n              <div\n                aria-hidden\n                className=\"absolute h-px bg-current opacity-15\"\n                style={{ left: geometry.x0, width: geometry.travel, top: REEL.track }}\n              />\n              <div\n                ref={fillRef}\n                aria-hidden\n                className=\"absolute h-px origin-left bg-current opacity-70\"\n                style={{ left: geometry.x0, width: geometry.travel, top: REEL.track }}\n              />\n              <svg aria-hidden className=\"pointer-events-none absolute inset-0 size-full overflow-visible\">\n                <path\n                  d={strip.slots.map((_, i) => `M${tickX(i)} ${REEL.track - 5}v5`).join(\"\")}\n                  stroke=\"currentColor\"\n                  strokeOpacity={0.3}\n                />\n                <path\n                  d={strip.starts.map(({ slot }) => `M${tickX(slot)} ${REEL.track - 8}v16`).join(\"\")}\n                  stroke=\"currentColor\"\n                  strokeOpacity={0.6}\n                />\n              </svg>\n\n              {/* Drag anywhere along the line to scrub; the handle rides inside. */}\n              <div\n                className=\"absolute inset-x-0 top-0 h-7 cursor-ew-resize touch-none\"\n                onPointerDown={(event) => {\n                  if (event.button !== 0) return;\n                  event.currentTarget.setPointerCapture(event.pointerId);\n                  handleRef.current?.querySelector<HTMLElement>(\"[role=slider]\")?.focus({ preventScroll: true });\n                  scrubTo(event.clientX);\n                }}\n                onPointerMove={(event) => {\n                  if (event.currentTarget.hasPointerCapture(event.pointerId)) scrubTo(event.clientX);\n                }}\n              >\n                <div ref={handleRef} className=\"absolute top-0 left-0 size-0 will-change-transform\" style={{ top: REEL.track }}>\n                  <div\n                    role=\"slider\"\n                    tabIndex={0}\n                    aria-label=\"Playhead\"\n                    aria-valuemin={0}\n                    aria-valuemax={Math.max(strip.slots.length - 1, 0)}\n                    aria-valuenow={active}\n                    aria-valuetext={\n                      activeSlot && activeGroup\n                        ? `${activeGroup.label}, ${\n                            activeGroup.items[activeSlot.index]?.alt ||\n                            `${activeSlot.index + 1} of ${activeGroup.items.length}`\n                          }`\n                        : undefined\n                    }\n                    onKeyDown={onKeyDown}\n                    className=\"absolute h-4 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current outline-current outline-offset-2 focus-visible:outline-2 before:absolute before:-inset-x-3 before:-inset-y-2\"\n                  />\n                </div>\n              </div>\n\n              {strip.starts.map(({ group, slot }, k) => {\n                const left = tickX(slot);\n                const next = strip.starts[k + 1];\n                const right = next ? tickX(next.slot) - 8 : geometry.width - 8;\n                const room = right - left - 4;\n                if (room < 24) return null;\n                return (\n                  <button\n                    key={group}\n                    type=\"button\"\n                    data-active={activeSlot?.group === group ? \"\" : undefined}\n                    onClick={() => glideTo(slot)}\n                    className=\"absolute truncate rounded-sm px-0.5 text-left text-[11px] leading-4 font-medium opacity-45 transition-opacity duration-200 outline-current hover:opacity-100 focus-visible:outline-2 data-active:opacity-100\"\n                    style={{ left: left + 2, top: REEL.track + 10, maxWidth: room }}\n                  >\n                    {groups[group].label}\n                  </button>\n                );\n              })}\n            </div>\n          ) : null}\n        </div>\n        {footer}\n      </div>\n      {/* Scroll runway: the stage stays pinned while this scrolls past. */}\n      <div\n        aria-hidden\n        style={{ height: strip.pitch ? (strip.length / strip.pitch) * scrollPerItem : 0 }}\n      />\n    </section>\n  );\n}\n\n/**\n * Every item, at its open size. The paint loop scales each one down to the\n * strip and back, so this only renders again when the stage resizes.\n */\nconst ReelItems = React.memo(function ReelItems({\n  groups,\n  strip,\n  open,\n  load,\n  className,\n  setItemEl,\n}: {\n  groups: readonly PlayheadReelGroup[];\n  strip: Strip;\n  open: number[];\n  load: boolean;\n  className?: string;\n  setItemEl: (index: number, el: HTMLDivElement | null) => void;\n}) {\n  return strip.slots.map((slot, i) => {\n    const item = groups[slot.group].items[slot.index];\n    const height = open[i];\n    return (\n      <div\n        key={`${slot.group}-${slot.index}`}\n        ref={(el) => setItemEl(i, el)}\n        data-reel-item={i}\n        className=\"group/item absolute top-1/2 left-0 z-(--z) cursor-pointer will-change-transform hover:z-[10000]\"\n        style={{ width: height * slot.aspect, height, \"--z\": i } as React.CSSProperties}\n      >\n        <div\n          className={cn(\n            \"size-full overflow-hidden rounded-lg bg-current/8 shadow-[0_12px_32px_-14px_rgb(0_0_0/0.4)] transition-[scale,box-shadow] duration-300 ease-out group-hover/item:shadow-[0_28px_56px_-18px_rgb(0_0_0/0.5)] motion-safe:group-hover/item:scale-[1.035]\",\n            className,\n          )}\n        >\n          {item.content ??\n            (item.src && load ? (\n              <img\n                src={item.src}\n                alt={item.alt ?? \"\"}\n                draggable={false}\n                decoding=\"async\"\n                className=\"size-full object-cover\"\n              />\n            ) : null)}\n        </div>\n      </div>\n    );\n  });\n});\n\nfunction layoutStrip(groups: readonly PlayheadReelGroup[], thumbHeight: number): Strip {\n  const slots: Slot[] = [];\n  const starts: Strip[\"starts\"] = [];\n  let x = 0;\n  groups.forEach((group, g) => {\n    if (!group.items.length) return;\n    if (slots.length) x += REEL.groupGap - REEL.gap;\n    starts.push({ group: g, slot: slots.length });\n    group.items.forEach((item, index) => {\n      const aspect = item.aspect ?? 4 / 3;\n      const width = thumbHeight * aspect;\n      const n = slots.length;\n      slots.push({\n        group: g,\n        index,\n        aspect,\n        center: x + width / 2,\n        size: lerp(REEL.sizes[0], REEL.sizes[1], hash(n + 0.5)),\n        // Alternate above and below the line, by a different amount each time.\n        lift: (n % 2 ? 1 : -1) * lerp(REEL.lifts[0], REEL.lifts[1], hash(n)),\n      });\n      x += width + REEL.gap;\n    });\n  });\n  const first = slots[0]?.center ?? 0;\n  const length = (slots.at(-1)?.center ?? 0) - first;\n  return { slots, starts, first, length, pitch: slots.length > 1 ? length / (slots.length - 1) : 0 };\n}\n\nfunction measureReel(\n  strip: Strip,\n  width: number,\n  lane: number,\n  thumbHeight: number,\n  reach: number,\n): Geometry | null {\n  if (!width || lane <= 0 || !strip.slots.length) return null;\n  const pad = width < 640 ? REEL.pad[0] : REEL.pad[1];\n  // A short reel doesn't stretch: the strip stays put and the playhead walks its length.\n  const travel = Math.min(Math.max(0, width - 2 * pad), strip.length);\n  const target = lane * REEL.bloom;\n  const open = strip.slots.map((slot) =>\n    Math.max(\n      thumbHeight,\n      Math.min(target * slot.size, lane - 2 * REEL.edge, (width * REEL.maxWidth) / slot.aspect),\n    ),\n  );\n  // On tall stages, stay near the line rather than scattering to the edges.\n  const room = open.map((height) =>\n    Math.max(0, Math.min((lane - height) / 2 - REEL.edge, height * REEL.room)),\n  );\n  const half = open.map((height, i) => (height * strip.slots[i].aspect) / 2);\n  const meanWidth = (2 * half.reduce((sum, h) => sum + h, 0)) / half.length;\n  const pitch = strip.pitch || thumbHeight;\n  const radius = Math.max(1, reach * pitch);\n  // Spread neighbors so they sit `overlap` of a width apart at the playhead:\n  // the strip's slope there is 1 + spread / radius.\n  const spread = Math.min(\n    radius * Math.max(0, (REEL.overlap * meanWidth) / pitch - 1),\n    width * REEL.maxSpread,\n  );\n  return {\n    width,\n    lane,\n    x0: (width - travel) / 2,\n    travel,\n    open,\n    room,\n    radius,\n    spread,\n    half,\n  };\n}\n\n/** Half-width of the open item at `x` along the strip, blended between neighbors. */\nfunction halfAt(slots: Slot[], half: number[], x: number) {\n  const next = slots.findIndex((slot) => slot.center >= x);\n  if (next <= 0) return half[next < 0 ? half.length - 1 : 0];\n  const t = (x - slots[next - 1].center) / (slots[next].center - slots[next - 1].center);\n  return lerp(half[next - 1], half[next], t);\n}\n\nfunction nearestSlot(slots: Slot[], x: number) {\n  let lo = 0;\n  let hi = slots.length - 1;\n  while (lo < hi) {\n    const mid = (lo + hi) >> 1;\n    if (slots[mid].center < x) lo = mid + 1;\n    else hi = mid;\n  }\n  return lo > 0 && x - slots[lo - 1].center < slots[lo].center - x ? lo - 1 : lo;\n}\n\nfunction useElementSize(ref: React.RefObject<HTMLElement | null>) {\n  const [size, setSize] = React.useState({ width: 0, height: 0 });\n\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n\n    const update = () => setSize({ width: el.clientWidth, height: el.clientHeight });\n    update();\n\n    const observer = new ResizeObserver(update);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [ref]);\n\n  return size;\n}\n\n/** True once the element comes within a screen of the viewport, and stays true. */\nfunction useNearViewport(ref: React.RefObject<HTMLElement | null>) {\n  const [near, setNear] = React.useState(false);\n\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el || near) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry?.isIntersecting) return;\n        setNear(true);\n        observer.disconnect();\n      },\n      { rootMargin: \"100% 0px\" },\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [ref, near]);\n\n  return near;\n}\n\nfunction hash(n: number) {\n  const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;\n  return s - Math.floor(s);\n}\n\nfunction lerp(a: number, b: number, t: number) {\n  return a + (b - a) * t;\n}\n\nfunction clamp01(v: number) {\n  return v < 0 ? 0 : v > 1 ? 1 : v;\n}\n\nfunction smoothstep(a: number, b: number, v: number) {\n  const x = clamp01((v - a) / (b - a));\n  return x * x * (3 - 2 * x);\n}\n"
    }
  ]
}