페이지 맨 위에 붙은 읽기 진행 막대, 스크롤을 내리면 하나씩 떠오르는 카드, 화면에 들어올 때 불러오는 이미지. 모두 "스크롤 위치에 맞춰 무언가를 바꾼다"는 같은 일이다.
이 일을 하는 방법은 크게 세 가지다. ① scroll 이벤트를 받아 JavaScript로 직접 계산하는 방법, ② IntersectionObserver에 "보이면 알려 달라"고 맡기는 방법, ③ CSS에 "스크롤 위치를 애니메이션의 시간으로 써라"라고 선언하는 방법이다. 셋은 0장에서 본 질문, 즉 매 프레임의 값을 브라우저가 계산하느냐, 메인 스레드의 JavaScript가 계산하느냐에서 갈린다.
① scroll 이벤트로 직접 계산하기
가장 오래된 방법이다. 스크롤될 때마다 위치를 읽어 막대 길이를 바꾼다.
const box = document.querySelector('.scroller')
const fill = document.querySelector('.fill')
box.addEventListener('scroll', () => {
const p = box.scrollTop / (box.scrollHeight - box.clientHeight) // 진행률 0~1
fill.style.transform = `scaleX(${p})`
})
scroll 이벤트 핸들러는 얼마나 자주 불릴까? HTML 명세에 따르면 브라우저는 화면을 새로 그리기 직전의 렌더링 갱신 과정에서 "스크롤 단계"를 돌며 scroll 이벤트를 보낸다. 그래서 scroll 핸들러는 스크롤 상자마다 한 프레임에 최대 한 번 불린다. 초당 60프레임을 그리는 화면이라면 스크롤하는 동안 초당 최대 60번이다. 이 단계는 requestAnimationFrame 콜백보다 먼저 돈다.
따라서 흔히 보는 "requestAnimationFrame으로 scroll 핸들러 묶기"는 효과가 없다. MDN도 scroll 이벤트 문서에서 애니메이션 프레임 콜백이 scroll 핸들러와 같은 빈도로 불리니 쓸모없다고 적는다. 정말 핸들러 안의 일을 덜 돌리고 싶다면 setTimeout으로 실행 사이에 간격을 직접 둬야 한다.
passive: true도 scroll 이벤트에는 효과가 없다. scroll은 취소할 수 없는 이벤트라서 핸들러가 스크롤을 막을 수 없기 때문이다. passive가 의미 있는 것은 wheel·touchstart·touchmove처럼 preventDefault()로 스크롤 자체를 취소할 수 있는 이벤트다. 이런 이벤트에 passive가 아닌 리스너가 걸려 있으면, 브라우저는 리스너가 끝날 때까지 스크롤을 시작하지 못한다. 그래서 명세는 window·document·<html>(documentElement)·<body>에 건 이런 리스너를 기본으로 passive로 본다. 문제가 되는 것은 다른 요소에 걸었거나 passive: false를 직접 준 리스너다. 다만 Safari는 wheel에는 이 기본값을 아직 적용하지 않으므로, 스크롤을 막지 않을 리스너라면 { passive: true }를 직접 주는 편이 안전하다(addEventListener 문서, 호환성 표).
진짜 비용은 핸들러 안에서 하는 일에서 나온다. 카드 수십 개가 화면에 들어왔는지 알려고 매번 getBoundingClientRect()를 부르고, 그 결과로 클래스를 바꾸고, 다시 다음 카드를 읽는다고 해 보자. 읽기와 쓰기가 섞이면 레이아웃 스래싱이 된다. 그리고 이 모든 일은 메인 스레드에서 돈다. 메인 스레드가 바쁘면 화면은 스크롤돼도 막대는 따라오지 못한다.
② IntersectionObserver에 맡기기
알고 싶은 것이 "이 요소가 보이기 시작했나"뿐이라면 매번 위치를 잴 필요가 없다. IntersectionObserver를 쓰면 요소가 루트(뷰포트나 스크롤 상자)와 얼마나 겹치는지 브라우저가 지켜보다가, 정해 둔 비율을 넘을 때만 콜백을 부른다.
const io = new IntersectionObserver(
// entries에는 경계를 넘은 카드만 들어 있다
entries => {
for (const e of entries) e.target.classList.toggle('shown', e.isIntersecting)
},
{ root: box, threshold: 0.5, rootMargin: '0px' },
)
cards.forEach(card => io.observe(card))
threshold: 콜백을 부를 겹침 비율. 기본값0은 경계에 닿기만 해도(보이는 픽셀이 아직 없어도),1은 전부 보여야 부른다.[0, 0.5, 1]처럼 여러 개를 줄 수도 있다.rootMargin: 루트 상자를 넓히거나 좁힌다.'200px 0px'이면 화면에 들어오기 200px 전에 미리 알려 준다. 이미지를 미리 불러올 때 쓴다(이미지 loading 속성 노트).
콜백은 여전히 메인 스레드에서 돈다(MDN). 달라지는 것은 언제 부르느냐다. 스크롤마다가 아니라 경계를 넘을 때만 부른다(처음 observe()할 때 현재 상태를 알려 주는 한 번은 예외다). 겹침 계산도 JavaScript가 스크롤마다 하지 않고, 브라우저가 렌더링을 갱신하는 단계에서 한꺼번에 한다. 대신 IntersectionObserver는 "정확히 몇 픽셀 겹쳤나"처럼 연속된 값은 주지 않는다. 진행 막대처럼 스크롤을 1픽셀 단위로 따라가야 하는 일에는 맞지 않는다.
③ CSS 스크롤 기반 애니메이션
보통 CSS 애니메이션의 진행률은 시간이 정한다. 2s짜리 애니메이션이면 1초 지났을 때 50%다. CSS 스크롤 기반 애니메이션(scroll-driven animations)은 그 시간 자리에 스크롤 위치를 넣는다. 이때 시간 자리에 들어가는 스크롤 진행을 스크롤 타임라인이라고 한다. 이 장의 표와 실험 라벨에서는 ③ 방법 전체를 이 이름으로 줄여 부른다.
.fill {
transform: scaleX(0);
transform-origin: left;
animation: grow linear both;
animation-timeline: scroll(nearest block); /* 가장 가까운 스크롤 상자의 세로(block) 진행률 */
}
@keyframes grow {
to { transform: scaleX(1); }
}
scroll(): 스크롤 상자의 진행률(맨 위 0% → 맨 아래 100%)을 타임라인으로 쓴다.nearest는 가장 가까운 조상 스크롤 상자,root는 문서 전체다.view(): 요소 하나가 스크롤 상자 안에 들어와서 나갈 때까지를 타임라인으로 쓴다. 카드 등장 효과에 알맞다.animation-range: 타임라인의 어느 구간에서 애니메이션을 돌릴지 정한다.animation-range: entry면 요소가 들어오는 구간에서만 돈다.animation단축 속성은animation-timeline을 기본값으로 되돌린다. 그래서animation-timeline은 단축 속성 뒤에 써야 한다.overflow: hidden인 요소도 스크롤바만 없을 뿐 스크롤 상자다(코드로는 스크롤할 수 있다). 막대 바깥 틀에overflow: hidden을 주면nearest가 바깥 스크롤 상자가 아니라 그 틀을 잡는다. 틀은 스크롤되지 않으니 막대는 영영 움직이지 않는다.
이 장의 실험을 만들 때 실제로 이 함정에 걸렸다. 넘치는 부분을 잘라 내기만 하려면 스크롤 상자를 만들지 않는 overflow: clip을 쓴다(MDN overflow).
이 방법에는 JavaScript가 한 줄도 없다. Chrome 문서는 이 애니메이션이 메인 스레드 밖에서 돌 수 있다고 설명한다. 단, 0장의 규칙이 그대로 적용된다. width가 아니라 transform처럼 합성만으로 처리할 수 있는 속성을 애니메이션해야 메인 스레드 밖에서 돈다. 그래서 위 예도 막대 너비 대신 scaleX를 바꾼다.
브라우저 지원은 아직 고르지 않다. MDN 호환성 데이터(2026-10-08 기준)에 따르면 animation-timeline은 Chrome·Edge 115와 Safari 26부터 쓸 수 있고, Firefox에서는 미리보기 판에서만 쓸 수 있다. 지원하지 않는 브라우저에서는 애니메이션이 아예 돌지 않으니, @supports (animation-timeline: scroll())나 JavaScript의 CSS.supports('animation-timeline: scroll()')로 확인하고 대안을 둔다.
메인 스레드를 막고 스크롤하면
세 방법을 한 스크롤 상자에 모았다. 0장처럼 메인 스레드를 막되, 이번에는 3초 동안 막고 그동안 상자를 스크롤해 본다. 스크롤을 막는 리스너(passive가 아닌 wheel·touchstart·touchmove 리스너)가 없으면 Chrome은 스크롤 자체를 컴포지터 스레드에서 처리할 수 있다(Chrome 블로그). 그래서 메인 스레드가 막혀도 상자는 움직인다. 문제는 그 스크롤을 따라가는 쪽이다.
다만 메인 스레드가 막혀도 상자가 늘 움직이지는 않는다. 이 실험을 만들며 확인해 보니, 2026년 10월의 Chrome에서는 스크롤 상자가 둥근 모서리로 잘리면 메인 스레드가 막힌 동안 그 상자의 스크롤이 시작되지 않았다. 상자 자체에 border-radius를 준 경우도, border-radius와 overflow: hidden을 함께 가진 조상 안에 상자가 든 경우도 그랬다. 바깥 페이지 스크롤과 각진 상자는 움직였고, Safari에서는 둥근 상자도 움직였다. 스크롤을 시작할 때 손가락 아래 어느 상자가 있는지 판정하는 일을, 모서리가 둥글면 메인 스레드에 넘기는 것으로 보인다. 그래서 아래 실험은 둥근 테두리를 잘라내기 없는 바깥 틀에만 그리고, 스크롤 상자와 그 조상에는 모서리 잘라내기를 두지 않았다.
메인 스레드가 막힌 3초 동안 상자를 스크롤한다. 그동안 스크롤을 따라 움직이는 것은?
숫자도 비교해 본다. 상자를 끝까지 내리면 scroll 핸들러는 수십에서 수백 번 불리지만, IntersectionObserver 콜백은 처음 등록할 때 한 번, 그 뒤로는 카드가 경계를 넘을 때만 불려서 훨씬 적다. ③은 JavaScript를 한 번도 부르지 않는다.
언제 무엇을 고를까
| 하고 싶은 일 | 고를 방법 | 이유 |
|---|---|---|
| 스크롤에 1픽셀 단위로 맞춰 시각 효과 주기(진행 막대, 시차 효과) | ③ 스크롤 타임라인 | JavaScript 없이, 조건이 맞으면 메인 스레드 밖에서 돈다 |
| 보이기 시작하면 한 번 무언가 하기(등장 효과, 이미지·데이터 불러오기, 노출 기록) | ② IntersectionObserver | 경계를 넘을 때만 불리고, 데이터 요청처럼 CSS로 못 하는 일도 할 수 있다 |
| 스크롤 위치로 로직 돌리기(헤더 숨기기 판단, 무한 스크롤 위치 저장) | ① scroll 이벤트 | 스크롤 위치를 매번 직접 읽어 원하는 로직에 쓸 수 있다. 핸들러 안에서 레이아웃을 읽고 쓰는 일은 최소로 줄인다 |
등장 효과는 ②와 ③ 모두로 만들 수 있다. 시각 효과만이라면 view() + animation-range가 더 간단하다. 지원하지 않는 브라우저를 고려해야 하거나 한 번 보인 카드는 다시 숨기지 않도록 상태를 기억해야 하면 ②가 낫다.
어느 방법이든 움직임에 민감한 사용자를 생각한다. 운영체제에서 동작 줄이기를 켠 사용자에게는 @media (prefers-reduced-motion: reduce)로 움직임을 끄거나 줄인다(prefers-reduced-motion 노트). 위 실험의 카드도 이 설정에서는 떠오르지 않고 진하기만 바뀐다.
“스크롤하면 카드가 보일 때 나타나는 효과를 성능 좋게 만들어 줘.”
의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.
1. scroll 핸들러를 requestAnimationFrame으로 감싸면 호출 횟수가 줄어든다.
2. 화면에 들어오기 200px 전에 이미지를 불러오고 싶다. IntersectionObserver의 어느 옵션을 쓸까?
3. CSS 스크롤 기반 애니메이션으로 진행 막대를 만들 때 메인 스레드 밖에서 돌 수 있는 쪽은?