CSS 프로그레스바 애니메이션 9종 — 스크롤 읽기 진행 표시
CSS 프로그레스바 애니메이션은 지금까지 얼마나 읽었는지를 스크롤 위치만으로 계산해 보여주는 장치입니다. AOS·GSAP 없이, JS 0줄로 아홉 가지 형태를 모았습니다 — 상단 바, 원형 배지, 점 내비, 세로 바, 글자 채우기, 숫자, 챕터 스텝, 목차, 읽은 시간까지.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 가지는 인기 순이 아니라 독자 눈에 먼저 걸리는 자리 순으로 놓았습니다. 화면 가장자리에서 의식하지 않아도 보이는 것(01~04) 다음, 본문 안에서 직접 눈에 들어오는 것(05~06), 마지막으로 글 구조 자체를 설명하는 것(07~09)입니다. 아홉 개 전부 animation-timeline: scroll() 하나로 스크립트 없이 움직이고, 이 기능을 아직 못 쓰는 브라우저를 위한 대체판은 함정 섹션에 있습니다.
01상단 얇은 바
화면 맨 위에 붙은 3px 바가 읽은 만큼 왼쪽부터 채워집니다. 긴 글이나 문서 페이지 헤더 아래에 가장 흔히 씁니다.
.bar {
width: 100%;
transform: scaleX(0);
transform-origin: left;
@supports (animation-timeline: scroll()) {
animation: fill linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill { to { transform: scaleX(1); } }
02원형 링 배지
화면 오른쪽 아래에 뜬 원형 배지가 conic-gradient 링으로 채워집니다. 플로팅 위로가기 버튼과 묶어 쓰면 버튼 하나가 내비게이션과 진행 표시를 겸합니다.
@property --p { syntax: '<number>'; initial-value: 0; inherits: false; }
.badge {
--p: 0;
border-radius: 50%;
background: conic-gradient($color calc(var(--p) * 1%), $stage-paper 0);
@supports (animation-timeline: scroll()) {
animation: fill-ring linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill-ring { from { --p: 0; } to { --p: 100; } }
03섹션 점 내비
세로로 늘어선 점 여섯 개 중 지금 읽는 구간의 점만 커지고 밝아집니다. 원페이지 스토리텔링 사이트나 랜딩 섹션 내비에 씁니다.
const io = new IntersectionObserver((entries) => {
const top = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (top) setActive(sections.indexOf(top.target));
}, { threshold: [0.3, 0.6] });
sections.forEach((el) => io.observe(el));
04사이드 세로 바
화면 오른쪽 가장자리 얇은 트랙이 위에서 아래로 채워져 읽은 위치를 보여줍니다. 문서 사이트나 긴 기술 블로그의 여백에 붙습니다.
.rail__fill {
height: 100%;
transform: scaleY(0);
transform-origin: top;
@supports (animation-timeline: scroll()) {
animation: fill-y linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill-y { to { transform: scaleY(1); } }
05글자 채우기
제목 글자 색이 읽은 비율만큼 왼쪽부터 채워집니다. background-clip: text로 그라데이션을 그리기 때문에 이미지 없이 순수 CSS만으로 구현됩니다.
@property --p { syntax: '<percentage>'; initial-value: 0%; inherits: false; }
.headline {
--p: 0%;
background-image: linear-gradient(90deg, $color var(--p), $dim var(--p));
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
06숫자 %
오른쪽 위 작은 숫자가 스크롤한 만큼 0에서 100까지 정수로 오릅니다. 긴 설문이나 튜토리얼의 진행률에 씁니다.
@property --num { syntax: '<integer>'; initial-value: 0; inherits: false; }
.counter__num {
--num: 0;
counter-reset: num var(--num);
&::before { content: counter(num) '%'; }
@supports (animation-timeline: scroll()) {
animation: count-up linear both;
animation-timeline: scroll(root);
}
}
@keyframes count-up { from { --num: 0; } to { --num: 100; } }
07챕터 스텝
챕터 번호 원 네 개 중 지금 읽는 챕터의 원만 채워집니다. 튜토리얼 시리즈나 다단계 가이드 글에 씁니다.
@keyframes fill-2 {
0%, 25% { background: transparent; transform: scale(1); }
33% { background: $color; transform: scale(1.25); }
40%, 100% { background: $color; transform: scale(1); }
}
.step:nth-child(2) .step__circle { animation: fill-2 linear both; }
08목차 하이라이트
사이드 목차에서 지금 읽는 제목에만 옅은 색 알약이 슬라이드로 따라붙습니다. 진한 단색 알약은 전환 중 글자 대비가 깨져서 14% 옅은 틴트로 바꿨습니다.
.toc__pill {
position: absolute;
background: rgba(47, 109, 246, .14);
border-left: 3px solid $color;
transition: transform 200ms ease-in-out;
}
09읽은 시간
"남은 N분" 문구의 숫자가 스크롤할수록 줄어듭니다. 남은 분은 전체 분량에서 읽은 비율만큼 뺀 값입니다.
@property --min { syntax: '<integer>'; initial-value: 12; inherits: false; }
.time__num {
--min: 12;
counter-reset: min var(--min);
&::before { content: counter(min); }
}
@keyframes count-down { from { --min: 12; } to { --min: 0; } }
어디서 깨지는가 — 함정 하나
animation-timeline: scroll()의 지원이 아직 갈립니다. 실측(2026-09-10)은 이렇습니다.
| 기능 | Chrome·Edge | Safari | Firefox |
|---|---|---|---|
animation-timeline scroll() |
115+ | 26+ (2025-09) | 158+, 2026-09 현재 정식판은 플래그 뒤 |
@property |
85+ | 16.4+ | 128+ |
두 기능의 최신 지원 범위는 MDN animation-timeline 문서와 MDN @property 문서에서 다시 확인할 수 있습니다.
Firefox처럼 아직 정식 지원이 없는 브라우저에서 9개 전부 CSS만으로 두면 진행 표시가 그냥 멈춰 있습니다. 그래서 zip의 react/ 판은 처음부터 window의 scroll 이벤트(또는 03·08처럼 섹션 단위면 IntersectionObserver)로 같은 값을 계산해 넘기는 JS 대체판을 함께 담았습니다 — CSS가 못 움직이는 브라우저에서도 React 컴포넌트는 그대로 움직입니다. 코드를 실제로 렌더해 보며 겪은 함정도 하나 있습니다. 07 챕터 스텝을 순간 전환(steps(1))으로만 만들었더니 프레임 사이 변화가 있는 구간이 네 장뿐이어서 측정 스크립트가 "거의 안 움직인다"고 판정했습니다. 전환 지점에 scale(1.25) 팝을 넣어 구간을 벌리자 그제서야 눈에 보이는 움직임이 됐습니다. 이 값들이 이미 반영된 아홉 개 파일의 압축 비밀번호는 3rmg234a이고, 아래에서 zip 하나로 받을 수 있습니다.
접근성
아홉 개 모두 prefers-reduced-motion: reduce에서 애니메이션이 꺼집니다. 01·02·04·05·06·07·09처럼 누적 진행을 채워 보여주는 항목은 진행률이 0인 상태가 아니라 다 읽은 최종 상태로 고정되고, 03 점 내비와 08 목차처럼 지금 보는 구간 하나만 켜서 보여주는 항목은 첫 항목이 켜진 상태로 고정됩니다. 06 숫자와 09 읽은 시간처럼 counter()로 그린 숫자는 스크린 리더가 아예 못 읽으므로, 숫자 자체는 aria-hidden으로 숨기고 감싸는 요소에 role="status" 또는 role="progressbar"와 aria-valuenow를 따로 붙였습니다. 08 목차처럼 지금 읽는 제목을 가리키는 항목은 실제 <a href="#섹션id">라서 키보드로도 그대로 도달할 수 있습니다. 03 점 내비의 점은 장식이라 aria-hidden으로 숨겨져 있어 스크린 리더나 키보드 이동의 대상이 아닙니다. prefers-reduced-motion 미디어 쿼리 자체의 지원 범위는 MDN 문서에서 확인할 수 있습니다.
다른 스크롤 애니메이션과 CSS 주제는 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.
FAQ
scroll() 타임라인을 못 쓰는 브라우저에서는 어떻게 하나요?
@supports (animation-timeline: scroll())로 감싸 두었기 때문에 지원이 없으면 그 안의 애니메이션은 그냥 적용되지 않습니다. zip의 react/ 판은 처음부터 scroll 이벤트로 같은 값을 계산하므로, CSS 대신 이 React 컴포넌트를 쓰면 Firefox를 포함한 모든 브라우저에서 움직입니다.
아홉 개 중 어떤 걸 같이 써도 되나요?
01 상단 바와 04 사이드 바처럼 위치가 겹치지 않는 것끼리는 함께 둬도 됩니다. 다만 06 숫자와 09 읽은 시간처럼 같은 진행 정보를 서로 다른 숫자로 보여주는 것끼리는 하나만 고릅니다. 둘 다 켜면 어느 숫자를 봐야 할지 헷갈립니다.
목차 항목이 몇 개일 때부터 08을 쓰는 게 좋나요?
H2가 4개 미만이면 알약이 거의 움직이지 않아 효과가 안 보입니다. 저희는 H2 4개 이상인 글에만 08을 붙이고, 그보다 짧은 글에는 01 상단 바만 남깁니다.