{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scroll-expand","title":"Scroll Expand","description":"A framed panel pinned to the viewport that opens out to full bleed as you scroll past it, with the media inside settling back from a slight push-in. Progress is published as two CSS custom properties, so anything you drop inside can join the animation with one line of CSS.","dependencies":[],"files":[{"path":"src/components/ScrollExpand/index.js","content":"export { default } from './ScrollExpand.jsx'\n","type":"registry:component","target":"components/scroll-expand/index.js"},{"path":"src/components/ScrollExpand/ScrollExpand.jsx","content":"'use client'\n\nimport { useEffect, useRef } from 'react'\nimport './ScrollExpand.css'\n\n/* =========================================================\n   ScrollExpand\n   A framed panel pinned to the viewport that opens out to\n   full bleed as you scroll past it, with the media inside\n   settling back from a slight push-in.\n\n   The whole component pushes exactly two numbers across the\n   JS/CSS boundary — `--sx-p` (raw progress) and `--sx-e`\n   (eased) — on one element. Every visual mapping (frame\n   inset, corner radius, media zoom, scrim, slot fades) is a\n   CSS calc() off those. So anything you drop inside can join\n   the animation with a line of CSS and no wiring:\n\n     .my-caption { opacity: calc(1 - var(--sx-e)); }\n\n   Two stock slots are wired already: data-sx=\"out\" leaves as\n   the panel opens, data-sx=\"in\" arrives at the end.\n\n   The pin is `position: sticky`, so there is no measured\n   stage/track bookkeeping to keep in sync — the track's own\n   height sets the scroll budget. Progress is read against\n   whichever scroller actually owns the component, so it works\n   on the page or inside an overflow container. The rAF loop\n   only runs while the track is on screen, and the smoothing\n   is integrated against real frame delta rather than an\n   assumed 60Hz, so it settles identically on a 120Hz display.\n   ========================================================= */\n\nconst EASES = {\n  linear: (t) => t,\n  out: (t) => 1 - (1 - t) ** 3,\n  inOut: (t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2),\n}\n\nconst clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n)\n\n/* Nearest ancestor that actually scrolls, or the window.\n\n   The root is deliberately not a candidate. `html { overflow-y: scroll }` is a\n   common reset, and matching it here breaks the component twice over: the\n   root's rect top is -scrollY, so `origin - trackTop` reduces to a negative\n   constant and progress clamps to 0 for the whole page; and the root's scroll\n   events are dispatched at the document, so the listener never fires either.\n   Both are silent — the panel just renders framed and never opens. */\nfunction scrollerFor(node) {\n  const doc = node.ownerDocument\n  const root = doc.scrollingElement || doc.documentElement\n\n  for (let el = node.parentElement; el; el = el.parentElement) {\n    if (el === root || el === doc.documentElement || el === doc.body) break\n    const { overflowY } = getComputedStyle(el)\n    if (overflowY === 'auto' || overflowY === 'scroll') return el\n  }\n  return window\n}\n\nexport default function ScrollExpand({\n  media,\n  width = 42,\n  height = 58,\n  radius = 24,\n  endRadius = 0,\n  zoom = 1.35,\n  scrim = 0.45,\n  travel = 1.2,\n  hold = 0.35,\n  smoothing = 0.09,\n  ease = 'inOut',\n  onProgress,\n  className = '',\n  style,\n  children,\n  ...rest\n}) {\n  const trackRef = useRef(null)\n  const pinRef = useRef(null)\n\n  const live = useRef({})\n  // Synced after every render and before the effects below, so the loop reads\n  // current props without being torn down and restarted.\n  useEffect(() => {\n    live.current = { travel, smoothing, ease, onProgress }\n  })\n\n  useEffect(() => {\n    const track = trackRef.current\n    const pin = pinRef.current\n    if (!track || !pin) return\n\n    const scroller = scrollerFor(track)\n    const snap = window.matchMedia('(prefers-reduced-motion: reduce)').matches\n\n    let raf = 0\n    let last = 0\n    let target = 0\n    let value = 0\n    let watching = false\n\n    const read = () => {\n      const { travel: t } = live.current\n      const span = pin.offsetHeight * Math.max(0.01, t)\n      const origin =\n        scroller === window ? 0 : scroller.getBoundingClientRect().top\n      return clamp01((origin - track.getBoundingClientRect().top) / span)\n    }\n\n    const write = (p) => {\n      const { ease: e, onProgress: cb } = live.current\n      const fn = typeof e === 'function' ? e : EASES[e] || EASES.inOut\n      const eased = fn(p)\n      track.style.setProperty('--sx-p', p.toFixed(4))\n      track.style.setProperty('--sx-e', eased.toFixed(4))\n      cb?.(p, eased)\n    }\n\n    const frame = (now) => {\n      const tau = live.current.smoothing\n      // Real elapsed time, capped so a backgrounded tab doesn't jump.\n      const dt = last ? Math.min(0.064, (now - last) / 1000) : 1 / 60\n      last = now\n\n      value += (target - value) * (1 - Math.exp(-dt / tau))\n\n      if (Math.abs(target - value) < 5e-4) {\n        value = target\n        raf = 0\n        last = 0\n        write(value)\n        return\n      }\n      write(value)\n      raf = requestAnimationFrame(frame)\n    }\n\n    const sync = () => {\n      target = read()\n      if (snap || live.current.smoothing <= 0) {\n        value = target\n        write(value)\n        return\n      }\n      if (!raf) {\n        last = 0\n        raf = requestAnimationFrame(frame)\n      }\n    }\n\n    // Only listen while the track is anywhere near the viewport.\n    const watch = (on) => {\n      if (on === watching) return\n      watching = on\n      if (on) {\n        scroller.addEventListener('scroll', sync, { passive: true })\n        sync()\n      } else {\n        scroller.removeEventListener('scroll', sync)\n        if (raf) cancelAnimationFrame(raf)\n        raf = 0\n        // Settle on whichever end we left through.\n        value = target = read()\n        write(value)\n      }\n    }\n\n    const io = new IntersectionObserver((entries) => watch(entries[0].isIntersecting), {\n      root: scroller === window ? null : scroller,\n      rootMargin: '20% 0px',\n    })\n    io.observe(track)\n\n    const ro = new ResizeObserver(sync)\n    ro.observe(pin)\n\n    window.addEventListener('resize', sync)\n    write(0)\n\n    return () => {\n      io.disconnect()\n      ro.disconnect()\n      window.removeEventListener('resize', sync)\n      scroller.removeEventListener('scroll', sync)\n      if (raf) cancelAnimationFrame(raf)\n    }\n  }, [])\n\n  const vars = {\n    '--sx-w': width,\n    '--sx-h': height,\n    '--sx-r': radius,\n    '--sx-r-end': endRadius,\n    '--sx-zoom': zoom,\n    '--sx-scrim': scrim,\n    '--sx-travel': travel,\n    '--sx-hold': hold,\n    ...style,\n  }\n\n  return (\n    <div\n      ref={trackRef}\n      className={`scroll-expand ${className}`.trim()}\n      style={vars}\n      {...rest}\n    >\n      <div ref={pinRef} className=\"scroll-expand__pin\">\n        <div className=\"scroll-expand__frame\">\n          <div className=\"scroll-expand__media\">{media}</div>\n          <div className=\"scroll-expand__scrim\" aria-hidden=\"true\" />\n        </div>\n\n        {children ? <div className=\"scroll-expand__stage\">{children}</div> : null}\n      </div>\n    </div>\n  )\n}\n","type":"registry:component","target":"components/scroll-expand/ScrollExpand.jsx"},{"path":"src/components/ScrollExpand/ScrollExpand.css","content":"/* ScrollExpand — every value below is derived from --sx-e, the eased\n   scroll progress the component writes on .scroll-expand. Nothing here\n   is set from JS, so the whole look is tunable in one place. */\n\n.scroll-expand {\n  /* written each frame */\n  --sx-p: 0;\n  --sx-e: 0;\n\n  /* configuration (overridden by props via inline style) */\n  --sx-w: 42;\n  --sx-h: 58;\n  --sx-r: 24;\n  --sx-r-end: 0;\n  --sx-zoom: 1.35;\n  --sx-scrim: 0.45;\n  --sx-travel: 1.2;\n  --sx-hold: 0.35;\n\n  /* derived: the frame opens from --sx-w/h percent to full bleed */\n  --sx-fw: calc(var(--sx-w) + (100 - var(--sx-w)) * var(--sx-e));\n  --sx-fh: calc(var(--sx-h) + (100 - var(--sx-h)) * var(--sx-e));\n  --sx-ix: calc((100 - var(--sx-fw)) * 0.5 * 1%);\n  --sx-iy: calc((100 - var(--sx-fh)) * 0.5 * 1%);\n  --sx-rr: calc((var(--sx-r) + (var(--sx-r-end) - var(--sx-r)) * var(--sx-e)) * 1px);\n\n  position: relative;\n  display: block;\n  /* One stage to sit in, plus the scroll budget for the open and the hold.\n\n     The stage is a viewport by default, which is what this is for. But the\n     scroll maths already works against whatever element owns the scroll, so\n     the geometry should too: set --sx-vh on an ancestor and the component\n     pins to that box instead — which is how it can live inside a preview\n     panel rather than only at full bleed. */\n  height: calc((1 + var(--sx-travel) + var(--sx-hold)) * var(--sx-vh, 100svh));\n}\n\n.scroll-expand__pin {\n  position: sticky;\n  top: 0;\n  height: var(--sx-vh, 100svh);\n  overflow: hidden;\n}\n\n/* clip-path opens the frame without touching layout */\n.scroll-expand__frame {\n  position: absolute;\n  inset: 0;\n  clip-path: inset(var(--sx-iy) var(--sx-ix) round var(--sx-rr));\n}\n\n.scroll-expand__media {\n  position: absolute;\n  inset: 0;\n  transform: scale(calc(var(--sx-zoom) + (1 - var(--sx-zoom)) * var(--sx-e)));\n  transform-origin: 50% 50%;\n  will-change: transform;\n}\n\n.scroll-expand__media > :is(img, video, canvas, picture, svg),\n.scroll-expand__media > picture > img {\n  display: block;\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  user-select: none;\n  -webkit-user-drag: none;\n}\n\n.scroll-expand__scrim {\n  position: absolute;\n  inset: 0;\n  background: linear-gradient(\n    180deg,\n    rgba(0, 0, 0, 0.3) 0%,\n    rgba(0, 0, 0, 0.15) 40%,\n    rgba(0, 0, 0, 0.7) 100%\n  );\n  opacity: calc(var(--sx-scrim) * var(--sx-e));\n}\n\n/* free-form layer over the frame; children are positioned by the consumer */\n.scroll-expand__stage {\n  position: absolute;\n  inset: 0;\n  pointer-events: none;\n}\n\n.scroll-expand__stage > * {\n  pointer-events: auto;\n}\n\n/* --- stock slots -------------------------------------------------------- */\n\n/* leaves as the panel opens */\n.scroll-expand [data-sx='out'] {\n  --a: clamp(0, (var(--sx-e) - 0.32) / 0.48, 1);\n  opacity: calc(1 - var(--a));\n  transform: translate3d(0, calc(var(--a) * -28px), 0) scale(calc(1 + var(--a) * 0.06));\n}\n\n/* arrives once the panel is nearly open */\n.scroll-expand [data-sx='in'] {\n  --a: clamp(0, (var(--sx-e) - 0.66) / 0.34, 1);\n  opacity: var(--a);\n  transform: translate3d(0, calc((1 - var(--a)) * 18px), 0);\n}\n\n/* goes on the first flick of the wheel */\n.scroll-expand [data-sx='hint'] {\n  --a: clamp(0, var(--sx-p) / 0.12, 1);\n  opacity: calc(1 - var(--a));\n  transform: translate3d(0, calc(var(--a) * 8px), 0);\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .scroll-expand__media {\n    transform: none;\n  }\n}\n","type":"registry:file","target":"components/scroll-expand/ScrollExpand.css"},{"path":"src/components/ScrollExpand/ScrollExpand.d.ts","content":"import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from 'react'\n\nexport type ScrollEase = 'inOut' | 'out' | 'linear' | ((t: number) => number)\n\nexport interface ScrollExpandProps extends Omit<ComponentPropsWithoutRef<'div'>, 'onProgress'> {\n  /** Whatever sits inside the frame — an image, a video, a canvas. */\n  media?: ReactNode\n  /** Starting frame width, as a percentage of the viewport. */\n  width?: number\n  /** Starting frame height, as a percentage of the viewport. */\n  height?: number\n  /** Corner radius at rest, in px. */\n  radius?: number\n  /** Corner radius once fully open, in px. */\n  endRadius?: number\n  /** Scale the media starts pushed in at. */\n  zoom?: number\n  /** Peak darkening over the media, 0..1. */\n  scrim?: number\n  /** Scroll budget, as a multiple of the pinned panel's height. */\n  travel?: number\n  /** Fraction of that budget spent held fully open. */\n  hold?: number\n  /** Per-frame approach rate for the eased value. */\n  smoothing?: number\n  /** Easing of the scroll progress: `inOut` (cubic), `out` or `linear`, or your own function from 0..1 to 0..1. */\n  ease?: ScrollEase\n  /** Called with raw progress, 0..1, on every frame it changes. */\n  onProgress?: (progress: number) => void\n  /** Laid over the panel in the same pinned box, for a heading or caption. The layer lets the pointer through everywhere except on the content itself. */\n  children?: ReactNode\n}\n\ndeclare const ScrollExpand: (props: ScrollExpandProps) => ReactElement\nexport default ScrollExpand\n","type":"registry:lib","target":"components/scroll-expand/ScrollExpand.d.ts"}],"type":"registry:component"}