이 글은 누구를 위한 것인가
- JavaScript IntersectionObserver 없이 스크롤 기반 애니메이션을 구현하려는 팀
- 스크롤 진행률 바, 패럴랙스, 요소 등장 효과를 CSS만으로 만들려는 개발자
- 스크롤 이벤트 리스너로 인한 성능 문제를 CSS로 해결하려는 팀
들어가며
기존 스크롤 애니메이션은 JavaScript 이벤트 리스너와 requestAnimationFrame이 필요했다. CSS Scroll-Driven Animations는 animation-timeline 속성 하나로 스크롤 위치와 CSS 애니메이션을 연결한다. 메인 스레드가 아닌 컴포지터에서 실행되어 성능도 우수하다.
이 글은 bluefoxdev.kr의 CSS 스크롤 애니메이션 가이드 를 참고하여 작성했습니다.
1. Scroll-Driven Animations 개요
[두 가지 타임라인]
1. scroll() - 스크롤 컨테이너의 스크롤 위치
scroll(scroller, axis)
scroller: nearest | root | self
axis: y | x | block | inline
animation-timeline: scroll() → 가장 가까운 스크롤 컨테이너, 세로
animation-timeline: scroll(root) → 페이지 전체 스크롤
animation-timeline: scroll(self x) → 자기 자신 가로 스크롤
2. view() - 뷰포트 내 요소의 가시 영역 비율
view(axis, inset)
animation-timeline: view() → 요소가 뷰포트에 들어오는 비율
범위 제어:
animation-range: cover → 요소가 뷰포트에 처음 나타날 때 ~ 완전히 벗어날 때
animation-range: entry → 뷰포트에 들어오는 구간만
animation-range: exit → 뷰포트에서 나가는 구간만
animation-range: entry 20% exit 80% → 구체적 범위
[브라우저 지원]
Chrome 115+, Edge 115+
Firefox: 플래그 필요 (2024 기준)
Safari: 일부 지원
[@supports 폴백]
@supports (animation-timeline: scroll()) { ... }
→ 미지원 브라우저는 기본 스타일 유지
2. Scroll-Driven 애니메이션 구현
/* 1. 스크롤 진행률 바 (가장 간단한 예시) */
@keyframes grow-progress {
from { width: 0%; }
to { width: 100%; }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: linear-gradient(to right, #6366f1, #8b5cf6);
animation: grow-progress linear;
animation-timeline: scroll(root); /* 페이지 스크롤에 연동 */
}
/* 2. 요소 등장 애니메이션 (view() 타임라인) */
@keyframes fade-slide-in {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.reveal {
animation: fade-slide-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%; /* 뷰포트에 40% 들어올 때까지 */
}
/* 3. 카드 순차 등장 (delay 없이 stagger 효과) */
.card-list .card {
animation: fade-slide-in linear both;
animation-timeline: view();
animation-range: entry 10% cover 30%;
}
.card:nth-child(1) { animation-range-start: entry 5%; }
.card:nth-child(2) { animation-range-start: entry 15%; }
.card:nth-child(3) { animation-range-start: entry 25%; }
/* 4. 패럴랙스 효과 */
@keyframes parallax-slow {
from { transform: translateY(0); }
to { transform: translateY(-120px); }
}
@keyframes parallax-fast {
from { transform: translateY(0); }
to { transform: translateY(-200px); }
}
.hero-background {
animation: parallax-slow linear;
animation-timeline: scroll(root);
}
.hero-foreground {
animation: parallax-fast linear;
animation-timeline: scroll(root);
}
/* 5. 수평 스크롤 갤러리 */
.horizontal-gallery {
overflow-x: scroll;
display: flex;
gap: 16px;
scroll-snap-type: x mandatory;
}
.gallery-item {
scroll-snap-align: start;
flex-shrink: 0;
width: 80vw;
animation: scale-in linear both;
animation-timeline: view(x); /* 가로 스크롤 */
animation-range: entry 0% cover 40%;
}
@keyframes scale-in {
from { transform: scale(0.8); opacity: 0.5; }
to { transform: scale(1); opacity: 1; }
}
/* 6. 스크롤 연동 헤더 변환 */
@keyframes header-shrink {
from {
padding: 24px 32px;
background: transparent;
box-shadow: none;
}
to {
padding: 12px 32px;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(8px);
}
}
header {
position: sticky;
top: 0;
z-index: 100;
animation: header-shrink linear forwards;
animation-timeline: scroll(root);
animation-range: 0px 200px; /* 처음 200px 스크롤 동안 */
}
/* 7. 텍스트 마스크 리빌 */
@keyframes text-reveal {
from { clip-path: inset(0 100% 0 0); }
to { clip-path: inset(0 0% 0 0); }
}
.animated-headline {
animation: text-reveal cubic-bezier(0.4, 0, 0.2, 1) both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
// JavaScript ScrollTimeline API (프로그래매틱 제어)
const target = document.querySelector('.animated-element');
const scroller = document.querySelector('.scroll-container');
// ScrollTimeline
const scrollTimeline = new ScrollTimeline({
source: scroller,
axis: 'block',
});
target.animate(
[
{ opacity: 0, transform: 'translateY(50px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{
duration: 1, // 타임라인 기반이므로 duration은 1
fill: 'both',
timeline: scrollTimeline,
}
);
// ViewTimeline
const viewTimeline = new ViewTimeline({
subject: target,
axis: 'block',
});
target.animate(
[{ opacity: 0 }, { opacity: 1 }],
{
fill: 'both',
timeline: viewTimeline,
rangeStart: 'entry 0%',
rangeEnd: 'entry 50%',
}
);
// 폴백 감지
if ('ScrollTimeline' in window) {
// CSS Scroll-Driven Animations 지원
} else {
// IntersectionObserver 폴백
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
entry.target.classList.toggle('visible', entry.isIntersecting);
});
},
{ threshold: [0, 0.5, 1] }
);
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
}
마무리
CSS Scroll-Driven Animations의 핵심은 animation-timeline: scroll()과 animation-timeline: view()다. scroll()은 스크롤 위치에 따라, view()는 요소의 뷰포트 가시 비율에 따라 애니메이션이 진행된다. animation-range로 전환 구간을 세밀하게 제어하고, CSS 자체가 컴포지터에서 실행되어 requestAnimationFrame보다 성능이 우수하다. Chrome 115 미만은 JavaScript ScrollTimeline API로 동일한 효과를 구현할 수 있다.