{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-expand",
  "title": "Scroll Expand & Focus Hero",
  "description": "An art-directed scroll hero that expands a detail into context or focuses a full-bleed scene into a discovery.",
  "registryDependencies": [
    "https://ui.ericts.com/r/use-reduced-motion.json",
    "https://ui.ericts.com/r/use-scroll-progress.json"
  ],
  "files": [
    {
      "path": "registry/base/blocks/scroll-expand.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { useScrollProgress } from \"@/hooks/use-scroll-progress\";\nimport { cn } from \"@/lib/utils\";\n\nimport \"./scroll-expand.css\";\n\nexport type ScrollExpandMediaType = \"image\" | \"video\";\nexport type ScrollExpandDirection = \"expand\" | \"focus\";\nexport type ScrollExpandFrameShape = \"rounded\" | \"circle\";\nexport type ScrollExpandAlignment = \"start\" | \"center\" | \"end\";\nexport type ScrollExpandContentPosition = \"center\" | \"bottom\";\nexport type ScrollExpandContentLayer = \"frame\" | \"stage\";\nexport type ScrollExpandStartPosition = {\n  /** Horizontal center of the resting frame, as a stage percentage. */\n  x?: number;\n  /** Vertical center of the resting frame, as a stage percentage. */\n  y?: number;\n};\n\n/**\n * The subject of the shot, as a percentage of the media's own dimensions.\n *\n * `startPosition` lives in stage space while the subject lives in image space,\n * so a `cover` crop pulls them apart as soon as the stage aspect ratio changes.\n * Naming the subject here lets the media pan itself under the frame instead.\n */\nexport type ScrollExpandFocalPoint = {\n  x?: number;\n  y?: number;\n};\n\n/**\n * Choreography values re-tuned for a narrow, portrait stage. The frame geometry\n * is computed in JavaScript, so a container query cannot reach it — a phone\n * needs its own numbers rather than a scaled-down desktop composition.\n */\nexport type ScrollExpandCompactOverrides = {\n  startWidth?: number;\n  startHeight?: number;\n  startPosition?: ScrollExpandStartPosition;\n  focalPoint?: ScrollExpandFocalPoint;\n  startRadius?: number;\n  endRadius?: number;\n  mediaZoom?: number;\n  mediaPosition?: React.CSSProperties[\"objectPosition\"];\n  mediaTransformOrigin?: React.CSSProperties[\"transformOrigin\"];\n  scrollDistance?: number;\n  holdDistance?: number;\n  overlayScrim?: number;\n  titleAlign?: ScrollExpandAlignment;\n  contentAlign?: ScrollExpandAlignment;\n  contentPosition?: ScrollExpandContentPosition;\n};\n\ntype ScrollExpandItemRegistry = {\n  register: (node: HTMLDivElement) => void;\n  unregister: (node: HTMLDivElement) => void;\n};\n\nconst ScrollExpandItemContext = React.createContext<\n  ScrollExpandItemRegistry | undefined\n>(undefined);\n\nexport interface ScrollExpandProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    \"title\" | \"onProgress\"\n  > {\n  src?: string;\n  mediaType?: ScrollExpandMediaType;\n  /** Expand a detail into context, or focus full-bleed context into a detail. */\n  direction?: ScrollExpandDirection;\n  /** Shape used by the resting or focused frame. */\n  frameShape?: ScrollExpandFrameShape;\n  poster?: string;\n  alt?: string;\n  title?: string;\n  scrollHint?: string;\n  startWidth?: number;\n  startHeight?: number;\n  startPosition?: ScrollExpandStartPosition;\n  /**\n   * Anchor the media to its subject rather than to the stage. When set, the\n   * media covers the stage and pans so this point sits under the frame center\n   * at every stage size, and `mediaPosition` / `mediaTransformOrigin` are\n   * ignored. Requires a `mediaZoom` of at least `1`.\n   */\n  focalPoint?: ScrollExpandFocalPoint;\n  startRadius?: number;\n  endRadius?: number;\n  mediaZoom?: number;\n  mediaPosition?: React.CSSProperties[\"objectPosition\"];\n  /** Pivot used while the media zooms, useful for keeping an off-center subject aligned. */\n  mediaTransformOrigin?: React.CSSProperties[\"transformOrigin\"];\n  scrollDistance?: number;\n  holdDistance?: number;\n  smoothing?: number;\n  overlayScrim?: number;\n  titleAlign?: ScrollExpandAlignment;\n  /** Additional classes for sizing or styling the overlaid title. */\n  titleClassName?: string;\n  contentAlign?: ScrollExpandAlignment;\n  contentPosition?: ScrollExpandContentPosition;\n  /** Keep content clipped to the media, or place it over the whole stage. */\n  contentLayer?: ScrollExpandContentLayer;\n  /** Choreography overrides applied while the stage is narrower than `compactAt`. */\n  compact?: ScrollExpandCompactOverrides;\n  /** Stage width in px below which `compact` applies. */\n  compactAt?: number;\n  /**\n   * Drive the scroll from the page instead of a nested scroller. Prefer this on\n   * touch devices, where a nested scroller traps momentum and chains awkwardly.\n   */\n  useWindowScroll?: boolean;\n  enabled?: boolean;\n  /**\n   * Honour `prefers-reduced-motion` by settling on the resting composition.\n   * Set to `false` only where the viewer has explicitly asked to see the motion.\n   */\n  respectReducedMotion?: boolean;\n  /** Receives the same raw 0–1 progress used by the internal choreography. */\n  onProgress?: (progress: number) => void;\n}\n\nexport interface ScrollExpandItemProps\n  extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Scroll progress where this item starts entering. */\n  start?: number;\n  /** Scroll progress where this item finishes entering. */\n  end?: number;\n  /** Vertical travel in px before the item settles. */\n  offsetY?: number;\n  /** Initial scale before the item settles at `1`. */\n  scaleFrom?: number;\n}\n\ntype MotionValues = {\n  startWidth: number;\n  startHeight: number;\n  startX: number;\n  startY: number;\n  startInsetTop: number;\n  startInsetRight: number;\n  startInsetBottom: number;\n  startInsetLeft: number;\n  startRadius: number;\n  endRadius: number;\n  mediaZoom: number;\n  overlayScrim: number;\n};\n\nconst DEFAULT_MOTION_VALUES: MotionValues = {\n  startWidth: 42,\n  startHeight: 58,\n  startX: 50,\n  startY: 50,\n  startInsetTop: 21,\n  startInsetRight: 29,\n  startInsetBottom: 21,\n  startInsetLeft: 29,\n  startRadius: 24,\n  endRadius: 0,\n  mediaZoom: 1.35,\n  overlayScrim: 0.45,\n};\n\nexport function ScrollExpand({\n  src = \"\",\n  mediaType = \"image\",\n  direction = \"expand\",\n  frameShape = \"rounded\",\n  poster = \"\",\n  alt = \"\",\n  title = \"\",\n  scrollHint = \"\",\n  startWidth = 42,\n  startHeight = 58,\n  startPosition,\n  focalPoint,\n  startRadius = DEFAULT_MOTION_VALUES.startRadius,\n  endRadius = DEFAULT_MOTION_VALUES.endRadius,\n  mediaZoom = DEFAULT_MOTION_VALUES.mediaZoom,\n  mediaPosition = \"center\",\n  mediaTransformOrigin = \"center\",\n  scrollDistance = 1.2,\n  holdDistance = 0.35,\n  smoothing = 0.1,\n  overlayScrim = DEFAULT_MOTION_VALUES.overlayScrim,\n  titleAlign = \"center\",\n  titleClassName,\n  contentAlign = \"center\",\n  contentPosition = \"center\",\n  contentLayer = \"frame\",\n  compact,\n  compactAt = 640,\n  useWindowScroll = false,\n  enabled = true,\n  respectReducedMotion = true,\n  onProgress,\n  children,\n  className,\n  style,\n  tabIndex,\n  role,\n  \"aria-label\": ariaLabel,\n  ...props\n}: ScrollExpandProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const frameRef = React.useRef<HTMLDivElement>(null);\n  const mediaRef = React.useRef<HTMLElement | null>(null);\n  const titleRef = React.useRef<HTMLHeadingElement>(null);\n  const overlayRef = React.useRef<HTMLDivElement>(null);\n  const scrimRef = React.useRef<HTMLDivElement>(null);\n  const hintRef = React.useRef<HTMLDivElement>(null);\n  const itemNodesRef = React.useRef(new Set<HTMLDivElement>());\n  const stageSizeRef = React.useRef({ width: 0, height: 0 });\n  const mediaSizeRef = React.useRef({ width: 0, height: 0 });\n  const mediaBoxRef = React.useRef({\n    active: false,\n    originX: 0,\n    originY: 0,\n    boxWidth: 0,\n    boxHeight: 0,\n    offsetX: 0,\n    offsetY: 0,\n  });\n  const progressRef = React.useRef(enabled ? 0 : 1);\n  const [isCompact, setIsCompact] = React.useState(false);\n  const systemReducedMotion = useReducedMotion();\n  const prefersReducedMotion = systemReducedMotion && respectReducedMotion;\n  const motionEnabled = enabled && !prefersReducedMotion;\n\n  // Resolved one scalar at a time so an inline `compact` object literal does not\n  // invalidate the memos and callbacks below on every render.\n  const overrides = isCompact ? compact : undefined;\n  const startX = overrides?.startPosition?.x ?? startPosition?.x ?? 50;\n  const startY = overrides?.startPosition?.y ?? startPosition?.y ?? 50;\n  const resolvedFocalPoint = overrides?.focalPoint ?? focalPoint;\n  const focalX = resolvedFocalPoint\n    ? clamp(finiteNumber(resolvedFocalPoint.x ?? 50, 50), 0, 100) / 100\n    : null;\n  const focalY = resolvedFocalPoint\n    ? clamp(finiteNumber(resolvedFocalPoint.y ?? 50, 50), 0, 100) / 100\n    : null;\n  const resolvedStartWidth = overrides?.startWidth ?? startWidth;\n  const resolvedStartHeight = overrides?.startHeight ?? startHeight;\n  const resolvedStartRadius = overrides?.startRadius ?? startRadius;\n  const resolvedEndRadius = overrides?.endRadius ?? endRadius;\n  const resolvedMediaZoom = overrides?.mediaZoom ?? mediaZoom;\n  const resolvedMediaPosition = overrides?.mediaPosition ?? mediaPosition;\n  const resolvedMediaTransformOrigin =\n    overrides?.mediaTransformOrigin ?? mediaTransformOrigin;\n  const resolvedScrollDistance = overrides?.scrollDistance ?? scrollDistance;\n  const resolvedHoldDistance = overrides?.holdDistance ?? holdDistance;\n  const resolvedOverlayScrim = overrides?.overlayScrim ?? overlayScrim;\n  const resolvedTitleAlign = overrides?.titleAlign ?? titleAlign;\n  const resolvedContentAlign = overrides?.contentAlign ?? contentAlign;\n  const resolvedContentPosition = overrides?.contentPosition ?? contentPosition;\n\n  const motionValues = React.useMemo<MotionValues>(\n    () => {\n      const width = clamp(finiteNumber(resolvedStartWidth, 42), 0, 100);\n      const height = clamp(finiteNumber(resolvedStartHeight, 58), 0, 100);\n      const left = clamp(\n        finiteNumber(startX, 50) - width / 2,\n        0,\n        100 - width,\n      );\n      const top = clamp(\n        finiteNumber(startY, 50) - height / 2,\n        0,\n        100 - height,\n      );\n\n      return {\n        startWidth: width,\n        startHeight: height,\n        startX: finiteNumber(startX, 50),\n        startY: finiteNumber(startY, 50),\n        startInsetTop: top,\n        startInsetRight: 100 - left - width,\n        startInsetBottom: 100 - top - height,\n        startInsetLeft: left,\n        startRadius: Math.max(0, finiteNumber(resolvedStartRadius, 24)),\n        endRadius: Math.max(0, finiteNumber(resolvedEndRadius, 0)),\n        mediaZoom: Math.max(0.01, finiteNumber(resolvedMediaZoom, 1.35)),\n        overlayScrim: clamp(finiteNumber(resolvedOverlayScrim, 0.45), 0, 1),\n      };\n    },\n    [\n      resolvedEndRadius,\n      resolvedMediaZoom,\n      resolvedOverlayScrim,\n      resolvedStartHeight,\n      resolvedStartRadius,\n      resolvedStartWidth,\n      startX,\n      startY,\n    ],\n  );\n\n  const applyItemProgress = React.useCallback(\n    (node: HTMLDivElement, progress: number) => {\n      const start = clamp(\n        finiteNumber(Number(node.dataset.start), 0.64),\n        0,\n        1,\n      );\n      const end = clamp(\n        finiteNumber(Number(node.dataset.end), 0.94),\n        start,\n        1,\n      );\n      const offsetY = finiteNumber(Number(node.dataset.offsetY), 24);\n      const scaleFrom = Math.max(\n        0.01,\n        finiteNumber(Number(node.dataset.scaleFrom), 0.98),\n      );\n      const revealProgress = smoothstep(start, end, progress);\n\n      node.style.opacity = `${revealProgress}`;\n      node.style.transform = `translate3d(0, ${offsetY * (1 - revealProgress)}px, 0) scale(${scaleFrom + (1 - scaleFrom) * revealProgress})`;\n    },\n    [],\n  );\n\n  const itemRegistry = React.useMemo<ScrollExpandItemRegistry>(\n    () => ({\n      register: (node) => {\n        itemNodesRef.current.add(node);\n        applyItemProgress(node, progressRef.current);\n      },\n      unregister: (node) => {\n        itemNodesRef.current.delete(node);\n      },\n    }),\n    [applyItemProgress],\n  );\n\n  const applyProgress = React.useCallback((progress: number) => {\n    const frame = frameRef.current;\n    const media = mediaRef.current;\n\n    if (!frame || !media) {\n      return;\n    }\n\n    progressRef.current = progress;\n    rootRef.current?.style.setProperty(\n      \"--scroll-expand-progress\",\n      String(progress),\n    );\n    onProgress?.(progress);\n\n    const values = motionValues;\n    const geometry = resolveFrameGeometry(\n      values,\n      frameShape,\n      stageSizeRef.current,\n    );\n    const eased = smoothstep(0, 1, progress);\n    // Progress is pinned to 1 while motion is off, so both directions settle on\n    // their own end state: full bleed for `expand`, the detail frame for `focus`.\n    const frameProgress = direction === \"focus\" ? 1 - eased : eased;\n    const remainingInset = 1 - frameProgress;\n    const insetTop = geometry.insetTop * remainingInset;\n    const insetRight = geometry.insetRight * remainingInset;\n    const insetBottom = geometry.insetBottom * remainingInset;\n    const insetLeft = geometry.insetLeft * remainingInset;\n    const radius =\n      geometry.startRadius +\n      (values.endRadius - geometry.startRadius) * frameProgress;\n\n    frame.style.clipPath = `inset(${insetTop}${geometry.unit} ${insetRight}${geometry.unit} ${insetBottom}${geometry.unit} ${insetLeft}${geometry.unit} round ${radius}px)`;\n\n    const zoom = values.mediaZoom + (1 - values.mediaZoom) * frameProgress;\n    const box = mediaBoxRef.current;\n\n    if (box.active) {\n      // The zoom pivots on the subject and the offset that parks the subject on\n      // its target is a constant, so nothing here depends on progress: the media\n      // only ever scales. `handleMeasure` sizes the box so this offset already\n      // covers the stage at the shallowest zoom, which is what removes the pan\n      // a minimal cover box would otherwise force near full bleed.\n      media.style.transform = `translate3d(${roundPixel(box.offsetX)}px, ${roundPixel(box.offsetY)}px, 0) scale(${zoom})`;\n    } else {\n      media.style.transform = `scale(${zoom})`;\n    }\n\n    if (scrimRef.current) {\n      const scrimProgress = smoothstep(0.38, 1, progress);\n      scrimRef.current.style.opacity = `${values.overlayScrim * scrimProgress}`;\n    }\n\n    if (titleRef.current) {\n      const exitProgress = smoothstep(0.32, 0.74, progress);\n      titleRef.current.style.opacity = `${1 - exitProgress}`;\n      titleRef.current.style.transform = `translate3d(0, ${-22 * exitProgress}px, 0) scale(${1 - 0.04 * exitProgress})`;\n    }\n\n    if (hintRef.current) {\n      const exitProgress = smoothstep(0, 0.12, progress);\n      hintRef.current.style.opacity = `${1 - exitProgress}`;\n      hintRef.current.style.transform = `translate3d(0, ${8 * exitProgress}px, 0)`;\n    }\n\n    if (overlayRef.current) {\n      const enterProgress = smoothstep(0.56, 0.9, progress);\n      const isHidden = enterProgress < 0.98;\n\n      overlayRef.current.style.opacity = `${enterProgress}`;\n      overlayRef.current.style.transform = `translate3d(0, ${18 * (1 - enterProgress)}px, 0)`;\n      overlayRef.current.toggleAttribute(\"inert\", isHidden);\n      overlayRef.current.setAttribute(\"aria-hidden\", String(isHidden));\n    }\n\n    itemNodesRef.current.forEach((node) => {\n      applyItemProgress(node, progress);\n    });\n  }, [applyItemProgress, direction, frameShape, motionValues, onProgress]);\n\n  const handleMeasure = React.useCallback(\n    (viewportHeight: number) => {\n      const track = trackRef.current;\n      const stage = stageRef.current;\n\n      if (!track || !stage) {\n        return;\n      }\n\n      const stageWidth = rootRef.current?.clientWidth ?? 0;\n\n      stageSizeRef.current = { width: stageWidth, height: viewportHeight };\n\n      if (stageWidth > 0) {\n        setIsCompact(stageWidth < Math.max(0, finiteNumber(compactAt, 640)));\n      }\n\n      // Focal mode grows the media to the full cover box so the crop can be\n      // chosen here, once, rather than by `object-fit` reacting to the stage\n      // aspect ratio. The box keeps the media's own ratio, so nothing stretches.\n      const media = mediaRef.current;\n      const intrinsic = mediaSizeRef.current;\n\n      if (\n        media &&\n        focalX !== null &&\n        focalY !== null &&\n        stageWidth > 0 &&\n        intrinsic.width > 0 &&\n        intrinsic.height > 0\n      ) {\n        const coverScale = Math.max(\n          stageWidth / intrinsic.width,\n          viewportHeight / intrinsic.height,\n        );\n        // Where the subject wants to sit: the centre of the resting detail\n        // frame. Fixed for the whole scroll, so the media is never chased around\n        // by the frame centre as it travels.\n        const restX =\n          ((motionValues.startInsetLeft + motionValues.startWidth / 2) / 100) *\n          stageWidth;\n        const restY =\n          ((motionValues.startInsetTop + motionValues.startHeight / 2) / 100) *\n          viewportHeight;\n        // Holding the subject on that target with a constant offset is what\n        // makes the travel a pure zoom. It only stays coverage-legal if the box\n        // is big enough to absorb the offset at the shallowest zoom the media is\n        // ever drawn at, so solve each stage edge for the box width it needs and\n        // take the largest. A minimal cover box is the floor.\n        const zoomFloor = Math.min(1, motionValues.mediaZoom);\n        const zoomCeiling = Math.max(1, motionValues.mediaZoom);\n        const scale = Math.min(\n          Math.max(\n            coverScale,\n            edgeScale(restX, focalX, intrinsic.width, zoomFloor),\n            edgeScale(stageWidth - restX, 1 - focalX, intrinsic.width, zoomFloor),\n            edgeScale(restY, focalY, intrinsic.height, zoomFloor),\n            edgeScale(\n              viewportHeight - restY,\n              1 - focalY,\n              intrinsic.height,\n              zoomFloor,\n            ),\n          ),\n          // A subject pinned against its own edge can ask for an unbounded box.\n          // Stop at the coverage the old pan reached at its widest zoom — past\n          // that the request is unsatisfiable at any size, and the clamp below\n          // keeps the stage covered instead.\n          (coverScale * zoomCeiling) / zoomFloor,\n        );\n        const boxWidth = intrinsic.width * scale;\n        const boxHeight = intrinsic.height * scale;\n        const subjectX = focalX * boxWidth;\n        const subjectY = focalY * boxHeight;\n        // Only bites for the unsatisfiable configurations above.\n        const offsetX = clamp(\n          restX - subjectX,\n          stageWidth - subjectX - zoomFloor * (boxWidth - subjectX),\n          subjectX * (zoomFloor - 1),\n        );\n        const offsetY = clamp(\n          restY - subjectY,\n          viewportHeight - subjectY - zoomFloor * (boxHeight - subjectY),\n          subjectY * (zoomFloor - 1),\n        );\n\n        mediaBoxRef.current = {\n          active: true,\n          originX: subjectX,\n          originY: subjectY,\n          boxWidth,\n          boxHeight,\n          offsetX,\n          offsetY,\n        };\n        media.style.width = `${roundPixel(boxWidth)}px`;\n        media.style.height = `${roundPixel(boxHeight)}px`;\n        media.style.transformOrigin = `${roundPixel(subjectX)}px ${roundPixel(subjectY)}px`;\n      } else {\n        mediaBoxRef.current = {\n          active: false,\n          originX: 0,\n          originY: 0,\n          boxWidth: 0,\n          boxHeight: 0,\n          offsetX: 0,\n          offsetY: 0,\n        };\n\n        if (media) {\n          media.style.width = \"\";\n          media.style.height = \"\";\n        }\n      }\n\n      const expansion = Math.max(0, finiteNumber(resolvedScrollDistance, 1.2));\n      const hold = Math.max(0, finiteNumber(resolvedHoldDistance, 0.35));\n      const trackMultiplier = motionEnabled ? 1 + expansion + hold : 1;\n\n      stage.style.height = `${viewportHeight}px`;\n      track.style.height = `${roundPixel(viewportHeight * trackMultiplier)}px`;\n    },\n    [\n      compactAt,\n      focalX,\n      focalY,\n      motionEnabled,\n      motionValues,\n      resolvedHoldDistance,\n      resolvedScrollDistance,\n    ],\n  );\n\n  const { measure } = useScrollProgress({\n    containerRef: rootRef,\n    trackRef,\n    source: useWindowScroll ? \"window\" : \"container\",\n    distance: Math.max(0.01, finiteNumber(resolvedScrollDistance, 1.2)),\n    smoothing: Math.max(0, finiteNumber(smoothing, 0.1)),\n    enabled: motionEnabled,\n    disabledProgress: 1,\n    onProgress: applyProgress,\n    onMeasure: handleMeasure,\n  });\n\n  const handleMediaLoad = React.useCallback(() => {\n    const media = mediaRef.current;\n\n    if (!media) {\n      return;\n    }\n\n    const width =\n      media instanceof HTMLVideoElement\n        ? media.videoWidth\n        : media instanceof HTMLImageElement\n          ? media.naturalWidth\n          : 0;\n    const height =\n      media instanceof HTMLVideoElement\n        ? media.videoHeight\n        : media instanceof HTMLImageElement\n          ? media.naturalHeight\n          : 0;\n    const current = mediaSizeRef.current;\n\n    if (\n      width <= 0 ||\n      height <= 0 ||\n      (current.width === width && current.height === height)\n    ) {\n      return;\n    }\n\n    mediaSizeRef.current = { width, height };\n    measure();\n  }, [measure]);\n\n  // Covers media that was already complete on mount, where `load` never fires.\n  React.useEffect(() => {\n    handleMediaLoad();\n  }, [handleMediaLoad, mediaType, src]);\n\n  const hasChildren = Boolean(children);\n\n  React.useEffect(() => {\n    measure();\n  }, [\n    direction,\n    frameShape,\n    handleMeasure,\n    measure,\n    resolvedEndRadius,\n    resolvedMediaZoom,\n    resolvedOverlayScrim,\n    resolvedStartHeight,\n    resolvedStartRadius,\n    resolvedStartWidth,\n    startX,\n    startY,\n    title,\n    scrollHint,\n    hasChildren,\n  ]);\n\n  React.useEffect(() => {\n    if (mediaType !== \"video\" || !(mediaRef.current instanceof HTMLVideoElement)) {\n      return;\n    }\n\n    const video = mediaRef.current;\n\n    if (prefersReducedMotion) {\n      video.pause();\n      return;\n    }\n\n    void video.play().catch(() => {\n      // Autoplay can be blocked by the browser; the poster remains visible.\n    });\n  }, [mediaType, prefersReducedMotion, src]);\n\n  const setMediaRef = React.useCallback((node: HTMLElement | null) => {\n    mediaRef.current = node;\n  }, []);\n  // In focal mode the element is grown to the cover box by `handleMeasure`, so\n  // it is anchored top-left and left to overflow. `object-fit: cover` from the\n  // stylesheet stays put deliberately: the sized box already carries the media's\n  // ratio, and before the intrinsic size is known it still crops rather than\n  // stretches. The transform origin is the subject, and is written on measure.\n  const mediaStyle: React.CSSProperties =\n    focalX !== null\n      ? { right: \"auto\", bottom: \"auto\" }\n      : {\n          objectPosition: resolvedMediaPosition,\n          transformOrigin: resolvedMediaTransformOrigin,\n        };\n  const nestedScroller = motionEnabled && !useWindowScroll;\n  const rootStyle = {\n    \"--scroll-expand-progress\": motionEnabled ? 0 : 1,\n    \"--scroll-expand-inset-top\": `${motionValues.startInsetTop}%`,\n    \"--scroll-expand-inset-right\": `${motionValues.startInsetRight}%`,\n    \"--scroll-expand-inset-bottom\": `${motionValues.startInsetBottom}%`,\n    \"--scroll-expand-inset-left\": `${motionValues.startInsetLeft}%`,\n    \"--scroll-expand-start-radius\": `${motionValues.startRadius}px`,\n    \"--scroll-expand-end-radius\": `${motionValues.endRadius}px`,\n    \"--scroll-expand-media-zoom\": motionValues.mediaZoom,\n    ...style,\n  } as React.CSSProperties;\n  const overlay = children ? (\n    <ScrollExpandItemContext.Provider value={itemRegistry}>\n      <div\n        ref={overlayRef}\n        className=\"scroll-expand__overlay\"\n        data-align={resolvedContentAlign}\n        data-position={resolvedContentPosition}\n        data-layer={contentLayer}\n        aria-hidden=\"true\"\n        inert\n      >\n        {children}\n      </div>\n    </ScrollExpandItemContext.Provider>\n  ) : null;\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"scroll-expand\"\n      data-direction={direction}\n      data-frame-shape={frameShape}\n      data-motion={motionEnabled ? \"enabled\" : \"disabled\"}\n      data-size={isCompact ? \"compact\" : \"regular\"}\n      className={cn(\n        \"scroll-expand\",\n        nestedScroller && \"scroll-expand--scroller\",\n        className,\n      )}\n      style={rootStyle}\n      tabIndex={tabIndex ?? (nestedScroller ? 0 : undefined)}\n      role={role ?? (nestedScroller ? \"region\" : undefined)}\n      aria-label={\n        ariaLabel ??\n        (nestedScroller ? title || \"Scroll-controlled media\" : undefined)\n      }\n      {...props}\n    >\n      <div ref={trackRef} className=\"scroll-expand__track\">\n        <div ref={stageRef} className=\"scroll-expand__stage\">\n          <div ref={frameRef} className=\"scroll-expand__frame\">\n            {src ? (\n              mediaType === \"video\" ? (\n                <video\n                  ref={setMediaRef}\n                  className=\"scroll-expand__media\"\n                  src={src}\n                  poster={poster || undefined}\n                  aria-label={alt || undefined}\n                  aria-hidden={alt ? undefined : true}\n                  autoPlay={!prefersReducedMotion}\n                  muted\n                  loop\n                  playsInline\n                  preload=\"metadata\"\n                  onLoadedMetadata={handleMediaLoad}\n                  style={mediaStyle}\n                />\n              ) : (\n                // Native media keeps the registry block framework-agnostic.\n                // eslint-disable-next-line @next/next/no-img-element\n                <img\n                  ref={setMediaRef}\n                  className=\"scroll-expand__media\"\n                  src={src}\n                  alt={alt}\n                  draggable={false}\n                  onLoad={handleMediaLoad}\n                  style={mediaStyle}\n                />\n              )\n            ) : (\n              <div\n                ref={setMediaRef}\n                className=\"scroll-expand__media scroll-expand__media--empty\"\n                aria-hidden=\"true\"\n              />\n            )}\n            <div\n              ref={scrimRef}\n              className=\"scroll-expand__scrim\"\n              aria-hidden=\"true\"\n            />\n            {contentLayer === \"frame\" ? overlay : null}\n          </div>\n          {contentLayer === \"stage\" ? overlay : null}\n          {title ? (\n            <h2\n              ref={titleRef}\n              className={cn(\"scroll-expand__title\", titleClassName)}\n              data-align={resolvedTitleAlign}\n            >\n              {title}\n            </h2>\n          ) : null}\n          {scrollHint ? (\n            <div\n              ref={hintRef}\n              className=\"scroll-expand__hint\"\n              aria-hidden=\"true\"\n            >\n              {scrollHint}\n            </div>\n          ) : null}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function ScrollExpandItem({\n  start = 0.64,\n  end = 0.94,\n  offsetY = 24,\n  scaleFrom = 0.98,\n  className,\n  style,\n  ...props\n}: ScrollExpandItemProps) {\n  const registry = React.useContext(ScrollExpandItemContext);\n  const itemRef = React.useRef<HTMLDivElement>(null);\n\n  React.useEffect(() => {\n    const node = itemRef.current;\n\n    if (!node || !registry) {\n      return;\n    }\n\n    registry.register(node);\n\n    return () => {\n      registry.unregister(node);\n    };\n  }, [registry]);\n\n  const managed = Boolean(registry);\n  const itemStyle = managed\n    ? ({\n        \"--scroll-expand-item-offset-y\": `${offsetY}px`,\n        \"--scroll-expand-item-scale\": scaleFrom,\n        ...style,\n      } as React.CSSProperties)\n    : style;\n\n  return (\n    <div\n      ref={itemRef}\n      data-scroll-expand-item={managed ? \"\" : undefined}\n      data-start={managed ? clamp(start, 0, 1) : undefined}\n      data-end={managed ? clamp(end, 0, 1) : undefined}\n      data-offset-y={managed ? offsetY : undefined}\n      data-scale-from={managed ? scaleFrom : undefined}\n      className={cn(managed && \"scroll-expand__item\", className)}\n      style={itemStyle}\n      {...props}\n    />\n  );\n}\n\nfunction smoothstep(edgeStart: number, edgeEnd: number, value: number) {\n  const progress = clamp(\n    (value - edgeStart) / (edgeEnd - edgeStart || 1e-6),\n    0,\n    1,\n  );\n\n  return progress * progress * (3 - 2 * progress);\n}\n\nfunction resolveFrameGeometry(\n  values: MotionValues,\n  shape: ScrollExpandFrameShape,\n  stageSize: { width: number; height: number },\n) {\n  if (shape !== \"circle\" || stageSize.width <= 0 || stageSize.height <= 0) {\n    return {\n      insetTop: values.startInsetTop,\n      insetRight: values.startInsetRight,\n      insetBottom: values.startInsetBottom,\n      insetLeft: values.startInsetLeft,\n      startRadius: values.startRadius,\n      unit: \"%\",\n    } as const;\n  }\n\n  const diameter = Math.min(\n    stageSize.width * (values.startWidth / 100),\n    stageSize.height * (values.startHeight / 100),\n  );\n  const left = clamp(\n    stageSize.width * (values.startX / 100) - diameter / 2,\n    0,\n    stageSize.width - diameter,\n  );\n  const top = clamp(\n    stageSize.height * (values.startY / 100) - diameter / 2,\n    0,\n    stageSize.height - diameter,\n  );\n\n  return {\n    insetTop: top,\n    insetRight: stageSize.width - left - diameter,\n    insetBottom: stageSize.height - top - diameter,\n    insetLeft: left,\n    startRadius: diameter / 2,\n    unit: \"px\",\n  } as const;\n}\n\n/**\n * Smallest box scale that still reaches `edge` from a subject sitting `share` of\n * the way across the media, at zoom `zoom`. A subject flush against that edge\n * (`share` of zero) can never reach it, so it contributes no constraint at all.\n */\nfunction edgeScale(\n  edge: number,\n  share: number,\n  intrinsicSize: number,\n  zoom: number,\n) {\n  if (share <= 0 || intrinsicSize <= 0 || zoom <= 0) {\n    return 0;\n  }\n\n  return edge / (zoom * share * intrinsicSize);\n}\n\nfunction finiteNumber(value: number, fallback: number) {\n  return Number.isFinite(value) ? value : fallback;\n}\n\nfunction roundPixel(value: number) {\n  return Math.round(value * 1000) / 1000;\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nexport default ScrollExpand;\n",
      "type": "registry:block",
      "target": "components/blocks/scroll-expand.tsx"
    },
    {
      "path": "registry/base/blocks/scroll-expand.css",
      "content": ".scroll-expand {\n  --scroll-expand-inset-top: 21%;\n  --scroll-expand-inset-right: 29%;\n  --scroll-expand-inset-bottom: 21%;\n  --scroll-expand-inset-left: 29%;\n  --scroll-expand-start-radius: 24px;\n  --scroll-expand-end-radius: 0px;\n  --scroll-expand-media-zoom: 1.35;\n  --scroll-expand-progress: 0;\n  position: relative;\n  width: 100%;\n  height: 100%;\n  min-height: 0;\n  isolation: isolate;\n  container-type: inline-size;\n}\n\n.scroll-expand--scroller {\n  overflow-x: hidden;\n  overflow-y: auto;\n  scrollbar-width: none;\n  -ms-overflow-style: none;\n}\n\n.scroll-expand--scroller::-webkit-scrollbar {\n  display: none;\n}\n\n.scroll-expand--scroller:focus-visible {\n  outline: 2px solid currentColor;\n  outline-offset: 3px;\n}\n\n.scroll-expand__track {\n  position: relative;\n  width: 100%;\n  height: 100%;\n}\n\n.scroll-expand__stage {\n  position: sticky;\n  top: 0;\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n  background: #101010;\n  color: #fff;\n}\n\n.scroll-expand__frame {\n  position: absolute;\n  inset: 0;\n  overflow: hidden;\n  clip-path: inset(\n    var(--scroll-expand-inset-top) var(--scroll-expand-inset-right)\n      var(--scroll-expand-inset-bottom) var(--scroll-expand-inset-left) round\n      var(--scroll-expand-start-radius)\n  );\n  will-change: clip-path;\n}\n\n.scroll-expand__media {\n  position: absolute;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n  /* Focal mode sizes this box in JS to something deliberately wider than the\n     stage. A CSS reset that caps media at `max-width: 100%` — Tailwind Preflight\n     among them — would silently truncate that box while the transform still\n     assumes the full size, which uncovers the stage and drags the subject off\n     its mark. The sizing here is always explicit, so the cap has nothing to\n     protect. */\n  max-width: none;\n  max-height: none;\n  object-fit: cover;\n  transform: scale(var(--scroll-expand-media-zoom));\n  transform-origin: center;\n  backface-visibility: hidden;\n  user-select: none;\n  -webkit-user-drag: none;\n  will-change: transform;\n}\n\n.scroll-expand[data-direction=\"focus\"] .scroll-expand__frame {\n  clip-path: inset(0 round var(--scroll-expand-end-radius));\n}\n\n.scroll-expand[data-direction=\"focus\"] .scroll-expand__media {\n  transform: scale(1);\n}\n\n.scroll-expand[data-direction=\"focus\"]\n  .scroll-expand__overlay[data-layer=\"frame\"] {\n  inset: var(--scroll-expand-inset-top) var(--scroll-expand-inset-right)\n    var(--scroll-expand-inset-bottom) var(--scroll-expand-inset-left);\n  padding: clamp(1rem, 3cqw, 2.5rem);\n}\n\n.scroll-expand[data-direction=\"focus\"] .scroll-expand__scrim {\n  background: linear-gradient(\n    to right,\n    rgb(0 0 0 / 88%),\n    rgb(0 0 0 / 72%) 72%,\n    rgb(0 0 0 / 22%)\n  );\n}\n\n/* Default for a compact stage, which most often stacks the frame above the copy:\n   the focus scrim falls from the bottom instead of the side. A consumer that\n   keeps a side-by-side composition on a narrow stage overrides this back. */\n.scroll-expand[data-size=\"compact\"][data-direction=\"focus\"]\n  .scroll-expand__scrim {\n  background: linear-gradient(\n    to top,\n    rgb(0 0 0 / 88%),\n    rgb(0 0 0 / 60%) 44%,\n    rgb(0 0 0 / 10%)\n  );\n}\n\n.scroll-expand[data-size=\"compact\"][data-direction=\"focus\"]\n  .scroll-expand__overlay[data-layer=\"frame\"] {\n  padding: clamp(1rem, 5cqw, 1.75rem);\n}\n\n.scroll-expand[data-size=\"compact\"] .scroll-expand__overlay {\n  padding: 8% 6%;\n}\n\n.scroll-expand[data-size=\"compact\"] .scroll-expand__title {\n  padding: 0 6%;\n}\n\n.scroll-expand__media--empty {\n  background:\n    radial-gradient(circle at 72% 22%, rgb(255 255 255 / 18%), transparent 28%),\n    linear-gradient(145deg, #343434, #111 68%);\n}\n\n.scroll-expand__scrim {\n  position: absolute;\n  inset: 0;\n  pointer-events: none;\n  background: linear-gradient(\n    to top,\n    rgb(0 0 0 / 75%),\n    rgb(0 0 0 / 10%) 45%,\n    rgb(0 0 0 / 35%)\n  );\n  opacity: 0;\n}\n\n.scroll-expand__overlay {\n  position: absolute;\n  inset: 0;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  padding: 6%;\n  color: #fff;\n  text-align: center;\n  opacity: 0;\n  transform: translate3d(0, 18px, 0);\n  will-change: opacity, transform;\n}\n\n.scroll-expand__overlay[data-align=\"start\"] {\n  align-items: flex-start;\n  text-align: start;\n}\n\n.scroll-expand__overlay[data-align=\"end\"] {\n  align-items: flex-end;\n  text-align: end;\n}\n\n.scroll-expand__overlay[data-position=\"bottom\"] {\n  justify-content: flex-end;\n}\n\n.scroll-expand__item {\n  opacity: 0;\n  transform: translate3d(\n      0,\n      var(--scroll-expand-item-offset-y, 24px),\n      0\n    )\n    scale(var(--scroll-expand-item-scale, 0.98));\n  transform-origin: center;\n  will-change: opacity, transform;\n}\n\n.scroll-expand__title {\n  position: absolute;\n  inset: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  margin: 0;\n  padding: 0 6%;\n  color: #fff;\n  font-size: clamp(1.25rem, 7.5cqw, 5.25rem);\n  font-weight: 700;\n  letter-spacing: -0.03em;\n  line-height: 1;\n  text-align: center;\n  text-shadow: 0 2px 24px rgb(0 0 0 / 45%);\n  text-wrap: balance;\n  pointer-events: none;\n  will-change: opacity, transform;\n}\n\n.scroll-expand__title[data-align=\"start\"] {\n  justify-content: flex-start;\n  text-align: start;\n}\n\n.scroll-expand__title[data-align=\"end\"] {\n  justify-content: flex-end;\n  text-align: end;\n}\n\n.scroll-expand__hint {\n  position: absolute;\n  right: 0;\n  bottom: 1.25rem;\n  left: 0;\n  color: rgb(255 255 255 / 72%);\n  font-size: 0.8125rem;\n  letter-spacing: 0.02em;\n  text-align: center;\n  pointer-events: none;\n  will-change: opacity, transform;\n}\n\n/* Pre-hydration resting state. `[data-motion=\"enabled\"]` is the escape hatch for\n   a viewer who asked to see the motion anyway via `respectReducedMotion`. */\n@media (prefers-reduced-motion: reduce) {\n  .scroll-expand:not([data-motion=\"enabled\"]) .scroll-expand__frame {\n    clip-path: inset(0 round 0);\n    will-change: auto;\n  }\n\n  .scroll-expand:not([data-motion=\"enabled\"]) .scroll-expand__media {\n    transform: none;\n    will-change: auto;\n  }\n\n  /* `focus` resolves the other way round: its end state is the detail frame. */\n  .scroll-expand[data-direction=\"focus\"]:not([data-motion=\"enabled\"])\n    .scroll-expand__frame {\n    clip-path: inset(\n      var(--scroll-expand-inset-top) var(--scroll-expand-inset-right)\n        var(--scroll-expand-inset-bottom) var(--scroll-expand-inset-left) round\n        var(--scroll-expand-start-radius)\n    );\n  }\n\n  .scroll-expand[data-direction=\"focus\"]:not([data-motion=\"enabled\"])\n    .scroll-expand__media {\n    transform: scale(var(--scroll-expand-media-zoom));\n  }\n\n  .scroll-expand:not([data-motion=\"enabled\"]) .scroll-expand__scrim {\n    opacity: 0.45;\n  }\n\n  .scroll-expand:not([data-motion=\"enabled\"]) .scroll-expand__overlay {\n    opacity: 1;\n    transform: none;\n    will-change: auto;\n  }\n\n  .scroll-expand:not([data-motion=\"enabled\"]) .scroll-expand__item {\n    opacity: 1;\n    transform: none;\n    will-change: auto;\n  }\n\n  .scroll-expand:not([data-motion=\"enabled\"]) .scroll-expand__title,\n  .scroll-expand:not([data-motion=\"enabled\"]) .scroll-expand__hint {\n    display: none;\n  }\n}\n",
      "type": "registry:file",
      "target": "components/blocks/scroll-expand.css"
    }
  ],
  "meta": {
    "tags": [
      "scroll",
      "hero",
      "image",
      "video",
      "storytelling",
      "art-direction",
      "focus"
    ],
    "effects": [
      "clip-path",
      "media-zoom",
      "focus-origin",
      "scroll-progress",
      "content-choreography",
      "frame-offset",
      "reverse-focus",
      "circular-focus",
      "stage-content",
      "reduced-motion"
    ]
  },
  "categories": [
    "marketing",
    "hero"
  ],
  "type": "registry:block"
}