06 — Scroll-driven Animations (시간 대신 스크롤)
한 줄 답: Scroll-driven Animations는
@keyframes의 진행도(0~100%)를 시간이 아니라 스크롤 위치에 연결한다.animation-timeline: scroll()또는view()한 줄로, JavaScript·scroll이벤트·IntersectionObserver없이 스크롤할수록 진행하는 애니메이션을 만든다. Chrome 115+ Baseline.
Why — 왜 새 메커니즘이 필요했나
스크롤 인터랙션의 4가지 고전적 사용처:
- 읽기 진행 바 (상단의 progress bar)
- 패럴랙스 (스크롤 시 배경이 다른 속도로 움직임)
- 요소 등장 애니메이션 (뷰포트 진입 시 페이드인)
- 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 작업 중. 폴리필 또는
@supportsfallback 필수. - 멀미 유발 1순위 →
prefers-reduced-motion의무 처리.
다음: 07-accessibility — 이 모든 모션을 꺼야 할 때 끄는 방법.