Web Animations Easy
CSS Scroll-timeline Reveal
Reveal content with native animation-timeline and a scroll-position fallback for older browsers.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
/* CSS Scroll-timeline Reveal */
@property --p {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
:root {
--bg: #0b1020;
--panel: #121a2d;
--line: #263555;
--fg: #eef2ff;
--muted: #9eb1d4;
--accent: #78a9ff;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
padding: clamp(1rem, 4vw, 3rem);
background: var(--bg);
color: var(--fg);
font: 15px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.demo {
width: min(640px, 100%);
margin: auto;
padding: clamp(1rem, 3vw, 1.75rem);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
box-shadow: 0 20px 70px #0006;
}
.demo__head h1 {
margin: 0 0 .35rem;
font-size: 1.35rem;
letter-spacing: -.02em;
}
.hint { margin: 0 0 .9rem; font-size: .85rem; color: var(--muted); }
.badge {
display: inline-block;
margin-left: .35rem;
padding: 2px 9px;
border: 1px solid var(--line);
border-radius: 999px;
background: #1b2540;
color: var(--accent);
font-size: .7rem;
font-weight: 600;
}
.progress {
height: .35rem;
background: #1c2540;
border-radius: 99px;
overflow: hidden;
}
.progress > i {
display: block;
height: 100%;
width: 0;
border-radius: 99px;
background: linear-gradient(90deg, var(--accent), #b98cff);
}
.scroller {
height: 340px;
margin-top: 1.1rem;
padding: 1.5rem 1rem 12rem;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid var(--line);
border-radius: 16px;
background: radial-gradient(120% 60% at 50% 0%, #172038 0%, #10192d 70%);
-webkit-mask-image: linear-gradient(180deg, transparent 0, #000 2rem, #000 calc(100% - 2rem), transparent 100%);
mask-image: linear-gradient(180deg, transparent 0, #000 2rem, #000 calc(100% - 2rem), transparent 100%);
}
.scroller:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
/* --p runs 0 -> 1 as a card enters the scrollport.
Native scroll-timeline and the JS fallback both write this one property. */
.reveal {
--p: 0;
margin: 0 0 1.1rem;
padding: 1rem 1.15rem;
border: 1px solid var(--line);
border-radius: 14px;
background: #18233b;
opacity: calc(.12 + .88 * var(--p));
transform: translateY(calc((1 - var(--p)) * 28px)) scale(calc(.96 + .04 * var(--p)));
filter: blur(calc((1 - var(--p)) * 5px));
box-shadow:
0 0 0 1px rgba(120, 169, 255, calc(var(--p) * .22)),
0 14px 34px rgba(0, 0, 0, calc(var(--p) * .45));
}
.reveal h2 { margin: 0 0 .3rem; font-size: .95rem; color: var(--accent); }
.reveal p { margin: 0; font-size: .87rem; color: #c3cee6; }
code {
padding: 1px 5px;
border-radius: 5px;
background: #212c48;
font-size: .78rem;
}
/* Native scroll-driven path — enabled by script.js via html.native */
@supports (animation-timeline: view()) {
html.native .reveal {
animation: reveal-p linear both;
animation-timeline: view(block);
animation-range: entry 0% cover 45%;
}
}
@keyframes reveal-p {
from { --p: 0; }
to { --p: 1; }
}
@media (prefers-reduced-motion: reduce) {
html.native .reveal { animation: none; }
.reveal {
--p: 1;
filter: none;
transform: none;
opacity: 1;
}
}
@media (max-width: 600px) {
.demo { border-radius: 16px; }
.scroller { height: 300px; }
}/* CSS Scroll-timeline Reveal
*
* Feature-detects native scroll-driven animations. When available the CSS owns
* everything (html.native). Otherwise this file computes the same 0..1 progress
* per card from scroll geometry and writes it to the `--p` custom property, so
* the visual result and the authoring surface stay identical on both paths.
*/
(function () {
"use strict";
var scroller = document.getElementById("scroller");
var cards = Array.prototype.slice.call(document.querySelectorAll(".reveal"));
var bar = document.getElementById("bar");
var progress = document.getElementById("progress");
var badge = document.getElementById("engine");
if (!scroller || !cards.length) return;
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var native =
typeof CSS !== "undefined" &&
CSS.supports &&
CSS.supports("animation-timeline", "view()") &&
CSS.supports("animation-range", "entry 0% cover 45%");
if (badge) badge.textContent = reduced
? "reduced motion"
: native
? "native animation-timeline"
: "JS fallback";
// ---- shared progress reporting -------------------------------------------
function setScrollProgress() {
var max = scroller.scrollHeight - scroller.clientHeight;
var pct = max > 0 ? Math.round((scroller.scrollTop / max) * 100) : 100;
if (bar) bar.style.width = pct + "%";
if (progress) progress.setAttribute("aria-valuenow", String(pct));
}
if (native && !reduced) {
document.documentElement.classList.add("native");
scroller.addEventListener("scroll", onScroll, { passive: true });
setScrollProgress();
return;
}
if (reduced) {
cards.forEach(function (el) { el.style.setProperty("--p", "1"); });
scroller.addEventListener("scroll", onScroll, { passive: true });
setScrollProgress();
return;
}
// ---- JS fallback ----------------------------------------------------------
// Mirrors `animation-range: entry 0% cover 45%`:
// entry 0% -> card top hits the bottom edge of the scrollport
// cover 45% -> card has travelled 45% of (scrollport height + card height)
var easeOut = function (t) { return 1 - Math.pow(1 - t, 3); };
function update() {
var port = scroller.getBoundingClientRect();
var portH = port.height;
for (var i = 0; i < cards.length; i++) {
var el = cards[i];
var r = el.getBoundingClientRect();
// Distance travelled since the card's top crossed the bottom edge.
var travelled = portH - (r.top - port.top);
var range = (portH + r.height) * 0.45;
var p = range > 0 ? travelled / range : 1;
p = p < 0 ? 0 : p > 1 ? 1 : p;
var eased = easeOut(p).toFixed(4);
if (el.dataset.p !== eased) {
el.dataset.p = eased;
el.style.setProperty("--p", eased);
}
}
setScrollProgress();
}
var ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(function () {
ticking = false;
update();
});
}
scroller.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
if (typeof ResizeObserver === "function") {
var ro = new ResizeObserver(onScroll);
ro.observe(scroller);
cards.forEach(function (el) { ro.observe(el); });
}
update();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CSS Scroll-timeline Reveal</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="CSS Scroll-timeline Reveal">
<header class="demo__head">
<h1>Scroll-timeline Reveal</h1>
<p class="hint">
Cards animate against the scroller's own timeline.
<span class="badge" id="engine" role="status" aria-live="polite">detecting…</span>
</p>
<div class="progress" id="progress" role="progressbar"
aria-label="Scroll progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<i id="bar"></i>
</div>
</header>
<div class="scroller" id="scroller" tabindex="0" aria-label="Scrollable reveal list">
<section class="reveal" aria-labelledby="s1">
<h2 id="s1">01 · Native first</h2>
<p>Where <code>animation-timeline: view()</code> is supported, the browser drives every frame off the main thread.</p>
</section>
<section class="reveal" aria-labelledby="s2">
<h2 id="s2">02 · Fallback second</h2>
<p>Otherwise a scroll-ratio pass in JavaScript reproduces the same curve per element.</p>
</section>
<section class="reveal" aria-labelledby="s3">
<h2 id="s3">03 · One authoring model</h2>
<p>Both paths write the identical <code>--p</code> custom property, so the styling never forks.</p>
</section>
<section class="reveal" aria-labelledby="s4">
<h2 id="s4">04 · Range control</h2>
<p>The reveal maps to the <code>entry 0% cover 45%</code> range of each card inside the scrollport.</p>
</section>
<section class="reveal" aria-labelledby="s5">
<h2 id="s5">05 · Reduced motion</h2>
<p>With <code>prefers-reduced-motion</code>, everything resolves to its final state immediately.</p>
</section>
<section class="reveal" aria-labelledby="s6">
<h2 id="s6">06 · Zero dependencies</h2>
<p>No libraries — about a hundred lines of vanilla JavaScript for the fallback path.</p>
</section>
</div>
</main>
<script src="script.js"></script>
</body>
</html>import { useEffect, useState } from "react";
export function ScrollTimelineReveal() {
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>CSS Scroll-timeline Reveal</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>
);
}CSS Scroll-timeline Reveal
Reveal content with native animation-timeline and a scroll-position fallback for older browsers.
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.