Scroll Scrub
Purpose
The purpose of this script is to allow smooth scroll-based transitions without the need for
external animation libraries. By exposing progress values as CSS variables, it enables
the creation of animations entirely in CSS, offering both high performance and easy maintainability.
How It Works
- The script tracks elements in the viewport on scroll and resize.
- It calculates:
- The element’s position relative to the viewport.
- A scrub value (
--ab-scrub) between0and1representing scroll progress. - An inverse scrub value (
--ab-scrub-negative) equal to1 - scrub.
- These CSS variables are applied to the tracked element and its direct children.
- The variables can then be used in CSS to control animations, transforms, opacity, and filters.
Configuration Options
| Attribute | Type | Default | Description |
|---|---|---|---|
data-track-side |
text | top | Determines the point used for tracking: top, center, bottom, or ends. |
data-track-top-offset |
number / % | 0 | Extra offset from the top of the viewport before scrubbing starts. |
data-track-bottom-offset |
number / % | 0 | Extra offset from the bottom of the viewport before scrubbing ends. |
data-invert |
flag | none | Inverts scrub behavior, tapering off outside the defined bounds. |
Applying the Variables with CSS
The script provides two variables for styling:
--ab-scrub: A value between0and1representing scroll progress.--ab-scrub-negative: The inverse of the scrub, equal to1 - scrub.
This script provides a lightweight alternative to GSAP for implementing scroll-based scrub transitions. By using custom data attributes and CSS variables (--ab-scrub and --ab-scrub-negative), developers can control animations and transitions directly in CSS without heavy JavaScript libraries. It calculates element visibility, position, and scroll progress, then exposes these values for use in CSS transforms, opacity shifts, and other effects.
Examples
Opacity and Scale Transition
.ab-vp-tracker { opacity: calc(var(--ab-scrub)); transform: scale(calc(0.8 + 0.2 * var(--ab-scrub))); transition: transform 0.3s ease, opacity 0.3s ease; }
Translate and Fade
.ab-vp-tracker[data-in-vp] { transform: translateY(calc((1 - var(--ab-scrub)) * 40px)); opacity: var(--ab-scrub); }
Inverse Effect
.ab-vp-tracker { filter: blur(calc(var(--ab-scrub-negative) * 10px)); }
Code
(() => {
const parseOffset = (offset, vh) =>
offset?.endsWith('%') ? (parseFloat(offset) / 100) * vh : parseFloat(offset || 0);
const getTargetPoint = (rect, side, vh) => {
switch (side) {
case 'top':
return rect.top;
case 'center':
return rect.top + rect.height / 2;
case 'bottom':
return rect.bottom;
case 'ends': {
const center = rect.top + rect.height / 2;
return center {
if (inverted) {
if (point >= topOffset && point <= bottomOffset) return 1;
const isAbove = point = topOffset && point <= bottomOffset) return 1;
const range = bottomOffset - topOffset;
const distance = point {
const vh = window.innerHeight;
const trackers = [...document.querySelectorAll('.ab-vp-tracker')];
trackers.forEach(el => {
const rect = el.getBoundingClientRect();
const partiallyVisible = rect.bottom > 0 && rect.top < vh;
const side = el.dataset.trackSide || 'top';
const topOffset = parseOffset(el.dataset.trackTopOffset, vh);
const bottomOffset = vh - parseOffset(el.dataset.trackBottomOffset, vh);
const point = getTargetPoint(rect, side, vh);
el.style.setProperty('--ab-scroll-position', point.toFixed(2));
const isInverted = el.hasAttribute('data-invert');
const scrub = calculateScrub(point, topOffset, bottomOffset, vh, isInverted);
/* Apply data-outside-* in both modes */
if (point bottomOffset) {
el.setAttribute('data-outside-bottom', '');
el.removeAttribute('data-outside-top');
} else {
el.removeAttribute('data-outside-top');
el.removeAttribute('data-outside-bottom');
}
const scrubNeg = 1 - scrub;
el.style.setProperty('--ab-scrub', scrub.toFixed(4));
el.style.setProperty('--ab-scrub-negative', scrubNeg.toFixed(4));
const children = el.children;
for (let i = 0; i = minOffset && point <= maxOffset) {
el.setAttribute('data-in-bounds', '');
} else {
el.removeAttribute('data-in-bounds');
}
});
};
window.addEventListener('scroll', updateVisibility, { passive: true });
window.addEventListener('resize', updateVisibility);
document.addEventListener('DOMContentLoaded', updateVisibility);
})();