06 — Scroll-driven Animations (시간 대신 스크롤)

한 줄 답: Scroll-driven Animations는 @keyframes의 진행도(0~100%)를 시간이 아니라 스크롤 위치에 연결한다. animation-timeline: scroll() 또는 view() 한 줄로, JavaScript·scroll 이벤트·IntersectionObserver 없이 스크롤할수록 진행하는 애니메이션을 만든다. Chrome 115+ Baseline.


Why — 왜 새 메커니즘이 필요했나

스크롤 인터랙션의 4가지 고전적 사용처:

  1. 읽기 진행 바 (상단의 progress bar)
  2. 패럴랙스 (스크롤 시 배경이 다른 속도로 움직임)
  3. 요소 등장 애니메이션 (뷰포트 진입 시 페이드인)
  4. Sticky timeline (Apple 제품 페이지 같은 단계적 텍스트 전환)

기존 구현 방법:

window.addEventListener('scroll', () => {
  const progress = window.scrollY / (document.body.scrollHeight - innerHeight);
  bar.style.width = `${progress * 100}%`;
});

문제:

  • scroll 이벤트는 메인 스레드에서 발생 → 무거운 콜백이 들어가면 스크롤이 끊긴다.
  • 60fps에 맞추려면 requestAnimationFrame + throttle.
  • IntersectionObserver진입/이탈 트리거는 가능하지만 진행도 보간은 불가.
  • 컴포저빌리티가 낮다 — 여러 요소에 적용하려면 코드가 폭증.

Scroll-driven Animations는 이걸 CSS 선언만으로 해결한다. Compositor 스레드에서 동작하므로 메인 스레드와 분리됨 → 60fps 보장에 훨씬 유리.


How — 어떻게 동작하는가

1) 핵심 개념: Animation Timeline

기존 CSS animation의 진행도 0→1은 벽시계 시간을 따른다. Scroll-driven은 그 진행도를 다른 신호에 연결한다.

.bar {
  animation: grow linear;
  animation-timeline: scroll();  /* 시간 대신 스크롤 */
}
@keyframes grow {
  to { transform: scaleX(1); }
}

animation-timeline 값:

  • auto (기본) — 시간 (document.timeline)
  • scroll(...) — 스크롤러의 진행도
  • view(...) — 요소가 뷰포트 안에서 차지하는 위치
  • 이름 (--my-timeline) — scroll-timeline-name / view-timeline-name으로 정의한 named timeline

2) scroll() — 스크롤러 진행도

.read-bar {
  position: fixed; top: 0; left: 0;
  width: 100%; height: 4px;
  background: orange;
  transform-origin: left;
  transform: scaleX(0);
  animation: read linear;
  animation-timeline: scroll(root block);  /* 루트 스크롤러의 세로축 */
}
@keyframes read {
  to { transform: scaleX(1); }
}

scroll(<scroller>? <axis>?):

  • <scroller>root (기본, viewport), nearest (가장 가까운 조상 스크롤 컨테이너), self
  • <axis>block (기본, 세로), inline (가로), x, y

3) view() — 요소의 뷰포트 가시성 진행도

요소 자체가 뷰포트에 들어오고 나가는 진행도를 0→1로 매핑.

.card {
  opacity: 0;
  transform: translateY(40px);
  animation: enter linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;  /* 진입 구간만 */
}
@keyframes enter {
  to { opacity: 1; transform: translateY(0); }
}

view() 자체는 요소가 처음 보이기 시작한 순간(0)부터 완전히 떠나는 순간(1)까지를 매핑. 보통 animation-range그 안의 구간을 지정한다.

4) animation-range — 구간 한정

키워드:

  • entry — 위에서부터 들어오는 구간 (요소 바닥 = 뷰포트 바닥 → 요소 천장 = 뷰포트 바닥)
  • exit — 위로 나가는 구간
  • contain — 요소가 뷰포트보다 작을 때 완전히 보이는 동안
  • cover — 처음 진입~완전 이탈 전체
animation-range: entry 0% entry 100%;       /* 진입할 때만 */
animation-range: exit 0% exit 100%;         /* 나갈 때만 */
animation-range: cover 0% cover 100%;       /* 통째로 */
animation-range: entry 25% contain 75%;     /* 임의 구간 */

5) Named Timeline — 다른 요소의 스크롤·뷰에 연결

스크롤되는 요소와 애니메이션 대상이 다를 때.

.gallery {
  overflow-x: scroll;
  scroll-timeline-name: --gallery;
  scroll-timeline-axis: inline;
}
 
.indicator {
  animation: dot-move linear;
  animation-timeline: --gallery;  /* gallery의 가로 스크롤에 연결 */
}

view-timeline-name도 동일하게 다른 요소의 뷰포트 가시성에 연결 가능.

6) animation-fill-mode: both 의 의미 변화

scroll-driven에서는 forwards/backwards보다 both가 BP. 스크롤이 진행 전에는 키프레임 시작값, 완료 후에는 끝값으로 고정.


What — 구체 패턴

패턴 1: 페이지 읽기 진행 바

@keyframes read-progress {
  to { transform: scaleX(1); }
}
.progress-bar {
  position: fixed; inset: 0 0 auto 0;
  height: 4px;
  background: var(--accent);
  transform-origin: left;
  transform: scaleX(0);
  animation: read-progress linear;
  animation-timeline: scroll(root);
}

JS·이벤트 0줄. Compositor 스레드에서 매끄럽게.

패턴 2: 카드 viewport 진입 페이드인

.card {
  opacity: 0;
  transform: translateY(40px);
  animation: enter linear both;
  animation-timeline: view();
  animation-range: entry 10% cover 30%;
}
@keyframes enter {
  to { opacity: 1; transform: none; }
}

진입의 10%~30% 구간에서만 진행. 너무 일찍 또는 너무 길게 끌지 않음.

패턴 3: 패럴랙스 배경

.hero {
  background-image: url(...);
  background-attachment: fixed;  /* iOS Safari 비권장 */
  /* 또는: */
  animation: parallax linear;
  animation-timeline: view();
  animation-range: cover;
}
@keyframes parallax {
  from { background-position-y: 0%; }
  to { background-position-y: 50%; }
}

⚠️ background-position 보간은 Paint trigger. 가능하면 transform: translateY()로 별도 레이어를 움직이는 방식 권장.

패턴 4: 가로 스크롤 갤러리 + 인디케이터

.gallery {
  display: flex;
  overflow-x: scroll;
  scroll-timeline: --gallery inline;
}
.indicator {
  animation: indicator-move linear;
  animation-timeline: --gallery;
}
@keyframes indicator-move {
  to { transform: translateX(100cqi); }  /* 컨테이너 너비만큼 */
}

패턴 5: Sticky timeline (Apple 제품 페이지 스타일)

긴 sticky 섹션 안에서 스크롤할수록 다음 단계의 텍스트·이미지가 바뀜.

.scene {
  height: 400vh;  /* 길게 잡아서 스크롤 여유 확보 */
}
.scene .pinned {
  position: sticky; top: 0;
  height: 100vh;
}
.scene .step-1, .scene .step-2 {
  animation: fade linear both;
  animation-timeline: view();
}
.scene .step-1 { animation-range: cover 0% cover 33%; }
.scene .step-2 { animation-range: cover 33% cover 66%; }
@keyframes fade {
  0%, 100% { opacity: 0; }
  50%      { opacity: 1; }
}

브라우저 지원

  • Chrome 115+ (2023-07) — full Baseline
  • Edge 115+
  • Firefox 132+ (2024)
  • Safari — 부분 지원 작업 중 (2025년 진행)

폴리필: scroll-timeline-polyfill (Bramus가 관리). 점진적 향상으로 fallback 디자인 필수.

@supports (animation-timeline: scroll()) {
  /* scroll-driven 버전 */
}

What-if — 잘못 쓰면

1) Layout-trigger 속성 키프레임

@keyframes grow { to { width: 100%; } }  /* ❌ */
.bar { animation-timeline: scroll(); animation: grow; }

스크롤할 때마다 layout 재계산 → 스크롤 자체가 끊김. Compositor가 도와줄 수 없다. transform·opacity로 한정.

2) animation-timeline: scroll() + 너무 큰 페이지

페이지가 매우 길면 진행도 1당 픽셀 수가 너무 큼 → 사용자가 진행 변화를 거의 못 느낌. animation-range로 *전체가 아니라 중간 50%*에 한정.

3) view()의 기본 range 오해

.card {
  animation: enter linear;
  animation-timeline: view();
  /* animation-range 누락 → cover 전체 → 카드가 보이는 내내 진행 */
}

대부분의 경우 진입 시 한 번만 원함. animation-range: entry를 명시.

4) 여러 named timeline 충돌

scroll-timeline-name같은 이름으로 여러 컨테이너에 → 가장 가까운 조상이 이김. 의도와 다를 수 있음. CSS 변수처럼 --로 시작하는 명시적 이름과 적절한 범위 선언이 BP.

5) prefers-reduced-motion 미고려

스크롤 기반 패럴랙스는 멀미 유발 1순위. 의무적으로 비활성:

@media (prefers-reduced-motion: reduce) {
  * {
    animation-timeline: auto !important;
    animation-duration: 0.01ms !important;
  }
}

자세한 건 07-accessibility.

6) Sticky timeline에서 스크롤 잭킹 안티패턴

스크롤이 너무 길어서 사용자가 *“끝났나?”*를 의심하면 이미 실패. 한 sticky scene은 200~400vh 이내가 BP. 그 이상이면 사용자가 섹션을 건너뛰는 방법을 알 수 없어 이탈한다.


Insight — 한 단락 이야기

“Scroll-driven은 Apple의 패럴랙스 + Notion 페이지 진행 바플랫폼화다.”

2007년 parallax.js 같은 라이브러리들이 등장한 이후, 디자이너의 모든 스크롤 시그니처 모먼트는 JavaScript에 의존했다. 2010년대 Pinterest·Apple은 Compositor-only scroll handler를 위해 Chrome 팀과 직접 소통했다 (Passive Event Listeners, IntersectionObserver가 그 결과). 하지만 진행도 보간은 여전히 JS였다. 2020년 Robert Flack(Google)이 Scroll Timeline을 W3C에 제안했고, 핵심 발상은 *“Animation의 time source를 추상화하자”*였다. Animation은 본래 *progress = f(time)*이지만, 이제 time을 어떤 신호로든 대체 가능. 이는 모션의 좌표계를 재정의한 것이다. 같은 추상이 AnimationTimeline 인터페이스로 Web Animations API에도 들어왔다. 흥미로운 점은, 이 발상이 *애니메이션 소프트웨어(After Effects의 Expression, Cinema 4D의 XPresso)*에서 이미 50년 된 표준이라는 것 — 어떤 값에든 보간을 묶을 수 있게 한다. 웹이 그걸 따라잡는 데 30년이 걸렸다.


요약 + Mermaid

  • animation-timeline시간 외 신호에 연결 — scroll(), view(), named timeline.
  • scroll() = 스크롤러 진행도. view() = 요소의 뷰포트 가시성.
  • animation-range어느 구간만 진행할지 한정 — entry / exit / cover / contain.
  • Compositor 스레드 동작 → JS·scroll 이벤트 불필요, 60fps 안정.
  • 키프레임은 반드시 composite-only 속성으로 (transform·opacity·filter).
  • 패럴랙스·진행 바·viewport-enter·sticky-timeline의 네 가지 사용처가 거의 전부.
  • Chrome 115+ Baseline. Safari·Firefox 작업 중. 폴리필 또는 @supports fallback 필수.
  • 멀미 유발 1순위 → prefers-reduced-motion 의무 처리.

다음: 07-accessibility — 이 모든 모션을 꺼야 할 때 끄는 방법.