{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-over-hero",
  "type": "registry:ui",
  "title": "Scroll Over Hero",
  "description": "A hero with a pinned, centered title that a product panel rises up and overlaps on scroll. Theme-aware with a reduced-motion stacked fallback.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/ruixenui/scroll-over-hero.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ── types ───────────────────────────────────────────────────── */\n\nexport interface ScrollOverHeroProps {\n  /** Small pill / label rendered above the title. */\n  eyebrow?: React.ReactNode;\n  /** Main headline. */\n  title: React.ReactNode;\n  /** Supporting paragraph under the title. */\n  description?: React.ReactNode;\n  /** Row of CTA buttons / actions. */\n  actions?: React.ReactNode;\n  /**\n   * The panel that rises and overlaps the pinned title as the page scrolls.\n   * Drop in a screenshot, `<video>`, or a custom mock. Falls back to a themed\n   * placeholder.\n   */\n  media?: React.ReactNode;\n  /**\n   * Total scroll height of the pinned section. Taller = the title stays pinned\n   * longer and the media rises more slowly. Default `\"220vh\"`.\n   */\n  scrollLength?: string;\n  className?: string;\n}\n\n/* ── helpers ─────────────────────────────────────────────────── */\n\nconst useIsomorphicLayoutEffect =\n  typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect;\n\n// Title rest padding (px). Must match the `pt-24` class on the title layer so\n// the measured title position and the panel peek agree.\nconst TITLE_PT = 96;\n// Gap kept between the resting title and the top of the peeking panel (px).\nconst PEEK_GAP = 24;\n// The panel must show at least this much (px) for the overlap to be worth it;\n// otherwise (short/landscape viewports) we stack instead.\nconst MIN_PEEK = 120;\n\n/* ── pieces ──────────────────────────────────────────────────── */\n\nfunction TitleStack({\n  eyebrow,\n  title,\n  description,\n  actions,\n}: Pick<ScrollOverHeroProps, \"eyebrow\" | \"title\" | \"description\" | \"actions\">) {\n  return (\n    <div className=\"flex flex-col items-center px-6 text-center\">\n      {eyebrow && <div className=\"mb-5\">{eyebrow}</div>}\n      <h1 className=\"mx-auto max-w-3xl text-balance text-4xl font-semibold tracking-tight text-foreground sm:text-5xl md:text-6xl lg:text-7xl\">\n        {title}\n      </h1>\n      {description && (\n        <p className=\"mx-auto mt-5 max-w-xl text-balance text-base text-muted-foreground md:mt-6 md:text-lg\">\n          {description}\n        </p>\n      )}\n      {actions && (\n        <div className=\"mt-8 flex flex-wrap items-center justify-center gap-3\">\n          {actions}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction MediaFrame({ children }: { children?: React.ReactNode }) {\n  return (\n    <div className=\"overflow-hidden rounded-2xl border border-border/60 bg-card shadow-[0_40px_120px_-30px_rgba(0,0,0,0.45)] ring-1 ring-border/40\">\n      {children ?? (\n        <div className=\"flex aspect-video w-full items-center justify-center bg-muted\">\n          <span className=\"text-xs font-medium uppercase tracking-wider text-muted-foreground/70\">\n            Replace media\n          </span>\n        </div>\n      )}\n    </div>\n  );\n}\n\n/* ── component ───────────────────────────────────────────────── */\n\nexport function ScrollOverHero({\n  eyebrow,\n  title,\n  description,\n  actions,\n  media,\n  scrollLength = \"220vh\",\n  className,\n}: ScrollOverHeroProps) {\n  const prefersReducedMotion = useReducedMotion();\n  const sectionRef = React.useRef<HTMLElement>(null);\n  const titleRef = React.useRef<HTMLDivElement>(null);\n\n  // Scroll progress (0 → 1) of the section through its *own* scroll context.\n  // We compute it by hand against the section's owner window rather than\n  // motion's `useScroll`, because that always binds to the global `window`.\n  // When this component is portaled into another document (e.g. the docs\n  // preview iframe) that global is the *outer* page — so the effect would\n  // never respond to the container it actually lives in. Reading the owner\n  // window makes it work in the top page and inside the iframe alike.\n  const scrollYProgress = useMotionValue(0);\n\n  // Measure the real title height so the panel starts *just below it* on every\n  // viewport — that's what keeps the CTAs clear of the panel at rest and lets\n  // the overlap run on phones too (not just desktop). We stack only when the\n  // title nearly fills the viewport (landscape / tiny screens) or for\n  // reduced-motion. Starts \"stacked\" so SSR + first paint match; the layout\n  // effect upgrades before paint, so no hydration mismatch and no flash.\n  const [state, setState] = React.useState<{\n    mode: \"stacked\" | \"overlap\";\n    peek: number;\n  }>({ mode: \"stacked\", peek: 0 });\n\n  useIsomorphicLayoutEffect(() => {\n    if (prefersReducedMotion) {\n      setState({ mode: \"stacked\", peek: 0 });\n      return;\n    }\n    const win = sectionRef.current?.ownerDocument.defaultView ?? window;\n    const measure = () => {\n      const el = titleRef.current;\n      if (!el) return;\n      const titleBottom = TITLE_PT + el.offsetHeight;\n      if (titleBottom > win.innerHeight - MIN_PEEK) {\n        setState((s) =>\n          s.mode === \"stacked\" ? s : { mode: \"stacked\", peek: 0 },\n        );\n      } else {\n        const peek = Math.round(titleBottom + PEEK_GAP);\n        setState((s) =>\n          s.mode === \"overlap\" && s.peek === peek\n            ? s\n            : { mode: \"overlap\", peek },\n        );\n      }\n    };\n    measure();\n    win.addEventListener(\"resize\", measure);\n    return () => win.removeEventListener(\"resize\", measure);\n  }, [prefersReducedMotion]);\n\n  // Drive `scrollYProgress` from the section's owner window. Mirrors motion's\n  // [\"start start\", \"end start\"] offset: 0 when the section top hits the\n  // viewport top, 1 when its bottom does (rect.top === -height).\n  useIsomorphicLayoutEffect(() => {\n    if (state.mode !== \"overlap\") return;\n    const el = sectionRef.current;\n    if (!el) return;\n    const win = el.ownerDocument.defaultView ?? window;\n    let raf = 0;\n    const update = () => {\n      raf = 0;\n      const rect = el.getBoundingClientRect();\n      const denom = rect.height || 1;\n      scrollYProgress.set(Math.min(1, Math.max(0, -rect.top / denom)));\n    };\n    const onScroll = () => {\n      if (!raf) raf = win.requestAnimationFrame(update);\n    };\n    update();\n    win.addEventListener(\"scroll\", onScroll, { passive: true });\n    win.addEventListener(\"resize\", onScroll);\n    return () => {\n      win.removeEventListener(\"scroll\", onScroll);\n      win.removeEventListener(\"resize\", onScroll);\n      if (raf) win.cancelAnimationFrame(raf);\n    };\n  }, [state.mode, scrollYProgress]);\n\n  // The title fades and lifts away early; the media rises from its peek to fully\n  // cover it. Both finish before the pin releases, so nothing snaps.\n  const titleOpacity = useTransform(scrollYProgress, [0, 0.35], [1, 0]);\n  const titleScale = useTransform(scrollYProgress, [0, 0.35], [1, 0.92]);\n  const titleY = useTransform(scrollYProgress, [0, 0.35], [0, -48]);\n  const mediaY = useTransform(\n    scrollYProgress,\n    [0, 0.5],\n    [`${state.peek}px`, \"0px\"],\n  );\n  const mediaScale = useTransform(scrollYProgress, [0, 0.5], [0.95, 1]);\n\n  return (\n    <section\n      ref={sectionRef}\n      aria-label=\"Hero\"\n      className={cn(\"relative w-full bg-background\", className)}\n      style={state.mode === \"overlap\" ? { height: scrollLength } : undefined}\n    >\n      {state.mode === \"overlap\" ? (\n        // Soft bottom fade so the rising panel dissolves into the page instead\n        // of ending on a hard clip line at the viewport edge.\n        <div className=\"sticky top-0 h-screen overflow-hidden [mask-image:linear-gradient(to_bottom,black_82%,transparent)]\">\n          {/* Pinned title — fades and lifts as the media rises over it. */}\n          <motion.div\n            style={{ opacity: titleOpacity, scale: titleScale, y: titleY }}\n            className=\"absolute inset-x-0 top-0 z-0 pt-24\"\n          >\n            <div ref={titleRef}>\n              <TitleStack\n                eyebrow={eyebrow}\n                title={title}\n                description={description}\n                actions={actions}\n              />\n            </div>\n          </motion.div>\n\n          {/* Rising media — higher z-index, so it overlaps the title. */}\n          <motion.div\n            style={{ y: mediaY, scale: mediaScale }}\n            className=\"absolute inset-x-0 top-0 z-10 mx-auto h-full w-full max-w-5xl px-4 sm:px-6 lg:max-w-6xl\"\n          >\n            <MediaFrame>{media}</MediaFrame>\n          </motion.div>\n        </div>\n      ) : (\n        // Stacked layout — reduced-motion, landscape/tiny viewports, no-JS.\n        <div className=\"py-20 md:py-28\">\n          <div ref={titleRef}>\n            <TitleStack\n              eyebrow={eyebrow}\n              title={title}\n              description={description}\n              actions={actions}\n            />\n          </div>\n          <div className=\"mx-auto mt-12 w-full max-w-5xl px-4 sm:px-6 md:mt-14 lg:max-w-6xl\">\n            <MediaFrame>{media}</MediaFrame>\n          </div>\n        </div>\n      )}\n    </section>\n  );\n}\n\nexport default ScrollOverHero;\n",
      "type": "registry:ui",
      "target": "components/ruixen/scroll-over-hero.tsx"
    }
  ]
}