{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expanding-hero",
  "type": "registry:ui",
  "title": "Expanding Hero",
  "description": "A headline with a small video set between its words. As you scroll, the window opens until it fills the screen, pushing the words off its edges.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/expanding-hero.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  cubicBezier,\n  motion,\n  useMotionValue,\n  useScroll,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * EXPANDING HERO STORYBOARD   (driven by scroll progress, not time)\n *\n *     0%   section pins. The media plays in a small rounded\n *          window set into the headline, between its halves\n *   0→78%  the window opens until it fills the stage\n *          the media never resizes: it always covers the stage\n *          and the window is a clip-path over it. It settles\n *          from 115% to 100% as the window opens\n *          each half rides the window edge it touches, off the\n *          stage. The children fade over the first 20% and\n *          ride the bottom edge down\n *          the corners square off as the edges meet the stage's\n *  80→92%  the overlay fades in\n *    100%  section unpins and the page carries on\n *\n *  Wide stages put the halves either side of the window and\n *  push them sideways. Narrow ones stack the window between\n *  them and push them up and down.\n * ───────────────────────────────────────────────────────── */\n\nconst HERO = {\n  grow: 0.78, // share of the pin the window takes to fill the stage\n  ease: cubicBezier(0.45, 0, 0.2, 1), // the window's pace across the grow\n  zoom: 1.15, // media scale at the start; settles to 1 as the window opens\n  fade: 0.2, // share of the pin over which the children fade out\n  overlay: [0.8, 0.92], // where the overlay fades in, as shares of the pin\n  reveal: 0.4, // s the media takes to fade in once the window is placed\n};\n\nconst SCROLL_SPRING = { stiffness: 120, damping: 30, mass: 0.4 };\n\nconst HEADLINE =\n  \"flex items-center font-semibold leading-[0.9] tracking-[-0.045em] text-[clamp(2.75rem,12cqw,5rem)] @3xl:text-[clamp(3rem,7cqw,8.5rem)]\";\n\nconst CHILDREN = \"mt-[clamp(1.75rem,4cqw,3rem)] flex flex-col items-center gap-6\";\n\n// Each half's box is trimmed to its cap height and baseline, so the window\n// centers on the capitals beside it and sits evenly between stacked halves.\nconst HALF = \"block whitespace-nowrap [text-box:trim-both_cap_alphabetic] will-change-transform\";\n\n// The media covers whatever box it's in, and never resizes with the window.\nconst MEDIA = \"*:size-full *:object-cover\";\n\nexport interface ExpandingHeroProps extends React.HTMLAttributes<HTMLElement> {\n  /** The words before the window. One to three short words read best. */\n  before: React.ReactNode;\n  /** The words after the window. */\n  after: React.ReactNode;\n  /** What plays in the window: a video, an image or any element. It covers the stage. */\n  media: React.ReactNode;\n  /** Shown over the media once it fills the stage, like a play button. Centered. */\n  overlay?: React.ReactNode;\n  /** How long the section stays pinned, in stage heights. */\n  scrollLength?: number;\n  /**\n   * Corner radius of the window at the start. A number is in px; a string is\n   * any CSS length, and `em` follows the headline's size.\n   */\n  radius?: number | string;\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 here. */\n  stageClassName?: string;\n  /** Classes for the headline, such as its font and size. */\n  headlineClassName?: string;\n  /** Classes for the window's starting size. */\n  windowClassName?: string;\n}\n\nexport function ExpandingHero({\n  scrollLength,\n  radius,\n  containerRef,\n  stageClassName,\n  windowClassName,\n  ...props\n}: ExpandingHeroProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  if (reduceMotion) return <StillHero {...props} />;\n  return (\n    <PinnedHero\n      scrollLength={scrollLength}\n      radius={radius}\n      containerRef={containerRef}\n      stageClassName={stageClassName}\n      windowClassName={windowClassName}\n      {...props}\n    />\n  );\n}\n\n/**\n * Where the window starts, measured from the stage: how far each of its edges\n * travels to reach the stage's, and which way each half is pushed.\n */\ntype Geometry = {\n  top: number;\n  right: number;\n  bottom: number;\n  left: number;\n  /** The starting corner radius, in px. */\n  radius: number;\n  /** \"x\": the half sits beside the window. \"y\": it's stacked above or below. */\n  before: \"x\" | \"y\";\n  after: \"x\" | \"y\";\n};\n\n// How far each piece travels by the time the window fills the stage. Halves\n// beside the window ride its side edge and follow its center up or down;\n// stacked halves ride its top or bottom edge.\nconst TRAVEL = {\n  beforeX: (g: Geometry) => (g.before === \"x\" ? -g.left : (g.right - g.left) / 2),\n  beforeY: (g: Geometry) => (g.before === \"x\" ? (g.bottom - g.top) / 2 : -g.top),\n  afterX: (g: Geometry) => (g.after === \"x\" ? g.right : (g.right - g.left) / 2),\n  afterY: (g: Geometry) => (g.after === \"x\" ? (g.bottom - g.top) / 2 : g.bottom),\n  childrenY: (g: Geometry) => g.bottom,\n};\n\nfunction PinnedHero({\n  before,\n  after,\n  media,\n  overlay,\n  scrollLength = 2,\n  radius = \"0.22em\",\n  containerRef,\n  stageClassName,\n  headlineClassName,\n  windowClassName,\n  className,\n  children,\n  ...props\n}: ExpandingHeroProps) {\n  const sectionRef = React.useRef<HTMLElement>(null);\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const headlineRef = React.useRef<HTMLHeadingElement>(null);\n  const windowRef = React.useRef<HTMLSpanElement>(null);\n  const beforeRef = React.useRef<HTMLSpanElement>(null);\n  const afterRef = React.useRef<HTMLSpanElement>(null);\n  const childrenRef = React.useRef<HTMLDivElement>(null);\n  const [stageHeight, setStageHeight] = React.useState(0);\n  const hasChildren = Boolean(children);\n\n  const geometry = useMotionValue<Geometry | null>(null);\n  // Hidden until the window is measured, so it never shows in the wrong place.\n  const mediaOpacity = useMotionValue(0);\n\n  const { scrollYProgress } = useScroll({\n    target: sectionRef,\n    container: containerRef,\n    offset: [\"start start\", \"end end\"],\n  });\n  const progress = useSpring(scrollYProgress, SCROLL_SPRING);\n  const grow = useTransform(progress, [0, HERO.grow], [0, 1], { ease: HERO.ease });\n\n  const clipPath = useTransform(() => {\n    const g = geometry.get();\n    const rest = 1 - grow.get();\n    if (!g || rest <= 0) return \"none\";\n    // The corners square off as the nearest edge closes in on the stage's.\n    const corner = Math.min(g.radius, Math.min(g.top, g.right, g.bottom, g.left) * rest);\n    return `inset(${g.top * rest}px ${g.right * rest}px ${g.bottom * rest}px ${g.left * rest}px round ${corner}px)`;\n  });\n  const zoom = useTransform(grow, [0, 1], [HERO.zoom, 1]);\n\n  const beforeX = useTravel(geometry, grow, TRAVEL.beforeX);\n  const beforeY = useTravel(geometry, grow, TRAVEL.beforeY);\n  const afterX = useTravel(geometry, grow, TRAVEL.afterX);\n  const afterY = useTravel(geometry, grow, TRAVEL.afterY);\n  const childrenY = useTravel(geometry, grow, TRAVEL.childrenY);\n\n  const childrenOpacity = useTransform(progress, [0, HERO.fade], [1, 0]);\n  const childrenVisibility = useTransform(childrenOpacity, (o) => (o > 0 ? \"visible\" : \"hidden\"));\n  const overlayOpacity = useTransform(progress, HERO.overlay, [0, 1]);\n  const overlayVisibility = useTransform(overlayOpacity, (o) => (o > 0 ? \"visible\" : \"hidden\"));\n\n  React.useLayoutEffect(() => {\n    const stage = stageRef.current;\n    const win = windowRef.current;\n    const first = beforeRef.current;\n    const last = afterRef.current;\n    if (!stage || !win || !first || !last) return;\n\n    let placed = false;\n    const measure = () => {\n      const s = stage.getBoundingClientRect();\n      const w = win.getBoundingClientRect();\n      if (!w.width || !w.height) return;\n      geometry.set({\n        top: w.top - s.top,\n        right: s.right - w.right,\n        bottom: s.bottom - w.bottom,\n        left: w.left - s.left,\n        radius: parseFloat(getComputedStyle(win).borderTopLeftRadius) || 0,\n        before: beside(first, win) ? \"x\" : \"y\",\n        after: beside(last, win) ? \"x\" : \"y\",\n      });\n      setStageHeight(s.height);\n      if (!placed) {\n        placed = true;\n        animate(mediaOpacity, 1, { duration: HERO.reveal, ease: \"easeOut\" });\n      }\n    };\n    measure();\n\n    // The content is centered, so anything in it changing size can move the\n    // window: fonts loading, a resize, new copy.\n    const observer = new ResizeObserver(measure);\n    for (const el of [stage, headlineRef.current, win, first, last, childrenRef.current]) {\n      if (el) observer.observe(el);\n    }\n    return () => observer.disconnect();\n  }, [geometry, mediaOpacity, hasChildren]);\n\n  return (\n    <section ref={sectionRef} className={cn(\"relative\", className)} {...props}>\n      <div\n        ref={stageRef}\n        className={cn(\n          \"@container sticky top-0 flex h-dvh w-full flex-col items-center justify-center overflow-hidden px-6 text-center\",\n          stageClassName,\n        )}\n      >\n        <h1\n          ref={headlineRef}\n          className={cn(\n            HEADLINE,\n            \"flex-col gap-y-[0.34em] @3xl:flex-row @3xl:gap-x-[0.2em]\",\n            headlineClassName,\n          )}\n        >\n          <motion.span ref={beforeRef} className={HALF} style={{ x: beforeX, y: beforeY }}>\n            {before}\n          </motion.span>{\" \"}\n          {/* Holds the window's place in the line. The media opens from here. */}\n          <span\n            ref={windowRef}\n            aria-hidden\n            className={cn(\n              \"block aspect-[16/10] w-[min(68cqw,26rem)] shrink-0 bg-current/10 @3xl:aspect-auto @3xl:h-[0.8em] @3xl:w-[1.6em]\",\n              windowClassName,\n            )}\n            style={{ borderRadius: radius }}\n          />{\" \"}\n          <motion.span ref={afterRef} className={HALF} style={{ x: afterX, y: afterY }}>\n            {after}\n          </motion.span>\n        </h1>\n\n        {children ? (\n          <motion.div\n            ref={childrenRef}\n            className={cn(CHILDREN, \"will-change-transform\")}\n            style={{ y: childrenY, opacity: childrenOpacity, visibility: childrenVisibility }}\n          >\n            {children}\n          </motion.div>\n        ) : null}\n\n        <motion.div\n          className=\"absolute inset-0 z-10 overflow-hidden\"\n          style={{ clipPath, opacity: mediaOpacity }}\n        >\n          <motion.div className={cn(\"size-full will-change-transform\", MEDIA)} style={{ scale: zoom }}>\n            {media}\n          </motion.div>\n        </motion.div>\n\n        {overlay ? (\n          <motion.div\n            className=\"absolute inset-0 z-20 flex items-center justify-center\"\n            style={{ opacity: overlayOpacity, visibility: overlayVisibility }}\n          >\n            {overlay}\n          </motion.div>\n        ) : null}\n      </div>\n      {/* Scroll runway: the stage stays pinned while this scrolls past. */}\n      <div\n        aria-hidden\n        style={{ height: stageHeight ? stageHeight * scrollLength : `${scrollLength * 100}dvh` }}\n      />\n    </section>\n  );\n}\n\n/** Reduced motion: nothing pins. The headline, then the media at full width. */\nfunction StillHero({\n  before,\n  after,\n  media,\n  overlay,\n  headlineClassName,\n  className,\n  children,\n  ...props\n}: Omit<\n  ExpandingHeroProps,\n  \"scrollLength\" | \"radius\" | \"containerRef\" | \"stageClassName\" | \"windowClassName\"\n>) {\n  return (\n    <section className={cn(\"@container relative\", className)} {...props}>\n      <div className=\"flex flex-col items-center px-6 pt-[clamp(4rem,12cqw,8rem)] pb-[clamp(2.5rem,6cqw,4rem)] text-center\">\n        <h1 className={cn(HEADLINE, \"flex-wrap justify-center gap-x-[0.25em]\", headlineClassName)}>\n          <span className=\"whitespace-nowrap\">{before}</span>{\" \"}\n          <span className=\"whitespace-nowrap\">{after}</span>\n        </h1>\n        {children ? <div className={CHILDREN}>{children}</div> : null}\n      </div>\n      <div className=\"relative aspect-video w-full overflow-hidden\">\n        <div className={cn(\"absolute inset-0\", MEDIA)}>{media}</div>\n        {overlay ? (\n          <div className=\"absolute inset-0 flex items-center justify-center\">{overlay}</div>\n        ) : null}\n      </div>\n    </section>\n  );\n}\n\n/** How far a piece has traveled: its full travel, scaled by how far the window has opened. */\nfunction useTravel(\n  geometry: MotionValue<Geometry | null>,\n  grow: MotionValue<number>,\n  travel: (g: Geometry) => number,\n) {\n  return useTransform(() => {\n    const g = geometry.get();\n    return g ? travel(g) * grow.get() : 0;\n  });\n}\n\n/** Whether `el` shares a line with the window. Layout offsets ignore the pushes. */\nfunction beside(el: HTMLElement, win: HTMLElement) {\n  return el.offsetTop < win.offsetTop + win.offsetHeight && win.offsetTop < el.offsetTop + el.offsetHeight;\n}\n\nconst REDUCED_MOTION = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeToReducedMotion(onChange: () => void) {\n  const query = window.matchMedia(REDUCED_MOTION);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\n\n/** Reads false on the server and while hydrating, so the markup always matches. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeToReducedMotion,\n    () => window.matchMedia(REDUCED_MOTION).matches,\n    () => false,\n  );\n}\n"
    }
  ]
}