Scroll scrub

스크롤 스크럽

Binding an animation's progress directly to scroll position, one-to-one, instead of to time — stop scrolling and the animation stops; scroll back up and it plays in reverse.

Also known as: Scrubbed animationScrollTrigger scrub
···
html
<div class="wrap">
  <div class="scroller" id="scroller"><div class="content">
    <div class="mark">1</div><div class="mark">2</div><div class="mark">3</div><div class="mark">4</div><div class="mark">5</div>
  </div></div>
  <div class="stage">
    <svg class="ring" viewBox="0 0 100 100">
      <circle class="ring-bg" cx="50" cy="50" r="40"/>
      <circle id="ringFg" class="ring-fg" cx="50" cy="50" r="40"/>
    </svg>
    <div class="needle" id="needle"></div>
  </div>
</div>
css
.wrap{position:absolute;inset:8%;display:flex;gap:6%;align-items:stretch}
.scroller{position:relative;flex:none;height:100%;width:clamp(26px,8vmin,40px);overflow-y:scroll;border:1px solid var(--line);border-radius:8px;scrollbar-width:none}
.scroller::-webkit-scrollbar{display:none}
.content{height:280%;display:flex;flex-direction:column;justify-content:space-between;padding:10% 0}
.mark{text-align:center;color:var(--muted);font:700 13px/1 ui-monospace,monospace}
.stage{position:relative;flex:1;display:grid;place-items:center}
.ring{width:min(64%,180px);aspect-ratio:1;transform:rotate(-90deg)}
.ring-bg{fill:none;stroke:var(--line);stroke-width:8}
.ring-fg{fill:none;stroke:var(--accent);stroke-width:8;stroke-linecap:round;stroke-dasharray:251.2;stroke-dashoffset:251.2}
.needle{position:absolute;top:50%;left:50%;width:2px;height:22%;margin-left:-1px;background:var(--accent-2);transform-origin:bottom center;transform:translateY(-100%) rotate(0deg)}
js
const el = document.getElementById('scroller');
const ringFg = document.getElementById('ringFg');
const needle = document.getElementById('needle');
const CIRC = 251.2;
let dir = 1;
function loop() {
  const max = el.scrollHeight - el.clientHeight;
  el.scrollTop += dir * 0.9;
  if (el.scrollTop >= max) dir = -1;
  if (el.scrollTop <= 0) dir = 1;
  const progress = max > 0 ? el.scrollTop / max : 0;
  ringFg.style.strokeDashoffset = String(CIRC * (1 - progress));
  needle.style.transform = 'translateY(-100%) rotate(' + (progress * 320) + 'deg)';
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

"Scrubbing" comes from video editing — dragging a timeline scrubber back and forth by hand. Scroll scrub turns that scrubber into the scrollbar itself: you compute (scrollTop / max scroll) as a 0–1 progress value on every scroll event (or rAF), and feed that value straight into a rotation, a stroke-dashoffset, or several tracks at once.

The key difference from a trigger-based effect (like scroll-reveal, which fires once when an element enters the viewport) is that scrub doesn't ask "has this played yet?" — the progress value itself is enslaved to scroll position. Move the scroll 5px and the animation moves exactly that much, in either direction. GSAP's ScrollTrigger popularized this with its scrub: true option; the native CSS equivalent is animation-timeline: scroll().

Combined with momentum scrolling (the inertial drift after you lift your finger on mobile), a scrubbed animation glides to a stop instead of snapping — which reads nicely. But scroll fires very often, so anything expensive tied to it will drop frames; stick to properties the GPU can composite, like transform and opacity.

When to use

Use it when scroll position itself represents progress through content — step-by-step explainers, a chart filling in. If you only need an entrance, scroll reveal is enough.