Skip to content

Repository files navigation

gzip size

Scroll Progress Web Component

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.

Live Demo

Features

  • 🎯 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: transform and backface-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 100svh viewport probe to avoid address bar layout shifts

Installation

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>

Usage

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 Anchors

  • playhead-element-start and playhead-element-end: anchors on the element
  • playhead-viewport-start and playhead-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.

Smoothing

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 smoothing ms, 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-progress variable, the scroll-progress:update event, and getProgress() 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 ResizeObserver callback, 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: reduce bypasses smoothing entirely, matching the way velocity is disabled.

API

Public Methods

  • getProgress(): Returns current scroll progress (0 to 1)
  • getVelocity(): Returns current scroll velocity
  • update(): Manually triggers recalculation of cached positions
  • pause(): Pauses the internal animation frame loop
  • resume(): Resumes the internal animation frame loop

Events

  • scroll-progress:update: Fired whenever the scroll progress updates significantly
    • event.detail.progress: number between 0 and 1
  • scroll-progress:velocity: Fired whenever the scroll velocity updates significantly
    • event.detail.velocity: scroll velocity with physics simulation

Customization

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));
}

Styling the Host

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.


How It Works

  • A global ScrollProgressManager singleton runs a single shared requestAnimationFrame loop for all <scroll-progress> instances on the page.
  • On initialization, each component calculates anchor positions relative to both the element and viewport. A ViewportMetrics helper measures stable viewport height via a 100svh probe 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: reduce is 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.

Integration Example

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());

Browser Support

Supports all modern browsers with Web Components, Intersection Observer, and Resize Observer:

  • Chrome 54+
  • Firefox 63+
  • Safari 10.1+
  • Edge 79+

License

MIT


Made by Cory Schulz

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages