{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["data","minimal"],"dependencies":["gsap"],"description":"An endless wheel of labels glides past a fixed center line while a giant gradient-filled figure swaps in beside it; scroll to seize the reel and it resumes on its own after four seconds.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { StatReel } from \"./stat-reel\";\n\nconst Preview = () => <StatReel />;\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/stat-reel/preview.tsx","type":"registry:file"},{"content":"/**\n * Static dataset for the stat reel.\n *\n * The gradient and the stat are declared together so the 1:1 pairing between a\n * figure and its colour triad cannot drift apart: there is no second array to\n * keep in sync.\n */\n\nconst GRAD_SIZE = \"95% 95%\";\nconst GRAD_POS = { x: 50, y: 110 } as const;\nconst GRAD_BASE = \"#2a2e36\";\n\ntype Triad = readonly [string, string, string];\n\nconst gradientFrom = ([c0, c1, c2]: Triad) =>\n  `radial-gradient(${GRAD_SIZE} at ${GRAD_POS.x}% ${GRAD_POS.y}%, ${c0} 0%, rgba(${c1},0.669) 34.13%, rgba(${c2},0.5) 65.38%, rgba(158,144,96,0) 100%), linear-gradient(${GRAD_BASE}, ${GRAD_BASE})`;\n\ninterface RawStat {\n  readonly prefix?: string;\n  readonly value: string;\n  readonly label: string;\n  readonly triad: Triad;\n}\n\nexport interface Stat {\n  readonly prefix?: string;\n  readonly value: string;\n  readonly label: string;\n  /** Ready-to-paint `background-image` for the transparent-text figure. */\n  readonly gradient: string;\n}\n\nconst RAW = [\n  {\n    label: \"Depth of the Challenger Deep\",\n    prefix: \"Down to\",\n    triad: [\"#d7be68\", \"215,50,154\", \"84,109,208\"],\n    value: \"10,935m\",\n  },\n  {\n    label: \"Pressure at the trench floor\",\n    triad: [\"#78ec81\", \"50,215,193\", \"208,84,177\"],\n    value: \"1,100×\",\n  },\n  {\n    label: \"Water at two kilometres down\",\n    triad: [\"#ecbf78\", \"215,50,66\", \"84,88,208\"],\n    value: \"4°C\",\n  },\n  {\n    label: \"Light in the midnight zone\",\n    triad: [\"#78c8ec\", \"120,84,236\", \"236,84,140\"],\n    value: \"0%\",\n  },\n  {\n    label: \"Of seafloor mapped in detail\",\n    prefix: \"Under\",\n    triad: [\"#c8ec78\", \"50,200,120\", \"90,84,208\"],\n    value: \"1%\",\n  },\n  {\n    label: \"Top of the twilight zone\",\n    triad: [\"#ecae78\", \"236,130,50\", \"150,84,208\"],\n    value: \"200m\",\n  },\n  {\n    label: \"Of deep-sea life that glows\",\n    triad: [\"#78ece4\", \"84,140,236\", \"208,84,200\"],\n    value: \"76%\",\n  },\n  {\n    label: \"Round trip for a crewed dive\",\n    triad: [\"#ec78b4\", \"236,84,150\", \"84,120,208\"],\n    value: \"11h\",\n  },\n  {\n    label: \"New marine species each year\",\n    prefix: \"Over\",\n    triad: [\"#ece078\", \"150,215,50\", \"50,200,193\"],\n    value: \"2,000\",\n  },\n  {\n    label: \"Average depth of the ocean\",\n    triad: [\"#b478ec\", \"208,84,200\", \"236,180,84\"],\n    value: \"3.7km\",\n  },\n  {\n    label: \"Of Earth's water is ocean\",\n    triad: [\"#78ecc8\", \"50,200,215\", \"120,84,236\"],\n    value: \"97%\",\n  },\n  {\n    label: \"Speed of sound in seawater\",\n    triad: [\"#ec8878\", \"236,84,84\", \"150,84,236\"],\n    value: \"1,500m/s\",\n  },\n  {\n    label: \"Abyssal sediment per 1,000yr\",\n    triad: [\"#9aecb0\", \"50,215,180\", \"84,120,236\"],\n    value: \"1cm\",\n  },\n  {\n    label: \"Lifespan of Greenland shark\",\n    prefix: \"Up to\",\n    triad: [\"#a8a8ec\", \"84,120,236\", \"236,84,180\"],\n    value: \"400yr\",\n  },\n  {\n    label: \"Deepest fish ever filmed\",\n    triad: [\"#ecc878\", \"236,84,180\", \"50,200,200\"],\n    value: \"8,336m\",\n  },\n  {\n    label: \"Freezing point of seawater\",\n    triad: [\"#a8ec78\", \"150,215,50\", \"84,100,236\"],\n    value: \"−1.8°C\",\n  },\n  {\n    label: \"Known hydrothermal vents\",\n    prefix: \"Over\",\n    triad: [\"#ec9ab0\", \"236,84,140\", \"100,84,236\"],\n    value: \"600\",\n  },\n  {\n    label: \"Hottest water microbes take\",\n    triad: [\"#78ecd0\", \"50,215,140\", \"215,84,200\"],\n    value: \"121°C\",\n  },\n  {\n    label: \"Added pressure per 10 metres\",\n    triad: [\"#ecd278\", \"236,160,50\", \"84,110,236\"],\n    value: \"1atm\",\n  },\n  {\n    label: \"Between two high tides\",\n    triad: [\"#90a8ec\", \"140,84,236\", \"236,90,150\"],\n    value: \"12h25m\",\n  },\n  {\n    label: \"Carbon absorbed each year\",\n    prefix: \"Roughly\",\n    triad: [\"#78ecb8\", \"50,200,200\", \"160,84,236\"],\n    value: \"2.5Gt\",\n  },\n  {\n    label: \"Where the hadal zone begins\",\n    triad: [\"#eca090\", \"236,110,84\", \"84,130,236\"],\n    value: \"6,000m\",\n  },\n  {\n    label: \"Of your oxygen from plankton\",\n    prefix: \"Nearly\",\n    triad: [\"#d0ec78\", \"80,210,110\", \"215,84,190\"],\n    value: \"50%\",\n  },\n  {\n    label: \"One global conveyor cycle\",\n    triad: [\"#9ad8ec\", \"84,150,236\", \"215,84,180\"],\n    value: \"1,000yr\",\n  },\n] as const satisfies readonly [RawStat, ...RawStat[]];\n\nconst toStat = (raw: RawStat): Stat => ({\n  gradient: gradientFrom(raw.triad),\n  label: raw.label,\n  prefix: raw.prefix,\n  value: raw.value,\n});\n\nconst [FIRST, ...REST] = RAW;\n\n/**\n * Non-empty by construction, so `STATS[0]` is a safe fallback under\n * `noUncheckedIndexedAccess`.\n */\nexport const STATS: readonly [Stat, ...Stat[]] = [toStat(FIRST), ...REST.map(toStat)];\n\nexport const N = STATS.length;\n\n/** Wraps any integer (including negatives) into a defined stat. */\nexport const statAt = (index: number): Stat => {\n  const wrapped = ((Math.trunc(index) % N) + N) % N;\n  return STATS[wrapped] ?? STATS[0];\n};\n","path":"/stat-reel-data.ts","target":"uicapsule/stat-reel/stat-reel-data.ts","type":"registry:file"},{"content":"\"use client\";\n\nimport type { CSSProperties, FC } from \"react\";\nimport { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { gsap } from \"gsap\";\n\nimport { N, STATS, statAt } from \"./stat-reel-data\";\n\nconst DESKTOP = { font: 26, itemH: 62 };\nconst MOBILE = { font: 19, itemH: 46 };\n/**\n * Gates the first auto-step behind the entrance timeline. Floor is ~250ms:\n * below that the per-focus figure pop (overwrite: true) fires while the\n * entrance still owns the figure and kills the cold open.\n */\nconst START_DELAY_MS = 600;\nconst BREAKPOINT = 900;\nconst TOUCH_GAIN = 1.2;\nconst ROW_COUNT = N * 2;\n\n/**\n * Wheel deltas arrive in three units (`WheelEvent.deltaMode`): pixels (0),\n * lines (1) and pages (2). Chrome and Safari only ever report pixels, but\n * Firefox on Windows/Linux reports lines, so the accumulator has to convert\n * to pixels before comparing against `CFG.wheelStep`. This nominal line\n * height only has to be in the right order of magnitude.\n */\nconst WHEEL_LINE_PX = 16;\n\n/**\n * A wheel or touch gesture delivers events milliseconds apart, so a gap this\n * long means the gesture ended and any sub-threshold residue in the\n * accumulator is stale. Without expiry, 44px of ignored input waits\n * indefinitely for an unrelated flick minutes later to top it over\n * `CFG.wheelStep` and steal a row. Not a feel constant: every value well above\n * a frame and well below human re-engagement behaves identically for real\n * gestures.\n */\nconst GESTURE_IDLE_MS = 500;\n\nconst COLOR_REST = \"#636365\";\nconst COLOR_FOCUS = \"#010102\";\n\n/**\n * Declared locally: the host app never defines `--font-sans`, so Tailwind's\n * `font-sans` resolves to a cycle and falls back to Times.\n */\nconst SANS =\n  'ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif';\n\nconst CFG = {\n  centerScale: 1.68,\n  dwell: 0.6,\n  falloff: 270,\n  falloffMobile: 150,\n  farScale: 0.7,\n  manualStep: 1.2,\n  numScale: 1.05,\n  resume: 4,\n  step: 0.8,\n  wheelStep: 45,\n};\n\n/** Scale a fully-defocused row shrinks to, relative to the focused row. */\nconst FAR_RATIO = CFG.farScale / CFG.centerScale;\n\nconst clamp01 = (v: number) => (v < 0 ? 0 : Math.min(1, v));\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst smoothstep = (t: number) => t * t * (3 - 2 * t);\n\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n/** `CSSProperties` widened to accept custom properties without a type assertion. */\ninterface CSSVarProperties extends CSSProperties {\n  [key: `--${string}`]: string | number | undefined;\n}\n\nconst PANEL_MASK =\n  \"linear-gradient(to bottom, transparent 0%, #000 16%, #000 84%, transparent 100%)\";\n\nconst SECTION_STYLE: CSSProperties = {\n  fontFamily: SANS,\n};\n\nconst PANEL_STYLE: CSSProperties = {\n  WebkitMaskImage: PANEL_MASK,\n  maskImage: PANEL_MASK,\n};\n\nconst ROW_STYLE: CSSProperties = {\n  fontSize: \"calc(var(--bm-base-font) * var(--center-scale))\",\n  height: \"var(--bm-item-h)\",\n};\n\n/** Breathing room kept between the widest figure and the column's inner edge. */\nconst FIT_GUTTER_PX = 10;\n\n/** Smallest fraction of its natural size the figure may be shrunk to. */\nconst FIT_FLOOR = 0.4;\n\n/**\n * Shrinks the figure until it fits the left column, down to a 40% floor.\n * Writes `--bm-num-fit`, which the font-size `calc()` multiplies in.\n */\nconst applyFit = (num: HTMLElement, col: HTMLElement) => {\n  num.style.setProperty(\"--bm-num-fit\", \"1\");\n  const natural = num.scrollWidth;\n  const cs = window.getComputedStyle(col);\n  // oxlint-disable-next-line unicorn/prefer-number-coercion -- computed padding carries a px unit, which Number() rejects\n  const padL = Number.parseFloat(cs.paddingLeft) || 0;\n  // oxlint-disable-next-line unicorn/prefer-number-coercion -- computed padding carries a px unit, which Number() rejects\n  const padR = Number.parseFloat(cs.paddingRight) || 0;\n  const available = col.clientWidth - padL - padR - FIT_GUTTER_PX;\n  // A 0-wide column (hidden panel, freshly-mounted iframe) makes `available`\n  // negative, which the 0.4 floor would silently absorb into a real-looking\n  // shrink. Keep the `1` written above until there is something to fit into.\n  if (available <= 0) {\n    return;\n  }\n  if (natural > 0 && natural > available) {\n    num.style.setProperty(\"--bm-num-fit\", String(Math.max(FIT_FLOOR, available / natural)));\n  }\n};\n\ninterface RowsProps {\n  register: (index: number, el: HTMLDivElement | null) => void;\n}\n\nconst RowsBase: FC<RowsProps> = ({ register }) => (\n  <>\n    {Array.from({ length: ROW_COUNT }, (_, j) => (\n      <div\n        key={j}\n        ref={(el) => register(j, el)}\n        style={ROW_STYLE}\n        className=\"pointer-events-none absolute top-0 right-0 left-[clamp(32px,5vw,80px)] flex origin-left items-center leading-[1.05] font-medium whitespace-nowrap text-[#636365] [will-change:transform]\"\n      >\n        {statAt(j).label}\n      </div>\n    ))}\n  </>\n);\n\nconst Rows = memo(RowsBase);\n\nRows.displayName = \"StatReelRows\";\n\nexport const StatReel: FC = () => {\n  const [focusedIndex, setFocusedIndex] = useState(0);\n\n  const sectionRef = useRef<HTMLElement>(null);\n  const leftRef = useRef<HTMLDivElement>(null);\n  const figureRef = useRef<HTMLDivElement>(null);\n  const numberRef = useRef<HTMLSpanElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const rowsRef = useRef<(HTMLDivElement | null)[]>([]);\n\n  /** The single scalar every tween drives; the rAF loop paints from it. */\n  const proxy = useRef({ idx: 0 });\n  const targetRef = useRef(0);\n  const modeRef = useRef<\"auto\" | \"user\">(\"auto\");\n  const focusedRef = useRef(0);\n  const accumRef = useRef(0);\n  const reducedRef = useRef(false);\n  const firstFigure = useRef(true);\n  const sizeRef = useRef({\n    cy: 0,\n    falloff: CFG.falloff,\n    itemH: DESKTOP.itemH,\n    panelH: 0,\n  });\n\n  const register = useCallback((index: number, el: HTMLDivElement | null) => {\n    rowsRef.current[index] = el;\n  }, []);\n\n  useEffect(() => {\n    const section = sectionRef.current;\n    const figure = figureRef.current;\n    const panel = panelRef.current;\n\n    if (!section || !figure || !panel) {\n      return;\n    }\n\n    let disposed = false;\n    const proxyState = proxy.current;\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n\n    const onMq = (e: MediaQueryListEvent) => {\n      reducedRef.current = e.matches;\n    };\n\n    mq.addEventListener(\"change\", onMq);\n\n    const isReduced = () => reducedRef.current;\n\n    const measure = () => {\n      const mobile = window.innerWidth < BREAKPOINT;\n      const base = mobile ? MOBILE : DESKTOP;\n      const panelH = panel.clientHeight;\n\n      sizeRef.current = {\n        cy: panelH / 2,\n        falloff: mobile ? CFG.falloffMobile : CFG.falloff,\n        itemH: base.itemH,\n        panelH,\n      };\n\n      section.style.setProperty(\"--bm-item-h\", `${base.itemH}px`);\n      section.style.setProperty(\"--bm-base-font\", `${base.font}px`);\n      section.style.setProperty(\"--center-scale\", String(CFG.centerScale));\n\n      if (numberRef.current && leftRef.current) {\n        applyFit(numberRef.current, leftRef.current);\n      }\n    };\n\n    measure();\n    window.addEventListener(\"resize\", measure);\n\n    // The gallery resizes the frame without ever firing a window resize.\n    const ro = new ResizeObserver(() => {\n      // A freshly-mounted iframe reports 0x0 on the first callback.\n      if (section.clientWidth === 0 || section.clientHeight === 0) {\n        return;\n      }\n      measure();\n    });\n    ro.observe(section);\n\n    // Fonts swap in after first paint; the initial applyFit measured fallbacks.\n    const refitAfterFonts = async () => {\n      await document.fonts.ready;\n      if (!disposed) {\n        measure();\n      }\n    };\n\n    void refitAfterFonts();\n\n    let dwellTimer: ReturnType<typeof setTimeout> | null = null;\n    let resumeTimer: ReturnType<typeof setTimeout> | null = null;\n    let startTimer: ReturnType<typeof setTimeout> | null = null;\n\n    const tweenTo = (t: number, duration: number, ease: string, onComplete: () => void) => {\n      gsap.to(proxyState, {\n        duration: isReduced() ? 0 : duration,\n        ease,\n        idx: t,\n        onComplete,\n        overwrite: true,\n      });\n    };\n\n    /**\n     * In auto mode, queues the next advance one dwell from now; every tween\n     * hands back here on completion, so the reel keeps stepping until the\n     * mode changes.\n     */\n    const queueAutoStep = () => {\n      if (modeRef.current !== \"auto\" || isReduced()) {\n        return;\n      }\n      dwellTimer = setTimeout(() => {\n        targetRef.current += 1;\n        tweenTo(targetRef.current, CFG.step, \"expo.inOut\", queueAutoStep);\n      }, CFG.dwell * 1000);\n    };\n\n    const startAuto = () => {\n      if (isReduced()) {\n        return;\n      }\n\n      modeRef.current = \"auto\";\n      targetRef.current = Math.round(proxyState.idx);\n\n      if (dwellTimer) {\n        clearTimeout(dwellTimer);\n      }\n      queueAutoStep();\n    };\n\n    const stepManual = (dir: number) => {\n      if (startTimer) {\n        clearTimeout(startTimer);\n      }\n      if (dwellTimer) {\n        clearTimeout(dwellTimer);\n      }\n      if (resumeTimer) {\n        clearTimeout(resumeTimer);\n      }\n\n      modeRef.current = \"user\";\n      targetRef.current += dir;\n      tweenTo(targetRef.current, CFG.manualStep, \"expo.out\", queueAutoStep);\n\n      resumeTimer = setTimeout(startAuto, CFG.resume * 1000);\n    };\n\n    /** Input accumulates continuously but only advances in whole rows. */\n    const drain = () => {\n      while (Math.abs(accumRef.current) >= CFG.wheelStep) {\n        const d = Math.sign(accumRef.current);\n        accumRef.current -= d * CFG.wheelStep;\n        stepManual(d);\n      }\n    };\n\n    let idleTimer: ReturnType<typeof setTimeout> | null = null;\n\n    /**\n     * The single entry point for wheel and touch alike: both feed pixels in,\n     * whole rows come out, and the remainder is dropped once the gesture ends.\n     */\n    const accumulate = (deltaPx: number) => {\n      accumRef.current += deltaPx;\n      drain();\n\n      if (idleTimer) {\n        clearTimeout(idleTimer);\n      }\n      idleTimer = setTimeout(() => {\n        accumRef.current = 0;\n      }, GESTURE_IDLE_MS);\n    };\n\n    let rafId = 0;\n    let prevNearest: HTMLDivElement | null = null;\n\n    const render = () => {\n      const { itemH, falloff, panelH, cy } = sizeRef.current;\n\n      // Skip painting until the panel has a real height, or every row would\n      // land offscreen for a frame inside a freshly-mounted iframe.\n      if (panelH > 0) {\n        const { idx } = proxyState;\n        const rows = rowsRef.current;\n        const windowH = ROW_COUNT * itemH;\n        const halfWin = windowH / 2;\n\n        let nearestEl: HTMLDivElement | null = null;\n        let nearestAbs = Infinity;\n\n        for (let j = 0; j < ROW_COUNT; j += 1) {\n          const row = rows[j];\n          if (!row) {\n            continue;\n          }\n\n          let off = (j - idx) * itemH;\n          // Wrap into (-halfWin, halfWin] so the reel never ends.\n          off = ((((off + halfWin) % windowH) + windowH) % windowH) - halfWin;\n          const screenY = cy + off;\n\n          if (screenY < -itemH || screenY > panelH + itemH) {\n            if (row.style.opacity !== \"0\") {\n              row.style.opacity = \"0\";\n            }\n            continue;\n          }\n\n          const s = smoothstep(clamp01(Math.abs(off) / falloff));\n          if (row.style.opacity !== \"1\") {\n            row.style.opacity = \"1\";\n          }\n          row.style.transform = `translateY(${screenY - itemH / 2}px) scale(${lerp(1, FAR_RATIO, s)})`;\n\n          const a = Math.abs(off);\n          if (a < nearestAbs) {\n            nearestAbs = a;\n            nearestEl = row;\n          }\n        }\n\n        // Only touch color when the centre row actually changes.\n        if (nearestEl !== prevNearest) {\n          if (prevNearest) {\n            prevNearest.style.color = COLOR_REST;\n          }\n          if (nearestEl) {\n            nearestEl.style.color = COLOR_FOCUS;\n          }\n          prevNearest = nearestEl;\n        }\n\n        const focused = ((Math.round(idx) % N) + N) % N;\n        if (focused !== focusedRef.current) {\n          focusedRef.current = focused;\n          setFocusedIndex(focused);\n        }\n      }\n\n      rafId = requestAnimationFrame(render);\n    };\n\n    rafId = requestAnimationFrame(render);\n\n    const onWheel = (e: WheelEvent) => {\n      e.preventDefault();\n      let unit = 1;\n      if (e.deltaMode === 1) {\n        unit = WHEEL_LINE_PX;\n      } else if (e.deltaMode === 2) {\n        unit = sizeRef.current.panelH;\n      }\n      accumulate(e.deltaY * unit);\n    };\n\n    /**\n     * The reel tracks exactly one finger, latched by identifier. Reading\n     * `touches[0]` instead would silently re-anchor onto a different contact\n     * the moment the tracked finger lifts out of a multi-touch gesture,\n     * turning the next 2px move into a jump of a dozen rows.\n     */\n    let activeTouch: number | null = null;\n    let touchY = 0;\n\n    const findTouch = (list: TouchList, id: number): Touch | null => {\n      for (const t of list) {\n        if (t.identifier === id) {\n          return t;\n        }\n      }\n      return null;\n    };\n\n    const onTouchStart = (e: TouchEvent) => {\n      if (activeTouch !== null) {\n        return;\n      }\n\n      const [t] = e.changedTouches;\n      if (!t) {\n        return;\n      }\n\n      activeTouch = t.identifier;\n      touchY = t.clientY;\n    };\n\n    const onTouchMove = (e: TouchEvent) => {\n      if (activeTouch === null) {\n        return;\n      }\n\n      const t = findTouch(e.touches, activeTouch);\n      if (!t) {\n        return;\n      }\n\n      const dy = touchY - t.clientY;\n      touchY = t.clientY;\n      e.preventDefault();\n      accumulate(dy * TOUCH_GAIN);\n    };\n\n    const onTouchEnd = (e: TouchEvent) => {\n      if (activeTouch === null) {\n        return;\n      }\n      if (!findTouch(e.changedTouches, activeTouch)) {\n        return;\n      }\n\n      // Hand tracking to a finger that is still down rather than going dead\n      // until the next touchstart. Re-anchoring `touchY` to its current\n      // position is what keeps the handover from registering as a jump.\n      const [next] = e.touches;\n      activeTouch = next ? next.identifier : null;\n      if (next) {\n        touchY = next.clientY;\n      }\n    };\n\n    const onKeyDown = (e: KeyboardEvent) => {\n      let dir = 0;\n      if (e.key === \"ArrowDown\") {\n        dir = 1;\n      } else if (e.key === \"ArrowUp\") {\n        dir = -1;\n      }\n      if (dir === 0) {\n        return;\n      }\n\n      e.preventDefault();\n      stepManual(dir);\n    };\n\n    section.addEventListener(\"wheel\", onWheel, { passive: false });\n    section.addEventListener(\"touchstart\", onTouchStart, { passive: true });\n    section.addEventListener(\"touchmove\", onTouchMove, { passive: false });\n    section.addEventListener(\"touchend\", onTouchEnd, { passive: true });\n    section.addEventListener(\"touchcancel\", onTouchEnd, { passive: true });\n    section.addEventListener(\"keydown\", onKeyDown);\n\n    const groups = [figure, panel];\n    let intro: gsap.core.Timeline | null = null;\n\n    if (isReduced()) {\n      gsap.set(groups, { opacity: 1, scale: 1, y: 0 });\n      firstFigure.current = false;\n    } else {\n      const tl = gsap.timeline({\n        delay: 0.12,\n        onComplete: () => {\n          firstFigure.current = false;\n        },\n      });\n\n      tl.fromTo(\n        groups,\n        { opacity: 0, scale: 0.96, y: 26 },\n        {\n          duration: 1,\n          ease: \"power4.out\",\n          opacity: 1,\n          scale: 1,\n          stagger: 0.12,\n          y: 0,\n        },\n      );\n\n      intro = tl;\n      startTimer = setTimeout(startAuto, START_DELAY_MS);\n    }\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(rafId);\n      ro.disconnect();\n      window.removeEventListener(\"resize\", measure);\n      mq.removeEventListener(\"change\", onMq);\n      section.removeEventListener(\"wheel\", onWheel);\n      section.removeEventListener(\"touchstart\", onTouchStart);\n      section.removeEventListener(\"touchmove\", onTouchMove);\n      section.removeEventListener(\"touchend\", onTouchEnd);\n      section.removeEventListener(\"touchcancel\", onTouchEnd);\n      section.removeEventListener(\"keydown\", onKeyDown);\n\n      if (dwellTimer) {\n        clearTimeout(dwellTimer);\n      }\n      if (resumeTimer) {\n        clearTimeout(resumeTimer);\n      }\n      if (startTimer) {\n        clearTimeout(startTimer);\n      }\n      if (idleTimer) {\n        clearTimeout(idleTimer);\n      }\n\n      gsap.killTweensOf(proxyState);\n      gsap.killTweensOf(groups);\n      // Killing only the child tween would leave the timeline on the ticker,\n      // where it completes and fires `onComplete` after the reset below.\n      intro?.kill();\n\n      // StrictMode remounts: every latch must return to its mount-time value\n      // or the second mount skips the cold open.\n      firstFigure.current = true;\n      modeRef.current = \"auto\";\n      proxyState.idx = 0;\n      targetRef.current = 0;\n      focusedRef.current = 0;\n      accumRef.current = 0;\n    };\n  }, []);\n\n  // Both halves of a focus change: pop the incoming figure in, then refit it to\n  // the left column now that its digits — and so its natural width — differ.\n  // The entrance timeline owns the figure until it completes, so the pop stays\n  // latched off for the cold open while the fit still has to run.\n  useIsoLayoutEffect(() => {\n    const figure = figureRef.current;\n\n    if (figure && !firstFigure.current) {\n      if (reducedRef.current) {\n        gsap.set(figure, { opacity: 1, scale: 1, y: 0 });\n      } else {\n        gsap.fromTo(\n          figure,\n          { opacity: 0, scale: 0.95, y: 14 },\n          {\n            duration: Math.max(0.32, CFG.step * 0.7),\n            ease: \"expo.out\",\n            opacity: 1,\n            overwrite: true,\n            scale: 1,\n            y: 0,\n          },\n        );\n      }\n    }\n\n    if (numberRef.current && leftRef.current) {\n      applyFit(numberRef.current, leftRef.current);\n    }\n  }, [focusedIndex]);\n\n  const current = statAt(focusedIndex);\n\n  const numberStyle: CSSVarProperties = {\n    \"--bm-num-scale\": String(CFG.numScale),\n    backgroundImage: current.gradient,\n  };\n\n  return (\n    <section\n      className=\"relative flex h-full w-full flex-col overflow-hidden bg-[#f2f2f4] text-[#010102] [--bm-base-font:19px] [--bm-item-h:46px] [--center-scale:1.68] min-[900px]:flex-row min-[900px]:[--bm-base-font:26px] min-[900px]:[--bm-item-h:62px]\"\n      style={SECTION_STYLE}\n      ref={sectionRef}\n      aria-label=\"Ocean depth statistics\"\n      // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- wheel and touch already seize the reel; the tab stop is what gives a keyboard-only user the same arrow-key access to it\n      tabIndex={0}\n    >\n      <div\n        className=\"z-[1] flex min-w-0 shrink-0 grow-0 basis-auto flex-col items-center justify-center pt-[clamp(28px,7vw,56px)] pr-4 pb-0 pl-4 text-center min-[900px]:basis-[38%] min-[900px]:items-start min-[900px]:pt-0 min-[900px]:pr-[clamp(12px,1.4vw,28px)] min-[900px]:pl-[clamp(20px,2.6vw,52px)] min-[900px]:text-left\"\n        ref={leftRef}\n        aria-hidden\n      >\n        <div className=\"opacity-0 [will-change:transform,opacity]\" ref={figureRef}>\n          <div className=\"mb-1.5 min-h-[26px] w-full text-center text-[clamp(14px,1.4vw,20px)] font-medium text-[#636365] min-[900px]:w-auto min-[900px]:text-left\">\n            {current.prefix ?? \" \"}\n          </div>\n\n          <span\n            className=\"block bg-clip-text bg-no-repeat text-[calc(clamp(68px,20vw,124px)*var(--bm-num-scale,1.05)*var(--bm-num-fit,1))] leading-none font-semibold tracking-[-0.055em] whitespace-nowrap text-transparent [-webkit-background-clip:text] [-webkit-text-fill-color:transparent] min-[900px]:text-[calc(clamp(56px,9vw,152px)*var(--bm-num-scale,1.05)*var(--bm-num-fit,1))]\"\n            ref={numberRef}\n            style={numberStyle}\n          >\n            {current.value}\n          </span>\n        </div>\n      </div>\n\n      <div\n        className=\"relative z-[1] min-w-0 shrink grow basis-auto overflow-hidden px-[clamp(32px,5vw,80px)] opacity-0\"\n        ref={panelRef}\n        style={PANEL_STYLE}\n        aria-hidden\n      >\n        <Rows register={register} />\n      </div>\n\n      <ul className=\"sr-only\">\n        {STATS.map((stat) => (\n          <li key={stat.label}>\n            {`${stat.prefix ? `${stat.prefix} ` : \"\"}${stat.value} — ${stat.label}`}\n          </li>\n        ))}\n      </ul>\n    </section>\n  );\n};\n","path":"/stat-reel.tsx","target":"uicapsule/stat-reel/stat-reel.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/stat-reel","name":"stat-reel","registryDependencies":[],"title":"Stat Reel","type":"registry:block"}