{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sticky-scroll-stack","type":"registry:component","title":"Sticky Scroll Stack","description":"Cards that pin and pile up as you scroll: each one shrinks and dims as the next slides over it, with a progress index on the side that jumps between cards. Hand-written scroll logic: a built-in scroll box by default, or the page, or your own scroller.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/sticky-scroll-stack.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type RefObject } from \"react\";\n\nexport type StickyScrollStackCard = { title: string; body: string };\n\nexport type StickyScrollStackProps = {\n  /** How much each card shrinks per card stacked on top of it (0.06 = 6%). */\n  scaleStep?: number;\n  /**\n   * What scrolls. Leave it out and the component renders its own scroll box\n   * (see height). Pass \"window\" to follow the page scroll, or the ref of your\n   * own overflow-y-auto element.\n   */\n  scroller?: RefObject<HTMLElement | null> | \"window\";\n  /** Height of the built-in scroll box (only used when scroller is not set). */\n  height?: number | string;\n  /** Card content. Falls back to four sample cards. */\n  cards?: StickyScrollStackCard[];\n  /** Where the first card pins, in px from the top of the scroll area. */\n  top?: number;\n  /** How far each stacked card sits below the one under it, in px. */\n  stackOffset?: number;\n  /** Space between cards before they stack, in px (more = longer scroll). */\n  gap?: number;\n  /** Card height: a number of px or any CSS length. */\n  cardHeight?: number | string;\n  /** Root width: the root is min(100%, width). Any CSS length. */\n  width?: string;\n  /** Show the progress index beside the stack. */\n  showIndex?: boolean;\n  className?: string;\n};\n\nconst DEFAULT_CARDS: StickyScrollStackCard[] = [\n  { title: \"Design\", body: \"Sketch the shape before you touch a component.\" },\n  { title: \"Build\", body: \"Wire it up with the real content, not lorem ipsum.\" },\n  { title: \"Ship\", body: \"Small, reversible, boring deploys beat big risky ones.\" },\n  { title: \"Measure\", body: \"Read the one number that moved, then decide what to cut.\" },\n];\n\nconst HUES = [212, 168, 38, 292];\nconst TWEEN_MS = 620;\nconst clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);\n\n/** A small line illustration per card (pure SVG, no assets). */\nfunction Art({ kind, hue }: { kind: number; hue: number }) {\n  const ink = `hsl(${hue} 92% 74%)`;\n  const wash = `hsl(${hue} 90% 60% / 0.14)`;\n  const draw =\n    \"[stroke-dasharray:1] [stroke-dashoffset:1] transition-[stroke-dashoffset] duration-[900ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[in=true]/card:[stroke-dashoffset:0] motion-reduce:transition-none\";\n  const common = { fill: \"none\", stroke: ink, strokeWidth: 1.5, strokeLinecap: \"round\", strokeLinejoin: \"round\" } as const;\n  return (\n    <svg viewBox=\"0 0 160 120\" className=\"block h-auto w-full\" aria-hidden=\"true\">\n      {kind === 0 && (\n        <g>\n          <rect x=\"24\" y=\"24\" width=\"112\" height=\"68\" rx=\"10\" fill={wash} stroke=\"rgba(255,255,255,0.28)\" strokeDasharray=\"3 4\" />\n          {[[24, 24], [136, 24], [24, 92], [136, 92]].map(([x, y]) => (\n            <rect key={`${x}-${y}`} x={(x ?? 0) - 3.5} y={(y ?? 0) - 3.5} width=\"7\" height=\"7\" rx=\"1.5\" fill=\"#0c0d10\" stroke={ink} strokeWidth=\"1.2\" />\n          ))}\n          <circle cx=\"56\" cy=\"58\" r=\"13\" {...common} />\n          <rect x=\"82\" y=\"46\" width=\"38\" height=\"6\" rx=\"3\" fill=\"rgba(255,255,255,0.22)\" />\n          <rect x=\"82\" y=\"60\" width=\"26\" height=\"6\" rx=\"3\" fill=\"rgba(255,255,255,0.12)\" />\n          <path pathLength={1} d=\"M24 106 C 50 78, 88 120, 136 100\" {...common} className={draw} />\n        </g>\n      )}\n      {kind === 1 && (\n        <g>\n          {[44, 26, 8].map((dy, n) => (\n            <path\n              key={dy}\n              d={`M80 ${dy + 14} L132 ${dy + 36} L80 ${dy + 58} L28 ${dy + 36} Z`}\n              fill={n === 2 ? wash : \"rgba(255,255,255,0.04)\"}\n              stroke={n === 2 ? ink : \"rgba(255,255,255,0.22)\"}\n              strokeWidth=\"1.3\"\n              strokeLinejoin=\"round\"\n            />\n          ))}\n          <path pathLength={1} d=\"M80 22 L106 33 L80 44 L54 33 Z\" {...common} className={draw} />\n          <path d=\"M80 70 V 96\" stroke=\"rgba(255,255,255,0.18)\" strokeDasharray=\"2 4\" />\n        </g>\n      )}\n      {kind === 2 && (\n        <g>\n          <circle cx=\"80\" cy=\"62\" r=\"38\" fill={wash} stroke=\"rgba(255,255,255,0.14)\" strokeWidth=\"1.2\" />\n          <circle pathLength={1} cx=\"80\" cy=\"62\" r=\"38\" {...common} transform=\"rotate(-90 80 62)\" className={draw} />\n          <path pathLength={1} d=\"M58 80 L92 48 M78 46 H94 V62\" {...common} strokeWidth={1.8} className={draw} />\n          <circle cx=\"58\" cy=\"80\" r=\"2.6\" fill={ink} />\n        </g>\n      )}\n      {kind === 3 && (\n        <g>\n          {[22, 38, 30, 54, 70].map((h, n) => (\n            <rect key={n} x={26 + n * 22} y={100 - h} width=\"14\" height={h} rx=\"4\" fill={n === 4 ? wash : \"rgba(255,255,255,0.08)\"} stroke={n === 4 ? ink : \"rgba(255,255,255,0.2)\"} strokeWidth=\"1.2\" />\n          ))}\n          <path pathLength={1} d=\"M33 74 L55 58 L77 66 L99 40 L121 22\" {...common} className={draw} />\n          <circle cx=\"121\" cy=\"22\" r=\"3.2\" fill={ink} />\n        </g>\n      )}\n    </svg>\n  );\n}\n\nexport function StickyScrollStack({\n  scaleStep = 0.06,\n  scroller,\n  cards = DEFAULT_CARDS,\n  top = 16,\n  stackOffset = 12,\n  gap = 24,\n  cardHeight = 192,\n  width = \"18rem\",\n  height = 420,\n  showIndex = true,\n  className = \"\",\n}: StickyScrollStackProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const trackRef = useRef<HTMLDivElement>(null);\n  const columnRef = useRef<HTMLDivElement>(null);\n  const spacerRef = useRef<HTMLDivElement>(null);\n  const fillRef = useRef<HTMLSpanElement>(null);\n  const goRef = useRef<(i: number) => void>(() => {});\n  const [active, setActive] = useState(0);\n  const count = cards.length;\n\n  useEffect(() => {\n    const track = trackRef.current;\n    const column = columnRef.current;\n    if (!track || !column || count === 0) return;\n    const el = scroller === \"window\" ? null : scroller ? scroller.current : boxRef.current;\n    const target: HTMLElement | Window = el ?? window;\n    const cardEls = Array.from(column.querySelectorAll<HTMLElement>(\"[data-sss-card]\"));\n    const dims = cardEls.map((c) => c.querySelector<HTMLElement>(\"[data-sss-dim]\"));\n    const reduced = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const by = (dy: number) => {\n      if (el) el.scrollBy({ top: dy, behavior: \"instant\" });\n      else window.scrollBy({ top: dy, behavior: \"instant\" });\n    };\n\n    let alive = true;\n    let raf = 0;\n    let tween = 0;\n    let cardH = 0;\n\n    // Distance of the track's top edge below the scrollport's top edge, in layout px\n    // (library cards scale their previews, so divide the scale out).\n    const trackOffset = () => {\n      const r = track.getBoundingClientRect();\n      const k = track.offsetWidth ? r.width / track.offsetWidth : 1;\n      const portTop = el ? el.getBoundingClientRect().top + el.clientTop * k : 0;\n      return (r.top - portTop) / k;\n    };\n\n    const measure = () => {\n      cardH = cardEls[0]?.offsetHeight ?? 0;\n      const portH = el ? el.clientHeight : window.innerHeight;\n      const spacer = spacerRef.current;\n      // Enough trailing room that the last card can finish pinning.\n      if (spacer) spacer.style.height = `${Math.max(0, portH - (top + (count - 1) * stackOffset) - cardH) + 48}px`;\n    };\n\n    const update = () => {\n      raf = 0;\n      if (!alive || !cardH) return;\n      const rel = trackOffset();\n      const span = Math.max(1, cardH - stackOffset);\n      const step = cardH + gap;\n      // p[m]: how far card m+1 has travelled over card m (0 = not touching, 1 = pinned on it).\n      const p: number[] = [];\n      let total = 0;\n      for (let m = 0; m < count - 1; m++) {\n        const j = m + 1;\n        const d = rel + j * step - (top + j * stackOffset);\n        const v = clamp01(1 - d / span);\n        p.push(v);\n        total += v;\n      }\n      let depth = 0;\n      for (let i = count - 1; i >= 0; i--) {\n        depth += p[i] ?? 0;\n        const c = cardEls[i];\n        if (!c) continue;\n        c.style.transform = depth > 0.0005 ? `scale(${Math.max(0.6, 1 - scaleStep * depth).toFixed(4)})` : \"\";\n        const dim = dims[i];\n        if (dim) dim.style.opacity = Math.min(0.6, depth * 0.2).toFixed(3);\n        c.setAttribute(\"data-in\", i === 0 || (p[i - 1] ?? 0) > 0.35 ? \"true\" : \"false\");\n      }\n      let a = 0;\n      for (const v of p) if (v >= 0.5) a++;\n      setActive(a);\n      if (fillRef.current) fillRef.current.style.transform = `scaleY(${count > 1 ? (total / (count - 1)).toFixed(4) : 0})`;\n    };\n    const schedule = () => {\n      if (!raf) raf = requestAnimationFrame(update);\n    };\n    const remeasure = () => {\n      measure();\n      schedule();\n    };\n\n    goRef.current = (i: number) => {\n      const delta = i * (cardH + gap) - top - i * stackOffset + trackOffset();\n      cancelAnimationFrame(tween);\n      if (Math.abs(delta) < 2) return;\n      if (reduced()) {\n        by(delta);\n        return;\n      }\n      let t0 = -1;\n      let done = 0;\n      const tick = (ts: number) => {\n        if (!alive) return;\n        if (t0 < 0) t0 = ts;\n        const t = Math.min(1, (ts - t0) / TWEEN_MS);\n        const want = delta * (1 - Math.pow(1 - t, 3));\n        by(want - done);\n        done = want;\n        if (t < 1) tween = requestAnimationFrame(tick);\n      };\n      tween = requestAnimationFrame(tick);\n    };\n\n    const stopTween = () => cancelAnimationFrame(tween);\n    measure();\n    schedule();\n    target.addEventListener(\"scroll\", schedule, { passive: true });\n    target.addEventListener(\"wheel\", stopTween, { passive: true });\n    target.addEventListener(\"touchstart\", stopTween, { passive: true });\n    window.addEventListener(\"resize\", remeasure);\n    const ro = new ResizeObserver(remeasure);\n    ro.observe(track);\n    if (el) ro.observe(el);\n    return () => {\n      alive = false;\n      cancelAnimationFrame(raf);\n      cancelAnimationFrame(tween);\n      target.removeEventListener(\"scroll\", schedule);\n      target.removeEventListener(\"wheel\", stopTween);\n      target.removeEventListener(\"touchstart\", stopTween);\n      window.removeEventListener(\"resize\", remeasure);\n      ro.disconnect();\n      goRef.current = () => {};\n    };\n  }, [scroller, count, top, stackOffset, gap, scaleStep, cardHeight]);\n\n  const own = scroller === undefined;\n  const body = (\n      <div\n        ref={trackRef}\n        className={`grid items-start gap-x-1 @md:gap-x-3 ${showIndex ? \"grid-cols-[minmax(0,1fr)_auto]\" : \"grid-cols-[minmax(0,1fr)]\"}`}\n      >\n        <div ref={columnRef}>\n          {cards.map((card, i) => {\n            const hue = HUES[i % HUES.length] ?? 212;\n            return (\n              <article\n                key={i}\n                data-sss-card=\"\"\n                data-in={i === 0 ? \"true\" : \"false\"}\n                className=\"group/card sticky isolate overflow-hidden rounded-[20px]\"\n                style={{\n                  height: cardHeight,\n                  top: top + i * stackOffset,\n                  marginBottom: i < count - 1 ? gap : 0,\n                  transformOrigin: \"50% 0%\",\n                  background: \"linear-gradient(180deg, #16181d 0%, #0c0d10 100%)\",\n                  border: \"1px solid rgba(255,255,255,0.09)\",\n                  boxShadow: \"inset 0 1px 0 rgba(255,255,255,0.07), 0 -14px 28px -12px rgba(0,0,0,0.55), 0 18px 40px -22px rgba(0,0,0,0.6)\",\n                  color: \"#fff\",\n                }}\n              >\n                <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" style={{ background: `radial-gradient(70% 95% at 0% 0%, hsl(${hue} 90% 60% / 0.17), transparent 70%)` }} />\n                <div className=\"relative flex h-full items-stretch gap-4 p-5 @md:p-7\">\n                  <div className=\"flex min-w-0 flex-1 flex-col justify-between\">\n                    <div className=\"flex items-center gap-3 text-[11px] leading-none tracking-[0.18em]\" style={{ fontFamily: \"ui-monospace, SFMono-Regular, Menlo, monospace\" }}>\n                      <span style={{ color: `hsl(${hue} 92% 76%)` }}>{String(i + 1).padStart(2, \"0\")}</span>\n                      <span className=\"h-px w-8\" style={{ background: \"rgba(255,255,255,0.16)\" }} />\n                      <span style={{ color: \"rgba(255,255,255,0.4)\" }}>{String(count).padStart(2, \"0\")}</span>\n                    </div>\n                    <div>\n                      <h3 className=\"m-0 text-[22px] font-semibold leading-[1.05] tracking-[-0.02em] @md:text-[30px]\">{card.title}</h3>\n                      <p className=\"m-0 mt-2 max-w-[34ch] text-[13px] leading-snug @md:text-[15px]\" style={{ color: \"rgba(255,255,255,0.6)\" }}>\n                        {card.body}\n                      </p>\n                    </div>\n                  </div>\n                  <div aria-hidden=\"true\" className=\"hidden w-[38%] max-w-[220px] shrink-0 self-center @md:block\">\n                    <Art kind={i % 4} hue={hue} />\n                  </div>\n                  <div aria-hidden=\"true\" className=\"absolute right-4 top-4 w-[72px] @md:hidden\">\n                    <Art kind={i % 4} hue={hue} />\n                  </div>\n                </div>\n                <div aria-hidden=\"true\" data-sss-dim=\"\" className=\"pointer-events-none absolute inset-0 z-10 bg-black\" style={{ opacity: 0 }} />\n              </article>\n            );\n          })}\n          <div ref={spacerRef} aria-hidden=\"true\" style={{ height: 0 }} />\n        </div>\n        {showIndex && count > 1 && (\n          <nav aria-label=\"Stack progress\" className=\"sticky self-start\" style={{ top }}>\n            <div className=\"relative\">\n              <span aria-hidden=\"true\" className=\"absolute right-[19.5px] w-px\" style={{ top: 20, bottom: 20, background: \"currentColor\", opacity: 0.16 }} />\n              <span\n                ref={fillRef}\n                aria-hidden=\"true\"\n                className=\"absolute right-[19.5px] w-px origin-top\"\n                style={{ top: 20, bottom: 20, background: \"currentColor\", opacity: 0.85, transform: \"scaleY(0)\" }}\n              />\n              <ol className=\"relative m-0 flex list-none flex-col items-end p-0\">\n              {cards.map((card, i) => {\n                const on = i <= active;\n                return (\n                  <li key={i} className=\"m-0 p-0\">\n                    <button\n                      type=\"button\"\n                      aria-label={`Go to card ${i + 1}: ${card.title}`}\n                      aria-current={i === active ? \"step\" : undefined}\n                      onClick={() => goRef.current(i)}\n                      className=\"relative flex h-10 cursor-pointer items-center justify-end rounded-md border-0 bg-transparent p-0 text-inherit focus-visible:!outline-2 focus-visible:!outline-offset-[-3px] focus-visible:!outline-current\"\n                    >\n                      <span\n                        className=\"mr-1 hidden text-[11px] uppercase tracking-[0.18em] transition-opacity duration-200 @md:inline\"\n                        style={{ opacity: i === active ? 0.95 : 0.4 }}\n                      >\n                        {card.title}\n                      </span>\n                      <span className=\"relative grid h-10 w-10 place-items-center\">\n                        <span\n                          className=\"block rounded-full bg-current transition-[transform,opacity] duration-300 motion-reduce:transition-none\"\n                          style={{ width: 8, height: 8, opacity: on ? 1 : 0.3, transform: i === active ? \"scale(1.5)\" : \"scale(1)\" }}\n                        />\n                      </span>\n                    </button>\n                  </li>\n                );\n              })}\n              </ol>\n            </div>\n          </nav>\n        )}\n      </div>\n  );\n  return (\n    <div className={`@container relative ${className}`} style={{ width: `min(100%, ${width})` }}>\n      {own ? (\n        <div\n          ref={boxRef}\n          className=\"relative overflow-y-auto overscroll-contain rounded-2xl\"\n          style={{ height, border: \"1px solid rgba(128,128,128,0.28)\" }}\n        >\n          {body}\n        </div>\n      ) : (\n        body\n      )}\n    </div>\n  );\n}\n","type":"registry:component","target":"components/viberdy/sticky-scroll-stack.tsx"}],"meta":{"tier":"free","tags":["scroll","sticky","stack","cards","scrollytelling","progress","pinned"],"href":"https://viberdy.dev/library/sticky-scroll-stack","aiPrompt":"Create a React + Tailwind (v4) component named StickyScrollStack. React only: no framer-motion, no other dependency. One \"use client\" file.\n\nBehaviour: a column of cards that each pin with position: sticky near the top of the scroll area and pile up as you scroll. Card i sticks at top + i * stackOffset (defaults 16 and 12 px), so each buried card peeks 12px above the one on it. The cards are siblings in one column, each with marginBottom = gap (default 24px) except the last, followed by a trailing spacer so the last card can finish pinning: spacer height = max(0, scrollportHeight - (top + (n-1) * stackOffset) - cardHeight) + 48px.\n\nProps: scaleStep (0.06), scroller (undefined = render a built-in scroll box: overflow-y-auto rounded-2xl with a 1px rgba(128,128,128,0.28) border, height prop default 420, root width min(100%, 18rem); \"window\" = follow the page scroll; or a RefObject<HTMLElement | null> to an external scroller), cards ({title, body}[], default four sample cards: Design, Build, Ship, Measure), height (420), top (16), stackOffset (12), gap (24), cardHeight (px number or CSS length, default 192), width (CSS length, default \"18rem\"; the root is min(100%, width) and is an @container), showIndex (true), className.\n\nScroll maths (hand written, no library): attach one passive scroll listener to the scroller element (or window), a window resize listener and a ResizeObserver on the track (and scroller). Coalesce with one requestAnimationFrame. Each frame: rel = track.getBoundingClientRect().top minus the scroller's top (0 for the window), divided by the scale factor rect.width / offsetWidth (previews may be transform-scaled). For m = 0..n-2 let j = m+1, d = rel + j * (cardH + gap) - (top + j * stackOffset), p[m] = clamp(1 - d / (cardH - stackOffset), 0, 1): how far card j has travelled over card m. depth_i = sum of p[m] for m >= i. Card i gets transform scale(max(0.6, 1 - scaleStep * depth_i)) with transform-origin 50% 0%, and a black overlay with opacity min(0.6, depth_i * 0.2). Write these straight to the DOM through refs (no setState per scroll). A card is \"in\" (data-in=true) when it is the first or p[i-1] > 0.35; \"in\" draws its illustration. Only the active index (count of p >= 0.5) goes through useState.\n\nCards: 20px radius, background linear-gradient(180deg, #16181d, #0c0d10), 1px border rgba(255,255,255,0.09) set inline, inset top highlight plus a shadow cast upward onto the card below, a faint radial wash in the card's hue at the top-left. Hues cycle 212, 168, 38, 292. Content: mono step counter \"01 -- 04\" (hue colour, then 40% white), title 22px (30px at the @md container breakpoint) semibold with -0.02em tracking, body 13px (15px at @md) at 60% white, max 34ch. A 160x120 SVG line illustration per card (selection frame with handles, stacked layers, a progress ring with a rising arrow, bars with a trend line); its accent path animates stroke-dashoffset 1 to 0 over 900ms cubic-bezier(0.22,1,0.36,1) when the card becomes \"in\". Wide containers (@md, 448px) show the illustration on the right (38% width, max 220px); narrower ones show a 72px version in the top-right corner.\n\nProgress index (when showIndex and 2+ cards): a nav (aria-label \"Stack progress\") in a second grid column, sticky at the same top. One button per card, 40x40px minimum, with an 8px dot (30% opacity, 100% once passed, scale 1.5 for the active one), an uppercase 11px label from @md up, aria-current=\"step\" on the active one, aria-label \"Go to card N: title\". A 1px rail through the dot centres whose fill scaleY follows total progress. Colours inherit currentColor so the index works on light and dark pages. Click scrolls so that card is pinned in front: target = i * (cardH + gap) - top - i * stackOffset relative to the track, eased with a 620ms ease-out-cubic tween driven by requestAnimationFrame using scrollBy({ behavior: \"instant\" }) (the site may set smooth scrolling); a wheel or touchstart cancels it. Focus ring: focus-visible:!outline-2 with currentColor.\n\nAccessibility and fallback: real articles and headings in order, decorative art aria-hidden, prefers-reduced-motion makes index jumps instant and removes the stroke transition (scroll-linked scaling stays). Without JavaScript the cards still stack with plain sticky positioning.","license":"https://viberdy.dev/license"}}