{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sticky-scroll",
  "title": "Sticky Scroll",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Scroll-driven storytelling: text steps scroll past while one visual stays pinned and crossfades to match, with each visual inline on small screens.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/motion-presets.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/sticky-scroll.tsx",
      "content": "// Ballmac UI: Sticky Scroll. https://ui.ballmac.com/components/sticky-scroll\n\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ease } from \"@/lib/ballmac/motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype StickyScrollItem = {\n  /** Unique id of the step. */\n  id: string;\n  /** Step heading. */\n  title: string;\n  /** Step text. */\n  description: React.ReactNode;\n  /** The visual shown in the sticky panel while this step is active. */\n  visual: React.ReactNode;\n};\n\ntype StickyScrollProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Steps, in reading order. */\n  items: StickyScrollItem[];\n  /** Which side the sticky visual sits on from the `lg` breakpoint up. */\n  visualSide?: \"left\" | \"right\";\n  /** Distance from the top of the viewport where the sticky visual stops, in pixels. */\n  stickyOffset?: number;\n  /** Called when the active step changes. */\n  onActiveChange?: (id: string) => void;\n  /** A scrollable element that contains the steps, when the page itself does not scroll (panels, previews). */\n  container?: React.RefObject<HTMLElement | null>;\n  /** Minimum height of each step from `lg` up. Longer steps give the reader more time on each visual. */\n  stepMinHeight?: string;\n};\n\n/**\n * Scroll-driven storytelling: text steps scroll past while one visual stays pinned and crossfades to match the step.\n * Below `lg` each step shows its own visual inline, so nothing is hidden and nothing is pinned.\n */\nfunction StickyScroll({\n  items,\n  visualSide = \"right\",\n  stickyOffset = 96,\n  onActiveChange,\n  container,\n  stepMinHeight = \"70svh\",\n  className,\n  ...props\n}: StickyScrollProps) {\n  const reduce = useReducedMotion();\n  const [activeId, setActiveId] = React.useState(items[0]?.id);\n  const stepRefs = React.useRef(new Map<string, HTMLElement>());\n  const callback = React.useRef(onActiveChange);\n  React.useEffect(() => {\n    callback.current = onActiveChange;\n  });\n\n  React.useEffect(() => {\n    const steps = [...stepRefs.current.entries()];\n    if (!steps.length) return;\n    const observer = new IntersectionObserver(\n      (entries) => {\n        const visible = entries.filter((e) => e.isIntersecting);\n        if (!visible.length) return;\n        const best = visible.reduce((a, b) => (b.intersectionRatio > a.intersectionRatio ? b : a));\n        const id = steps.find(([, el]) => el === best.target)?.[0];\n        if (id) {\n          setActiveId(id);\n          callback.current?.(id);\n        }\n      },\n      { root: container?.current ?? null, rootMargin: \"-45% 0px -45% 0px\", threshold: [0, 0.25, 0.5, 0.75, 1] },\n    );\n    steps.forEach(([, el]) => observer.observe(el));\n    return () => observer.disconnect();\n  }, [items, container]);\n\n  const active = items.find((i) => i.id === activeId) ?? items[0];\n  return (\n    <div\n      data-slot=\"sticky-scroll\"\n      className={cn(\"grid gap-10 lg:grid-cols-2 lg:gap-16\", className)}\n      {...props}\n    >\n      <ol className={cn(\"grid gap-16 lg:gap-0\", visualSide === \"left\" && \"lg:order-2\")}>\n        {items.map((item) => {\n          const isActive = item.id === active?.id;\n          return (\n            <li\n              key={item.id}\n              ref={(el) => {\n                if (el) stepRefs.current.set(item.id, el);\n                else stepRefs.current.delete(item.id);\n              }}\n              aria-current={isActive ? \"step\" : undefined}\n              data-active={isActive || undefined}\n              style={{ \"--step-min-h\": stepMinHeight } as React.CSSProperties}\n              className=\"group/step relative grid content-center gap-4 lg:min-h-(--step-min-h) lg:border-s-2 lg:border-transparent lg:ps-6 lg:transition-colors lg:data-[active]:border-primary motion-reduce:transition-none\"\n            >\n              <div className=\"lg:hidden\" aria-hidden=\"true\">\n                <div className=\"aspect-[4/3] overflow-hidden rounded-2xl border bg-card\">{item.visual}</div>\n              </div>\n              <h3 className=\"text-2xl font-semibold tracking-tight text-balance transition-colors sm:text-3xl lg:text-muted-foreground lg:group-data-[active]/step:text-foreground motion-reduce:transition-none\">{item.title}</h3>\n              <div className=\"max-w-prose text-base leading-relaxed text-muted-foreground\">{item.description}</div>\n            </li>\n          );\n        })}\n      </ol>\n      <div className={cn(\"hidden lg:block\", visualSide === \"left\" && \"lg:order-1\")} aria-hidden=\"true\">\n        <div\n          className=\"sticky aspect-[4/3] w-full overflow-hidden rounded-3xl border bg-card shadow-[0_24px_60px_-24px_rgb(0_0_0/0.25)]\"\n          style={{ top: stickyOffset }}\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {active && (\n              <motion.div\n                key={active.id}\n                className=\"absolute inset-0\"\n                initial={reduce ? false : { opacity: 0, scale: 0.98 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 1.02 }}\n                transition={{ duration: reduce ? 0 : 0.4, ease: ease.out }}\n              >\n                {active.visual}\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport { StickyScroll, type StickyScrollProps, type StickyScrollItem };\n",
      "type": "registry:ui",
      "target": "@components/ballmac/sticky-scroll.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "scrollytelling",
      "sticky",
      "feature",
      "marketing"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "items: {id,title,description,visual}. The step nearest the middle of the viewport is active (aria-current=step) and its visual shows in the pinned panel.",
      "whenToUse": [
        "Feature tours on landing pages",
        "Step-by-step explanations with a matching picture"
      ],
      "whenNotToUse": [
        "Short lists of benefits; use a plain grid",
        "Content that must all be visible at once"
      ],
      "composesWith": [
        "container-scroll",
        "bento-grid"
      ],
      "a11y": [
        {
          "keys": "Reading order",
          "action": "Steps are an ordered list; the pinned visual is decorative and hidden from assistive technology"
        },
        {
          "keys": "Reduced motion",
          "action": "No crossfade or scale"
        }
      ],
      "customization": [
        "visualSide: left | right",
        "stickyOffset and stepMinHeight",
        "container for panel scrolling",
        "onActiveChange"
      ]
    },
    "examples": [
      "sticky-scroll-demo",
      "sticky-scroll-states"
    ],
    "url": "https://ui.ballmac.com/components/sticky-scroll"
  },
  "categories": [
    "layout"
  ],
  "type": "registry:ui"
}