Stacking Scroll Cards — Free HTML CSS JS Sticky Snippet

Stacking Scroll Cards · Layouts · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Native sticky pinning
position: sticky pins each card with no JS.
Scale-back depth cue
Covered cards shrink up to 8% via transform.
Brightness dimming
Buried cards darken to recede into the stack.
Top-anchored shrink
transform-origin keeps the spine aligned.
rAF-throttled scroll
One batched read/write per frame, passive.
Resize-aware
Recomputes progress when viewport height changes.
Per-card color variable
Theme each step with one custom property.
Auto-linked cards
Each card measures against its successor.

About this UI Snippet

Stacking Scroll Cards — Sticky Cards That Layer on Scroll

Screenshot of the Stacking Scroll Cards snippet rendered live

Stacking scroll cards are the storytelling pattern where full-width cards pin to the top of the viewport one after another, and as you keep scrolling the next card slides up to cover the previous one — which shrinks back like a card going into a deck. It's a favorite for explaining a product in numbered steps. This snippet builds the effect in plain HTML, CSS, and a small amount of vanilla JavaScript, leaning on position: sticky so the heavy lifting is native.

Sticky positioning does the pinning

Each card is position: sticky; top: 90px. As you scroll, a card scrolls normally until its top hits 90px from the viewport top, then it sticks there while the rest of the page continues moving. Because all cards share the same sticky offset, the next card scrolls up from below and slides directly over the pinned one — no JavaScript is needed for the pinning itself, which keeps it smooth even on low-end devices. The cards have descending nothing; they simply stack in document order with their drop shadows separating the layers.

JavaScript adds the depth cues

Pure sticky stacking looks flat — the covered card just disappears behind the next. The JavaScript adds the physical "into the deck" feel by measuring, each frame, how close the next card has risen to the pin line. It computes a progress value p from the gap between the next card's top and the pin position, normalized over roughly 70% of the viewport height. As p goes from 0 to 1, the pinned card scales down up to 8% via transform: scale and dims up to 25% via filter: brightness. The combination makes each card recede as it's buried, so the stack reads as real layers.

transform-origin keeps the shrink anchored

The scale uses transform-origin: top center so cards shrink toward their top edge rather than their middle. That keeps the top of each card aligned at the pin line as it shrinks, so the visible "spine" of the stack stays put — exactly how a physical deck looks from the front.

Efficient scroll handling

The scroll listener is { passive: true } and throttled through requestAnimationFrame with a ticking guard, so at most one measurement-and-write happens per frame no matter how many scroll events fire. Reads (getBoundingClientRect) and writes (setting transform and filter) are batched in the single rAF callback. A resize listener re-runs the update because viewport height feeds the progress math.

Numbered, colorful steps

Each card carries a CSS custom property for its background color and a number, making it a natural fit for "how it works" sections. The content is centered vertically and capped in width for readability. Because color is a variable, theming the stack is a per-card one-liner.

Customizing it

Adjust top: 90px to change where cards pin, tune the 0.08 scale and 0.25 brightness factors for a stronger or subtler recede, and change the 0.7 span to make the effect happen over more or less scroll. Add or remove cards freely — the script reads them from the DOM and links each to its successor. Pair it with a feature tabs showcase or follow it with an animated gradient CTA to convert at the end of the story.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSFour colorful numbered cards stack vertically with a scroll prompt.
  2. 2
    Scroll downEach card pins near the top of the viewport in turn.
  3. 3
    Keep scrollingThe next card slides over the pinned one, which shrinks back.
  4. 4
    Watch the dimmingCovered cards also darken slightly to recede into the deck.
  5. 5
    Add or remove cardsThe script reads cards from the DOM automatically.
  6. 6
    Tune the depthAdjust the pin offset, scale, and brightness factors.

Real-world uses

Common Use Cases

How-it-works sections
Numbered steps before a feature tabs showcase.
Product storytelling
Walk through value, then an animated gradient CTA.
Onboarding pages
A scroll version of an onboarding tour.
Pricing narratives
Lead into a pricing card comparison.
Case studies
Reveal milestones over a vertical timeline.
Sticky scroll demos
A reference for sticky-based card stacking.

Got questions?

Frequently Asked Questions

Each card is position: sticky with the same top: 90px. A card scrolls normally until its top reaches the pin line, then sticks there while the page keeps moving, so the next card rises from below and slides over it. The pinning is entirely native CSS, which keeps it smooth even without JavaScript.

Sticky stacking alone looks flat. The script measures how close the next card has risen to the pin line each frame, derives a progress value, and uses it to scale the pinned card down up to 8% and dim it up to 25%. Those depth cues make each card recede like it's going into a physical deck rather than just vanishing.

So cards shrink toward their top edge instead of their center. That keeps the top of every card aligned at the pin line as it scales down, preserving the visible stacked spine. Shrinking from the center would pull the tops downward and break the layered look.

Yes. The scroll listener is passive and throttled with requestAnimationFrame guarded by a ticking flag, so there's at most one update per frame regardless of how many scroll events fire. All layout reads and style writes happen together in that single rAF callback to avoid layout thrashing, and a resize listener refreshes the viewport-height math.

Render the cards from an array with their color and step text. The sticky CSS works as-is. Put the scroll and resize listeners in a mount effect with cleanup, and query the card elements via a container ref rather than getElementById. Keep the ticking flag in a ref. In Tailwind, use sticky top-[90px], and apply the scale and brightness through inline styles updated in the effect.

Stacking Scroll Cards — Export as HTML, React, Vue, Angular & Tailwind

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Stacking Scroll Cards</title>
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#070710;color:#fff}
    
    .ss-intro,.ss-outro{height:55vh;display:flex;align-items:center;justify-content:center;color:#5b5b72;font-size:15px;letter-spacing:.04em}
    
    .ss-stack{display:flex;flex-direction:column;align-items:center;gap:5vh}
    .ss-card{position:sticky;top:90px;width:min(560px,calc(100% - 32px));min-height:300px;border-radius:24px;padding:34px;background:var(--bg);box-shadow:0 30px 60px -24px rgba(0,0,0,.6);transform:scale(1);transform-origin:top center;display:flex;flex-direction:column;justify-content:center;will-change:transform}
    .ss-num{font-size:13px;font-weight:800;letter-spacing:.2em;opacity:.7;margin-bottom:14px}
    .ss-card h3{font-size:clamp(26px,5vw,40px);font-weight:900;letter-spacing:-.02em;margin-bottom:10px}
    .ss-card p{font-size:16px;line-height:1.55;max-width:400px;opacity:.92}
  </style>
</head>
<body>
  <div class="ss-intro">Scroll down ↓</div>
  <div class="ss-stack" id="ssStack">
    <section class="ss-card" style="--bg:#6366f1;--t:0">
      <div class="ss-num">01</div><h3>Capture</h3><p>Collect every idea, link, and note in one fast inbox.</p>
    </section>
    <section class="ss-card" style="--bg:#0ea5e9;--t:1">
      <div class="ss-num">02</div><h3>Organize</h3><p>Tag, group, and connect notes into a living knowledge base.</p>
    </section>
    <section class="ss-card" style="--bg:#ec4899;--t:2">
      <div class="ss-num">03</div><h3>Recall</h3><p>Search instantly and resurface the right thing at the right time.</p>
    </section>
    <section class="ss-card" style="--bg:#10b981;--t:3">
      <div class="ss-num">04</div><h3>Share</h3><p>Publish polished pages or hand off a workspace in one click.</p>
    </section>
  </div>
  <div class="ss-outro">Each card pins, then the next slides over it.</div>
  <script>
    var cards = Array.prototype.slice.call(document.querySelectorAll('.ss-card'));
    var ticking = false;
    
    // As a card pins and the next one rises to cover it, scale the pinned card
    // down slightly so the stack reads as physical layers, not flat overlaps.
    function update() {
      var vh = window.innerHeight;
      cards.forEach(function (card, i) {
        var rect = card.getBoundingClientRect();
        var pinTop = 90;
        // progress: 0 while the card fills the viewport, ->1 as it gets covered.
        var next = cards[i + 1];
        if (!next) { card.style.transform = 'scale(1)'; return; }
        var nextRect = next.getBoundingClientRect();
        var dist = nextRect.top - pinTop;              // how far the next card is below the pin line
        var span = vh * 0.7;
        var p = 1 - Math.max(0, Math.min(1, dist / span));
        var scale = 1 - p * 0.08;                      // shrink up to 8%
        var bright = 1 - p * 0.25;                     // dim slightly as it is covered
        card.style.transform = 'scale(' + scale.toFixed(4) + ')';
        card.style.filter = 'brightness(' + bright.toFixed(3) + ')';
      });
      ticking = false;
    }
    
    window.addEventListener('scroll', function () {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(update);
    }, { passive: true });
    window.addEventListener('resize', update);
    update();
  </script>
</body>
</html>
Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Stacking Scroll Cards</title>
  <!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    * {
      box-sizing:border-box;margin:0;padding:0
    }

    body {
      font-family:system-ui,-apple-system,sans-serif;background:#070710;color:#fff
    }

    .ss-intro,.ss-outro {
      height:55vh;display:flex;align-items:center;justify-content:center;color:#5b5b72;font-size:15px;letter-spacing:.04em
    }

    .ss-card h3 {
      font-size:clamp(26px,5vw,40px);font-weight:900;letter-spacing:-.02em;margin-bottom:10px
    }

    .ss-card p {
      font-size:16px;line-height:1.55;max-width:400px;opacity:.92
    }
  </style>
</head>
<body>
  <div class="ss-intro">Scroll down ↓</div>
  <div class="flex flex-col items-center gap-[5vh]" id="ssStack">
    <section class="ss-card sticky top-[90px] w-[min(560px,calc(100% - 32px))] min-h-[300px] rounded-3xl p-[34px] [background:var(--bg)] shadow-[0_30px_60px_-24px_rgba(0,0,0,.6)] [transform:scale(1)] [transform-origin:top_center] flex flex-col justify-center [will-change:transform]" style="--bg:#6366f1;--t:0">
      <div class="text-[13px] font-extrabold tracking-[.2em] opacity-70 mb-3.5">01</div><h3>Capture</h3><p>Collect every idea, link, and note in one fast inbox.</p>
    </section>
    <section class="ss-card sticky top-[90px] w-[min(560px,calc(100% - 32px))] min-h-[300px] rounded-3xl p-[34px] [background:var(--bg)] shadow-[0_30px_60px_-24px_rgba(0,0,0,.6)] [transform:scale(1)] [transform-origin:top_center] flex flex-col justify-center [will-change:transform]" style="--bg:#0ea5e9;--t:1">
      <div class="text-[13px] font-extrabold tracking-[.2em] opacity-70 mb-3.5">02</div><h3>Organize</h3><p>Tag, group, and connect notes into a living knowledge base.</p>
    </section>
    <section class="ss-card sticky top-[90px] w-[min(560px,calc(100% - 32px))] min-h-[300px] rounded-3xl p-[34px] [background:var(--bg)] shadow-[0_30px_60px_-24px_rgba(0,0,0,.6)] [transform:scale(1)] [transform-origin:top_center] flex flex-col justify-center [will-change:transform]" style="--bg:#ec4899;--t:2">
      <div class="text-[13px] font-extrabold tracking-[.2em] opacity-70 mb-3.5">03</div><h3>Recall</h3><p>Search instantly and resurface the right thing at the right time.</p>
    </section>
    <section class="ss-card sticky top-[90px] w-[min(560px,calc(100% - 32px))] min-h-[300px] rounded-3xl p-[34px] [background:var(--bg)] shadow-[0_30px_60px_-24px_rgba(0,0,0,.6)] [transform:scale(1)] [transform-origin:top_center] flex flex-col justify-center [will-change:transform]" style="--bg:#10b981;--t:3">
      <div class="text-[13px] font-extrabold tracking-[.2em] opacity-70 mb-3.5">04</div><h3>Share</h3><p>Publish polished pages or hand off a workspace in one click.</p>
    </section>
  </div>
  <div class="ss-outro">Each card pins, then the next slides over it.</div>
  <script>
    var cards = Array.prototype.slice.call(document.querySelectorAll('.ss-card'));
    var ticking = false;
    
    // As a card pins and the next one rises to cover it, scale the pinned card
    // down slightly so the stack reads as physical layers, not flat overlaps.
    function update() {
      var vh = window.innerHeight;
      cards.forEach(function (card, i) {
        var rect = card.getBoundingClientRect();
        var pinTop = 90;
        // progress: 0 while the card fills the viewport, ->1 as it gets covered.
        var next = cards[i + 1];
        if (!next) { card.style.transform = 'scale(1)'; return; }
        var nextRect = next.getBoundingClientRect();
        var dist = nextRect.top - pinTop;              // how far the next card is below the pin line
        var span = vh * 0.7;
        var p = 1 - Math.max(0, Math.min(1, dist / span));
        var scale = 1 - p * 0.08;                      // shrink up to 8%
        var bright = 1 - p * 0.25;                     // dim slightly as it is covered
        card.style.transform = 'scale(' + scale.toFixed(4) + ')';
        card.style.filter = 'brightness(' + bright.toFixed(3) + ')';
      });
      ticking = false;
    }
    
    window.addEventListener('scroll', function () {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(update);
    }, { passive: true });
    window.addEventListener('resize', update);
    update();
  </script>
</body>
</html>
React
import React, { useEffect } from 'react';

// CSS — optionally move to StackingScrollCards.module.css
const css = `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#070710;color:#fff}

.ss-intro,.ss-outro{height:55vh;display:flex;align-items:center;justify-content:center;color:#5b5b72;font-size:15px;letter-spacing:.04em}

.ss-stack{display:flex;flex-direction:column;align-items:center;gap:5vh}
.ss-card{position:sticky;top:90px;width:min(560px,calc(100% - 32px));min-height:300px;border-radius:24px;padding:34px;background:var(--bg);box-shadow:0 30px 60px -24px rgba(0,0,0,.6);transform:scale(1);transform-origin:top center;display:flex;flex-direction:column;justify-content:center;will-change:transform}
.ss-num{font-size:13px;font-weight:800;letter-spacing:.2em;opacity:.7;margin-bottom:14px}
.ss-card h3{font-size:clamp(26px,5vw,40px);font-weight:900;letter-spacing:-.02em;margin-bottom:10px}
.ss-card p{font-size:16px;line-height:1.55;max-width:400px;opacity:.92}
`;

export default function StackingScrollCards() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var cards = Array.prototype.slice.call(document.querySelectorAll('.ss-card'));
    var ticking = false;
    
    // As a card pins and the next one rises to cover it, scale the pinned card
    // down slightly so the stack reads as physical layers, not flat overlaps.
    function update() {
      var vh = window.innerHeight;
      cards.forEach(function (card, i) {
        var rect = card.getBoundingClientRect();
        var pinTop = 90;
        // progress: 0 while the card fills the viewport, ->1 as it gets covered.
        var next = cards[i + 1];
        if (!next) { card.style.transform = 'scale(1)'; return; }
        var nextRect = next.getBoundingClientRect();
        var dist = nextRect.top - pinTop;              // how far the next card is below the pin line
        var span = vh * 0.7;
        var p = 1 - Math.max(0, Math.min(1, dist / span));
        var scale = 1 - p * 0.08;                      // shrink up to 8%
        var bright = 1 - p * 0.25;                     // dim slightly as it is covered
        card.style.transform = 'scale(' + scale.toFixed(4) + ')';
        card.style.filter = 'brightness(' + bright.toFixed(3) + ')';
      });
      ticking = false;
    }
    
    window.addEventListener('scroll', function () {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(update);
    }, { passive: true });
    window.addEventListener('resize', update);
    update();
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{css}</style>
      <div className="ss-intro">Scroll down ↓</div>
      <div className="ss-stack" id="ssStack">
        <section className="ss-card" style={{ '--bg': '#6366f1', '--t': '0' }}>
          <div className="ss-num">01</div><h3>Capture</h3><p>Collect every idea, link, and note in one fast inbox.</p>
        </section>
        <section className="ss-card" style={{ '--bg': '#0ea5e9', '--t': '1' }}>
          <div className="ss-num">02</div><h3>Organize</h3><p>Tag, group, and connect notes into a living knowledge base.</p>
        </section>
        <section className="ss-card" style={{ '--bg': '#ec4899', '--t': '2' }}>
          <div className="ss-num">03</div><h3>Recall</h3><p>Search instantly and resurface the right thing at the right time.</p>
        </section>
        <section className="ss-card" style={{ '--bg': '#10b981', '--t': '3' }}>
          <div className="ss-num">04</div><h3>Share</h3><p>Publish polished pages or hand off a workspace in one click.</p>
        </section>
      </div>
      <div className="ss-outro">Each card pins, then the next slides over it.</div>
    </>
  );
}
React + Tailwind
import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+

export default function StackingScrollCards() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var cards = Array.prototype.slice.call(document.querySelectorAll('.ss-card'));
    var ticking = false;
    
    // As a card pins and the next one rises to cover it, scale the pinned card
    // down slightly so the stack reads as physical layers, not flat overlaps.
    function update() {
      var vh = window.innerHeight;
      cards.forEach(function (card, i) {
        var rect = card.getBoundingClientRect();
        var pinTop = 90;
        // progress: 0 while the card fills the viewport, ->1 as it gets covered.
        var next = cards[i + 1];
        if (!next) { card.style.transform = 'scale(1)'; return; }
        var nextRect = next.getBoundingClientRect();
        var dist = nextRect.top - pinTop;              // how far the next card is below the pin line
        var span = vh * 0.7;
        var p = 1 - Math.max(0, Math.min(1, dist / span));
        var scale = 1 - p * 0.08;                      // shrink up to 8%
        var bright = 1 - p * 0.25;                     // dim slightly as it is covered
        card.style.transform = 'scale(' + scale.toFixed(4) + ')';
        card.style.filter = 'brightness(' + bright.toFixed(3) + ')';
      });
      ticking = false;
    }
    
    window.addEventListener('scroll', function () {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(update);
    }, { passive: true });
    window.addEventListener('resize', update);
    update();
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{`
* {
  box-sizing:border-box;margin:0;padding:0
}

body {
  font-family:system-ui,-apple-system,sans-serif;background:#070710;color:#fff
}

.ss-intro,.ss-outro {
  height:55vh;display:flex;align-items:center;justify-content:center;color:#5b5b72;font-size:15px;letter-spacing:.04em
}

.ss-card h3 {
  font-size:clamp(26px,5vw,40px);font-weight:900;letter-spacing:-.02em;margin-bottom:10px
}

.ss-card p {
  font-size:16px;line-height:1.55;max-width:400px;opacity:.92
}
      `}</style>
      <div className="ss-intro">Scroll down ↓</div>
      <div className="flex flex-col items-center gap-[5vh]" id="ssStack">
        <section className="ss-card sticky top-[90px] w-[min(560px,calc(100% - 32px))] min-h-[300px] rounded-3xl p-[34px] [background:var(--bg)] shadow-[0_30px_60px_-24px_rgba(0,0,0,.6)] [transform:scale(1)] [transform-origin:top_center] flex flex-col justify-center [will-change:transform]" style={{ '--bg': '#6366f1', '--t': '0' }}>
          <div className="text-[13px] font-extrabold tracking-[.2em] opacity-70 mb-3.5">01</div><h3>Capture</h3><p>Collect every idea, link, and note in one fast inbox.</p>
        </section>
        <section className="ss-card sticky top-[90px] w-[min(560px,calc(100% - 32px))] min-h-[300px] rounded-3xl p-[34px] [background:var(--bg)] shadow-[0_30px_60px_-24px_rgba(0,0,0,.6)] [transform:scale(1)] [transform-origin:top_center] flex flex-col justify-center [will-change:transform]" style={{ '--bg': '#0ea5e9', '--t': '1' }}>
          <div className="text-[13px] font-extrabold tracking-[.2em] opacity-70 mb-3.5">02</div><h3>Organize</h3><p>Tag, group, and connect notes into a living knowledge base.</p>
        </section>
        <section className="ss-card sticky top-[90px] w-[min(560px,calc(100% - 32px))] min-h-[300px] rounded-3xl p-[34px] [background:var(--bg)] shadow-[0_30px_60px_-24px_rgba(0,0,0,.6)] [transform:scale(1)] [transform-origin:top_center] flex flex-col justify-center [will-change:transform]" style={{ '--bg': '#ec4899', '--t': '2' }}>
          <div className="text-[13px] font-extrabold tracking-[.2em] opacity-70 mb-3.5">03</div><h3>Recall</h3><p>Search instantly and resurface the right thing at the right time.</p>
        </section>
        <section className="ss-card sticky top-[90px] w-[min(560px,calc(100% - 32px))] min-h-[300px] rounded-3xl p-[34px] [background:var(--bg)] shadow-[0_30px_60px_-24px_rgba(0,0,0,.6)] [transform:scale(1)] [transform-origin:top_center] flex flex-col justify-center [will-change:transform]" style={{ '--bg': '#10b981', '--t': '3' }}>
          <div className="text-[13px] font-extrabold tracking-[.2em] opacity-70 mb-3.5">04</div><h3>Share</h3><p>Publish polished pages or hand off a workspace in one click.</p>
        </section>
      </div>
      <div className="ss-outro">Each card pins, then the next slides over it.</div>
    </>
  );
}
Vue
<template>
  <div class="ss-intro">Scroll down ↓</div>
  <div class="ss-stack" id="ssStack">
    <section class="ss-card" style="--bg:#6366f1;--t:0">
      <div class="ss-num">01</div><h3>Capture</h3><p>Collect every idea, link, and note in one fast inbox.</p>
    </section>
    <section class="ss-card" style="--bg:#0ea5e9;--t:1">
      <div class="ss-num">02</div><h3>Organize</h3><p>Tag, group, and connect notes into a living knowledge base.</p>
    </section>
    <section class="ss-card" style="--bg:#ec4899;--t:2">
      <div class="ss-num">03</div><h3>Recall</h3><p>Search instantly and resurface the right thing at the right time.</p>
    </section>
    <section class="ss-card" style="--bg:#10b981;--t:3">
      <div class="ss-num">04</div><h3>Share</h3><p>Publish polished pages or hand off a workspace in one click.</p>
    </section>
  </div>
  <div class="ss-outro">Each card pins, then the next slides over it.</div>
</template>

<script setup>
import { onMounted } from 'vue';

var cards = Array.prototype.slice.call(document.querySelectorAll('.ss-card'));
var ticking = false;
// As a card pins and the next one rises to cover it, scale the pinned card
// down slightly so the stack reads as physical layers, not flat overlaps.
function update() {
  var vh = window.innerHeight;
  cards.forEach(function (card, i) {
    var rect = card.getBoundingClientRect();
    var pinTop = 90;
    // progress: 0 while the card fills the viewport, ->1 as it gets covered.
    var next = cards[i + 1];
    if (!next) { card.style.transform = 'scale(1)'; return; }
    var nextRect = next.getBoundingClientRect();
    var dist = nextRect.top - pinTop;              // how far the next card is below the pin line
    var span = vh * 0.7;
    var p = 1 - Math.max(0, Math.min(1, dist / span));
    var scale = 1 - p * 0.08;                      // shrink up to 8%
    var bright = 1 - p * 0.25;                     // dim slightly as it is covered
    card.style.transform = 'scale(' + scale.toFixed(4) + ')';
    card.style.filter = 'brightness(' + bright.toFixed(3) + ')';
  });
  ticking = false;
}

onMounted(() => {
  window.addEventListener('scroll', function () {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(update);
  }, { passive: true });
  window.addEventListener('resize', update);
  update();
});
</script>

<style scoped>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#070710;color:#fff}

.ss-intro,.ss-outro{height:55vh;display:flex;align-items:center;justify-content:center;color:#5b5b72;font-size:15px;letter-spacing:.04em}

.ss-stack{display:flex;flex-direction:column;align-items:center;gap:5vh}
.ss-card{position:sticky;top:90px;width:min(560px,calc(100% - 32px));min-height:300px;border-radius:24px;padding:34px;background:var(--bg);box-shadow:0 30px 60px -24px rgba(0,0,0,.6);transform:scale(1);transform-origin:top center;display:flex;flex-direction:column;justify-content:center;will-change:transform}
.ss-num{font-size:13px;font-weight:800;letter-spacing:.2em;opacity:.7;margin-bottom:14px}
.ss-card h3{font-size:clamp(26px,5vw,40px);font-weight:900;letter-spacing:-.02em;margin-bottom:10px}
.ss-card p{font-size:16px;line-height:1.55;max-width:400px;opacity:.92}
</style>
Angular
// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-stacking-scroll-cards',
  standalone: true,
  imports: [CommonModule],
  encapsulation: ViewEncapsulation.None,
  template: `
    <div class="ss-intro">Scroll down ↓</div>
    <div class="ss-stack" id="ssStack">
      <section class="ss-card" style="--bg:#6366f1;--t:0">
        <div class="ss-num">01</div><h3>Capture</h3><p>Collect every idea, link, and note in one fast inbox.</p>
      </section>
      <section class="ss-card" style="--bg:#0ea5e9;--t:1">
        <div class="ss-num">02</div><h3>Organize</h3><p>Tag, group, and connect notes into a living knowledge base.</p>
      </section>
      <section class="ss-card" style="--bg:#ec4899;--t:2">
        <div class="ss-num">03</div><h3>Recall</h3><p>Search instantly and resurface the right thing at the right time.</p>
      </section>
      <section class="ss-card" style="--bg:#10b981;--t:3">
        <div class="ss-num">04</div><h3>Share</h3><p>Publish polished pages or hand off a workspace in one click.</p>
      </section>
    </div>
    <div class="ss-outro">Each card pins, then the next slides over it.</div>
  `,
  styles: [`
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#070710;color:#fff}
    
    .ss-intro,.ss-outro{height:55vh;display:flex;align-items:center;justify-content:center;color:#5b5b72;font-size:15px;letter-spacing:.04em}
    
    .ss-stack{display:flex;flex-direction:column;align-items:center;gap:5vh}
    .ss-card{position:sticky;top:90px;width:min(560px,calc(100% - 32px));min-height:300px;border-radius:24px;padding:34px;background:var(--bg);box-shadow:0 30px 60px -24px rgba(0,0,0,.6);transform:scale(1);transform-origin:top center;display:flex;flex-direction:column;justify-content:center;will-change:transform}
    .ss-num{font-size:13px;font-weight:800;letter-spacing:.2em;opacity:.7;margin-bottom:14px}
    .ss-card h3{font-size:clamp(26px,5vw,40px);font-weight:900;letter-spacing:-.02em;margin-bottom:10px}
    .ss-card p{font-size:16px;line-height:1.55;max-width:400px;opacity:.92}
  `]
})
export class StackingScrollCardsComponent implements AfterViewInit {
  ngAfterViewInit(): void {
    var cards = Array.prototype.slice.call(document.querySelectorAll('.ss-card'));
    var ticking = false;
    
    // As a card pins and the next one rises to cover it, scale the pinned card
    // down slightly so the stack reads as physical layers, not flat overlaps.
    function update() {
      var vh = window.innerHeight;
      cards.forEach(function (card, i) {
        var rect = card.getBoundingClientRect();
        var pinTop = 90;
        // progress: 0 while the card fills the viewport, ->1 as it gets covered.
        var next = cards[i + 1];
        if (!next) { card.style.transform = 'scale(1)'; return; }
        var nextRect = next.getBoundingClientRect();
        var dist = nextRect.top - pinTop;              // how far the next card is below the pin line
        var span = vh * 0.7;
        var p = 1 - Math.max(0, Math.min(1, dist / span));
        var scale = 1 - p * 0.08;                      // shrink up to 8%
        var bright = 1 - p * 0.25;                     // dim slightly as it is covered
        card.style.transform = 'scale(' + scale.toFixed(4) + ')';
        card.style.filter = 'brightness(' + bright.toFixed(3) + ')';
      });
      ticking = false;
    }
    
    window.addEventListener('scroll', function () {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(update);
    }, { passive: true });
    window.addEventListener('resize', update);
    update();

    // Bind inline-handler functions to the component so the template can call them
    Object.assign(this, { update });
  }
}