Scroll-Driven Reveal
A pure-CSS scroll choreography kit — elements fade, slide, and scale into view with animation-timeline view(), a top progress bar wired to scroll(), and a layered parallax band, all with a graceful IntersectionObserver fallback.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
/* live-tuned by JS */
--enter-start: 15%;
--enter-end: 45%;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background:
radial-gradient(1200px 600px at 80% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
radial-gradient(900px 500px at -10% 20%, rgba(34, 211, 238, 0.1), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.85em;
background: var(--surface-2);
border: 1px solid var(--line);
padding: 0.08em 0.4em;
border-radius: 6px;
color: var(--accent-2);
}
/* ---------- Scroll progress bar : scroll() timeline ---------- */
.scroll-progress {
position: fixed;
inset: 0 0 auto 0;
height: 4px;
background: var(--line);
z-index: 50;
}
.scroll-progress__fill {
display: block;
height: 100%;
transform-origin: left center;
transform: scaleX(0);
background: linear-gradient(90deg, var(--accent), var(--accent-2));
box-shadow: 0 0 12px rgba(139, 92, 246, 0.6);
animation: grow-progress linear;
animation-timeline: scroll(root block);
}
@keyframes grow-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
/* ---------- Header ---------- */
.site-header {
position: sticky;
top: 4px;
z-index: 40;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px clamp(16px, 5vw, 48px);
background: color-mix(in srgb, var(--bg) 78%, transparent);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
}
.brand__dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: conic-gradient(from 180deg, var(--accent), var(--accent-2), var(--accent));
box-shadow: 0 0 10px rgba(139, 92, 246, 0.7);
}
.brand__name {
font-family: "JetBrains Mono", monospace;
font-size: 0.9rem;
letter-spacing: -0.01em;
}
.support {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
font-weight: 500;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
}
.support__led {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
box-shadow: 0 0 8px currentColor;
}
.support[data-state="native"] {
color: var(--ok);
border-color: color-mix(in srgb, var(--ok) 40%, var(--line));
}
.support[data-state="native"] .support__led {
background: var(--ok);
}
.support[data-state="fallback"] {
color: var(--warn);
border-color: color-mix(in srgb, var(--warn) 40%, var(--line));
}
.support[data-state="fallback"] .support__led {
background: var(--warn);
}
/* ---------- Controls ---------- */
.controls {
position: sticky;
top: 62px;
z-index: 35;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px 22px;
padding: 14px clamp(16px, 5vw, 48px);
margin: 0 auto;
background: color-mix(in srgb, var(--surface) 85%, transparent);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
}
.controls__group {
display: flex;
align-items: center;
gap: 10px;
}
.controls__legend {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
font-weight: 600;
}
.segmented {
display: inline-flex;
padding: 3px;
gap: 3px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.seg {
appearance: none;
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 0.82rem;
font-weight: 500;
padding: 6px 12px;
border-radius: 9px;
cursor: pointer;
transition:
background 0.18s ease,
color 0.18s ease;
}
.seg:hover {
color: var(--ink);
}
.seg[aria-checked="true"] {
background: linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 80%, #000));
color: #fff;
box-shadow: 0 2px 10px rgba(139, 92, 246, 0.35);
}
.controls__out {
font-family: "JetBrains Mono", monospace;
font-size: 0.78rem;
color: var(--accent-2);
min-width: 12ch;
}
input[type="range"] {
accent-color: var(--accent);
width: 130px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--ink);
font: inherit;
font-weight: 600;
font-size: 0.85rem;
padding: 8px 16px;
border-radius: var(--r-md);
cursor: pointer;
transition:
transform 0.12s ease,
border-color 0.18s ease,
background 0.18s ease;
}
.btn:hover {
border-color: var(--accent);
background: var(--surface);
}
.btn:active {
transform: translateY(1px) scale(0.98);
}
:where(.seg, .btn, input):focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
/* ---------- Layout ---------- */
main {
max-width: 980px;
margin: 0 auto;
padding: 0 clamp(16px, 5vw, 48px) 120px;
}
.hero {
padding: 96px 0 40px;
}
.eyebrow {
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
letter-spacing: 0.02em;
color: var(--accent-2);
margin: 0 0 14px;
}
.hero h1 {
font-size: clamp(2rem, 6vw, 3.4rem);
line-height: 1.05;
letter-spacing: -0.03em;
margin: 0 0 18px;
max-width: 16ch;
}
.lede {
color: var(--muted);
font-size: clamp(1rem, 2.4vw, 1.15rem);
max-width: 60ch;
margin: 0;
}
.block {
padding: 72px 0;
border-top: 1px solid var(--line);
}
.block__head {
margin-bottom: 30px;
}
.tag {
display: inline-block;
font-family: "JetBrains Mono", monospace;
font-size: 0.72rem;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 34%, transparent);
padding: 4px 10px;
border-radius: 999px;
margin-bottom: 14px;
}
.block__head h2 {
font-size: clamp(1.5rem, 4vw, 2.1rem);
letter-spacing: -0.02em;
margin: 0 0 10px;
}
.block__head p {
color: var(--muted);
max-width: 62ch;
margin: 0;
}
/* ---------- Reveal grid ---------- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px;
}
.card {
background: linear-gradient(180deg, var(--surface), var(--surface-2));
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 22px;
transition:
border-color 0.2s ease,
transform 0.2s ease;
}
.card:hover {
border-color: var(--line-2);
transform: translateY(-3px);
}
.card__icon {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--accent) 16%, var(--surface-2));
color: var(--accent-2);
margin-bottom: 16px;
font-size: 1.2rem;
}
.card h3 {
margin: 0 0 6px;
font-size: 1.05rem;
}
.card p {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
}
/* ---------- Stagger list ---------- */
.stagger {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 12px;
}
.stagger li {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.stagger .idx {
font-family: "JetBrains Mono", monospace;
color: var(--accent);
font-weight: 600;
min-width: 2ch;
}
.stagger .txt {
flex: 1;
}
.stagger .txt strong {
display: block;
}
.stagger .txt span {
color: var(--muted);
font-size: 0.86rem;
}
.stagger .pill {
font-family: "JetBrains Mono", monospace;
font-size: 0.72rem;
color: var(--accent-2);
border: 1px solid var(--line);
padding: 3px 8px;
border-radius: 999px;
white-space: nowrap;
}
/* ---------- Progress dial ---------- */
.block--progress .dial {
display: flex;
align-items: center;
gap: 22px;
padding: 28px;
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--surface);
}
.dial__ring {
--size: 96px;
width: var(--size);
height: var(--size);
border-radius: 50%;
flex: none;
background: conic-gradient(var(--accent) var(--p, 0%), var(--surface-2) 0);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 11px), #000 calc(100% - 10px));
mask: radial-gradient(farthest-side, transparent calc(100% - 11px), #000 calc(100% - 10px));
animation: dial-fill linear both;
animation-timeline: scroll(root block);
}
@keyframes dial-fill {
from {
--p: 0%;
}
to {
--p: 100%;
}
}
@property --p {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.dial__meta strong {
display: block;
font-family: "JetBrains Mono", monospace;
color: var(--accent-2);
margin-bottom: 4px;
}
.dial__meta span {
color: var(--muted);
font-size: 0.9rem;
}
/* ---------- Parallax ---------- */
.parallax {
position: relative;
height: 340px;
border-radius: var(--r-lg);
overflow: hidden;
border: 1px solid var(--line);
background: linear-gradient(180deg, #10131f, #0a0b12);
isolation: isolate;
display: grid;
place-items: center;
}
.parallax__layer {
position: absolute;
inset: -20% 0;
will-change: transform;
}
.parallax__stars {
background-image:
radial-gradient(1.5px 1.5px at 20% 30%, #fff, transparent),
radial-gradient(1.5px 1.5px at 70% 60%, #cbd5ff, transparent),
radial-gradient(1px 1px at 40% 80%, #fff, transparent),
radial-gradient(2px 2px at 85% 25%, #a5b4fc, transparent),
radial-gradient(1px 1px at 55% 15%, #fff, transparent);
animation: drift-slow linear both;
animation-timeline: view();
animation-range: cover;
}
.parallax__mid {
background: radial-gradient(120% 90% at 50% 130%, var(--accent) 0%, transparent 55%);
opacity: 0.55;
animation: drift-mid linear both;
animation-timeline: view();
animation-range: cover;
}
.parallax__fore {
background:
radial-gradient(90% 60% at 15% 120%, var(--accent-2) 0%, transparent 60%),
radial-gradient(80% 60% at 90% 120%, var(--accent) 0%, transparent 60%);
opacity: 0.5;
animation: drift-fast linear both;
animation-timeline: view();
animation-range: cover;
}
.parallax__title {
position: relative;
z-index: 2;
font-family: "JetBrains Mono", monospace;
font-size: clamp(2.5rem, 10vw, 5rem);
font-weight: 700;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.92);
text-shadow: 0 8px 40px rgba(139, 92, 246, 0.5);
animation: drift-title linear both;
animation-timeline: view();
animation-range: cover;
}
@keyframes drift-slow {
from {
transform: translateY(6%);
}
to {
transform: translateY(-6%);
}
}
@keyframes drift-mid {
from {
transform: translateY(18%);
}
to {
transform: translateY(-18%);
}
}
@keyframes drift-fast {
from {
transform: translateY(34%);
}
to {
transform: translateY(-34%);
}
}
@keyframes drift-title {
from {
transform: translateY(40%) scale(0.9);
opacity: 0.2;
}
to {
transform: translateY(-24%) scale(1);
opacity: 1;
}
}
/* ---------- Footer ---------- */
.foot {
padding: 60px 0 0;
color: var(--muted);
text-align: center;
}
.foot p {
max-width: 60ch;
margin: 0 auto;
}
/* =========================================================
REVEAL ANIMATIONS — driven by view() where supported
========================================================= */
[data-reveal],
.card {
animation: var(--reveal-name, reveal-fade) linear both;
animation-timeline: view();
animation-range: entry var(--enter-start) cover var(--enter-end);
}
@keyframes reveal-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes reveal-slide {
from {
opacity: 0;
transform: translateY(48px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes reveal-scale {
from {
opacity: 0;
transform: scale(0.86);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes reveal-blur {
from {
opacity: 0;
filter: blur(14px);
transform: translateY(20px);
}
to {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Effect switch via data attr on <html> */
html[data-effect="fade"] {
--reveal-name: reveal-fade;
}
html[data-effect="slide"] {
--reveal-name: reveal-slide;
}
html[data-effect="scale"] {
--reveal-name: reveal-scale;
}
html[data-effect="blur"] {
--reveal-name: reveal-blur;
}
/* Replay: briefly clear the timeline binding so animations re-run */
html[data-replay="1"] [data-reveal],
html[data-replay="1"] .card {
animation: none;
}
/* =========================================================
FALLBACK — no scroll-driven timelines.
IntersectionObserver toggles .is-in (see script.js).
========================================================= */
@supports not (animation-timeline: view()) {
[data-reveal],
.card {
animation: none;
opacity: 0;
transform: translateY(28px);
filter: blur(6px);
transition:
opacity 0.6s ease,
transform 0.6s ease,
filter 0.6s ease;
}
[data-reveal].is-in,
.card.is-in {
opacity: 1;
transform: none;
filter: none;
}
.scroll-progress__fill {
animation: none;
transform: scaleX(var(--js-progress, 0));
}
.dial__ring {
animation: none;
background: conic-gradient(var(--accent) calc(var(--js-progress, 0) * 100%), var(--surface-2) 0);
}
.parallax__layer,
.parallax__title {
animation: none;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
[data-reveal],
.card {
animation-range: entry 0% entry 1%;
}
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.controls {
top: 58px;
gap: 12px 16px;
}
.btn {
margin-left: 0;
}
input[type="range"] {
width: 100px;
}
.hero {
padding: 72px 0 24px;
}
.block {
padding: 56px 0;
}
.block--progress .dial {
flex-direction: column;
text-align: center;
align-items: center;
}
}(() => {
"use strict";
const root = document.documentElement;
/* ---------------------------------------------------------
* 1. Feature detection — CSS scroll-driven animations
* --------------------------------------------------------- */
const SUPPORTS_SDA =
typeof CSS !== "undefined" &&
CSS.supports &&
CSS.supports("animation-timeline: view()");
const support = document.getElementById("support");
if (support) {
if (SUPPORTS_SDA) {
support.dataset.state = "native";
support.querySelector(".support__label").textContent = "Native scroll-driven";
} else {
support.dataset.state = "fallback";
support.querySelector(".support__label").textContent = "IntersectionObserver fallback";
}
}
/* ---------------------------------------------------------
* 2. Populate demo content
* --------------------------------------------------------- */
const cards = [
{ icon: "◇", title: "Declarative", body: "Motion lives in CSS keyframes — no scroll listeners to write or leak." },
{ icon: "⟡", title: "Off main thread", body: "Timelines run in the compositor, so reveals stay smooth under load." },
{ icon: "◈", title: "Geometry-based", body: "animation-range maps keyframes to how far an element has entered." },
{ icon: "❖", title: "Composable", body: "Mix view() reveals with a scroll() progress bar on the same page." },
{ icon: "✦", title: "Accessible", body: "Honors prefers-reduced-motion by collapsing the animation range." },
{ icon: "⬡", title: "Progressive", body: "@supports gives non-supporting browsers a graceful fallback." },
];
const grid = document.getElementById("revealGrid");
if (grid) {
grid.innerHTML = cards
.map(
(c) => `
<article class="card" data-reveal-card>
<div class="card__icon" aria-hidden="true">${c.icon}</div>
<h3>${c.title}</h3>
<p>${c.body}</p>
</article>`
)
.join("");
}
const rows = [
{ title: "entry 10% → cover 40%", sub: "Row reveals as its top edge enters", pill: "delay 0ms" },
{ title: "entry 16% → cover 46%", sub: "Range start nudged later", pill: "delay ~120ms" },
{ title: "entry 22% → cover 52%", sub: "Later again — creates the cascade", pill: "delay ~240ms" },
{ title: "entry 28% → cover 58%", sub: "Each row scrubs independently", pill: "delay ~360ms" },
{ title: "entry 34% → cover 64%", sub: "All bound to one view() timeline", pill: "delay ~480ms" },
];
const stagger = document.getElementById("stagger");
if (stagger) {
stagger.innerHTML = rows
.map(
(r, i) => `
<li data-reveal style="--enter-start:${10 + i * 6}%; --enter-end:${40 + i * 6}%;">
<span class="idx">${String(i + 1).padStart(2, "0")}</span>
<span class="txt"><strong>${r.title}</strong><span>${r.sub}</span></span>
<span class="pill">${r.pill}</span>
</li>`
)
.join("");
}
/* ---------------------------------------------------------
* 3. Controls — effect, range, replay
* --------------------------------------------------------- */
root.dataset.effect = "fade";
const effect = document.getElementById("effect");
if (effect) {
effect.addEventListener("click", (e) => {
const btn = e.target.closest(".seg");
if (!btn) return;
effect.querySelectorAll(".seg").forEach((b) =>
b.setAttribute("aria-checked", String(b === btn))
);
root.dataset.effect = btn.dataset.effect;
replay();
});
}
const range = document.getElementById("range");
const rangeOut = document.getElementById("rangeOut");
function applyRange() {
if (!range) return;
const start = Number(range.value);
const end = start + 30;
root.style.setProperty("--enter-start", start + "%");
root.style.setProperty("--enter-end", end + "%");
if (rangeOut) rangeOut.textContent = `cover ${start}%→${end}%`;
}
if (range) {
range.addEventListener("input", applyRange);
applyRange();
}
const replayBtn = document.getElementById("replay");
function replay() {
// Toggling animation:none then forcing reflow restarts view() timelines.
root.dataset.replay = "1";
// force reflow
void document.body.offsetHeight;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
delete root.dataset.replay;
});
});
// In fallback mode, re-trigger the observer classes too.
if (!SUPPORTS_SDA) {
document.querySelectorAll("[data-reveal], .card").forEach((el) => {
el.classList.remove("is-in");
});
// observer will re-add on next intersection; nudge those already visible
requestAnimationFrame(refreshFallbackVisibility);
}
}
if (replayBtn) replayBtn.addEventListener("click", replay);
/* ---------------------------------------------------------
* 4. Fallback — tiny IntersectionObserver polyfill for reveals
* (only does real work when native SDA is missing)
* --------------------------------------------------------- */
let io = null;
function markInView(el) {
el.classList.add("is-in");
}
function refreshFallbackVisibility() {
document.querySelectorAll("[data-reveal], .card").forEach((el) => {
const r = el.getBoundingClientRect();
if (r.top < window.innerHeight * 0.9 && r.bottom > 0) markInView(el);
});
}
function initFallback() {
const targets = document.querySelectorAll("[data-reveal], .card");
if ("IntersectionObserver" in window) {
io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
markInView(entry.target);
io.unobserve(entry.target);
}
}
},
{ threshold: 0.15, rootMargin: "0px 0px -10% 0px" }
);
targets.forEach((el) => io.observe(el));
} else {
// No IO at all — reveal everything immediately.
targets.forEach(markInView);
}
// Progress bar + dial via scroll handler (rAF-throttled).
let ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const doc = document.documentElement;
const max = doc.scrollHeight - doc.clientHeight;
const p = max > 0 ? Math.min(1, doc.scrollTop / max) : 0;
root.style.setProperty("--js-progress", String(p));
ticking = false;
});
}
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
onScroll();
}
if (!SUPPORTS_SDA) {
initFallback();
}
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Scroll-Driven Reveal — CSS animation-timeline demo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- scroll(root block) progress bar -->
<div class="scroll-progress" role="progressbar" aria-label="Page scroll progress" aria-valuemin="0" aria-valuemax="100">
<span class="scroll-progress__fill"></span>
</div>
<header class="site-header">
<div class="brand">
<span class="brand__dot" aria-hidden="true"></span>
<span class="brand__name">scroll-driven-reveal</span>
</div>
<div class="support" id="support" data-state="checking">
<span class="support__led" aria-hidden="true"></span>
<span class="support__label">Checking support…</span>
</div>
</header>
<!-- Sticky control strip -->
<section class="controls" aria-label="Reveal controls">
<div class="controls__group" role="radiogroup" aria-label="Reveal effect">
<span class="controls__legend">Effect</span>
<div class="segmented" id="effect">
<button class="seg" role="radio" aria-checked="true" data-effect="fade">Fade</button>
<button class="seg" role="radio" aria-checked="false" data-effect="slide">Slide</button>
<button class="seg" role="radio" aria-checked="false" data-effect="scale">Scale</button>
<button class="seg" role="radio" aria-checked="false" data-effect="blur">Blur</button>
</div>
</div>
<div class="controls__group">
<label class="controls__legend" for="range">Enter range</label>
<input id="range" type="range" min="0" max="60" value="15" step="5" />
<output id="rangeOut" class="controls__out">cover 15%→45%</output>
</div>
<button class="btn" id="replay" type="button">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
d="M4 4v5h.582A7 7 0 1 1 5 15"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Replay
</button>
</section>
<main>
<!-- Intro -->
<section class="hero reveal" data-reveal>
<p class="eyebrow">CSS Scroll-Driven Animations</p>
<h1>Elements that animate to the beat of your scroll.</h1>
<p class="lede">
Every motion below is declared in CSS and bound to a
<code>scroll()</code> or <code>view()</code> timeline — no scroll listeners, no
<code>requestAnimationFrame</code>. Scroll down to see each technique labeled and explained.
</p>
</section>
<!-- BLOCK 1 — Reveal on enter (view timeline) -->
<section class="block" aria-labelledby="b1">
<div class="block__head reveal" data-reveal>
<span class="tag">01 · view()</span>
<h2 id="b1">Reveal on enter</h2>
<p>
Each card owns an <code>animation-timeline: view()</code>. The keyframes play across the
<code>animation-range</code> as the card crosses the viewport — driven by geometry, not time.
</p>
</div>
<div class="grid" id="revealGrid">
<!-- cards injected by script -->
</div>
</section>
<!-- BLOCK 2 — Progress rings driven by scroll() -->
<section class="block block--progress" aria-labelledby="b2">
<div class="block__head reveal" data-reveal>
<span class="tag">02 · scroll()</span>
<h2 id="b2">Progress from scroll()</h2>
<p>
The top bar and this dial share the anonymous <code>scroll()</code> timeline of the page
root. Their fill is a keyframe animation scrubbed 0→100% by the scroll position.
</p>
</div>
<div class="dial reveal" data-reveal>
<div class="dial__ring" aria-hidden="true"></div>
<div class="dial__meta">
<strong>scroll(root block)</strong>
<span>Animation is paused; scroll advances its timeline.</span>
</div>
</div>
</section>
<!-- BLOCK 3 — Staggered list (view timeline + per-item range) -->
<section class="block" aria-labelledby="b3">
<div class="block__head reveal" data-reveal>
<span class="tag">03 · stagger</span>
<h2 id="b3">Staggered rows</h2>
<p>
Same <code>view()</code> timeline, but each row nudges its <code>animation-range</code>
start a little later, so they cascade in as the group scrolls up.
</p>
</div>
<ol class="stagger" id="stagger"></ol>
</section>
<!-- BLOCK 4 — Parallax band (view timeline, layered ranges) -->
<section class="block" aria-labelledby="b4">
<div class="block__head reveal" data-reveal>
<span class="tag">04 · parallax</span>
<h2 id="b4">Layered parallax</h2>
<p>
One <code>view()</code> timeline drives three layers at different translate rates over the
<code>cover</code> range — depth without a single scroll event.
</p>
</div>
<div class="parallax" role="img" aria-label="Layered parallax scene of stars, mountains and a foreground title">
<span class="parallax__layer parallax__stars" aria-hidden="true"></span>
<span class="parallax__layer parallax__mid" aria-hidden="true"></span>
<span class="parallax__layer parallax__fore" aria-hidden="true"></span>
<span class="parallax__title">view()</span>
</div>
</section>
<footer class="foot reveal" data-reveal>
<p>
Built with <code>animation-timeline</code>, <code>scroll()</code> and <code>view()</code>.
Where unsupported, an <code>IntersectionObserver</code> fallback reveals every block.
</p>
</footer>
</main>
<script src="script.js"></script>
</body>
</html>Scroll-Driven Reveal is a self-contained showcase of CSS scroll-driven animations — the
animation-timeline property with view() and scroll() timelines. Instead of running on the
document clock, each keyframe animation is driven by the geometry of scrolling: cards fade and
lift as they cross into the viewport, a fixed progress bar tracks how far you are through the page
using a scroll() timeline, and a parallax band moves foreground and background layers at
different rates from a single view() range. Everything is declared in CSS, so it stays buttery
even while the main thread is busy.
The demo is organized into labeled blocks — Reveal, Progress, Stagger, and Parallax — each with a
short caption explaining exactly which timeline and range is at work. A control strip lets you
switch the reveal effect (fade, slide, scale, blur), tune the animation range, and replay the
sequence, updating CSS custom properties live. A support badge feature-detects the API with
CSS.supports("animation-timeline: view()").
When scroll-driven timelines are unavailable, an @supports not block plus a tiny
IntersectionObserver polyfill take over: the same reveal classes are toggled on intersection so
content never stays hidden. The result works everywhere while demonstrating the modern web platform
API where it is supported.