들어가며: 스크롤을 “시간"으로 다루는 감각#
스크롤 기반 애니메이션을 만들다 보면 이런 순간이 있습니다.
- “스크롤 위치를 그냥 이벤트로 처리하지 말고”
- “이걸 타임라인처럼 다루면 훨씬 깔끔할 텐데?”
Scroll-Driven Animation은 바로 그 감각을 공식화한 접근입니다. 스크롤을 단순한 입력이 아니라 애니메이션의 시간 축(timeline)으로 바라보는 방식이죠.
이번 글에서는 구현 방식을 3가지로 나눠서 소개합니다.
- 네이티브 CSS Scroll-Driven Animation
- IntersectionObserver 기반 토글 방식
- scroll + requestAnimationFrame으로 진행률을 직접 매핑하는 방식
그리고 각 방식의 원리, 장단점, 접근성 이슈와 대응까지 함께 정리합니다. 글 안의 코드만 봐도 동작을 이해할 수 있게 작성하였고, 직접 만져보는 데모는 각 절 끝에 링크로 붙여두었습니다.

사진: Unsplash의 Gilles Lambert
핵심 원리: 스크롤 진행률을 0~1로 보는 시선#
세 가지 접근은 디테일이 다르지만, 핵심은 같습니다.
- 어떤 기준(페이지, 특정 컨테이너, 특정 요소의 가시 영역)에 대해
- 현재 스크롤이 어디까지 왔는지를
- 0에서 1 사이의 진행률(progress)로 해석하고
- 그 값을 애니메이션에 연결한다
차이는 “이 연결을 누가 하느냐"입니다.
- CSS가 하게 할 것인가
- 브라우저 관찰자(IntersectionObserver)에게 맡길 것인가
- 우리가 JS로 직접 매핑할 것인가

스크롤을 시간처럼 바라보는 핵심 원리
이제 하나씩 보겠습니다.
1) 네이티브 CSS Scroll-Driven Animation#
어떻게 동작하나요?#
평소 CSS 애니메이션은 시간을 따라 흐릅니다. animation-duration: 2s라고 쓰면 2초 동안 진행되죠. 여기서 그 시간 축을 스크롤 축으로 갈아 끼우는 게 전부입니다.
시간 축을 갈아 끼우는 속성이 animation-timeline입니다. 넣을 수 있는 값은 크게 두 갈래예요.
이름 없이 바로 쓰기 (익명 타임라인)
scroll()— 스크롤 컨테이너의 스크롤 진행률을 시간으로 씁니다. 괄호 안에 기준 스크롤러(nearest·root·self)와 축(block·inline·x·y)을 적을 수 있어요. 예:scroll(root block)view()— 그 요소가 화면에 들어오고 나가는 동안을 0에서 1로 씁니다. 등장 연출에 어울립니다.
이름 붙여 쓰기 (명명 타임라인)
- 기준이 될 요소에
scroll-timeline: --이름또는view-timeline: --이름을 선언해두고 - 애니메이션할 요소에서
animation-timeline: --이름으로 불러옵니다 - 이때 이름은 반드시
--로 시작해야 합니다(CSS의<dashed-ident>규칙).--없이page-scroll이라고 쓰면 값 자체가 무효라 조용히 무시됩니다
예전 초안에는 @scroll-timeline이라는 at-rule도 있었는데, 명세가 다시 쓰이면서 사라졌습니다. 지금 문법은 위처럼 전부 속성이에요. 오래된 블로그 글을 보고 @scroll-timeline을 그대로 옮겨 적으면 동작하지 않습니다.
가장 작은 예시: 진행 바(progress bar)#
아래 예시는 문서 스크롤에 맞춰 상단 바가 좌우로 채워지는 패턴입니다.
<div class="sda-progress" aria-hidden="true"></div>.sda-progress {
position: fixed;
inset: 0 0 auto 0;
height: 4px;
background: linear-gradient(90deg, #0ea5e9, #22c55e);
transform-origin: 0 50%;
transform: scaleX(0);
animation: sda-fill linear both;
/* 진행은 스크롤이 정하므로 길이 값 자체는 의미가 없습니다.
다만 0이면 안 되므로 관례적으로 1ms를 적습니다. */
animation-duration: 1ms;
animation-timeline: scroll(root block);
}
@keyframes sda-fill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}이 코드는 “페이지 스크롤 진행률"을 그대로 scaleX에 연결합니다. JS 없이도 스크롤 진행과 애니메이션 진행이 동기화됩니다.
같은 걸 명명 타임라인으로 쓰면 이렇게 됩니다. 스크롤 컨테이너가 문서 루트가 아니라 특정 박스일 때 이 방식이 필요해요.
.article-scroller {
overflow-y: auto;
scroll-timeline: --article-scroll block;
}
.sda-progress {
/* ...위와 동일... */
animation-timeline: --article-scroll;
}장점#
- 스크롤-애니메이션 동기화가 선언적으로 표현됩니다
- JS가 거의 필요 없습니다
- 애니메이션 의도가 코드에 잘 드러납니다
transform·opacity처럼 합성(compositing) 가능한 속성만 건드리면, 스크롤 핸들러 없이도 메인 스레드 밖에서 부드럽게 돌 여지가 생깁니다. 크롬 팀도 이 점을 네이티브 방식의 이점으로 설명합니다
단점 / 주의점#
- 브라우저 지원 범위를 반드시 확인해야 합니다
- 아직은 팀 내에서 낯선 문법일 수 있습니다
- 폴백(fallback) 설계가 필요할 수 있습니다
- 한 번 나타난 것을 그대로 붙잡아둘 수 없습니다
마지막 항목은 접근성과 직결되니 조금 더 풀어둘게요. 스크롤 진행률에 묶인 애니메이션은 위치에 대응합니다. 진행률 0.6에서 만들어진 상태는 다시 0.6으로 돌아오면 똑같이 재현되고, 0으로 올라가면 처음 상태로 되감깁니다.
animation-fill-mode: forwards로 붙잡을 수 있을 것 같지만 안 됩니다. fill-mode는 타임라인 구간 밖의 상태를 정할 뿐, 구간 안에서의 되감기를 막지는 못하거든요.
그래서 “한 번 읽은 내용은 다시 사라지지 않게 하고 싶다"면 — 되돌아 읽는 사람에게 중요한 배려입니다 — 네이티브 CSS만으로는 답이 안 나옵니다. 뒤에 나오는 IntersectionObserver나 rAF 쪽에서 한 번 켠 상태를 꺼주지 않는 방식으로 처리해야 합니다.
브라우저 지원 범위: “가능/불가능"보다 “전략"으로 보기#
네이티브 scroll-driven은 플래그 뒤에 숨어 있던 시기를 지났지만, 아직 모든 브라우저에서 쓸 수 있는 기능은 아닙니다. 2026년 8월 기준으로 정리하면 이렇습니다.
| 브라우저 | animation-timeline·scroll()·view() 지원 |
|---|---|
| Chrome · Edge | 115부터 지원 (2023년 7월) |
| Safari (macOS·iOS) | 26부터 지원 (2025년 9월) |
| Firefox | 정식 릴리스 미지원. Nightly에서만 기본 켜져 있고, 다른 채널에서는 about:config의 layout.css.scroll-driven-animations.enabled를 직접 켜야 합니다 |
여기서 Baseline이라는 말이 나오면 같이 봐두면 좋습니다. 웹 기능이 주요 브라우저에서 얼마나 두루 쓸 수 있는지를 세 등급(제한적 지원 / 새로 사용 가능 / 널리 사용 가능)으로 표시하는 지표예요. Scroll-Driven Animation은 파이어폭스가 빠져 있어서 아직 제한적 지원(Limited availability)입니다.
즉, “이제 다 되니까 그냥 쓰자"는 아직 이릅니다. 그래서 실무에서는 보통 이렇게 접근하는 편이 안전합니다.
animation-timeline/view-timeline을 1차 시도로 두고- 안 되는 브라우저에서는 “망가지지 않는 폴백"으로 흘려보내기
아래처럼 @supports로 가드를 걸어두면 폴백 경로를 분명히 할 수 있습니다.
.progress-fallback {
transform: scaleX(1);
}
@supports (animation-timeline: scroll()) {
.progress-fallback {
transform: scaleX(0);
animation: sda-fill linear both;
animation-duration: 1ms;
animation-timeline: scroll(root block);
}
}또 하나, 처음 쓰면 거의 한 번씩 걸리는 함정이 있습니다.
animation 단축 속성은 animation-timeline을 초기값 auto로 되돌립니다. 값을 안 적었으니 그대로 두는 게 아니라, 아예 리셋해버려요. 그래서 순서를 뒤집어 쓰면 타임라인이 통째로 날아갑니다.
/* 잘못된 순서 — 타임라인이 지워집니다 */
.bar {
animation-timeline: scroll(root block);
animation: sda-fill linear both;
}
/* 올바른 순서 — 단축 속성을 먼저, 타임라인을 나중에 */
.bar {
animation: sda-fill linear both;
animation-timeline: scroll(root block);
}그리고 animation-duration은 스크롤 타임라인에서 진행 속도를 결정하지 않지만, 값이 0이면 브라우저에 따라 애니메이션이 아예 시작되지 않습니다. 관례적으로 1ms를 적어두는 이유예요.
지원 범위는 한 곳만 보지 말고 caniuse·MDN·Web Platform Status를 같이 보는 편이 안전합니다. 미리보기 채널이 섞여 들어간 표를 초록색만 보고 넘기면 배포 뒤에 어긋나거든요.

지원 데이터: MDN · Web Platform Status (2026-08 확인)
직접 만져보기 — 네이티브 CSS 기본 · 방향 감지의 한계 · 접근성 반영 · 미지원 브라우저 대비
파이어폭스 정식 릴리스에서는 애니메이션이 돌지 않습니다. 그게 정상이고, 그때 무엇이 보이는지가 네 번째 링크입니다.
2) IntersectionObserver: “보이면” 바꾸는 방식#
어떻게 동작하나요?#
IntersectionObserver는 “이 요소가 뷰포트에 들어왔는지/얼마나 들어왔는지"를 브라우저가 효율적으로 알려주는 API입니다. 스크롤 이벤트를 매 프레임마다 직접 처리하지 않고, 상태 전환 지점을 기준으로 동작하게 만들 때 특히 강합니다.
예시: 등장 애니메이션(reveal on scroll)#
<section class="io-section">
<h2 class="io-reveal">스크롤에 따라 자연스럽게 등장</h2>
<p class="io-reveal">IntersectionObserver는 상태 전환을 다루기에 좋습니다.</p>
</section>.io-reveal {
opacity: 0;
transform: translateY(16px);
transition: opacity 500ms ease, transform 500ms ease;
will-change: opacity, transform;
}
.io-reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.io-reveal,
.io-reveal.is-visible {
transition: none;
transform: none;
opacity: 1;
}
}const revealEls = document.querySelectorAll(".io-reveal");
const observer = new IntersectionObserver(
(entries, obs) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add("is-visible");
obs.unobserve(entry.target);
}
},
{
threshold: 0.2,
}
);
for (const el of revealEls) observer.observe(el);장점#
- 성능 대비 효율이 매우 좋습니다
- “등장/퇴장/활성화” 같은 상태 전환에 강합니다
- 지원 범위가 넓고, 패턴이 안정적입니다
단점 / 주의점#
- 스크롤 진행률을 “연속값"으로 쓰는 데는 덜 적합합니다
- 결국 클래스 토글 + CSS 조합으로 귀결되기 쉽습니다
- 정교한 타임라인 연출에는 한계가 있습니다
직접 만져보기 — IntersectionObserver 기본 · 스크롤 방향 토글 · 접근성 반영
세 번째 링크는 한 번 나타난 요소를 다시 숨기지 않습니다. 되돌아 읽는 사람에게 글이 사라지지 않게요.
3) scroll + requestAnimationFrame: 진행률을 직접 매핑하기#
어떻게 동작하나요?#
가장 전통적이고, 가장 유연한 방식입니다.
여기서 쓰는 requestAnimationFrame(줄여서 rAF)은 “다음 화면을 그리기 직전에 이 함수를 한 번 실행해달라"고 브라우저에 예약하는 API입니다. 스크롤 이벤트는 한 프레임에 여러 번 들어올 수 있는데, 계산을 rAF로 미뤄두면 프레임당 한 번만 돌게 묶을 수 있어요.
- 스크롤 위치를 읽고
- 특정 구간의 진행률을 계산한 뒤
- 그 진행률을 transform/opacity 같은 스타일에 직접 연결합니다
이 방식은 정확히 원하는 연출을 만들 수 있는 대신, 우리가 성능과 접근성까지 책임져야 합니다.
예시: 구간 진행률로 카드 스케일 조절하기#
<section class="raf-stage">
<div class="raf-card" data-raf-card>Scroll drives me</div>
</section>.raf-stage {
min-height: 160vh;
display: grid;
place-items: center;
padding: 24vh 0;
}
.raf-card {
width: min(680px, 92vw);
padding: 48px;
border-radius: 20px;
background: #0f172a;
color: #e5e7eb;
font-weight: 700;
font-size: clamp(28px, 4vw, 44px);
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.35);
transform: scale(0.92);
transform-origin: 50% 50%;
will-change: transform, opacity;
}
@media (prefers-reduced-motion: reduce) {
.raf-card {
transform: none !important;
opacity: 1 !important;
}
}const card = document.querySelector("[data-raf-card]");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (card && !reduceMotion) {
let ticking = false;
const clamp01 = (v) => Math.min(1, Math.max(0, v));
const update = () => {
ticking = false;
const rect = card.getBoundingClientRect();
const viewportH = window.innerHeight;
// 카드가 화면 아래에서 들어와 중앙을 지나 위로 나갈 때를 0~1로 정규화
const start = viewportH;
const end = -rect.height;
const progress = clamp01((start - rect.top) / (start - end));
const scale = 0.92 + progress * 0.08;
const opacity = 0.6 + progress * 0.4;
card.style.transform = `scale(${scale.toFixed(4)})`;
card.style.opacity = opacity.toFixed(4);
};
const onScroll = () => {
if (ticking) return;
ticking = true;
window.requestAnimationFrame(update);
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
update();
}장점#
- 연출 자유도가 가장 높습니다
- 어떤 수학적 매핑도 가능합니다
- 기존 코드베이스와 쉽게 섞을 수 있습니다
단점 / 주의점#
- 성능/버벅임/과도한 리플로우를 직접 관리해야 합니다
- 접근성 대응을 놓치기 쉽습니다
- 유지보수 난이도가 가장 높아지기 쉽습니다
직접 만져보기 — scroll + rAF 기본 · 방향 토글 · 접근성 반영
화면에 진행률이 퍼센트로 표시되니, 스크롤하면서 숫자가 어떻게 움직이는지 보세요. 앞의 두 방식에는 없는 값입니다.
React에서의 구현: rAF 매핑을 “React스럽게” 감싸기#
React에서는 보통 이렇게 접근합니다.
- 애니메이션 대상은
ref로 잡고 - 스크롤 핸들러는
useEffect안에서 등록하고 prefers-reduced-motion이면 아예 로직을 건너뜁니다
아래 예시는 “한 구간의 진행률을 직접 매핑"하는 rAF 패턴을 React 훅 형태로 옮긴 것입니다.
import { useEffect, useRef } from "react";
function clamp01(v: number) {
return Math.min(1, Math.max(0, v));
}
export default function ScrollMappedCard() {
const cardRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const card = cardRef.current;
if (!card) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
// 모션을 끄는 대신, 최종 상태로 바로 보여줍니다.
card.style.transform = "none";
card.style.opacity = "1";
return;
}
let rafId = 0;
const update = () => {
rafId = 0;
const rect = card.getBoundingClientRect();
const viewportH = window.innerHeight;
const start = viewportH;
const end = -rect.height;
const progress = clamp01((start - rect.top) / (start - end));
const scale = 0.92 + progress * 0.08;
const opacity = 0.6 + progress * 0.4;
card.style.transform = `scale(${scale.toFixed(4)})`;
card.style.opacity = opacity.toFixed(4);
};
const onScroll = () => {
if (rafId) return;
rafId = window.requestAnimationFrame(update);
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
update();
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
// 언마운트 직후 예약된 프레임이 남아 있으면 취소합니다.
if (rafId) window.cancelAnimationFrame(rafId);
};
}, []);
return (
<section style={{ minHeight: "160vh", display: "grid", placeItems: "center" }}>
<div
ref={cardRef}
style={{
width: "min(680px, 92vw)",
padding: "48px",
borderRadius: "20px",
background: "#0f172a",
color: "#e5e7eb",
fontWeight: 700,
fontSize: "clamp(28px, 4vw, 44px)",
transform: "scale(0.92)",
transformOrigin: "50% 50%",
willChange: "transform, opacity",
}}
>
Scroll drives me
</div>
</section>
);
}이 방식의 장점은 명확합니다.
- “언제 바인딩되고 언제 정리되는지"가 코드로 분명해집니다
- 컴포넌트 단위로 스크롤 연출을 격리하기 쉽습니다
단점도 분명합니다.
- 결국 DOM을 직접 만지는 로직이 들어갑니다
- 여러 개가 중첩되면 성능 관리가 더 중요해집니다
React에서도 결론은 같습니다. “원리 + 비용 + 접근성"을 한 세트로 같이 들고 가는 편이 훨씬 안전합니다.
직접 만져보기 React판 데모는 아직 없습니다. 매핑 로직은 같으니 순수 JS 데모로 동작을 확인하시고, 위 훅 코드를 그대로 옮기시면 됩니다.
어떤 방식을 선택하면 좋을까?#

어떤 방식이 더 낫다기보다, 어떤 목적에 맞는지가 더 중요합니다
저는 보통 이렇게 고릅니다.
- “스크롤이 곧 타임라인"이라는 성격이 강하다 -> 네이티브 CSS 우선 검토
- “등장/활성화/트리거"가 핵심이다 -> IntersectionObserver
- “구간별 정교한 연출/수학적 매핑"이 필요하다 -> rAF 직접 매핑
그리고 실제 제품에서는 이 셋을 섞는 경우가 가장 많습니다. 예를 들어:
- 전체 진행 바는 CSS Scroll-Driven Animation
- 섹션 등장 연출은 IntersectionObserver
- 아주 특별한 히어로 연출만 rAF 커스텀
접근성 이슈: 멋있음보다 먼저 체크할 것들#
여기까지가 “어떻게 만드느냐"의 이야기였습니다. 그런데 하나 빠뜨릴 수 없는 게 있습니다.
그런데, 접근성을 곁들인..

네, 접근성이요.
스크롤 기반 애니메이션은 잘못 만들면 “보기 좋은 방해물"이 됩니다. 특히 아래 항목은 실무에서 자주 문제가 됩니다.

멋있는 연출보다 먼저 지켜야 할 안전장치들
1) 멀미/피로: prefers-reduced-motion은 기본값처럼 다루기#
prefers-reduced-motion은 사용자가 운영체제 설정에서 “동작 줄이기"를 켰는지 브라우저가 알려주는 미디어 쿼리입니다. 전정기관 장애가 있는 분들에게 큰 움직임은 실제로 멀미와 어지럼을 일으키기 때문에, 이건 취향 설정이 아니라 신체적 요구에 가깝습니다.
- CSS에서는
@media (prefers-reduced-motion: reduce)로 애니메이션/트랜지션을 끕니다 - JS에서는
matchMedia("(prefers-reduced-motion: reduce)")로 애니메이션 로직 자체를 우회합니다
위 예시 코드들은 이 대응을 이미 포함하고 있습니다.
기준으로 붙잡을 만한 조항도 있습니다. WCAG 2.2의 2.3.3 상호작용으로부터의 애니메이션(Animation from Interactions, 레벨 AAA)은 “상호작용으로 촉발된 모션 애니메이션은, 그것이 기능이나 정보 전달에 필수적이지 않은 한 비활성화할 수 있어야 한다"고 말합니다. 스크롤도 상호작용이니 이 조항의 사정권이에요. 다만 스크롤로 콘텐츠가 화면에 들어오는 움직임 자체는 스크롤의 본질이라 예외로 봅니다. 그 위에 얹은 패럴랙스나 확대·축소 연출이 문제가 되는 거죠.
레벨 AAA라 법정 의무 수준(보통 AA)은 아니지만, 스크롤 애니메이션을 다루는 이상 이 조항이 가리키는 위험은 그대로 우리 것입니다.
한 가지 덧붙이면, 아래 코드들처럼 matchMedia(...).matches를 한 번만 읽는 방식은 페이지를 켜둔 채 설정을 바꾼 사용자를 놓칩니다. 엄밀하게 가려면 mql.addEventListener("change", ...)로 변화도 받아야 해요. 여기서는 흐름을 보기 위해 한 번 읽는 형태로 두었습니다.
개선 전/후 코드: reduced motion을 실제로 반영하기#
아래처럼 “보기엔 잘 동작하지만” 접근성 대응이 빠진 코드가 흔합니다.
개선 전:
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add("is-visible");
}
});
document.querySelectorAll(".io-reveal").forEach((el) => observer.observe(el));개선 후:
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const targets = document.querySelectorAll(".io-reveal");
if (reduceMotion) {
// 모션을 줄이는 대신, 즉시 읽을 수 있는 상태로 둡니다.
targets.forEach((el) => el.classList.add("is-visible"));
} else {
const observer = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add("is-visible");
obs.unobserve(entry.target);
}
}, { threshold: 0.2 });
targets.forEach((el) => observer.observe(el));
}rAF 매핑도 같은 방식으로 나눌 수 있습니다.
개선 전:
const card = document.querySelector("[data-raf-card]");
window.addEventListener("scroll", () => {
const rect = card.getBoundingClientRect();
const progress = (window.innerHeight - rect.top) / (window.innerHeight + rect.height);
card.style.transform = `scale(${0.92 + progress * 0.08})`;
});개선 후:
const card = document.querySelector("[data-raf-card]");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (card && !reduceMotion) {
let ticking = false;
const clamp01 = (v) => Math.min(1, Math.max(0, v));
const update = () => {
ticking = false;
const rect = card.getBoundingClientRect();
const start = window.innerHeight;
const end = -rect.height;
const progress = clamp01((start - rect.top) / (start - end));
const scale = 0.92 + progress * 0.08;
card.style.transform = `scale(${scale.toFixed(4)})`;
};
const onScroll = () => {
if (ticking) return;
ticking = true;
window.requestAnimationFrame(update);
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
update();
}2) “읽는 흐름"을 깨지 않기#
스크롤 애니메이션은 종종 콘텐츠의 의미 순서를 망가뜨립니다.
- 시각적으로는 나중에 등장하지만
- DOM 순서상으로는 먼저 읽히는 경우
- 혹은 그 반대
가능하면 의미 순서(문서 구조)와 시각적 순서가 크게 어긋나지 않게 설계하세요. 애니메이션은 의미를 보조해야지, 의미를 뒤틀면 안 됩니다.
3) 포커스와 키보드 사용성#
특히 아래는 꼭 피하고 싶습니다.
- 스크롤에 따라
display: none으로 사라지는 인터랙티브 요소 - 포커스가 이동했는데 시각적으로는 멀리 사라져버리는 구성
권장 패턴:
- 숨김이 필요하면
visibility/opacity중심으로 다루고 - 포커스 가능한 요소는 스크롤 연출의 희생양으로 만들지 않기
4) 성능도 접근성이다#
버벅임은 모두에게 불편하지만, 특히 보조 기술 사용자에게 더 큰 장벽이 됩니다.
- transform/opacity 위주로 애니메이션 구성
- 스크롤 핸들러는 passive + rAF 조합
- 레이아웃 측정(getBoundingClientRect)은 필요한 만큼만
위 예시들이 쓴
will-change는 “이 속성이 곧 변할 거야"라고 브라우저에 미리 알려 별도 레이어를 준비시키는 힌트입니다. 편하지만 요소 수백 개에 상시로 걸어두면 메모리만 잡아먹어요. 연출이 끝나면 걷어내거나, 애초에 애니메이션 직전에만 붙이는 편이 낫습니다.
데모와 외부 공유: 글 밖에서도 재현 가능하게#
스크롤 애니메이션은 글로 읽는 것과 직접 굴려보는 것의 차이가 유난히 큽니다. 그래서 각 절 끝에 데모를 붙여뒀고, 전부 한곳에 모아뒀습니다.
방식마다 같은 결과를 세 가지로 만들어두었으니 나란히 비교해보세요.
- 기본 — 스크롤에 따라 요소가 반응하는 가장 단순한 형태
- 방향/한계 — 그 방식이 못 하는 일을 일부러 드러낸 것 (네이티브 CSS는 스크롤 방향을 알지 못합니다)
- 접근성 반영 —
prefers-reduced-motion을 존중하고, 한 번 나타난 내용을 다시 숨기지 않는 형태 (다만 네이티브 CSS는 이 고정이 안 됩니다. 스크롤 진행률에 묶인 애니메이션은 올라가면 반드시 되감기거든요. IntersectionObserver·rAF 데모에서만 진짜로 고정됩니다)
여기에 네이티브 CSS만 하나 더 있습니다 — 미지원 브라우저에서 무엇이 보이는지. 파이어폭스 정식 릴리스로 열면 애니메이션 없이 정적으로 보일 텐데, 그게 고장이 아니라 설계라는 걸 확인하는 용도입니다.
한 장 요약#
- 세 방식 모두 하는 일은 같습니다. 스크롤을 0에서 1 사이 진행률로 읽어 애니메이션에 연결하는 것 — 차이는 그 연결을 누가 하느냐입니다.
- 네이티브 CSS(
animation-timeline: scroll()·view())는 JS가 거의 필요 없지만, 파이어폭스 정식 릴리스가 빠져 Baseline은 아직 제한적 지원입니다.@supports로 감싸고 안 될 때 보일 모습을 정해두세요. - IntersectionObserver는 등장·활성화 같은 상태 전환에 강하고 지원 범위가 넓습니다. 대신 진행률을 연속값으로 쓰기에는 맞지 않아요.
- scroll + rAF는 무엇이든 매핑할 수 있는 대신 성능과 접근성을 우리가 책임집니다.
passive리스너와 rAF로 한 프레임에 한 번만 계산하는 게 기본입니다. - 무엇을 쓰든
prefers-reduced-motion은 기본 대응으로 다룹니다(WCAG 2.3.3이 가리키는 위험). 다만 한 번 나타난 내용을 고정하는 건 JS 쪽에서만 됩니다 — 스크롤 진행률에 묶인 CSS 애니메이션은 되돌아가면 반드시 되감깁니다.
마무리: 스크롤은 입력이 아니라 시간일 수도 있습니다#
스크롤은 단지 이벤트가 아니라, 애니메이션을 이해하는 또 하나의 시간 축이 될 수 있습니다.
중요한 건 “어떤 기술이 더 멋진가"보다 “어떤 원리로 연결하고, 어떤 비용을 치르는가"를 함께 보는 일이라고 생각해요.
원리, 장단점, 접근성까지 한 세트로 들고 가면 Scroll-Driven Animation은 훨씬 믿을 만한 도구가 됩니다.
