Web Animations Medium
Sticky-stack Sections
Stack sections in place with sticky positioning and subtle depth as each section approaches.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
/* Sticky-stack Sections */
:root {
--bg: #0b1020;
--surface: #151d31;
--text: #eef2ff;
--muted: #9eb1d4;
--line: rgba(255, 255, 255, 0.09);
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background:
radial-gradient(900px 500px at 50% -10%, rgba(120, 169, 255, 0.16), transparent 70%),
var(--bg);
color: var(--text);
font: 15px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.demo {
width: min(720px, 100%);
margin: 0 auto;
padding: clamp(1.5rem, 5vw, 3.5rem) 1.25rem 4rem;
}
.intro h1 {
margin: 0 0 .5rem;
font-size: clamp(1.7rem, 4vw, 2.4rem);
letter-spacing: -0.02em;
}
.intro p { margin: 0 0 .5rem; color: var(--muted); }
.hint { font-size: .85rem; }
kbd {
font: inherit;
font-size: .78em;
padding: .1em .42em;
border: 1px solid var(--line);
border-radius: 5px;
background: rgba(255, 255, 255, .06);
}
/* --- the stack --------------------------------------------------------- */
.stack {
margin: 2.5rem 0;
outline: none;
padding-bottom: 40vh; /* room for the last card to unpin */
}
.stack:focus-visible {
outline: 2px solid #78a9ff;
outline-offset: 12px;
border-radius: 22px;
}
.card {
position: sticky;
top: 12vh;
min-height: 14rem;
margin-bottom: 3.5rem;
padding: 1.75rem 1.75rem 2rem;
border: 1px solid var(--line);
border-radius: 20px;
background: linear-gradient(160deg,
color-mix(in srgb, var(--accent) 16%, var(--surface)),
var(--surface) 62%);
box-shadow: 0 24px 60px -24px rgba(0, 0, 0, .85);
overflow: hidden;
transform-origin: 50% 0%;
/* --p is written by script.js: 0 = frontmost, 1 = fully buried */
transform: scale(calc(1 - .08 * var(--p, 0)));
filter: brightness(calc(1 - .38 * var(--p, 0))) blur(calc(2px * var(--p, 0)));
will-change: transform, filter;
}
.card::before {
content: "";
position: absolute;
inset: 0 0 auto;
height: 3px;
background: linear-gradient(90deg, var(--accent), transparent);
}
.card:last-child { margin-bottom: 0; }
.num {
display: block;
font-variant-numeric: tabular-nums;
font-size: .78rem;
letter-spacing: .22em;
color: var(--accent);
}
.card h2 { margin: .35rem 0 .6rem; font-size: 1.45rem; letter-spacing: -.01em; }
.card p { margin: 0; color: var(--muted); max-width: 36ch; }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .88em;
padding: .1em .35em;
border-radius: 5px;
background: rgba(255, 255, 255, .07);
}
.outro {
text-align: center;
color: var(--muted);
font-size: .9rem;
padding-top: 2rem;
border-top: 1px solid var(--line);
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.card { position: static; transform: none; filter: none; }
}/* Sticky-stack Sections — scroll-linked depth for sticky panels.
Each card writes a progress value --p (0 = frontmost, 1 = fully buried),
computed from how far the NEXT card has advanced over it. */
(() => {
const stack = document.getElementById("stack");
const status = document.getElementById("status");
if (!stack) return;
const cards = Array.from(stack.querySelectorAll(".card"));
if (!cards.length) return;
const reduced = matchMedia("(prefers-reduced-motion: reduce)");
const clamp = (n) => (n < 0 ? 0 : n > 1 ? 1 : n);
// z-order: later cards sit above earlier ones so they can cover them.
cards.forEach((c, i) => { c.style.zIndex = String(i + 1); });
let ticking = false;
function update() {
ticking = false;
if (reduced.matches) {
cards.forEach((c) => c.style.setProperty("--p", "0"));
return;
}
let front = 0;
for (let i = 0; i < cards.length; i++) {
const card = cards[i];
const next = cards[i + 1];
const rect = card.getBoundingClientRect();
let p = 0;
if (next) {
const nextTop = next.getBoundingClientRect().top;
// travel = distance the next card covers, from "just below this card"
// to "fully overlapping its pinned position".
const travel = rect.height;
p = clamp((rect.bottom - nextTop) / travel);
}
card.style.setProperty("--p", p.toFixed(3));
card.setAttribute("aria-hidden", p > 0.95 ? "true" : "false");
if (p < 0.5) front = i;
}
if (status && status.dataset.front !== String(front)) {
status.dataset.front = String(front);
const h = cards[front].querySelector("h2");
status.textContent = `Showing section ${front + 1} of ${cards.length}${h ? " — " + h.textContent : ""}`;
}
}
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(update);
}
addEventListener("scroll", onScroll, { passive: true });
addEventListener("resize", onScroll);
reduced.addEventListener?.("change", update);
// Keyboard fallback: step between cards instead of relying on wheel/touch.
stack.addEventListener("keydown", (e) => {
const step = { ArrowDown: 1, PageDown: 1, ArrowUp: -1, PageUp: -1 }[e.key];
if (!step) return;
e.preventDefault();
const current = Number(status?.dataset.front || 0);
const target = Math.min(cards.length - 1, Math.max(0, current + step));
cards[target].scrollIntoView({
block: "start",
behavior: reduced.matches ? "auto" : "smooth",
});
});
update();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sticky-stack Sections</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Sticky-stack Sections">
<header class="intro">
<h1>Sticky-stack Sections</h1>
<p>Scroll down — each panel pins to the top while the next one slides over it, scaling and dimming as it recedes.</p>
<p class="hint">Keyboard: focus the stack, then use <kbd>↓</kbd> / <kbd>↑</kbd> or <kbd>PageDown</kbd> / <kbd>PageUp</kbd>.</p>
</header>
<section class="stack" id="stack" tabindex="0" aria-label="Stacked sections">
<article class="card" style="--accent:#7c9cff">
<span class="num" aria-hidden="true">01</span>
<h2>Context</h2>
<p>Every panel is <code>position: sticky</code> at the top of the scroller, so it stops instead of scrolling away.</p>
</article>
<article class="card" style="--accent:#57d0a8">
<span class="num" aria-hidden="true">02</span>
<h2>Constraint</h2>
<p>The next panel keeps moving and covers the pinned one, which produces the stacked-card illusion.</p>
</article>
<article class="card" style="--accent:#f2b45c">
<span class="num" aria-hidden="true">03</span>
<h2>Decision</h2>
<p>Progress is measured per card and mapped to scale, blur and brightness so buried panels read as further back.</p>
</article>
<article class="card" style="--accent:#e07bd0">
<span class="num" aria-hidden="true">04</span>
<h2>Outcome</h2>
<p>The last panel releases normally and the document continues — no scroll trap left behind.</p>
</article>
</section>
<p class="outro" aria-live="polite" id="status">End of stack — normal flow resumes.</p>
</main>
<script src="script.js"></script>
</body>
</html>import { useEffect, useState } from "react";
export function ScrollStickyStack() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const onScroll = () =>
setProgress(
Math.min(
100,
Math.round((scrollY / Math.max(1, document.body.scrollHeight - innerHeight)) * 100)
)
);
addEventListener("scroll", onScroll, { passive: true });
return () => removeEventListener("scroll", onScroll);
}, []);
return (
<section className="demo">
<h2>Sticky-stack Sections</h2>
<div className="progress">
<i style={{ width: `${progress}%` }} />
</div>
<div style={{ minHeight: 420, paddingTop: 120 }}>
<article className="card">Scroll-linked content · {progress}%</article>
</div>
</section>
);
}Sticky-stack Sections
Stack sections in place with sticky positioning and subtle depth as each section approaches.
Support notes
The CSS uses animation-timeline where available and a small JavaScript fallback where it is not. Keep the fallback because scroll-linked CSS support varies by browser.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.