{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "horizontal-scroll-stack",
  "title": "Horizontal Scroll Stack",
  "author": "AlgorizeTech",
  "description": "Ask the user to pick one of 6 scroll stack variants: https://avaraui.com/view/scroll-stack. This is the Horizontal scroll stack. Skip the question if the user already named one. A section that converts vertical scrolling into horizontal card movement with a progress indicator. Use it for process timelines and step sequences.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/HorizontalScrollStackBlock.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport React, { useEffect, useRef, useState } from \"react\";\nimport { cubicBezier } from \"./motion-curves\";\n\n// Stack progress (0 → 1) lives in this custom property on the container,\n// written once per frame by the tween below; cards and bars derive from it.\nconst PROGRESS_VAR = \"--horizontal-scroll-stack-progress\";\nconst PROGRESS_EASE = cubicBezier(0.25, 1, 0.5, 1);\nconst PROGRESS_DURATION = 600;\n\nexport type ScrollStackItem = {\n  id: string | number;\n  number?: string;\n  title: string;\n  description: string;\n  icon?: React.ReactNode;\n};\n\nexport interface HorizontalScrollStackProps\n  extends React.HTMLAttributes<HTMLDivElement> {\n  items: ScrollStackItem[];\n  title?: string;\n  subtitle?: string;\n}\n\n// --- CHILD COMPONENT: Stacking Card ---\nfunction StackCard({\n  item,\n  index,\n  total,\n  cardWidth,\n  gap,\n  stackOffset,\n  isMobile = false,\n}: {\n  item: ScrollStackItem;\n  index: number;\n  total: number;\n  cardWidth: number;\n  gap: number;\n  stackOffset: number;\n  isMobile?: boolean;\n}) {\n  const steps = Math.max(1, total - 1);\n  const stepSize = 1 / steps;\n  const shiftPerStep = cardWidth + gap - stackOffset;\n\n  // Card index `index` reaches its stacked position at progress = index * stepSize\n  const lockProgress = index * stepSize;\n  const finalTranslation = -index * shiftPerStep;\n\n  // Linear shift from 0 to lockProgress; stays locked at finalTranslation afterwards.\n  // All unstacked cards shift together, keeping equal gap between them to the right.\n  const x =\n    isMobile || index === 0\n      ? undefined\n      : `translateX(calc(min(var(${PROGRESS_VAR}, 0), ${lockProgress}) / ${lockProgress} * ${finalTranslation}px))`;\n\n  const displayNum = item.number || `0${index + 1}`;\n\n  return (\n    <div\n      style={{\n        width: isMobile ? \"100%\" : `${cardWidth}px`,\n        transform: x,\n        zIndex: index + 1,\n      }}\n      className={cn(\n        \"shrink-0 p-6 sm:p-8 md:p-10 rounded-3xl bg-card/95 backdrop-blur-xl border border-border/80 flex flex-col justify-between group hover:border-[var(--theme-color,var(--primary))] transition-all duration-300 relative\",\n        isMobile ? \"w-full\" : \"\",\n      )}\n    >\n      <div>\n        <div className=\"flex items-center justify-between mb-6 sm:mb-8\">\n          <div className=\"w-12 h-12 rounded-2xl flex items-center justify-center border transition-all duration-300 bg-[var(--theme-color,var(--primary))]/10 border-[var(--theme-color,var(--primary))]/20 text-[var(--theme-color,var(--primary))] group-hover:scale-110 group-hover:!bg-[var(--theme-color,var(--primary))] group-hover:!border-[var(--theme-color,var(--primary))] group-hover:!text-primary-foreground group-hover:[&_svg]:!text-primary-foreground\">\n            {item.icon}\n          </div>\n          <span className=\"text-4xl sm:text-5xl font-bold text-foreground/10 tracking-tighter select-none group-hover:text-[var(--theme-color,var(--primary))]/25 transition-colors duration-300\">\n            {displayNum}\n          </span>\n        </div>\n\n        <h3 className=\"text-xl sm:text-2xl font-semibold tracking-tight text-foreground mb-2 sm:mb-3\">\n          {item.title}\n        </h3>\n        <p className=\"text-sm sm:text-base text-muted-foreground leading-relaxed font-normal\">\n          {item.description}\n        </p>\n      </div>\n\n      <div className=\"mt-8 w-full h-1 bg-muted rounded-full overflow-hidden\">\n        <div className=\"h-full bg-[var(--theme-color,var(--primary))] w-0 group-hover:w-full transition-all duration-500 ease-out\" />\n      </div>\n    </div>\n  );\n}\n\n// --- CHILD COMPONENT: Progress Indicator Bar ---\nfunction ProgressBarItem({\n  index,\n  total,\n}: {\n  index: number;\n  total: number;\n}) {\n  const steps = Math.max(1, total - 1);\n\n  // Bar `index` fills while progress runs from (index - 1) / steps to\n  // index / steps; the first bar is always full.\n  const scaleX =\n    index === 0\n      ? undefined\n      : `scaleX(clamp(0, var(${PROGRESS_VAR}, 0) * ${steps} - ${index - 1}, 1))`;\n\n  return (\n    <div className=\"h-2 flex-1 bg-muted rounded-full overflow-hidden relative\">\n      <div\n        className=\"h-full bg-[var(--theme-color,var(--primary))]\"\n        style={{ transform: scaleX, transformOrigin: \"left\" }}\n      />\n    </div>\n  );\n}\n\n// --- MAIN COMPONENT ---\nexport function HorizontalScrollStack({\n  items,\n  title,\n  subtitle,\n  className,\n  style,\n  ...props\n}: HorizontalScrollStackProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const [activeStep, setActiveStep] = useState(0);\n  const progressRef = useRef(0);\n  const lastScrollTimeRef = useRef(0);\n\n  const steps = Math.max(1, items.length - 1);\n\n  // Smoothly animate progress whenever activeStep changes, always from where\n  // it is now, so a new step mid-glide picks up without a jump. Written inline\n  // each frame on purpose: the cards' `transition-all` then trails the glide\n  // slightly, which is the look the section has always had.\n  useEffect(() => {\n    const container = containerRef.current;\n    const targetProgress = activeStep / steps;\n    const from = progressRef.current;\n    if (!container || from === targetProgress) return;\n\n    let frame = 0;\n    let startTime = 0;\n    const tick = (now: number) => {\n      if (!startTime) startTime = now;\n      const t = Math.min((now - startTime) / PROGRESS_DURATION, 1);\n      const value = from + (targetProgress - from) * PROGRESS_EASE(t);\n      progressRef.current = value;\n      container.style.setProperty(PROGRESS_VAR, String(value));\n      if (t < 1) frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [activeStep, steps]);\n\n  const [layout, setLayout] = useState({\n    cardWidth: 440,\n    gap: 32,\n    stackOffset: 0,\n  });\n  const [isMobile, setIsMobile] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\") return;\n\n    const updateLayout = () => {\n      const w = window.innerWidth;\n      setIsMobile(w < 768);\n\n      if (w < 640) {\n        setLayout({ cardWidth: Math.max(260, w - 64), gap: 16, stackOffset: 0 });\n      } else if (w < 1024) {\n        setLayout({ cardWidth: 360, gap: 24, stackOffset: 0 });\n      } else {\n        setLayout({ cardWidth: 440, gap: 32, stackOffset: 0 });\n      }\n    };\n\n    updateLayout();\n    window.addEventListener(\"resize\", updateLayout);\n    return () => window.removeEventListener(\"resize\", updateLayout);\n  }, []);\n\n  // Stepped scroll control: Exactly 1 card per scroll action\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n    const doc = container.ownerDocument;\n    const win = doc.defaultView ?? window;\n\n    const handleWheel = (e: WheelEvent) => {\n      if (isMobile) return;\n      const rect = container.getBoundingClientRect();\n      const isVisibleInViewport =\n        rect.top < win.innerHeight * 0.85 && rect.bottom > win.innerHeight * 0.15;\n\n      if (!isVisibleInViewport) return;\n\n      const now = Date.now();\n      const isCooldown = now - lastScrollTimeRef.current < 550;\n\n      if (e.deltaY > 0) {\n        // Scrolling DOWN\n        if (activeStep < steps) {\n          e.preventDefault();\n          e.stopPropagation();\n          if (!isCooldown) {\n            lastScrollTimeRef.current = now;\n            setActiveStep((prev) => Math.min(steps, prev + 1));\n          }\n        }\n      } else if (e.deltaY < 0) {\n        // Scrolling UP\n        if (activeStep > 0) {\n          e.preventDefault();\n          e.stopPropagation();\n          if (!isCooldown) {\n            lastScrollTimeRef.current = now;\n            setActiveStep((prev) => Math.max(0, prev - 1));\n          }\n        }\n      }\n    };\n\n    win.addEventListener(\"wheel\", handleWheel, { passive: false });\n    return () => win.removeEventListener(\"wheel\", handleWheel);\n  }, [activeStep, steps, isMobile]);\n\n  return (\n    <div\n      ref={containerRef}\n      style={{\n        height: isMobile ? \"auto\" : \"540px\",\n        ...style,\n      }}\n      className={cn(\n        \"relative w-full text-foreground overflow-hidden [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\",\n        isMobile ? \"py-12 px-4 sm:px-8\" : \"\",\n        className,\n      )}\n      {...props}\n    >\n      {isMobile ? (\n        <div className=\"w-full flex flex-col gap-6\">\n          {(title || subtitle) && (\n            <div className=\"max-w-3xl mb-4 shrink-0\">\n              {title && (\n                <h2 className=\"text-2xl sm:text-3xl font-extrabold tracking-tight leading-tight text-foreground\">\n                  {title}\n                </h2>\n              )}\n              {subtitle && (\n                <p className=\"text-muted-foreground text-sm sm:text-base mt-2 font-normal\">\n                  {subtitle}\n                </p>\n              )}\n            </div>\n          )}\n          <div className=\"flex flex-col gap-6 w-full mt-2\">\n            {items.map((item, index) => (\n              <StackCard\n                key={item.id}\n                item={item}\n                index={index}\n                total={items.length}\n                cardWidth={0}\n                gap={0}\n                stackOffset={0}\n                isMobile={true}\n              />\n            ))}\n          </div>\n        </div>\n      ) : (\n        <div className=\"w-full h-full overflow-hidden flex flex-col justify-between px-4 sm:px-8 lg:px-12 py-6\">\n          {(title || subtitle) && (\n            <div className=\"max-w-3xl shrink-0\">\n              {title && (\n                <h2 className=\"text-xl sm:text-3xl md:text-4xl font-extrabold tracking-tight leading-tight text-foreground\">\n                  {title}\n                </h2>\n              )}\n              {subtitle && (\n                <p className=\"text-muted-foreground text-xs sm:text-base mt-1 font-normal\">\n                  {subtitle}\n                </p>\n              )}\n            </div>\n          )}\n\n          <div className=\"w-full overflow-hidden py-2 my-auto\">\n            <div\n              style={{ gap: `${layout.gap}px` }}\n              className=\"flex w-max items-stretch\"\n            >\n              {items.map((item, index) => (\n                <StackCard\n                  key={item.id}\n                  item={item}\n                  index={index}\n                  total={items.length}\n                  cardWidth={layout.cardWidth}\n                  gap={layout.gap}\n                  stackOffset={layout.stackOffset}\n                  isMobile={false}\n                />\n              ))}\n            </div>\n          </div>\n\n          <div className=\"flex items-center gap-2 max-w-xs w-full shrink-0\">\n            {items.map((_, i) => (\n              <ProgressBarItem\n                key={i}\n                index={i}\n                total={items.length}\n              />\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nHorizontalScrollStack.displayName = \"HorizontalScrollStack\";\n",
      "type": "registry:component",
      "target": "components/custom/blocks/HorizontalScrollStackBlock.tsx"
    },
    {
      "path": "src/components/custom/blocks/motion-curves.ts",
      "content": "/**\n * Motion curves with no animation library behind them.\n *\n * The components here ship as CSS, GSAP or Motion builds that must move\n * identically, so the curves are computed once, from the same numbers, and\n * handed to whichever engine runs them:\n *\n *   - `cubicBezier(x1, y1, x2, y2)` → an easing function (GSAP accepts any\n *     `(progress) => value` as `ease`), exact for Motion's `ease: [..]` arrays\n *     and for CSS `cubic-bezier()`.\n *   - `spring({ stiffness, damping, mass })` → the analytic solution Motion\n *     Motion uses, as an easing function plus the time it takes to settle, or\n *     as a CSS `linear()` string for a fixed start and end.\n *   - `createSpringFollower` → a value that chases a moving target through the\n *     same spring every frame, like Motion's `useSpring`, for pointer-driven\n *     motion that no fixed curve can describe.\n */\n\n// ---------------------------------------------------------------------------\n// Cubic bezier\n// ---------------------------------------------------------------------------\n\n/**\n * The CSS / Motion cubic-bezier as a function of progress. Newton-Raphson\n * with a bisection fallback, the same solver browsers use.\n */\nexport function cubicBezier(x1: number, y1: number, x2: number, y2: number) {\n  if (x1 === y1 && x2 === y2) return (t: number) => t;\n\n  const a = (p1: number, p2: number) => 1 - 3 * p2 + 3 * p1;\n  const b = (p1: number, p2: number) => 3 * p2 - 6 * p1;\n  const c = (p1: number) => 3 * p1;\n  const at = (t: number, p1: number, p2: number) =>\n    ((a(p1, p2) * t + b(p1, p2)) * t + c(p1)) * t;\n  const slope = (t: number, p1: number, p2: number) =>\n    3 * a(p1, p2) * t * t + 2 * b(p1, p2) * t + c(p1);\n\n  const solveX = (x: number) => {\n    let t = x;\n    for (let i = 0; i < 8; i++) {\n      const s = slope(t, x1, x2);\n      if (Math.abs(s) < 1e-6) break;\n      const err = at(t, x1, x2) - x;\n      if (Math.abs(err) < 1e-7) return t;\n      t -= err / s;\n    }\n    let lo = 0;\n    let hi = 1;\n    t = x;\n    for (let i = 0; i < 30 && hi - lo > 1e-7; i++) {\n      const value = at(t, x1, x2);\n      if (value < x) lo = t;\n      else hi = t;\n      t = (lo + hi) / 2;\n    }\n    return t;\n  };\n\n  return (x: number) => (x <= 0 ? 0 : x >= 1 ? 1 : at(solveX(x), y1, y2));\n}\n\n/** Motion's named eases, as the cubic-beziers they are. */\nexport const MOTION_EASE = {\n  linear: [0, 0, 1, 1],\n  easeIn: [0.42, 0, 1, 1],\n  easeOut: [0, 0, 0.58, 1],\n  easeInOut: [0.42, 0, 0.58, 1],\n  circIn: [0.55, 0, 1, 0.45],\n  circOut: [0.55, 0, 0.45, 1],\n  circInOut: [0.85, 0, 0.15, 1],\n  backIn: [0.31, 0.01, 0.66, -0.59],\n  backOut: [0.33, 1.53, 0.69, 0.99],\n  anticipate: [0.66, -0.56, 0.34, 1.35],\n} as const;\n\n/**\n * Motion's default layout transition (`layout` / `layoutId`):\n * `{ duration: 0.45, ease: [0.4, 0, 0.1, 1] }`.\n */\nexport const LAYOUT_TRANSITION = { duration: 0.45, ease: [0.4, 0, 0.1, 1] as const };\n\n// ---------------------------------------------------------------------------\n// Spring\n// ---------------------------------------------------------------------------\n\nexport interface SpringOptions {\n  /** Motion default 100. */\n  stiffness?: number;\n  /** Motion default 10. */\n  damping?: number;\n  /** Motion default 1. */\n  mass?: number;\n  /** Starting velocity, in units of the whole distance per second. */\n  velocity?: number;\n  /** How close to the end counts as settled, as a fraction of the distance. */\n  restDelta?: number;\n}\n\n/**\n * Normalised position (0 → 1) of a spring released from 0 towards 1, at time\n * `t` seconds. The closed-form solution Motion's `spring()` generator\n * uses for the under-, critically and over-damped cases.\n */\nexport function springAt(options: SpringOptions = {}) {\n  const { stiffness = 100, damping = 10, mass = 1, velocity = 0 } = options;\n  const zeta = damping / (2 * Math.sqrt(stiffness * mass));\n  const omega0 = Math.sqrt(stiffness / mass);\n  // Motion's convention: distance still to cover, velocity toward it negated.\n  const delta = 1;\n  const v0 = -velocity;\n\n  if (zeta < 1) {\n    const omegaD = omega0 * Math.sqrt(1 - zeta * zeta);\n    return (t: number) => {\n      const envelope = Math.exp(-zeta * omega0 * t);\n      return (\n        1 -\n        envelope *\n          (((v0 + zeta * omega0 * delta) / omegaD) * Math.sin(omegaD * t) +\n            delta * Math.cos(omegaD * t))\n      );\n    };\n  }\n  if (zeta === 1) {\n    return (t: number) => 1 - Math.exp(-omega0 * t) * (delta + (v0 + omega0 * delta) * t);\n  }\n  const omegaD = omega0 * Math.sqrt(zeta * zeta - 1);\n  return (t: number) => {\n    const envelope = Math.exp(-zeta * omega0 * t);\n    const freq = Math.min(omegaD * t, 300);\n    return (\n      1 -\n      (envelope *\n        ((v0 + zeta * omega0 * delta) * Math.sinh(freq) + omegaD * delta * Math.cosh(freq))) /\n        omegaD\n    );\n  };\n}\n\n/** Seconds until the spring stays within `restDelta` of its end. */\nexport function springDuration(options: SpringOptions = {}) {\n  const at = springAt(options);\n  const restDelta = options.restDelta ?? 0.001;\n  const step = 1 / 240;\n  let lastOutside = 0;\n  for (let t = 0; t <= 10; t += step) {\n    if (Math.abs(1 - at(t)) > restDelta) lastOutside = t;\n    else if (t - lastOutside > 0.1) break;\n  }\n  return Math.max(lastOutside + step, step);\n}\n\n/**\n * A spring as a duration and an easing function over it — what GSAP takes:\n *\n *   const { duration, ease } = springEase({ stiffness: 300, damping: 20 });\n *   gsap.to(el, { y: 0, duration, ease });\n *\n * Exact for a fixed start and end; a spring that has to react mid-flight to a\n * new target needs `createSpringFollower` instead.\n */\nexport function springEase(options: SpringOptions = {}) {\n  const duration = springDuration(options);\n  const at = springAt(options);\n  return {\n    duration,\n    ease: (progress: number) => (progress >= 1 ? 1 : at(progress * duration)),\n  };\n}\n\n/**\n * A spring as a CSS `linear()` easing and its duration in ms, for\n * `transition` / `animation` with a fixed start and end.\n *\n *   const { easing, duration } = springLinear({ stiffness: 400, damping: 30 });\n *   el.style.transition = `transform ${duration}ms ${easing}`;\n */\nexport function springLinear(options: SpringOptions = {}, pointsPerSecond = 60) {\n  const duration = springDuration(options);\n  const at = springAt(options);\n  const count = Math.max(Math.ceil(duration * pointsPerSecond), 8);\n  const points: string[] = [];\n  for (let i = 0; i <= count; i++) {\n    const t = (i / count) * duration;\n    points.push(String(Math.round((i === count ? 1 : at(t)) * 10000) / 10000));\n  }\n  return { easing: `linear(${points.join(\", \")})`, duration: Math.round(duration * 1000) };\n}\n\n// ---------------------------------------------------------------------------\n// Spring follower (useSpring equivalent)\n// ---------------------------------------------------------------------------\n\n/** Something that calls back once per frame with the time since the last, in ms. */\nexport interface FrameTicker {\n  add: (callback: (deltaMs: number) => void) => () => void;\n}\n\n/** requestAnimationFrame as a ticker — the library-free default. */\nexport const rafTicker: FrameTicker = {\n  add(callback) {\n    let last = performance.now();\n    let frame = requestAnimationFrame(function tick(now) {\n      callback(now - last);\n      last = now;\n      frame = requestAnimationFrame(tick);\n    });\n    return () => cancelAnimationFrame(frame);\n  },\n};\n\nexport interface SpringFollower {\n  /** Chase a new target from wherever the value is now, keeping its velocity. */\n  set: (target: number) => void;\n  /** Move to a value instantly, with no motion. */\n  jump: (value: number) => void;\n  get: () => number;\n  destroy: () => void;\n}\n\n/**\n * A value that follows its target through a spring, integrating the same\n * equation of motion Motion's `useSpring` solves — so a pointer-driven spring\n * reads the same in every build. Runs only while moving; `onUpdate` receives\n * each new value.\n */\nexport function createSpringFollower(\n  initial: number,\n  onUpdate: (value: number) => void,\n  options: SpringOptions & { restSpeed?: number; ticker?: FrameTicker } = {},\n): SpringFollower {\n  const { stiffness = 100, damping = 10, mass = 1, ticker = rafTicker } = options;\n  const restDelta = options.restDelta ?? 0.01;\n  const restSpeed = options.restSpeed ?? 0.01;\n  let value = initial;\n  let target = initial;\n  let velocity = 0;\n  let stop: (() => void) | null = null;\n\n  const step = (deltaMs: number) => {\n    // Fixed 1ms sub-steps keep the integration stable for stiff springs and\n    // independent of the frame rate.\n    const steps = Math.min(Math.max(Math.round(deltaMs), 1), 250);\n    for (let i = 0; i < steps; i++) {\n      const force = -stiffness * (value - target) - damping * velocity;\n      velocity += (force / mass) * 0.001;\n      value += velocity * 0.001;\n    }\n    if (Math.abs(target - value) < restDelta && Math.abs(velocity) < restSpeed) {\n      value = target;\n      velocity = 0;\n      stop?.();\n      stop = null;\n    }\n    onUpdate(value);\n  };\n\n  return {\n    set(next) {\n      target = next;\n      if (!stop && value !== target) stop = ticker.add(step);\n    },\n    jump(next) {\n      stop?.();\n      stop = null;\n      value = target = next;\n      velocity = 0;\n      onUpdate(value);\n    },\n    get: () => value,\n    destroy() {\n      stop?.();\n      stop = null;\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/custom/blocks/motion-curves.ts"
    }
  ],
  "docs": "Named export: HorizontalScrollStack. Items take { id, number, title, description, icon? }. Pins the section and maps scroll progress to horizontal travel, so it needs enough page height to complete — and it takes over scrolling while pinned, which is worth testing on touch.",
  "categories": [
    "scroll",
    "marketing"
  ],
  "type": "registry:component"
}
