Scroll Progress
Scroll Progress
Reading progress bar, parallax layer, and entrance cards — scroll() drives a progress callback, inView() + animate() handles reveals.
Dependencies
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scroll Progress — Motion Recipe</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/motion@11.11.13/dist/motion.js"></script>
<style>
#progress-bar {
transform-origin: left center;
transform: scaleX(0);
}
.reveal-card {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
#progress-bar { display: none; }
.reveal-card { opacity: 1 !important; transform: none !important; }
}
</style>
</head>
<body class="bg-slate-950 text-slate-200 font-sans">
<!-- Sticky progress bar -->
<div class="sticky top-0 z-50 h-[3px] bg-slate-800/60">
<div id="progress-bar" class="h-full bg-gradient-to-r from-violet-500 via-fuchsia-400 to-pink-500"></div>
</div>
<!-- Hero -->
<div class="mx-auto max-w-3xl px-8 pt-16 pb-20 text-center">
<span class="mb-5 inline-block rounded-full border border-violet-500/20 bg-violet-500/10 px-3.5 py-1 text-[0.7rem] font-semibold uppercase tracking-widest text-violet-400">Motion · scroll()</span>
<h1 class="mb-5 text-4xl font-black leading-[1.1] text-slate-100 sm:text-5xl">Scroll-linked<br>animations</h1>
<p class="mx-auto max-w-md text-base leading-relaxed text-slate-400">The gradient bar above tracks your scroll position. Cards below reveal with <code class="rounded bg-slate-800 px-1.5 py-0.5 font-mono text-xs text-violet-300">inView()</code> + <code class="rounded bg-slate-800 px-1.5 py-0.5 font-mono text-xs text-violet-300">animate()</code>.</p>
<div class="mt-8 flex items-center justify-center gap-2 text-sm text-slate-500">
<svg class="h-4 w-4 animate-bounce text-violet-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/></svg>
Scroll down
</div>
</div>
<!-- Scroll counter + parallax -->
<div class="mx-auto max-w-3xl px-8 pb-20">
<p class="mb-5 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">scroll() — progress callback</p>
<div class="flex gap-6 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-8">
<div class="text-center shrink-0">
<p class="text-6xl font-black tabular-nums text-violet-400 leading-none" id="scroll-pct">0</p>
<p class="mt-2 text-xs font-semibold uppercase tracking-widest text-slate-500">% scrolled</p>
</div>
<div>
<p class="text-sm font-semibold text-slate-200 mb-2">One callback, three effects</p>
<p class="text-sm leading-relaxed text-slate-400">
<code class="rounded bg-slate-800 px-1 font-mono text-xs text-violet-300">scroll(fn)</code> calls <code class="rounded bg-slate-800 px-1 font-mono text-xs text-violet-300">fn</code> with a <code class="rounded bg-slate-800 px-1 font-mono text-xs text-violet-300">progress</code> value (0–1) on every scroll frame. The progress bar, parallax orb, and this counter all update from the same single callback.
</p>
</div>
</div>
</div>
<!-- Parallax section -->
<div class="mx-auto max-w-3xl px-8 pb-20">
<p class="mb-5 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">scroll() — parallax</p>
<div class="relative rounded-2xl border border-white/[0.06] bg-white/[0.03] overflow-hidden" style="height:200px">
<div id="parallax-orb" class="absolute inset-0 flex items-center justify-center pointer-events-none" aria-hidden="true">
<div class="h-48 w-48 rounded-full bg-gradient-to-br from-violet-600/40 to-fuchsia-600/30 blur-3xl"></div>
</div>
<div class="absolute inset-0 flex flex-col items-center justify-center text-center px-6">
<p class="relative text-lg font-black text-slate-100">Glow drifts upward</p>
<p class="relative mt-2 text-sm text-slate-500">Moves at 40% of page scroll speed via <code class="rounded bg-slate-800 px-1 font-mono text-xs text-violet-300">progress × −80px</code></p>
</div>
</div>
</div>
<!-- inView + animate cards -->
<div class="mx-auto max-w-3xl px-8 pb-20">
<p class="mb-5 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">inView() + animate() — entrance</p>
<div class="grid gap-4 sm:grid-cols-2">
<div class="reveal-card rounded-xl border border-white/[0.06] bg-white/[0.03] p-6">
<p class="mb-2 text-sm font-semibold text-slate-100">inView(selector, callback)</p>
<p class="text-xs leading-relaxed text-slate-500">Fires once when the element enters the viewport. Return the cleanup function to auto-disconnect after first play.</p>
</div>
<div class="reveal-card rounded-xl border border-white/[0.06] bg-white/[0.03] p-6">
<p class="mb-2 text-sm font-semibold text-slate-100">animate(target, keyframes)</p>
<p class="text-xs leading-relaxed text-slate-500">Drives WAAPI directly. Pass a selector string, element, or NodeList. Keyframes use the same syntax as CSS.</p>
</div>
<div class="reveal-card rounded-xl border border-white/[0.06] bg-white/[0.03] p-6">
<p class="mb-2 text-sm font-semibold text-slate-100">easing: [0.22, 1, 0.36, 1]</p>
<p class="text-xs leading-relaxed text-slate-500">Cubic-bezier array shorthand. Fast in, soft landing — a good default for entrance animations.</p>
</div>
<div class="reveal-card rounded-xl border border-white/[0.06] bg-white/[0.03] p-6">
<p class="mb-2 text-sm font-semibold text-slate-100">margin option</p>
<p class="text-xs leading-relaxed text-slate-500"><code class="font-mono text-violet-300">margin: '0px 0px -15% 0px'</code> shrinks the root margin — fires when the element is 15% inside the viewport, not at the edge.</p>
</div>
</div>
</div>
<div class="mx-auto max-w-3xl px-8 pb-24">
<p class="mb-5 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">The code</p>
<div class="rounded-xl border border-white/[0.06] bg-slate-900 p-5 font-mono text-[0.78rem] leading-7 text-slate-400">
<p><span class="text-slate-500">// scroll() — progress 0–1 in callback</span></p>
<p><span class="text-violet-400">scroll</span><span class="text-slate-500">(function(</span><span class="text-fuchsia-400">p</span><span class="text-slate-500">) {</span></p>
<p class="pl-4">bar<span class="text-slate-500">.</span>style<span class="text-slate-500">.</span>transform <span class="text-slate-500">=</span> <span class="text-amber-300">'scaleX('</span> <span class="text-slate-500">+</span> p <span class="text-slate-500">+</span> <span class="text-amber-300">')'</span><span class="text-slate-500">;</span></p>
<p class="pl-4">orb<span class="text-slate-500">.</span>style<span class="text-slate-500">.</span>transform <span class="text-slate-500">=</span> <span class="text-amber-300">'translateY('</span> <span class="text-slate-500">+ (</span><span class="text-amber-300">-80</span> <span class="text-slate-500">*</span> p<span class="text-slate-500">) +</span> <span class="text-amber-300">'px)'</span><span class="text-slate-500">;</span></p>
<p class="pl-4">pct<span class="text-slate-500">.</span>textContent <span class="text-slate-500">=</span> Math<span class="text-slate-500">.</span>round<span class="text-slate-500">(</span>p <span class="text-slate-500">*</span> <span class="text-amber-300">100</span><span class="text-slate-500">);</span></p>
<p><span class="text-slate-500">});</span></p>
<p class="mt-4"><span class="text-slate-500">// inView() + animate() — entrance on scroll</span></p>
<p><span class="text-violet-400">inView</span><span class="text-slate-500">(</span><span class="text-amber-300">'.reveal-card'</span><span class="text-slate-500">,</span> function<span class="text-slate-500">(</span><span class="text-fuchsia-400">info</span><span class="text-slate-500">) {</span></p>
<p class="pl-4"><span class="text-violet-400">animate</span><span class="text-slate-500">(</span>info<span class="text-slate-500">.</span>target<span class="text-slate-500">, {</span> <span class="text-fuchsia-400">opacity</span><span class="text-slate-500">: [</span><span class="text-amber-300">0, 1</span><span class="text-slate-500">],</span> <span class="text-fuchsia-400">y</span><span class="text-slate-500">: [</span><span class="text-amber-300">20, 0</span><span class="text-slate-500">] },</span></p>
<p class="pl-4"><span class="text-slate-500"> {</span> <span class="text-fuchsia-400">duration</span><span class="text-slate-500">:</span> <span class="text-amber-300">0.5</span><span class="text-slate-500">,</span> <span class="text-fuchsia-400">easing</span><span class="text-slate-500">: [</span><span class="text-amber-300">0.22, 1, 0.36, 1</span><span class="text-slate-500">] });</span></p>
<p><span class="text-slate-500">}, {</span> <span class="text-fuchsia-400">margin</span><span class="text-slate-500">:</span> <span class="text-amber-300">'0px 0px -15% 0px'</span> <span class="text-slate-500">});</span></p>
</div>
</div>
<div class="h-16"></div>
<script>
var { animate, scroll, inView } = Motion;
var progressBar = document.getElementById('progress-bar');
var parallaxOrb = document.getElementById('parallax-orb');
var pctEl = document.getElementById('scroll-pct');
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// scroll() callback receives progress as a plain number 0–1
scroll(function(progress) {
progressBar.style.transform = 'scaleX(' + progress + ')';
if (!reduced) parallaxOrb.style.transform = 'translateY(' + (-80 * progress) + 'px)';
if (pctEl) pctEl.textContent = String(Math.round(progress * 100));
});
// inView() + animate() for card entrances
if (!reduced) {
inView('.reveal-card', function(info) {
animate(info.target, { opacity: [0, 1], y: [20, 0] }, {
duration: 0.5,
easing: [0.22, 1, 0.36, 1],
});
}, { margin: '0px 0px -15% 0px' });
}
</script>
</body>
</html>
scroll(callback) receives a progress value from 0 to 1 on every scroll frame. A single callback drives the progress bar (scaleX), the parallax offset (translateY), and the percentage counter — no RAF loop needed.
Progress bar — scaleX(progress) with transform-origin: left center. The CSS initial state sets transform: scaleX(0) to prevent a flash before the script runs.
Parallax — progress × −80px moves the orb upward at 40% of the page scroll speed. Keep the max offset below the container height to avoid the element drifting out of view.
Card reveals — inView('.reveal-card', callback) fires once per element when it enters the viewport. The margin: '0px 0px -15% 0px' option delays firing until the element is 15% inside the visible area, giving the animation room to play before the element is fully in view.