{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-scene",
  "title": "Scroll Scene",
  "description": "A scrollytelling scene: a stage pinned beside steps, crossfading to the slide of the step on screen, with a 0 to 1 scroll progress variable for scrubbing.",
  "registryDependencies": [
    "@corsair-ui/utils",
    "@corsair-ui/use-scroll-spy"
  ],
  "files": [
    {
      "path": "registry/default/ui/scroll-scene.tsx",
      "content": "\"use client\";\n\nimport {\n  Children,\n  createContext,\n  isValidElement,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type CSSProperties,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\nimport { useScrollSpy } from \"@/registry/default/hooks/use-scroll-spy\";\nimport { cn } from \"@/registry/default/lib/utils\";\n\n// Registering the progress as a number lets the scroll-driven animation\n// interpolate it; an unregistered custom property would jump from 0 to 1\n// halfway through. It inherits, so every part of the scene can read it, and\n// it rests at 1 (the finished state) wherever the animation does not run.\nif (typeof CSS !== \"undefined\" && \"registerProperty\" in CSS) {\n  try {\n    CSS.registerProperty({\n      name: \"--scroll-scene-progress\",\n      syntax: \"<number>\",\n      inherits: true,\n      initialValue: \"1\",\n    });\n  } catch {\n    // Already registered by another copy of the component.\n  }\n}\n\ntype ScrollScenePinFrom = \"sm\" | \"md\" | \"lg\" | \"xl\" | false;\ntype ScrollSceneStageSide = \"start\" | \"end\";\ntype LayoutKey = \"sm\" | \"md\" | \"lg\" | \"xl\" | \"always\";\n\ninterface Layout {\n  root: string;\n  stage: string;\n  stageStart: string;\n  stageEnd: string;\n  steps: string;\n  stepsStart: string;\n  stepsEnd: string;\n  step: string;\n  inline: string;\n}\n\n// Literal class strings, one set per breakpoint, so Tailwind finds every one\n// of them when it scans this file.\nconst LAYOUTS: Record<LayoutKey, Layout> = {\n  sm: {\n    root: \"sm:grid sm:grid-cols-2 sm:items-start sm:gap-x-12\",\n    stage:\n      \"hidden sm:sticky sm:row-start-1 sm:grid sm:h-[var(--scroll-scene-stage-height)] sm:place-items-center sm:top-[var(--scroll-scene-top)]\",\n    stageStart: \"sm:col-start-1\",\n    stageEnd: \"sm:col-start-2\",\n    steps: \"flex flex-col gap-16 sm:row-start-1 sm:gap-0\",\n    stepsStart: \"sm:col-start-2\",\n    stepsEnd: \"sm:col-start-1\",\n    step: \"sm:flex sm:min-h-[70svh] sm:flex-col sm:justify-center\",\n    inline: \"mt-8 flex justify-center sm:hidden\",\n  },\n  md: {\n    root: \"md:grid md:grid-cols-2 md:items-start md:gap-x-12\",\n    stage:\n      \"hidden md:sticky md:row-start-1 md:grid md:h-[var(--scroll-scene-stage-height)] md:place-items-center md:top-[var(--scroll-scene-top)]\",\n    stageStart: \"md:col-start-1\",\n    stageEnd: \"md:col-start-2\",\n    steps: \"flex flex-col gap-16 md:row-start-1 md:gap-0\",\n    stepsStart: \"md:col-start-2\",\n    stepsEnd: \"md:col-start-1\",\n    step: \"md:flex md:min-h-[70svh] md:flex-col md:justify-center\",\n    inline: \"mt-8 flex justify-center md:hidden\",\n  },\n  lg: {\n    root: \"lg:grid lg:grid-cols-2 lg:items-start lg:gap-x-12\",\n    stage:\n      \"hidden lg:sticky lg:row-start-1 lg:grid lg:h-[var(--scroll-scene-stage-height)] lg:place-items-center lg:top-[var(--scroll-scene-top)]\",\n    stageStart: \"lg:col-start-1\",\n    stageEnd: \"lg:col-start-2\",\n    steps: \"flex flex-col gap-16 lg:row-start-1 lg:gap-0\",\n    stepsStart: \"lg:col-start-2\",\n    stepsEnd: \"lg:col-start-1\",\n    step: \"lg:flex lg:min-h-[70svh] lg:flex-col lg:justify-center\",\n    inline: \"mt-8 flex justify-center lg:hidden\",\n  },\n  xl: {\n    root: \"xl:grid xl:grid-cols-2 xl:items-start xl:gap-x-12\",\n    stage:\n      \"hidden xl:sticky xl:row-start-1 xl:grid xl:h-[var(--scroll-scene-stage-height)] xl:place-items-center xl:top-[var(--scroll-scene-top)]\",\n    stageStart: \"xl:col-start-1\",\n    stageEnd: \"xl:col-start-2\",\n    steps: \"flex flex-col gap-16 xl:row-start-1 xl:gap-0\",\n    stepsStart: \"xl:col-start-2\",\n    stepsEnd: \"xl:col-start-1\",\n    step: \"xl:flex xl:min-h-[70svh] xl:flex-col xl:justify-center\",\n    inline: \"mt-8 flex justify-center xl:hidden\",\n  },\n  always: {\n    root: \"grid grid-cols-2 items-start gap-x-12\",\n    stage:\n      \"sticky row-start-1 grid h-[var(--scroll-scene-stage-height)] place-items-center top-[var(--scroll-scene-top)]\",\n    stageStart: \"col-start-1\",\n    stageEnd: \"col-start-2\",\n    steps: \"flex flex-col row-start-1\",\n    stepsStart: \"col-start-2\",\n    stepsEnd: \"col-start-1\",\n    step: \"flex min-h-[70svh] flex-col justify-center\",\n    inline: \"hidden\",\n  },\n};\n\ninterface ScrollSceneContextValue {\n  activeStep: number;\n  stepCount: number;\n  sceneId: string;\n  layout: Layout;\n  stageSide: ScrollSceneStageSide;\n  /** Slides from the stage by step, rendered again inside the steps below the breakpoint. */\n  inlineSlides: ReadonlyMap<number, ReactElement<ScrollSceneSlideProps>> | null;\n  register: (step: number, id: string) => () => void;\n}\n\nconst ScrollSceneContext = createContext<ScrollSceneContextValue | null>(null);\n\n// True for the copy of a slide rendered inside its step, for small screens.\nconst InlineSlideContext = createContext(false);\n\nfunction useScrollSceneContext(part: string) {\n  const context = useContext(ScrollSceneContext);\n  if (!context) throw new Error(`${part} must be used inside <ScrollScene>.`);\n  return context;\n}\n\ninterface ScrollSceneState {\n  /** The step on the line, or the controlled `step`. */\n  activeStep: number;\n  /** How many `ScrollSceneStep`s the scene has. */\n  stepCount: number;\n}\n\n/**\n * The scene's active step and step count, for custom parts inside a\n * `ScrollScene`: a \"2 / 3\" counter, a progress dot row, a caption.\n *\n * @example\n * function Counter() {\n *   const { activeStep, stepCount } = useScrollScene();\n *   return <p className=\"text-muted-foreground text-sm\">{activeStep + 1} / {stepCount}</p>;\n * }\n */\nfunction useScrollScene(): ScrollSceneState {\n  const { activeStep, stepCount } = useScrollSceneContext(\"useScrollScene\");\n  return { activeStep, stepCount };\n}\n\ninterface ScrollSceneProps extends ComponentProps<\"div\"> {\n  /** The active step, controlled. Takes over from the scroll position. */\n  step?: number;\n  /** The active step before any step reaches the line, and in the server HTML. */\n  defaultStep?: number;\n  /** Called when a different step reaches the line. */\n  onStepChange?: (step: number) => void;\n  /**\n   * The breakpoint from which the stage is pinned beside the steps. Below it\n   * the scene is a plain sequence, each step followed by its own slide.\n   * `false` pins it at every width.\n   */\n  pinFrom?: ScrollScenePinFrom;\n  /** Which column the stage sits in when pinned. */\n  stageSide?: ScrollSceneStageSide;\n  /** Where the pinned stage sticks, as a CSS length (below a fixed header: \"4rem\"). */\n  stickyTop?: string;\n  /** The pinned stage's height, as a CSS length. Slides are centred in it. */\n  stageHeight?: string;\n  /**\n   * The line across the viewport that decides the active step, as an\n   * IntersectionObserver root margin. The default is the viewport's middle.\n   */\n  rootMargin?: string;\n  /** How far, in px, an incoming slide rises while it fades in. */\n  distance?: number;\n  /** Length of the slide crossfade, in ms. */\n  duration?: number;\n  /** Timing function of the slide crossfade. */\n  easing?: string;\n  /**\n   * The CSS `animation-range` that `--scroll-scene-progress` runs 0 to 1 over.\n   * The default goes from the scene's top reaching the viewport's top to its\n   * bottom reaching the viewport's bottom: the stretch the stage is pinned.\n   */\n  progressRange?: string;\n}\n\n/** Slides found in the stage, by step. The stage must be a direct child, its slides direct children of it. */\nfunction collectInlineSlides(children: ReactNode) {\n  const slides = new Map<number, ReactElement<ScrollSceneSlideProps>>();\n  for (const child of Children.toArray(children)) {\n    if (!isValidElement<ScrollSceneStageProps>(child) || child.type !== ScrollSceneStage) continue;\n    for (const slide of Children.toArray(child.props.children)) {\n      if (isValidElement<ScrollSceneSlideProps>(slide) && slide.type === ScrollSceneSlide) {\n        slides.set(slide.props.step, slide);\n      }\n    }\n  }\n  return slides;\n}\n\n/** How many steps sit directly in a direct `ScrollSceneSteps` child, known on the server too. */\nfunction countSteps(children: ReactNode) {\n  let count = 0;\n  for (const child of Children.toArray(children)) {\n    if (!isValidElement<ScrollSceneStepsProps>(child) || child.type !== ScrollSceneSteps) continue;\n    for (const step of Children.toArray(child.props.children)) {\n      if (isValidElement(step) && step.type === ScrollSceneStep) count++;\n    }\n  }\n  return count;\n}\n\n/**\n * A scrollytelling scene: a stage pinned beside steps of text, showing the\n * slide of the step crossing the middle of the viewport. Slides crossfade and\n * rise into place on a CSS transition; one IntersectionObserver picks the\n * step, so there are no scroll listeners.\n *\n * Below `pinFrom` nothing is pinned: the stage is hidden and every step shows\n * its own slide after its text. Each slide is rendered twice (once in the\n * stage, once in its step) and CSS shows one copy or the other, so the server\n * HTML matches every screen and nothing shifts on hydration. The hidden copy\n * is `display: none`, out of the accessibility tree. Images inside slides\n * with `loading=\"lazy\"` are only fetched for the copy that shows.\n *\n * `ScrollSceneStage` must be a direct child of `ScrollScene`, and its\n * `ScrollSceneSlide`s direct children of the stage, for the small-screen\n * copies to be found. Inactive slides are `inert` and hidden from screen\n * readers; the steps carry the story. Without JavaScript the steps read in\n * order and the stage shows `defaultStep`'s slide.\n *\n * `--scroll-scene-progress` runs from 0 to 1 while the scene scrolls past,\n * on a CSS scroll-driven animation, for parts that scrub with the scroll.\n * Read it as `var(--scroll-scene-progress, 1)`: browsers without scroll\n * timelines, and `prefers-reduced-motion`, keep it at 1, the finished state.\n * With reduced motion slides swap with a short fade and no movement.\n *\n * Sticky needs a scrolling ancestor that is not `overflow: hidden`; clip a\n * wrapper with `overflow: clip` (`overflow-clip`) instead.\n *\n * @example\n * <ScrollScene pinFrom=\"md\" onStepChange={setStep}>\n *   <ScrollSceneStage>\n *     <ScrollSceneSlide step={0}><Phone screen=\"inbox\" /></ScrollSceneSlide>\n *     <ScrollSceneSlide step={1}>\n *       <div style={{ transform: \"translateY(calc((1 - var(--scroll-scene-progress, 1)) * 40px))\" }}>\n *         <Phone screen=\"chat\" />\n *       </div>\n *     </ScrollSceneSlide>\n *   </ScrollSceneStage>\n *   <ScrollSceneSteps>\n *     <ScrollSceneStep step={0}><h3>Every message in one place</h3></ScrollSceneStep>\n *     <ScrollSceneStep step={1}><h3>Reply without switching apps</h3></ScrollSceneStep>\n *   </ScrollSceneSteps>\n * </ScrollScene>\n */\nfunction ScrollScene({\n  step,\n  defaultStep = 0,\n  onStepChange,\n  pinFrom = \"md\",\n  stageSide = \"start\",\n  stickyTop = \"0px\",\n  stageHeight = \"100svh\",\n  rootMargin = \"-50% 0px -50% 0px\",\n  distance = 24,\n  duration = 320,\n  easing = \"cubic-bezier(0.16, 1, 0.3, 1)\",\n  progressRange = \"contain 0% contain 100%\",\n  className,\n  style,\n  children,\n  ...props\n}: ScrollSceneProps) {\n  const sceneId = useId();\n  const layout = LAYOUTS[pinFrom === false ? \"always\" : pinFrom];\n\n  // Steps register their ids once mounted, so a step's own `id` is kept.\n  const [registered, setRegistered] = useState<Readonly<Record<number, string>>>({});\n  const register = useCallback((index: number, id: string) => {\n    setRegistered((current) => (current[index] === id ? current : { ...current, [index]: id }));\n    return () =>\n      setRegistered((current) => {\n        if (current[index] !== id) return current;\n        const next = { ...current };\n        delete next[index];\n        return next;\n      });\n  }, []);\n\n  const order = useMemo(\n    () =>\n      Object.keys(registered)\n        .map(Number)\n        .sort((a, b) => a - b),\n    [registered]\n  );\n  const ids = useMemo(() => order.map((index) => registered[index]!), [order, registered]);\n  const spyId = useScrollSpy(ids, { rootMargin });\n  // Without IntersectionObserver the hook falls back to the first id; keep\n  // `defaultStep` instead. On the server and while hydrating `spyId` is unset.\n  const spyStep =\n    typeof IntersectionObserver === \"undefined\" || spyId === undefined\n      ? undefined\n      : order[ids.indexOf(spyId)];\n  const activeStep = step ?? spyStep ?? defaultStep;\n\n  // The last step reported, to call `onStepChange` once per change of step.\n  const reported = useRef<number | undefined>(undefined);\n  useEffect(() => {\n    if (spyStep === undefined || spyStep === reported.current) return;\n    const previous = step ?? reported.current ?? defaultStep;\n    reported.current = spyStep;\n    if (spyStep !== previous) onStepChange?.(spyStep);\n  }, [spyStep, step, defaultStep, onStepChange]);\n\n  const inlineSlides = useMemo(\n    () => (pinFrom === false ? null : collectInlineSlides(children)),\n    [pinFrom, children]\n  );\n  const scannedSteps = useMemo(() => countSteps(children), [children]);\n  const stepCount = Math.max(scannedSteps, order.length);\n\n  const context = useMemo<ScrollSceneContextValue>(\n    () => ({ activeStep, stepCount, sceneId, layout, stageSide, inlineSlides, register }),\n    [activeStep, stepCount, sceneId, layout, stageSide, inlineSlides, register]\n  );\n\n  return (\n    <div\n      data-slot=\"scroll-scene\"\n      data-active-step={activeStep}\n      data-pin-from={pinFrom === false ? \"always\" : pinFrom}\n      data-stage-side={stageSide}\n      className={cn(\n        layout.root,\n        \"supports-[animation-timeline:view()]:animate-scroll-scene-progress motion-reduce:!animate-none\",\n        className\n      )}\n      style={\n        {\n          \"--scroll-scene-distance\": `${distance}px`,\n          \"--scroll-scene-duration\": `${duration}ms`,\n          \"--scroll-scene-easing\": easing,\n          \"--scroll-scene-top\": stickyTop,\n          \"--scroll-scene-stage-height\": stageHeight,\n          // After the utility's shorthand, which would reset them.\n          animationTimeline: \"view()\",\n          animationRange: progressRange,\n          ...style,\n        } as CSSProperties\n      }\n      {...props}\n    >\n      <ScrollSceneContext.Provider value={context}>{children}</ScrollSceneContext.Provider>\n    </div>\n  );\n}\n\ntype ScrollSceneStageProps = ComponentProps<\"div\">;\n\n/**\n * The pinned column that shows the active step's slide. Put it directly in\n * `ScrollScene`, with `ScrollSceneSlide`s directly in it. Slides share one\n * grid cell, so the stage is as big as the largest one and nothing shifts\n * when they swap. Hidden below the scene's `pinFrom` breakpoint.\n *\n * @example\n * <ScrollSceneStage>\n *   <ScrollSceneSlide step={0}>…</ScrollSceneSlide>\n *   <ScrollSceneSlide step={1}>…</ScrollSceneSlide>\n * </ScrollSceneStage>\n */\nfunction ScrollSceneStage({ className, ...props }: ScrollSceneStageProps) {\n  const { layout, stageSide } = useScrollSceneContext(\"ScrollSceneStage\");\n  return (\n    <div\n      data-slot=\"scroll-scene-stage\"\n      className={cn(\n        layout.stage,\n        stageSide === \"start\" ? layout.stageStart : layout.stageEnd,\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\ninterface ScrollSceneSlideProps extends ComponentProps<\"div\"> {\n  /** The step this slide belongs to, the same number as its `ScrollSceneStep`. */\n  step: number;\n}\n\n/**\n * What the stage shows for one step: a phone screen, a picture, a card.\n * Inactive slides are transparent, lowered by the scene's `distance`,\n * `inert` and hidden from screen readers. Below the breakpoint a copy of it\n * (`data-slot=\"scroll-scene-inline-slide\"`, without its `id` and `ref`)\n * follows its step's text instead.\n *\n * @example\n * <ScrollSceneSlide step={0}>\n *   <img src=\"/screens/inbox.png\" alt=\"The inbox, with three unread threads\" loading=\"lazy\" />\n * </ScrollSceneSlide>\n */\nfunction ScrollSceneSlide({ step, className, id, ref, ...props }: ScrollSceneSlideProps) {\n  const { activeStep, layout } = useScrollSceneContext(\"ScrollSceneSlide\");\n  const inline = useContext(InlineSlideContext);\n  const active = activeStep === step;\n  const state = active ? \"active\" : \"inactive\";\n\n  if (inline) {\n    return (\n      <div\n        data-slot=\"scroll-scene-inline-slide\"\n        data-state={state}\n        data-step={step}\n        className={cn(layout.inline, className)}\n        {...props}\n      />\n    );\n  }\n\n  return (\n    <div\n      ref={ref}\n      id={id}\n      data-slot=\"scroll-scene-slide\"\n      data-state={state}\n      data-step={step}\n      aria-hidden={active ? undefined : true}\n      inert={!active}\n      className={cn(\n        \"[--scroll-scene-shift:var(--scroll-scene-distance)] [grid-area:1/1] motion-reduce:[--scroll-scene-shift:0px]\",\n        \"[transition:opacity_var(--scroll-scene-duration)_var(--scroll-scene-easing),transform_var(--scroll-scene-duration)_var(--scroll-scene-easing)] motion-reduce:[transition:opacity_150ms_linear]\",\n        \"data-[state=inactive]:[transform:translate3d(0,var(--scroll-scene-shift),0)] data-[state=inactive]:opacity-0\",\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\ntype ScrollSceneStepsProps = ComponentProps<\"div\">;\n\n/**\n * The column of steps beside the stage. Below the breakpoint it is the whole\n * scene: the steps in order, each with its slide.\n *\n * @example\n * <ScrollSceneSteps>\n *   <ScrollSceneStep step={0}>…</ScrollSceneStep>\n *   <ScrollSceneStep step={1}>…</ScrollSceneStep>\n * </ScrollSceneSteps>\n */\nfunction ScrollSceneSteps({ className, ...props }: ScrollSceneStepsProps) {\n  const { layout, stageSide } = useScrollSceneContext(\"ScrollSceneSteps\");\n  return (\n    <div\n      data-slot=\"scroll-scene-steps\"\n      className={cn(\n        layout.steps,\n        stageSide === \"start\" ? layout.stepsStart : layout.stepsEnd,\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\ninterface ScrollSceneStepProps extends ComponentProps<\"div\"> {\n  /** Its number in the scene, from 0, matching a `ScrollSceneSlide`. */\n  step: number;\n}\n\n/**\n * One step of the story. It becomes the active step when it crosses the\n * scene's line (the middle of the viewport by default). When pinned it is at\n * least 70% of the viewport tall with its content centred; change that with\n * the same breakpoint prefix as `pinFrom` (`md:min-h-[50svh]`). Below the\n * breakpoint its slide follows its content. It gets an id if it has none.\n *\n * @example\n * <ScrollSceneStep step={1}>\n *   <h3 className=\"text-2xl font-semibold\">Reply without switching apps</h3>\n *   <p className=\"text-muted-foreground\">Every channel lands in the same thread.</p>\n * </ScrollSceneStep>\n */\nfunction ScrollSceneStep({ step, id, className, children, ...props }: ScrollSceneStepProps) {\n  const { activeStep, sceneId, layout, inlineSlides, register } =\n    useScrollSceneContext(\"ScrollSceneStep\");\n  const stepId = id ?? `${sceneId}-step-${step}`;\n  useEffect(() => register(step, stepId), [register, step, stepId]);\n  const slide = inlineSlides?.get(step);\n\n  return (\n    <div\n      id={stepId}\n      data-slot=\"scroll-scene-step\"\n      data-state={activeStep === step ? \"active\" : \"inactive\"}\n      data-step={step}\n      className={cn(layout.step, className)}\n      {...props}\n    >\n      {children}\n      {slide ? <InlineSlideContext.Provider value>{slide}</InlineSlideContext.Provider> : null}\n    </div>\n  );\n}\n\nexport {\n  ScrollScene,\n  ScrollSceneSlide,\n  ScrollSceneStage,\n  ScrollSceneStep,\n  ScrollSceneSteps,\n  useScrollScene,\n  type ScrollScenePinFrom,\n  type ScrollSceneProps,\n  type ScrollSceneSlideProps,\n  type ScrollSceneStageProps,\n  type ScrollSceneStageSide,\n  type ScrollSceneState,\n  type ScrollSceneStepProps,\n  type ScrollSceneStepsProps,\n};\n",
      "type": "registry:ui"
    }
  ],
  "tailwind": {
    "config": {
      "theme": {
        "extend": {
          "keyframes": {
            "scroll-scene-progress": {
              "from": {
                "--scroll-scene-progress": "0"
              },
              "to": {
                "--scroll-scene-progress": "1"
              }
            }
          },
          "animation": {
            "scroll-scene-progress": "scroll-scene-progress linear both"
          }
        }
      }
    }
  },
  "cssVars": {
    "theme": {
      "animate-scroll-scene-progress": "scroll-scene-progress linear both"
    }
  },
  "css": {
    "@keyframes scroll-scene-progress": {
      "from": {
        "--scroll-scene-progress": "0"
      },
      "to": {
        "--scroll-scene-progress": "1"
      }
    }
  },
  "categories": [
    "motion"
  ],
  "type": "registry:ui"
}