스크롤 스크럽

Scroll scrub

애니메이션의 진행률을 시간이 아니라 스크롤 위치 그 자체에 1:1로 묶는 기법. 스크롤을 멈추면 애니메이션도 멈추고, 위로 되돌리면 애니메이션도 거꾸로 재생됩니다.

다른 이름: 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);

"스크럽(scrub)"은 비디오 편집에서 타임라인을 손으로 끌며 앞뒤로 넘겨보는 동작을 가리킵니다. 스크롤 스크럽은 그 타임라인 막대를 스크롤바로 바꾼 것입니다 — 스크롤 컨테이너의 (scrollTop / 최대 스크롤량)을 0~1 진행률로 계산해, 매 스크롤 이벤트(또는 rAF)마다 그 값을 회전각·stroke-dashoffset·여러 트랙 값에 그대로 대입합니다.

트리거 방식(스크롤 리빌처럼 뷰포트에 들어오는 "순간"에 애니메이션을 한 번 재생)과 다른 점이 핵심입니다. 스크럽은 재생 여부가 아니라 진행 위치 자체가 스크롤에 종속됩니다 — 사용자가 스크롤을 5px만 움직이면 애니메이션도 딱 그만큼만 움직입니다. GSAP의 ScrollTrigger가 scrub: true 옵션으로 이 패턴을 대중화했고, CSS 네이티브로는 animation-timeline: scroll()이 같은 개념을 구현합니다.

관성 스크롤(모바일에서 손을 뗀 뒤에도 관성으로 계속 스크롤되는 것)과 만나면 애니메이션이 부드럽게 느려지며 끝나는 장점이 있지만, 스크롤 이벤트가 매우 잦은 환경에서 무거운 연산을 그대로 물리면 프레임이 끊깁니다 — transform·opacity처럼 GPU가 처리하는 속성 위주로 갱신해야 합니다.

언제 쓰나

스크롤 자체가 콘텐츠의 진행을 의미할 때(단계별 설명, 데이터 그래프가 채워지는 연출) 씁니다. 그냥 "등장"만 필요하면 스크롤 리빌로 충분합니다.