A high-performance, customizable Web Component that tracks scroll progress relative to configurable playhead anchors on the element and viewport. It exposes smooth, GPU-accelerated CSS variables (--scroll-progress and --scroll-progress-velocity) for scroll-based animations without relying on scroll event listeners.
- 🎯 Configurable playhead anchors for both element and viewport start/end positions — keywords or percentages
- 🪶 Optional progress smoothing with a frame-rate independent time constant
- ⚡ Optimized animations driven by
requestAnimationFrame(no scroll events) - 🖥️ GPU accelerated using
will-change: transformandbackface-visibility: hidden - 🕵️♂️ Visibility-aware updates using Intersection Observer to minimize CPU usage
- 📏 Responsive to viewport resizes with throttled resize handling
- 🌊 Scroll velocity tracking with physics simulation (friction, attraction to zero)
- 🌐 Framework agnostic — works with any frontend framework or vanilla JS
- 📦 Lightweight & zero dependencies — only 2.7kb gzipped!
- 🔧 Simple API & CSS custom property exposure for full styling control
- ♿ Respects
prefers-reduced-motion— velocity is disabled when reduced motion is preferred - 📱 Mobile Safari safe — uses
100svhviewport probe to avoid address bar layout shifts
npm install @magic-spells/scroll-progress// Import the component in your JS entry point
import '@magic-spells/scroll-progress';Or include directly via CDN:
<script src="https://unpkg.com/@magic-spells/scroll-progress"></script>Add the custom element anywhere in your HTML:
<scroll-progress
playhead-element-start="top"
playhead-viewport-start="bottom"
playhead-element-end="bottom"
playhead-viewport-end="top">
<!-- child elements animated with --scroll-progress -->
<div
class="animated-layer"
style="transform: translateX(calc(var(--scroll-progress) * 100%));">
Scroll me!
</div>
</scroll-progress>playhead-element-startandplayhead-element-end: anchors on the elementplayhead-viewport-startandplayhead-viewport-end: anchors on the viewport
These determine the start and end points for scroll progress calculation.
Each anchor accepts a keyword (top, center, bottom) or a percentage (25%, 75%). Percentages are measured from the top of the element or viewport, so top is 0%, center is 50%, and bottom is 100%:
<!-- progress runs across the middle half of the viewport -->
<scroll-progress playhead-viewport-start="75%" playhead-viewport-end="25%">
...
</scroll-progress>Percentages are not clamped to 0–100%, so over-scan anchors like 150% (below the viewport) or -20% (above it) are valid.
Defaults, if an attribute is omitted:
| Attribute | Default |
|---|---|
playhead-element-start |
top (0%) |
playhead-viewport-start |
bottom (100%) |
playhead-element-end |
bottom (100%) |
playhead-viewport-end |
top (0%) |
An unrecognized value never throws — it logs a single console.warn and falls back to that attribute's default.
The optional smoothing attribute eases progress toward the raw scroll position instead of tracking it exactly:
<scroll-progress smoothing="400">...</scroll-progress>- The value is a time constant in milliseconds — roughly 63% of the remaining gap is closed every
smoothingms, which makes the easing frame-rate independent. Larger values lag further behind. 0, absent, or an unparseable value means no smoothing (the default).- When smoothing is active, the
--scroll-progressvariable, thescroll-progress:updateevent, andgetProgress()all carry the smoothed value; the raw scroll position stays internal. - Progress snaps instead of easing when the range changes underneath it — a resize, a
ResizeObservercallback,update(), an attribute change, or the element scrolling back into view — so it never animates through a position the user never scrolled past. prefers-reduced-motion: reducebypasses smoothing entirely, matching the way velocity is disabled.
getProgress(): Returns current scroll progress (0 to 1)getVelocity(): Returns current scroll velocityupdate(): Manually triggers recalculation of cached positionspause(): Pauses the internal animation frame loopresume(): Resumes the internal animation frame loop
scroll-progress:update: Fired whenever the scroll progress updates significantlyevent.detail.progress: number between 0 and 1
scroll-progress:velocity: Fired whenever the scroll velocity updates significantlyevent.detail.velocity: scroll velocity with physics simulation
The component exposes CSS custom properties:
--scroll-progress— value between 0 and 1 representing the normalized scroll position between configured playheads--scroll-progress-velocity— scroll velocity with smooth physics simulation (friction, attraction to zero)
Use these properties in child elements to drive any CSS animation, e.g.:
.animated-layer {
transform: translateX(calc(var(--scroll-progress) * 100%));
transition: transform 0.1s ease-out;
}
.velocity-skew {
transform: skewX(calc(var(--scroll-progress-velocity) * 2deg));
}
.velocity-blur {
filter: blur(calc(abs(var(--scroll-progress-velocity)) * 1px));
}The component injects these base styles by default:
scroll-progress {
display: block;
--scroll-progress: 0;
--scroll-progress-velocity: 0;
will-change: transform;
backface-visibility: hidden;
}Override these as needed in your stylesheets.
- A global
ScrollProgressManagersingleton runs a single sharedrequestAnimationFrameloop for all<scroll-progress>instances on the page. - On initialization, each component calculates anchor positions relative to both the element and viewport. A
ViewportMetricshelper measures stable viewport height via a100svhprobe to avoid mobile Safari address bar shifts. - Scroll progress is updated per-element only when visible, gated by
IntersectionObserver. Elements that are off-screen skip updates entirely. - Velocity system tracks scroll delta frame-to-frame and applies physics:
- Smooth velocity accumulation (15% smoothing factor)
- Combined friction + attraction decay (0.76× per frame) for natural deceleration
- Clean zero state when velocity drops below threshold
- The global RAF loop automatically stops once velocity reaches zero and every smoothed element has settled on its target, then restarts on the next scroll or resize event. Off-screen and paused elements never hold the loop open.
- When
prefers-reduced-motion: reduceis active, velocity is forced to 0. - Both scroll progress and velocity are exposed as CSS variables for smooth, GPU-accelerated animations with no scroll event listeners.
const scrollProgress = document.querySelector('scroll-progress');
// listen for progress updates
scrollProgress.addEventListener('scroll-progress:update', (event) => {
console.log('scroll progress:', event.detail.progress);
});
// listen for velocity updates
scrollProgress.addEventListener('scroll-progress:velocity', (event) => {
console.log('scroll velocity:', event.detail.velocity);
});
// manually read progress and velocity
console.log(scrollProgress.getProgress());
console.log(scrollProgress.getVelocity());Supports all modern browsers with Web Components, Intersection Observer, and Resize Observer:
- Chrome 54+
- Firefox 63+
- Safari 10.1+
- Edge 79+
MIT
Made by Cory Schulz