GODRICH

스크롤 스토리텔링 섹션 9종 — 복붙하는 CSS 장면 구성

스크롤 스토리텔링은 스크롤 위치를 시간축 삼아 한 구간 안의 장면을 차례로 바꾸는 구성입니다. 여기 아홉 개는 효과가 아니라 장면을 짜는 방식이 서로 다르고, 전부 라이브러리 없이 CSS만으로 돕니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

순서는 인기순이 아니라 한 편의 이야기가 지나가는 순서입니다. 먼저 무대를 세웁니다 — 그림 하나를 붙여 두고 글이 지나가게 하거나(01), 배경을 통째로 붙여 두고 글 카드만 통과시킵니다(02). 무대가 서면 그 안에서 시간을 돌립니다. 한 장면을 여덟 컷으로 잘라 스크롤 양만큼 감고(03), 두 장면을 겹쳐 두고 경계를 밀어 바꿉니다(04). 그다음이 숫자와 물건입니다. 막대가 자라며 수치를 보여주고(05), 부품이 흩어졌다 모이며 속을 보여줍니다(06). 마지막 셋은 독자를 데리고 나갑니다 — 어디를 거쳤는지 지도로 보여주고(07), 장을 넘겨 다음 이야기로 이어 가고(08), 다 읽은 자리에서만 다음 행동을 권합니다(09). 아홉 개 전부 자동 재생 미리보기를 쓰지만, 손을 대는 순간 그 루프가 떨어지고 진짜 스크롤이 운전대를 잡습니다.

01고정 그림 · 스텝 텍스트

화면을 둘로 갈라 왼쪽 그림만 제자리에 붙여 두고, 오른쪽 문단이 올라오는 동안 그림이 한 프레임에 딱 끊어져 다음 장으로 바뀝니다. 두 장이 겹쳐 비치는 중간 프레임이 없어야 "설명이 바뀌었다"가 분명하게 읽힙니다. 문단이 화면 한가운데를 지나는 지점이 교체 시점이라, 문단 높이(96px)와 화면 높이(176px)에서 계산한 28%·76%가 키프레임 자리입니다.

position: stickysteps(1, end)grid-template-columns
.sf__figure {
  position: sticky;
  top: 0;
  align-self: start;
  display: grid;
  height: 104px;
}
.sf__panel {
  grid-area: 1 / 1;
  animation-name: sf-p1;
  animation-duration: 1s;
  animation-timing-function: steps(1, end);
  animation-fill-mode: both;
  animation-timeline: scroll(nearest block);
}
.sf__panel--2 { animation-name: sf-p2; }
@keyframes sf-p1 { 0% { opacity: 1 } 28% { opacity: 0 } 100% { opacity: 0 } }
@keyframes sf-p2 { 0% { opacity: 0 } 28% { opacity: 1 } 76% { opacity: 0 } 100% { opacity: 0 } }

02고정 배경 위 캡션 통과

배경 장면은 한 발짝도 움직이지 않고 반투명 카드만 아래에서 올라와 위로 빠져나갑니다. 가려지는 만큼 배경이 어두워지도록 덮개 한 장의 불투명도를 스크롤 진행에 직접 물렸습니다. 변한 픽셀이 화면의 43.372%로 아홉 개 중 가장 큰데, 이는 카드가 배경 전면을 쓸고 지나가기 때문입니다.

position: stickybackdrop-filtertranslateY
.cb__scroller {
  height: var(--h);
  overflow-y: auto;
  scroll-timeline-name: --cb-scroll;
  scroll-timeline-axis: block;
}
.cb__bg {
  position: sticky;
  top: 0;
  height: var(--h);
  overflow: hidden;
}
.cb__dim {
  position: absolute;
  inset: 0;
  animation-name: cb-dim;
  animation-fill-mode: both;
  animation-timeline: --cb-scroll;
}
@keyframes cb-dim { from { opacity: 0 } to { opacity: .46 } }

03프레임 시퀀스 스크럽

여덟 컷을 한 줄로 이어 붙인 띠를 스크롤 양만큼 옆으로 밀면, 동영상을 스크럽하는 것과 같은 화면이 됩니다. steps(8, jump-none) 은 0과 1을 모두 포함해 여덟 컷이 정확히 한 번씩 서므로 중간 위치가 생기지 않습니다. 컷 자체는 애니메이션이 아니라 생김새라, 컷 번호에 뚜껑 각도를 곱해 정적으로 그려 둡니다.

scroll-timeline-namesteps(8, jump-none)translateX
.sp__window {
  width: var(--frame);
  height: 104px;
  overflow: hidden;
}
.sp__strip {
  display: flex;
  width: max-content;
  animation-name: sp-scrub;
  animation-timing-function: steps(8, jump-none);
  animation-fill-mode: both;
  animation-timeline: --sp-scroll;
}
@keyframes sp-scrub {
  from { transform: translateX(0); }
  to   { transform: translateX(-87.5%); }
}
.sp__lid { transform: rotate(calc(var(--i) * -14deg)); }

04전후 비교 와이프

같은 자리에 두 장면을 겹쳐 두고 위층만 오른쪽부터 잘라 내면 아래층이 그만큼 드러납니다. 자르는 일은 clip-path: inset() 의 오른쪽 값 하나가 전부이고, 손잡이 선은 같은 타임라인에 물려 잘린 경계를 따라갑니다. 미리보기 곡선만 가운데가 가장 빠르도록 잡아 대표 이미지가 반쯤 걷힌 장면으로 뽑히게 했습니다.

clip-path: insetview-timeline-nametranslateX
.bw__frame {
  view-timeline-name: --bw-view;
  view-timeline-axis: block;
  position: relative;
  overflow: hidden;
}
.bw__layer--before {
  clip-path: inset(0 0 0 0);
  animation-name: bw-cut;
  animation-fill-mode: both;
  animation-timeline: --bw-view;
}
@keyframes bw-cut {
  from { clip-path: inset(0 0 0 0); }
  to   { clip-path: inset(0 100% 0 0); }
}
@keyframes bw-handle {
  from { transform: translateX(calc(var(--fw) - 4px)); }
  to   { transform: translateX(0); }
}

05진입하면 자라는 막대

막대 다섯 개가 바닥에 눌려 있다가 구간이 화면에 들어온 만큼 펴집니다. 높이를 직접 바꾸지 않고 바닥을 축으로 눌러 두었다 펴기 때문에 레이아웃 계산이 다시 돌지 않습니다. 시차는 지연이 아니라 막대마다 animation-range 를 조금씩 어긋나게 줘서 만들었습니다 — 스크롤 타임라인에는 시간이 없으니 지연이 아무 뜻도 갖지 못합니다.

animation-timeline: view()scaleYtransform-origin
.db__chart {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  align-items: end;
  height: 96px;
}
.db__bar {
  transform: scaleY(.04);
  transform-origin: bottom;
  animation-name: db-grow-in;
  animation-fill-mode: both;
  animation-timeline: view(block);
}
.db__col:nth-child(1) .db__bar { height: 34px; animation-range: entry 8%  cover 38%; }
.db__col:nth-child(5) .db__bar { height: 80px; animation-range: entry 32% cover 62%; }
@keyframes db-grow-in {
  from { transform: scaleY(.04); }
  to   { transform: scaleY(1); }
}

06부품 분해 조립

네 겹으로 포개진 판이 사방으로 벌어져 속을 보여주다가 다시 한자리로 모입니다. 판마다 목적지가 달라 키프레임을 넷으로 나눴고, 이름표는 판이 충분히 벌어진 뒤에야 나타나 글자가 겹치지 않습니다. 애니메이션 여덟 개가 한 타임라인에 물려 있어 어느 지점에서 멈춰도 한 장의 분해도가 됩니다.

animation-timeline: scroll()translaterotate
.ex__part {
  position: absolute;
  top: 48px;
  animation-duration: 1s;
  animation-fill-mode: both;
  animation-timeline: scroll(nearest block);
}
.ex__part--1 { animation-name: ex-p1; }
.ex__part--4 { animation-name: ex-p4; }
@keyframes ex-p1 {
  from { transform: translate(0, -6px) rotate(0deg); }
  to   { transform: translate(-16px, -54px) rotate(-7deg); }
}
@keyframes ex-p4 {
  from { transform: translate(0, 6px) rotate(0deg); }
  to   { transform: translate(16px, 54px) rotate(7deg); }
}
@keyframes ex-tag { 0%, 10% { opacity: 0 } 38%, 100% { opacity: 1 } }

07경로 지도 따라가기

흐린 점선 위로 진한 선이 그어지고 같은 곡선을 표식이 따라 달립니다. 선 길이를 몰라도 되도록 pathLength="100" 을 박아 두었고, 덕분에 stroke-dashoffset 은 100에서 0으로 쓰기만 하면 됩니다. 표식은 같은 곡선을 offset-path 로 받아 달리므로 선과 표식이 어긋날 수가 없습니다.

stroke-dashoffsetoffset-pathoffset-distance
.rm__line {
  stroke-dasharray: 100;
  stroke-dashoffset: 100;
  animation-name: rm-draw;
  animation-fill-mode: both;
  animation-timeline: scroll(nearest block);
}
@keyframes rm-draw {
  from { stroke-dashoffset: 100; }
  to   { stroke-dashoffset: 0; }
}
.rm__mark {
  offset-path: path("M20 96C62 96 58 34 100 34C142 34 138 92 180 92C206 92 214 60 228 36");
  offset-distance: 0%;
  offset-rotate: 0deg;
  animation-name: rm-run;
  animation-timeline: scroll(nearest block);
}
@keyframes rm-run { from { offset-distance: 0% } to { offset-distance: 100% } }

08챕터 페이지 넘김

왼쪽 모서리를 축으로 종이 한 장이 넘어가 다음 장이 드러납니다. 회전각을 스크롤 진행에 등속으로 물려야 스크롤 양과 넘어간 정도가 1:1로 맞고, 뒷면을 가려 둬야 앞면 글자가 비쳐 겹치지 않습니다. 변한 픽셀의 한 장 최대치가 21.014%로 아홉 개 중 가장 큰데, 이는 종이 한 장이 반대편 반쪽을 통째로 덮기 때문입니다.

rotateYbackface-visibilitysteps(1, end)
.pt__page {
  position: absolute;
  right: 0;
  width: 120px;
  transform-origin: left center;
  transform-style: preserve-3d;
  animation-name: pt-turn;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: scroll(nearest block);
}
@keyframes pt-turn {
  from { transform: rotateY(0deg); }
  to   { transform: rotateY(-180deg); }
}
.pt__face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
}
.pt__face--back { transform: rotateY(180deg); }

09끝에서 올라오는 CTA

본문을 읽는 동안에는 자리조차 없다가, 마지막 구간이 화면에 들어오는 만큼 아래쪽 막대가 올라옵니다. 구간 자신이 이름 붙은 뷰 타임라인을 내놓고 그 안에 붙은 막대가 그 진행률을 쓰므로, 화면에 보이는지 검사하는 코드가 한 줄도 없습니다. 안쪽 버튼은 animation-range 를 24%부터 잡아 한 박자 늦게 커집니다.

view-timeline-nameposition: stickytranslateY
.ec__end {
  display: flex;
  align-items: flex-end;
  height: var(--h);
  view-timeline-name: --ec-end;
  view-timeline-axis: block;
}
.ec__bar {
  position: sticky;
  bottom: $sp-2;
  transform: translateY(140%);
  animation-name: ec-rise;
  animation-fill-mode: both;
  animation-timeline: --ec-end;
  animation-range: entry 0% entry 80%;
}
@keyframes ec-rise {
  from { transform: translateY(140%); }
  to   { transform: translateY(0); }
}

어디서 깨지는가 — 함정 하나

이 편의 함정은 타임라인을 붙였다고 생각한 곳에 안 붙는 것이었습니다. scroll(nearest) 가 고르는 "가장 가까운 스크롤 상자"에는 overflow: hidden 인 상자도 포함됩니다. 03번 띠는 컷을 잘라 보여주는 창(overflow: hidden) 안에 있었고, 02번 덮개는 배경을 가두는 상자(같은 속성) 안에 있었습니다. 두 상자 다 스크롤이 0에서 0까지라, 선언은 멀쩡한데 진행률이 영원히 0이었습니다. 컴파일도 되고 콘솔도 조용해서 "왜 아무 일도 안 일어나지"만 남습니다. view() 도 같은 규칙을 쓰기 때문에 04번 위층이 자기를 감싼 상자를 스크롤포트로 잡았습니다. 셋 다 scroll-timeline-nameview-timeline-name 으로 이름을 붙여 고쳤습니다 — 이름 붙인 타임라인은 그 요소의 후손이면 어디서든 집어 쓸 수 있습니다.

붙는 자리 문제는 position: sticky 에서도 한 번 더 나왔습니다. 03·06·07·08번은 창을 붙여 두고 그 아래를 비워 스크롤 여유를 만들었는데, 처음에 그 여유를 부모의 padding-bottom 으로 줬습니다. 붙어 있을 수 있는 범위는 부모의 콘텐츠 박스라서 패딩으로 늘린 자리는 계산에 안 들어갑니다. 창이 붙자마자 위로 흘러나갔고, 실제로 스크롤해 보기 전에는 미리보기 루프가 멀쩡해서 눈치채지 못했습니다. 여유를 height 로 바꾸자 네 개가 한꺼번에 고쳐졌습니다.

미리보기와 실제 스크롤이 서로 다른 코드를 타는 것도 여기서 나온 함정입니다. 05번 막대는 루프용 키프레임이 다 자란 뒤 도로 눕히도록 돼 있었는데, 그 키프레임을 그대로 스크롤에 물리자 다 읽고 더 내리면 차트가 납작해졌습니다. animation-fill-modebackwards 로 둔 것도 같이 물렸습니다 — 구간이 끝난 뒤를 안 채우므로 막대가 작성 상태로 돌아갔습니다. 스크롤판은 db-grow-in 이라는 한 방향 키프레임에 both 로, 루프판은 원래 키프레임으로 갈라 놓았습니다. 09번은 그 반대였습니다. 막대가 position: sticky 로 붙는데 미리보기에서는 아무도 스크롤을 안 하니 sticky 가 걸릴 일이 없어, 24장 중 한 장에도 안 나왔습니다. 미리보기 동안만 절대 배치로 세워 뒀습니다. 아홉 개 전부 .is-demo 를 뗀 상태로 0%·50%·100% 를 따로 찍어 본 뒤에야 이 세 가지가 드러났습니다.

브라우저 지원은 함정이 아니라 조건입니다. 스크롤 타임라인은 2026년 9월 기준 Chrome·Edge 115 이상, Safari 26 이상, Firefox 158 이상에서 돌고(caniuse 집계 전 세계 87.22%), 그 아래 판에서는 선언이 통째로 무시됩니다. 무시되면 요소는 작성 상태 그대로 남으므로, 작성 상태를 "안 보이는 상태"로 두면 옛 브라우저에서 내용이 사라집니다. 자바스크립트 폴백은 넣지 않았습니다. 대신 아홉 개 모두 스크롤 타임라인이 없어도 읽히도록 짰습니다 — 01·02번은 position: sticky 만으로 배치가 서고, 05번 막대는 높이가 원래 값이고 눌린 상태만 못 펴집니다.

접근성

prefers-reduced-motion: reduce 에서는 아홉 개가 연출은 끄고 결과는 남깁니다. 무엇을 남기는지가 항목마다 다릅니다.

항목 끄는 것 남기는 것
01 고정 그림 그림 교체 · 문단 스크럽 첫 장 그림과 세 문단 전부
02 고정 배경 배경 어두워짐 배경 장면과 캡션 세 장
03 컷 스크럽 띠 이동 여섯 번째 컷 한 장
04 전후 와이프 경계 이동 반씩 나뉜 전·후 두 장면
05 자라는 막대 자라는 동작 다 자란 막대 다섯
06 부품 분해 벌어짐 · 이름표 등장 벌어진 분해도와 이름표
07 경로 지도 선 그리기 · 표식 이동 다 그어진 경로와 켜진 지점
08 페이지 넘김 회전 첫 장과 진행 막대 절반
09 끝 CTA 올라옴 · 버튼 커짐 올라와 있는 막대와 버튼

표의 "남기는 것"이 곧 정적 상태이고, 그 상태가 화면에서 말이 되는지를 아홉 개 모두 따로 찍어 확인했습니다. 05번이 처음에 scaleY(.04) 로 눌린 채 남아 빈 차트가 됐던 것도 이 확인에서 잡혔습니다. 세 언어 판과 React 판까지 같은 값을 쓰는 아홉 벌 소스는 압축 비밀번호 rgaqtmah 만 넣으면 열리는 zip 안에 있습니다.

읽는 순서도 그대로입니다. 아홉 개 어디에도 내용을 가로채는 스크롤 하이재킹이 없고, 모든 글자가 문서 순서대로 남아 있어 화면 낭독기는 애니메이션과 무관하게 위에서 아래로 읽습니다. 장식용 도형과 아이콘에는 aria-hidden 을 걸어 낭독에서 뺐습니다. 속성별 지원 범위와 값의 뜻은 MDN 의 animation-timeline 문서에 정리돼 있습니다.

스크롤 계열을 더 찾는다면 홈페이지 스크롤 효과 9가지스크롤 읽기 진행 표시 9종이 이웃입니다.

FAQ

스크롤 타임라인이 안 먹는 브라우저에서는 어떻게 보이나요?

선언이 무시되고 요소가 작성 상태 그대로 그려집니다. 그래서 작성 상태를 "완성된 모습"에 가깝게 두는 것이 폴백입니다. 05번 막대는 높이를 원래 값으로 적고 transform 으로만 눌러 두었기 때문에, 눌림이 안 풀려도 칸과 라벨은 제자리에 있습니다. 반대로 opacity: 0 이나 display: none 으로 숨겨 두면 지원 없는 브라우저에서 내용이 통째로 사라지므로 그렇게 짜지 않았습니다.

animation-timeline: scroll()view() 는 뭐가 다른가요?

scroll() 은 스크롤 상자가 위에서 아래까지 얼마나 굴렀는지를 0에서 1로 봅니다. 구간 전체를 하나의 진행률로 쓰고 싶을 때, 그러니까 01·02·03·06·07·08번처럼 창을 붙여 두고 그 안을 굴릴 때 씁니다. view()그 요소 자신이 화면에 얼마나 들어왔는지를 봅니다. 04·05·09번처럼 "이게 보이기 시작하면"이 조건일 때 씁니다. view() 쪽은 animation-rangeentry·cover 같은 구간 이름을 써서 언제부터 언제까지인지 따로 정합니다.

미리보기 루프와 실제 스크롤 동작을 왜 따로 짜나요?

격자에 박히는 데모는 iframe 안에서 아무도 스크롤하지 않기 때문입니다. 스크롤이 0이면 스크롤 타임라인의 진행률도 0이라 화면이 멈춘 그림이 됩니다. 그래서 아홉 개 모두 .is-demo 클래스가 붙어 있는 동안만 시간 축에서 돌리고, 손가락이 닿거나 실제로 스크롤되는 순간 그 클래스를 떼어 스크롤 타임라인에 넘깁니다. 01~04·06번은 같은 키프레임을 그대로 쓰고, 05·07·08·09번만 미리보기용 키프레임을 따로 두었습니다 — 넘길 때 화면이 튀지 않을 만큼 두 경로를 붙여 두는 것이 요령입니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.