Skip to content

Instantly share code, notes, and snippets.

@wpeasy
Created November 1, 2025 08:29
Show Gist options
  • Select an option

  • Save wpeasy/426374414a0ed7e95ea79e0f62144e3a to your computer and use it in GitHub Desktop.

Select an option

Save wpeasy/426374414a0ed7e95ea79e0f62144e3a to your computer and use it in GitHub Desktop.
Basic JavaScript SCRUB
/*
Add class .ab-vp-tracker to elements you want to track
[data-track-side="top|center|bottom|ends"] - part of element to track
[data-track-top-offset="px|%"] pixels or % from the top of VP to track
[data-track-bottom-offset="px|%"] pixels or % from the bottom of VP to track
[data-invert] Scrubs outside of the tracked window from vp top and bottom to offsets.
*/
(() => {
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 < vh / 2 ? rect.bottom : rect.top;
}
default:
return rect.top;
}
};
const calculateScrub = (point, topOffset, bottomOffset, vh, inverted = false) => {
if (inverted) {
if (point >= topOffset && point <= bottomOffset) return 1;
const isAbove = point < topOffset;
const distFromEdge = isAbove
? topOffset - point
: point - bottomOffset;
const maxDist = isAbove
? topOffset
: vh - bottomOffset;
return 1 - Math.min(distFromEdge / maxDist, 1);
} else {
if (point >= topOffset && point <= bottomOffset) return 1;
const range = bottomOffset - topOffset;
const distance = point < topOffset
? topOffset - point
: point - bottomOffset;
return Math.max(0, 1 - distance / range);
}
};
const updateVisibility = () => {
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 < topOffset) {
el.setAttribute('data-outside-top', '');
el.removeAttribute('data-outside-bottom');
} else 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 < children.length; i++) {
const child = children[i];
child.style.setProperty('--ab-scroll-position', point.toFixed(2));
child.style.setProperty('--ab-scrub', scrub.toFixed(4));
child.style.setProperty('--ab-scrub-negative', scrubNeg.toFixed(4));
}
if (partiallyVisible) {
el.setAttribute('data-in-vp', '');
} else {
el.removeAttribute('data-in-vp');
}
const minOffset = Math.min(topOffset, bottomOffset);
const maxOffset = Math.max(topOffset, bottomOffset);
if (point >= 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);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment