{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"motion-stage","type":"registry:ui","title":"MotionStage","description":"React stage that plays a timeline against its children.","package":"fancy-motion","dependencies":[],"registryDependencies":["timeline","engine"],"files":[{"path":"components\/fancy\/motion-stage\/MotionStage.tsx","content":"import { useLayoutEffect, useMemo, useRef, type CSSProperties, type ReactNode } from \"react\";\nimport type { TimelineDoc } from \"..\/timeline\/types\";\nimport { flattenTimeline, sampleKeyframes, toCss } from \"..\/engine\/interpolate\";\n\nexport interface MotionStageProps {\n  timeline: TimelineDoc;\n  \/** Attribute whose value identifies a node to animate (default `data-cms`). *\/\n  nodeAttr?: string;\n  className?: string;\n  children: ReactNode;\n}\n\n\/**\n * Runtime player: turns scroll into a timeline playhead and morphs the pinned\n * content between whole-page snapshots. A `frames \u00d7 viewport` spacer creates the\n * scroll distance; the content is pinned (sticky) and scrubbed by scroll\n * progress (0..1).\n *\n * Spine scope: continuous **scroll** scrub on the **vertical** axis (the\n * horizontal axis is the symmetric X mapping). Per-keyframe **snap** scrolling\n * and **scene** pinning are runtime behaviors layered on next.\n *\/\nexport function MotionStage({ timeline, nodeAttr = \"data-cms\", className, children }: MotionStageProps) {\n  const spacerRef = useRef<HTMLDivElement>(null);\n  const pinRef = useRef<HTMLDivElement>(null);\n  const flat = useMemo(() => flattenTimeline(timeline), [timeline]);\n  const horizontal = timeline.axis === \"horizontal\";\n\n  useLayoutEffect(() => {\n    const spacer = spacerRef.current;\n    const pin = pinRef.current;\n    if (!spacer || !pin || typeof window === \"undefined\") return;\n\n    let raf = 0;\n    const apply = () => {\n      raf = 0;\n      const rect = spacer.getBoundingClientRect();\n      const vp = horizontal ? window.innerWidth : window.innerHeight;\n      const total = horizontal ? spacer.offsetWidth : spacer.offsetHeight;\n      const scrolled = horizontal ? -rect.left : -rect.top;\n      const len = total - vp;\n      const progress = len > 0 ? Math.min(1, Math.max(0, scrolled \/ len)) : 0;\n\n      const snap = sampleKeyframes(flat, progress);\n      pin.querySelectorAll<HTMLElement>(`[${nodeAttr}]`).forEach((el) => {\n        const id = el.getAttribute(nodeAttr);\n        const state = id ? snap[id] : undefined;\n        if (!state) {\n          el.style.transform = \"\";\n          el.style.opacity = \"\";\n          return;\n        }\n        const css = toCss(state);\n        el.style.transform = css.transform;\n        el.style.opacity = String(css.opacity);\n      });\n    };\n\n    const onScroll = () => {\n      if (!raf) raf = requestAnimationFrame(apply);\n    };\n\n    apply();\n    window.addEventListener(\"scroll\", onScroll, { passive: true });\n    window.addEventListener(\"resize\", onScroll);\n    return () => {\n      window.removeEventListener(\"scroll\", onScroll);\n      window.removeEventListener(\"resize\", onScroll);\n      if (raf) cancelAnimationFrame(raf);\n    };\n  }, [flat, nodeAttr, horizontal]);\n\n  const frames = Math.max(1, timeline.frames);\n  const spacerStyle: CSSProperties = horizontal\n    ? { position: \"relative\", width: `${frames * 100}vw`, height: \"100vh\" }\n    : { position: \"relative\", height: `${frames * 100}vh` };\n  const pinStyle: CSSProperties = {\n    position: \"sticky\",\n    top: 0,\n    left: horizontal ? 0 : undefined,\n    height: \"100vh\",\n    width: horizontal ? \"100vw\" : undefined,\n    overflow: \"hidden\",\n  };\n\n  return (\n    <div ref={spacerRef} className={className} style={spacerStyle} data-fancy-motion-stage=\"\">\n      <div ref={pinRef} style={pinStyle}>\n        {children}\n      <\/div>\n    <\/div>\n  );\n}\n","type":"registry:ui","target":"components\/fancy\/motion-stage\/MotionStage.tsx"}]}