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

  1. The script tracks elements in the viewport on scroll and resize.
  2. It calculates:
    • The element’s position relative to the viewport.
    • A scrub value (--ab-scrub) between 0 and 1 representing scroll progress.
    • An inverse scrub value (--ab-scrub-negative) equal to 1 - scrub.
  3. These CSS variables are applied to the tracked element and its direct children.
  4. 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 between 0 and 1 representing scroll progress.
  • --ab-scrub-negative: The inverse of the scrub, equal to 1 - 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);
    })();