9 CSS Scroll Progress Bar Ideas, Copy-Paste
A css scroll progress bar reads how far a reader has scrolled and turns that into a fill, a ring, a count, or a countdown — nine of them here, no JS,
01 scroll-progress-indicator-top-bar
02 scroll-progress-indicator-ring-badge
03 scroll-progress-indicator-section-dots
04 scroll-progress-indicator-side-rail
05 scroll-progress-indicator-text-fill
06 scroll-progress-indicator-percent-counter
07 scroll-progress-indicator-chapter-steps
08 scroll-progress-indicator-toc-highlight
09 scroll-progress-indicator-reading-time
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Top thin bar
- 02 Circular ring badge
- 03 Section dot nav
- 04 Side vertical rail
- 05 Text fill
- 06 Number percent
- 07 Chapter steps
- 08 TOC highlight
- 09 Reading time left
The order of these css scroll effect patterns isn't ranked by how common each one is — it follows where a reader's eye actually lands. 01 through 04 and 06 sit at the screen's own edges or corners, noticed without anyone deciding to look. 05 sits inside the content itself, as part of a headline. 07 through 09 go a step further and describe the article's own structure — which chapter, which heading, how much time is left. Six of the nine tie their motion directly to animation-timeline: scroll(); the other three — 03, 07, and 08 — key off which section or chapter is on screen instead, and the fallback for browsers that don't support scroll-driven animations yet is covered in the trap section below. If the aim is revealing elements one after another rather than tracking how far the page has gone, the stagger animations are the faster read, while horizontal progress belongs to the pure CSS carousels.
01Top thin bar
Why a css reading progress bar costs no layout space
A 3px strip pinned to the very top of the page fills from the left as the reader scrolls down, and nothing else on the page moves to make room for it.

<main class="stage">
<div class="track" role="progressbar" aria-label="Reading progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="bar is-demo"></div>
</div>
<div class="page">
<div class="page__block"></div>
<div class="page__block"></div>
<div class="page__block short"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$thickness: 3px; // 바 두께. 굵게 6px
$color: $subject-blue; // 채워진 구간 색
$track: rgba(255, 255, 255, .16); // 안 읽은 구간
@include stage($stage-ink);
.track {
position: fixed;
top: 0; left: 0; right: 0;
height: $thickness;
background: $track;
}
.bar {
height: 100%;
width: 100%;
background: $color;
transform: scaleX(0);
transform-origin: left; // 규칙8: 진행바 left 채움
// 실제 사용: 문서 스크롤 진행률이 곧 바 길이 (Chrome·Edge 115+ · Safari 26+ · Firefox 158+ 플래그, 편브리프 표)
@supports (animation-timeline: scroll()) {
animation: fill linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill { to { transform: scaleX(1); } }
// ── 데모(.is-demo): 스크롤 없이 2초 루프로 흉내 ────────────────
.bar.is-demo { animation: fill-loop $dur-loop linear infinite; }
@keyframes fill-loop {
0% { transform: scaleX(0); }
92% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
.page { padding: $sp-10 $sp-6 0; }
.page__block {
height: $sp-6;
margin: 0 0 $sp-4;
border-radius: $r-sm;
background: rgba(255, 255, 255, .12);
&.short { width: 55%; }
}
// ── 접근성: 움직임 줄이기 — 다 채워진 상태로 고정 ────────────
@media (prefers-reduced-motion: reduce) {
.bar, .bar.is-demo { animation: none !important; transform: scaleX(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.track {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(255, 255, 255, 0.16);
}
.bar {
height: 100%;
width: 100%;
background: #2f6df6;
transform: scaleX(0);
transform-origin: left;
}
@supports (animation-timeline: scroll()) {
.bar {
animation: fill linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill {
to {
transform: scaleX(1);
}
}
.bar.is-demo {
animation: fill-loop 2s linear infinite;
}
@keyframes fill-loop {
0% {
transform: scaleX(0);
}
92% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
.page {
padding: 40px 24px 0;
}
.page__block {
height: 24px;
margin: 0 0 16px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.12);
}
.page__block.short {
width: 55%;
}
@media (prefers-reduced-motion: reduce) {
.bar, .bar.is-demo {
animation: none !important;
transform: scaleX(1);
}
}import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TopBar.module.scss";
export type TopBarProps = {
/** 바 두께 (기본 3px) */
thickness?: number;
/** 바 색 */
color?: string;
};
type Vars = CSSProperties & { "--thickness"?: string; "--color"?: string };
// 실제 쓰임: 문서 스크롤 비율을 window scroll 로 재서 --scale 에 반영한다.
// scroll() 타임라인을 쓸 수 있는 브라우저(Chrome·Edge 115+, Safari 26+)에서는 CSS 만으로도 되지만,
// React 컴포넌트는 아직 지원이 갈린 Firefox 를 포함해 항상 움직여야 하므로 스크롤 리스너로 재계산한다.
export default function TopBar({ thickness = 3, color = "#2f6df6" }: TopBarProps) {
const [scale, setScale] = useState(0);
useEffect(() => {
const onScroll = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
setScale(max > 0 ? Math.min(1, window.scrollY / max) : 0);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const vars: Vars = { "--thickness": `${thickness}px`, "--color": color };
return (
<div className={styles.track} style={vars} role="progressbar" aria-label="Reading progress"
aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(scale * 100)}>
<div className={styles.bar} style={{ transform: `scaleX(${scale})` }} />
</div>
);
}01 · Top thin bar — Measured area 1.0% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where long-form articles start
It's the shape most tutorial sites and long-form articles reach for first, since one thin line at the very top costs almost no layout space.
.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); } }
02Circular ring badge
A ring instead of a bar
A round badge floating at the bottom-right corner fills as a conic-gradient ring instead of a straight bar, so it can sit on top of other content without stretching across the page.

<main class="stage">
<div class="badge is-demo" role="progressbar" aria-label="Back to top" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<span class="badge__inner"><span class="badge__arrow" aria-hidden="true"></span></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$size: 56px; // 배지 지름
$color: $subject-ink; // 링 색
$duration: $dur-loop; // 한 바퀴(스크롤 전체) 도는 시간
@include stage($stage-yellow);
// 정수라야 링 각도가 매끄럽게 보간된다
@property --p {
syntax: '<number>';
initial-value: 0;
inherits: false;
}
.badge {
--p: 0;
position: fixed;
right: $sp-6; bottom: $sp-6;
width: $size; height: $size;
border-radius: 50%;
display: grid;
place-items: center;
background: conic-gradient($color calc(var(--p) * 1%), $stage-paper 0);
box-shadow: $shadow-raised;
// 실제 사용: 링 배지는 플로팅 위로가기 버튼에 흔히 결합된다
@supports (animation-timeline: scroll()) {
animation: fill-ring linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill-ring { from { --p: 0; } to { --p: 100; } }
.badge.is-demo { animation: fill-ring-loop $duration linear infinite; }
@keyframes fill-ring-loop {
0% { --p: 0; }
92% { --p: 100; }
100% { --p: 100; }
}
.badge__inner {
width: 44px; height: 44px;
border-radius: 50%;
background: $stage-paper;
display: grid;
place-items: center;
}
.badge__arrow {
width: 0; height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 9px solid $subject-ink;
}
@media (prefers-reduced-motion: reduce) {
.badge, .badge.is-demo { animation: none !important; --p: 100; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --p {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
.badge {
--p: 0;
position: fixed;
right: 24px;
bottom: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
display: grid;
place-items: center;
background: conic-gradient(#17141a calc(var(--p) * 1%), #ffffff 0);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
@supports (animation-timeline: scroll()) {
.badge {
animation: fill-ring linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill-ring {
from {
--p: 0;
}
to {
--p: 100;
}
}
.badge.is-demo {
animation: fill-ring-loop 2s linear infinite;
}
@keyframes fill-ring-loop {
0% {
--p: 0;
}
92% {
--p: 100;
}
100% {
--p: 100;
}
}
.badge__inner {
width: 44px;
height: 44px;
border-radius: 50%;
background: #ffffff;
display: grid;
place-items: center;
}
.badge__arrow {
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 9px solid #17141a;
}
@media (prefers-reduced-motion: reduce) {
.badge, .badge.is-demo {
animation: none !important;
--p: 100;
}
}import { useEffect, useState } from "react";
import type { CSSProperties, MouseEvent } from "react";
import styles from "./RingBadge.module.scss";
export type RingBadgeProps = {
/** 배지 지름 (기본 56px) */
size?: number;
/** 링 색 */
color?: string;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
};
type Vars = CSSProperties & { "--size"?: string; "--color"?: string; "--p"?: number };
// 실제 쓰임: 위로가기 버튼과 결합. percent 만큼 conic-gradient 링이 채워진다.
export default function RingBadge({ size = 56, color = "#17141a", onClick }: RingBadgeProps) {
const [percent, setPercent] = useState(0);
useEffect(() => {
const onScroll = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
setPercent(max > 0 ? Math.min(100, Math.round((window.scrollY / max) * 100)) : 0);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const vars: Vars = { "--size": `${size}px`, "--color": color, "--p": percent };
return (
<button
type="button"
className={styles.badge}
style={vars}
role="progressbar"
aria-label="Back to top"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
onClick={(e: MouseEvent<HTMLButtonElement>) => { window.scrollTo({ top: 0, behavior: "smooth" }); onClick?.(e); }}
>
<span className={styles.inner}><span className={styles.arrow} /></span>
</button>
);
}02 · Circular ring badge — Measured area 0.736% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Pairing with back-to-top
It pairs naturally with a floating back-to-top button, letting one element handle both navigation and progress.
@property --p {
syntax: '<number>';
initial-value: 0;
inherits: false;
}
.badge {
--p: 0;
border-radius: 50%;
background: conic-gradient($color calc(var(--p) * 1%), $track 0);
@supports (animation-timeline: scroll()) {
animation: fill-ring linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill-ring { from { --p: 0; } to { --p: 100; } }
03Section dot nav
Tracking sections, not a percentage
A vertical column of dots sits fixed at the screen's edge, and only the dot for whatever section is currently on screen grows and lights up.

<main class="stage">
<nav class="dots is-demo" aria-label="Section navigation">
<span class="dot" aria-hidden="true"></span>
<span class="dot" aria-hidden="true"></span>
<span class="dot" aria-hidden="true"></span>
<span class="dot" aria-hidden="true"></span>
<span class="dot" aria-hidden="true"></span>
<span class="dot" aria-hidden="true"></span>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 점 하나가 켜졌다 꺼지는 한 순환
$easing: $ease-out;
$color: $subject-cream; // 활성 점 색
@include stage($stage-orange);
.dots {
position: fixed;
right: $sp-6; top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: $sp-3;
}
.dot {
width: 8px; height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, .28);
transition: transform 200ms $easing, background 200ms $easing;
}
// ── 실제 사용: 섹션이 뷰포트에 들어오는 순간 그 섹션의 점에 .is-active 를 켠다 (IntersectionObserver) ──
.dot.is-active { transform: scale(1.6); background: $color; }
// ── 데모: 점을 순서대로 하나씩 밝힌다 ────────────────────
.dots.is-demo .dot { animation: pulse $duration linear infinite; }
.dots.is-demo .dot:nth-child(1) { animation-delay: 0s; }
.dots.is-demo .dot:nth-child(2) { animation-delay: .333s; }
.dots.is-demo .dot:nth-child(3) { animation-delay: .667s; }
.dots.is-demo .dot:nth-child(4) { animation-delay: 1s; }
.dots.is-demo .dot:nth-child(5) { animation-delay: 1.333s; }
.dots.is-demo .dot:nth-child(6) { animation-delay: 1.667s; }
@keyframes pulse {
0%, 83.34%, 100% { transform: scale(1); background: rgba(23, 20, 26, .28); }
8.33%, 16.66% { transform: scale(1.6); background: $color; }
}
@media (prefers-reduced-motion: reduce) {
.dots.is-demo .dot { animation: none !important; }
.dots.is-demo .dot:first-child { transform: scale(1.6); background: $color; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dots {
position: fixed;
right: 24px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 12px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.28);
transition: transform 200ms ease-out, background 200ms ease-out;
}
.dot.is-active {
transform: scale(1.6);
background: #fff7e6;
}
.dots.is-demo .dot {
animation: pulse 2s linear infinite;
}
.dots.is-demo .dot:nth-child(1) {
animation-delay: 0s;
}
.dots.is-demo .dot:nth-child(2) {
animation-delay: 0.333s;
}
.dots.is-demo .dot:nth-child(3) {
animation-delay: 0.667s;
}
.dots.is-demo .dot:nth-child(4) {
animation-delay: 1s;
}
.dots.is-demo .dot:nth-child(5) {
animation-delay: 1.333s;
}
.dots.is-demo .dot:nth-child(6) {
animation-delay: 1.667s;
}
@keyframes pulse {
0%, 83.34%, 100% {
transform: scale(1);
background: rgba(23, 20, 26, 0.28);
}
8.33%, 16.66% {
transform: scale(1.6);
background: #fff7e6;
}
}
@media (prefers-reduced-motion: reduce) {
.dots.is-demo .dot {
animation: none !important;
}
.dots.is-demo .dot:first-child {
transform: scale(1.6);
background: #fff7e6;
}
}import { useEffect, useState } from "react";
import styles from "./SectionDots.module.scss";
export type SectionDotsProps = {
/** 관찰할 섹션 id 목록 (순서대로) */
sectionIds: string[];
/** 활성 점 색 */
color?: string;
};
// 실제 쓰임: IntersectionObserver 로 지금 뷰포트에 가장 많이 걸친 섹션을 찾아 그 점만 켠다.
export default function SectionDots({ sectionIds, color = "#fff7e6" }: SectionDotsProps) {
const [active, setActive] = useState(0);
useEffect(() => {
const els = sectionIds.map((id) => document.getElementById(id)).filter(Boolean) as HTMLElement[];
const io = new IntersectionObserver(
(entries) => {
const top = entries.filter((e) => e.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (top) setActive(els.indexOf(top.target as HTMLElement));
},
{ threshold: [0.3, 0.6] }
);
els.forEach((el) => io.observe(el));
return () => io.disconnect();
}, [sectionIds]);
return (
<nav className={styles.dots} aria-label="Section navigation">
{sectionIds.map((id, i) => (
<a
key={id}
href={`#${id}`}
className={`${styles.dot} ${i === active ? styles.active : ""}`}
style={i === active ? { background: color } : undefined}
aria-hidden="true"
tabIndex={-1}
/>
))}
</nav>
);
}03 · Section dot nav — Measured area 0.683% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why IntersectionObserver here
Because this one tracks which section is visible rather than a single scroll percentage, the live version swaps animation-timeline for an IntersectionObserver watching each section directly.
const io = new IntersectionObserver((entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (visible) setActive(sections.indexOf(visible.target));
}, { threshold: [0.3, 0.6] });
sections.forEach((el) => io.observe(el));
04Side vertical rail
The same fill, turned vertical
A thin track running down the right edge of the screen fills from top to bottom, mirroring the top bar but along the vertical axis instead.

<main class="stage">
<div class="rail" role="progressbar" aria-label="Reading position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="rail__fill is-demo"></div>
</div>
<div class="page">
<div class="page__block"></div>
<div class="page__block"></div>
<div class="page__block short"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$thickness: 4px; // 레일 두께
$color: $subject-blue; // 채워진 구간 색
$track: rgba(23, 20, 26, .1); // 안 읽은 구간
@include stage($stage-paper);
.rail {
position: fixed;
top: 0; right: 0;
width: $thickness;
height: 100%;
background: $track;
}
.rail__fill {
width: 100%;
height: 100%;
background: $color;
transform: scaleY(0);
transform-origin: top; // 규칙8: 세로레일 top 채움
// 실제 사용: 문서 안에서 지금까지 읽은 위치를 세로로 보여준다
@supports (animation-timeline: scroll()) {
animation: fill-y linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill-y { to { transform: scaleY(1); } }
.rail__fill.is-demo { animation: fill-y-loop $dur-loop linear infinite; }
@keyframes fill-y-loop {
0% { transform: scaleY(0); }
92% { transform: scaleY(1); }
100% { transform: scaleY(1); }
}
.page { padding: $sp-10 $sp-8 0 $sp-6; }
.page__block {
height: $sp-6;
margin: 0 0 $sp-4;
border-radius: $r-sm;
background: rgba(23, 20, 26, .08);
&.short { width: 55%; }
}
@media (prefers-reduced-motion: reduce) {
.rail__fill, .rail__fill.is-demo { animation: none !important; transform: scaleY(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rail {
position: fixed;
top: 0;
right: 0;
width: 4px;
height: 100%;
background: rgba(23, 20, 26, 0.1);
}
.rail__fill {
width: 100%;
height: 100%;
background: #2f6df6;
transform: scaleY(0);
transform-origin: top;
}
@supports (animation-timeline: scroll()) {
.rail__fill {
animation: fill-y linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill-y {
to {
transform: scaleY(1);
}
}
.rail__fill.is-demo {
animation: fill-y-loop 2s linear infinite;
}
@keyframes fill-y-loop {
0% {
transform: scaleY(0);
}
92% {
transform: scaleY(1);
}
100% {
transform: scaleY(1);
}
}
.page {
padding: 40px 32px 0 24px;
}
.page__block {
height: 24px;
margin: 0 0 16px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.08);
}
.page__block.short {
width: 55%;
}
@media (prefers-reduced-motion: reduce) {
.rail__fill, .rail__fill.is-demo {
animation: none !important;
transform: scaleY(1);
}
}import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SideRail.module.scss";
export type SideRailProps = {
/** 레일 두께 (기본 4px) */
thickness?: number;
/** 채워진 구간 색 */
color?: string;
};
type Vars = CSSProperties & { "--thickness"?: string; "--color"?: string };
// 실제 쓰임: TopBar 와 같은 계산을 세로(scaleY)로 그린다.
export default function SideRail({ thickness = 4, color = "#2f6df6" }: SideRailProps) {
const [scale, setScale] = useState(0);
useEffect(() => {
const onScroll = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
setScale(max > 0 ? Math.min(1, window.scrollY / max) : 0);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const vars: Vars = { "--thickness": `${thickness}px`, "--color": color };
return (
<div className={styles.rail} style={vars} role="progressbar" aria-label="Reading position"
aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(scale * 100)}>
<div className={styles.fill} style={{ transform: `scaleY(${scale})` }} />
</div>
);
}04 · Side vertical rail — Measured area 0.833% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a horizontal bar has nowhere to sit
Docs sites with a fixed-width column often park it in the margin, where a horizontal bar would have nowhere natural to sit.
.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); } }
05Text fill
A gradient clipped to the text
A headline's own color fills in from the left in step with how much of the page has been read, drawn entirely with background-clip: text instead of an image or an overlay.

<main class="stage">
<h2 class="headline is-demo">A headline that fills as you read</h2>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$color: $subject-blue; // 읽은 구간 색
$dim: rgba(255, 255, 255, .32); // 안 읽은 구간 색
$duration: $dur-loop;
@include stage($stage-ink);
// 퍼센트라야 그라데이션 경계가 매끄럽게 보간된다
@property --p {
syntax: '<percentage>';
initial-value: 0%;
inherits: false;
}
.headline {
--p: 0%;
max-width: 80%;
margin: 0;
text-align: center;
font-weight: 800;
font-size: clamp(20px, 6vw, 28px);
line-height: 1.35;
font-family: inherit;
background-image: linear-gradient(90deg, $color var(--p), $dim var(--p));
background-clip: text;
-webkit-background-clip: text;
color: transparent;
// 실제 사용: 헤드라인 색이 스크롤 진행률만큼 왼쪽부터 채워진다
@supports (animation-timeline: scroll()) {
animation: fill-text linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill-text { from { --p: 0%; } to { --p: 100%; } }
.headline.is-demo { animation: fill-text-loop $duration linear infinite; }
@keyframes fill-text-loop {
0% { --p: 0%; }
92% { --p: 100%; }
100% { --p: 100%; }
}
@media (prefers-reduced-motion: reduce) {
.headline, .headline.is-demo { animation: none !important; --p: 100%; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --p {
syntax: "<percentage>";
initial-value: 0%;
inherits: false;
}
.headline {
--p: 0%;
max-width: 80%;
margin: 0;
text-align: center;
font-weight: 800;
font-size: clamp(20px, 6vw, 28px);
line-height: 1.35;
font-family: inherit;
background-image: linear-gradient(90deg, #2f6df6 var(--p), rgba(255, 255, 255, 0.32) var(--p));
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
@supports (animation-timeline: scroll()) {
.headline {
animation: fill-text linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill-text {
from {
--p: 0%;
}
to {
--p: 100%;
}
}
.headline.is-demo {
animation: fill-text-loop 2s linear infinite;
}
@keyframes fill-text-loop {
0% {
--p: 0%;
}
92% {
--p: 100%;
}
100% {
--p: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.headline, .headline.is-demo {
animation: none !important;
--p: 100%;
}
}import { useEffect, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./TextFill.module.scss";
export type TextFillProps = {
children: ReactNode;
/** 읽은 구간 색 */
color?: string;
/** 안 읽은 구간 색 */
dim?: string;
};
type Vars = CSSProperties & { "--p"?: string; "--color"?: string; "--dim"?: string };
// 실제 쓰임: 헤드라인 색이 스크롤 진행률만큼 왼쪽부터 채워진다.
export default function TextFill({ children, color = "#2f6df6", dim = "rgba(255,255,255,.32)" }: TextFillProps) {
const [percent, setPercent] = useState(0);
useEffect(() => {
const onScroll = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
setPercent(max > 0 ? Math.min(100, (window.scrollY / max) * 100) : 0);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const vars: Vars = { "--p": `${percent}%`, "--color": color, "--dim": dim };
return <h2 className={styles.headline} style={vars}>{children}</h2>;
}05 · Text fill — Measured area 2.938% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where the edge reads clearly
It works best on a hero headline or an article title that's already large enough for the gradient edge to read clearly.
@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;
@supports (animation-timeline: scroll()) {
animation: fill-text linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill-text { from { --p: 0%; } to { --p: 100%; } }
06Number percent
Whole integers, not decimals
A small number tucked in the top-right corner counts from 0 to 100 as the page scrolls, registered through @property so the browser tweens whole integers instead of decimals.

<main class="stage">
<div class="counter" role="status" aria-label="Reading progress">
<span class="counter__num is-demo" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$color: $subject-ink; // 숫자 색
$duration: $dur-loop;
$easing: linear; // 스크롤과 1:1로 움직이니 linear 가 맞다
@include stage($stage-yellow);
// 정수라야 애니메이션 중간값도 정수로 보간된다 (029 와 같은 기법)
@property --num {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
.counter {
position: fixed;
top: $sp-6; right: $sp-6;
}
.counter__num {
--num: 0;
counter-reset: num var(--num);
display: block;
font-weight: 800;
font-size: clamp(26px, 8vw, 36px);
line-height: 1;
font-family: inherit;
font-variant-numeric: tabular-nums;
color: $color;
&::before { content: counter(num) '%'; }
// 실제 사용: 전체 문서를 스크롤한 비율이 그대로 숫자가 된다
@supports (animation-timeline: scroll()) {
animation: count-up $easing both;
animation-timeline: scroll(root);
}
}
@keyframes count-up { from { --num: 0; } to { --num: 100; } }
.counter__num.is-demo { animation: count-up-loop $duration $easing infinite; }
@keyframes count-up-loop {
0% { --num: 0; }
92% { --num: 100; }
100% { --num: 100; }
}
@media (prefers-reduced-motion: reduce) {
.counter__num, .counter__num.is-demo { animation: none !important; --num: 100; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --num {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
.counter {
position: fixed;
top: 24px;
right: 24px;
}
.counter__num {
--num: 0;
counter-reset: num var(--num);
display: block;
font-weight: 800;
font-size: clamp(26px, 8vw, 36px);
line-height: 1;
font-family: inherit;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.counter__num::before {
content: counter(num) "%";
}
@supports (animation-timeline: scroll()) {
.counter__num {
animation: count-up linear both;
animation-timeline: scroll(root);
}
}
@keyframes count-up {
from {
--num: 0;
}
to {
--num: 100;
}
}
.counter__num.is-demo {
animation: count-up-loop 2s linear infinite;
}
@keyframes count-up-loop {
0% {
--num: 0;
}
92% {
--num: 100;
}
100% {
--num: 100;
}
}
@media (prefers-reduced-motion: reduce) {
.counter__num, .counter__num.is-demo {
animation: none !important;
--num: 100;
}
}import { useEffect, useState } from "react";
import styles from "./PercentCounter.module.scss";
export type PercentCounterProps = {
/** 숫자 색 */
color?: string;
};
// 실제 쓰임: 스크롤 진행률을 그대로 정수 퍼센트로 보여준다.
export default function PercentCounter({ color = "#17141a" }: PercentCounterProps) {
const [percent, setPercent] = useState(0);
useEffect(() => {
const onScroll = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
setPercent(max > 0 ? Math.min(100, Math.round((window.scrollY / max) * 100)) : 0);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<div className={styles.counter} role="status" aria-label="Reading progress">
<span className={styles.num} style={{ color }}>{percent}%</span>
</div>
);
}06 · Number percent — Measured area 0.872% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
When readers expect a figure
Long surveys and multi-page tutorials use it where readers expect an exact figure rather than a visual bar.
@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; } }
07Chapter steps
One keyframes block per circle
A row of numbered circles at the top of the page fills in one at a time as the reader passes through each chapter, so the whole structure of a long guide is visible at a glance.

<main class="stage">
<ol class="steps is-demo" aria-label="Chapter progress">
<li class="step"><span class="step__circle">1</span></li>
<li class="step"><span class="step__circle">2</span></li>
<li class="step"><span class="step__circle">3</span></li>
<li class="step"><span class="step__circle">4</span></li>
</ol>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$color: $subject-cream; // 채워진 원 색
$track: rgba(255, 255, 255, .22); // 연결선 색
@include stage($stage-orange);
.steps {
position: relative;
display: flex;
gap: $sp-6;
margin: 0; padding: 0;
list-style: none;
}
.step {
position: relative;
&:not(:first-child)::before {
content: "";
position: absolute;
right: 100%; top: 50%;
width: $sp-6; height: 2px;
background: $track;
}
}
.step__circle {
display: grid;
place-items: center;
width: 28px; height: 28px;
border-radius: 50%;
border: 2px solid $track;
background: transparent;
color: $subject-ink; // AA: 채워지기 전 원 숫자 — 주황 무대 위 5.50:1
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
// ── 실제 사용: 같은 스텝 키프레임을 시간 대신 스크롤 구간(animation-range)에 건다 ──
// .step:nth-child(2) .step__circle { animation: fill-2 linear both; animation-timeline: scroll(root); }
// ── 데모: 챕터 번호가 순서대로 하나씩 부풀며 채워지고, 루프가 돌면 처음으로 되돌아간다 ──
// (색만 순간 전환하면 프레임 사이 변화가 한두 장뿐이라 눈에 덜 띈다 — scale 팝으로 구간을 넓힌다)
.steps.is-demo .step:nth-child(1) .step__circle { animation: fill-1 $duration ease-out infinite; }
.steps.is-demo .step:nth-child(2) .step__circle { animation: fill-2 $duration ease-out infinite; }
.steps.is-demo .step:nth-child(3) .step__circle { animation: fill-3 $duration ease-out infinite; }
.steps.is-demo .step:nth-child(4) .step__circle { animation: fill-4 $duration ease-out infinite; }
@keyframes fill-1 {
0% { background: transparent; color: $subject-ink; border-color: $track; transform: scale(1); }
8% { background: $color; color: $subject-ink; border-color: $color; transform: scale(1.25); }
15%, 100% { background: $color; color: $subject-ink; border-color: $color; transform: scale(1); }
}
@keyframes fill-2 {
0%, 25% { background: transparent; color: $subject-ink; border-color: $track; transform: scale(1); }
33% { background: $color; color: $subject-ink; border-color: $color; transform: scale(1.25); }
40%, 100% { background: $color; color: $subject-ink; border-color: $color; transform: scale(1); }
}
@keyframes fill-3 {
0%, 50% { background: transparent; color: $subject-ink; border-color: $track; transform: scale(1); }
58% { background: $color; color: $subject-ink; border-color: $color; transform: scale(1.25); }
65%, 100% { background: $color; color: $subject-ink; border-color: $color; transform: scale(1); }
}
@keyframes fill-4 {
0%, 75% { background: transparent; color: $subject-ink; border-color: $track; transform: scale(1); }
83% { background: $color; color: $subject-ink; border-color: $color; transform: scale(1.25); }
90%, 100% { background: $color; color: $subject-ink; border-color: $color; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.steps.is-demo .step__circle { animation: none !important; background: $color; border-color: $color; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.steps {
position: relative;
display: flex;
gap: 24px;
margin: 0;
padding: 0;
list-style: none;
}
.step {
position: relative;
}
.step:not(:first-child)::before {
content: "";
position: absolute;
right: 100%;
top: 50%;
width: 24px;
height: 2px;
background: rgba(255, 255, 255, 0.22);
}
.step__circle {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.22);
background: transparent;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
.steps.is-demo .step:nth-child(1) .step__circle {
animation: fill-1 2s ease-out infinite;
}
.steps.is-demo .step:nth-child(2) .step__circle {
animation: fill-2 2s ease-out infinite;
}
.steps.is-demo .step:nth-child(3) .step__circle {
animation: fill-3 2s ease-out infinite;
}
.steps.is-demo .step:nth-child(4) .step__circle {
animation: fill-4 2s ease-out infinite;
}
@keyframes fill-1 {
0% {
background: transparent;
color: #17141a;
border-color: rgba(255, 255, 255, 0.22);
transform: scale(1);
}
8% {
background: #fff7e6;
color: #17141a;
border-color: #fff7e6;
transform: scale(1.25);
}
15%, 100% {
background: #fff7e6;
color: #17141a;
border-color: #fff7e6;
transform: scale(1);
}
}
@keyframes fill-2 {
0%, 25% {
background: transparent;
color: #17141a;
border-color: rgba(255, 255, 255, 0.22);
transform: scale(1);
}
33% {
background: #fff7e6;
color: #17141a;
border-color: #fff7e6;
transform: scale(1.25);
}
40%, 100% {
background: #fff7e6;
color: #17141a;
border-color: #fff7e6;
transform: scale(1);
}
}
@keyframes fill-3 {
0%, 50% {
background: transparent;
color: #17141a;
border-color: rgba(255, 255, 255, 0.22);
transform: scale(1);
}
58% {
background: #fff7e6;
color: #17141a;
border-color: #fff7e6;
transform: scale(1.25);
}
65%, 100% {
background: #fff7e6;
color: #17141a;
border-color: #fff7e6;
transform: scale(1);
}
}
@keyframes fill-4 {
0%, 75% {
background: transparent;
color: #17141a;
border-color: rgba(255, 255, 255, 0.22);
transform: scale(1);
}
83% {
background: #fff7e6;
color: #17141a;
border-color: #fff7e6;
transform: scale(1.25);
}
90%, 100% {
background: #fff7e6;
color: #17141a;
border-color: #fff7e6;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.steps.is-demo .step__circle {
animation: none !important;
background: #fff7e6;
border-color: #fff7e6;
}
}import { useEffect, useState } from "react";
import styles from "./ChapterSteps.module.scss";
export type ChapterStepsProps = {
/** 챕터 수 */
total: number;
/** 채워진 원 색 */
color?: string;
};
// 실제 쓰임: 전체 스크롤 진행률을 챕터 수로 나눠 지금 챕터를 계산한다.
export default function ChapterSteps({ total, color = "#fff7e6" }: ChapterStepsProps) {
const [current, setCurrent] = useState(0);
useEffect(() => {
const onScroll = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
const p = max > 0 ? Math.min(1, window.scrollY / max) : 0;
setCurrent(Math.min(total - 1, Math.floor(p * total)));
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [total]);
return (
<ol className={styles.steps} aria-label="Chapter progress">
{Array.from({ length: total }, (_, i) => (
<li key={i} className={styles.step}>
<span className={styles.circle} style={i <= current ? { background: color, borderColor: color } : undefined}>
{i + 1}
</span>
</li>
))}
</ol>
);
}07 · Chapter steps — Measured area 3.558% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Seeing a guide's structure at once
Each circle runs its own @keyframes block tied to a slice of the document's scroll range rather than one shared animation.
@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;
animation-timeline: scroll(root);
}
08TOC highlight
A pill that slides between rows
Inside a sidebar table of contents, a pill-shaped background slides between rows to mark whichever heading the reader is currently under, rather than lighting up every visited link. The pill first shipped as a solid, saturated fill, but that broke text contrast during the slide between two rows.

<main class="stage">
<nav class="toc is-demo" aria-label="Contents">
<span class="toc__pill" aria-hidden="true"></span>
<a class="toc__item" href="#" tabindex="-1">Intro</a>
<a class="toc__item" href="#" tabindex="-1">Install</a>
<a class="toc__item" href="#" tabindex="-1">Usage</a>
<a class="toc__item" href="#" tabindex="-1">Wrap-up</a>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$color: $subject-blue; // 활성 알약 색
$row: 36px; // 목차 한 줄 높이 (알약 이동 간격)
@include stage($stage-paper);
.toc {
position: relative;
display: flex;
flex-direction: column;
width: min(220px, 80vw);
}
// 옅은 색 칩이라야 알약이 두 줄에 걸치는 전환 중에도 글자 대비가 안 깨진다 (진한 배경 + 어두운 글자는 전환 중 대비가 무너진다)
.toc__pill {
position: absolute;
left: 0; top: 0;
width: 100%; height: $row;
border-radius: $r-sm;
background: rgba(47, 109, 246, .14);
border-left: 3px solid $color;
z-index: 0;
transform: translateY(0);
}
.toc__item {
position: relative;
z-index: 1;
height: $row;
display: flex;
align-items: center;
padding: 0 $sp-3;
font-weight: 600;
font-size: 14px;
line-height: 1;
font-family: inherit;
color: $subject-ink;
text-decoration: none;
}
// ── 실제 사용: 흔히 IntersectionObserver 로 지금 보이는 제목을 찾아 --active 를 바꾼다.
// 구간 크기가 고르면 animation-timeline: scroll(root) 로도 같은 스텝 키프레임을 쓸 수 있다 ──
// ── 데모: 알약이 항목 네 개를 순서대로 미끄러지듯 오르내린다 ──
// (구간마다 바로 다음 값으로 점프하면 프레임 사이 변화가 한두 장뿐이다 — 구간의 절반을 이동에 쓴다)
.toc.is-demo .toc__pill { animation: move $duration ease-in-out infinite; }
@keyframes move {
0%, 10% { transform: translateY(0); }
25%, 35% { transform: translateY($row); }
50%, 60% { transform: translateY($row * 2); }
75%, 85% { transform: translateY($row * 3); }
100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.toc.is-demo .toc__pill { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.toc {
position: relative;
display: flex;
flex-direction: column;
width: min(220px, 80vw);
}
.toc__pill {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 36px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.14);
border-left: 3px solid #2f6df6;
z-index: 0;
transform: translateY(0);
}
.toc__item {
position: relative;
z-index: 1;
height: 36px;
display: flex;
align-items: center;
padding: 0 12px;
font-weight: 600;
font-size: 14px;
line-height: 1;
font-family: inherit;
color: #17141a;
text-decoration: none;
}
.toc.is-demo .toc__pill {
animation: move 2s ease-in-out infinite;
}
@keyframes move {
0%, 10% {
transform: translateY(0);
}
25%, 35% {
transform: translateY(36px);
}
50%, 60% {
transform: translateY(72px);
}
75%, 85% {
transform: translateY(108px);
}
100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.toc.is-demo .toc__pill {
animation: none !important;
}
}import { useEffect, useState } from "react";
import styles from "./TocHighlight.module.scss";
export type TocItem = { id: string; label: string };
export type TocHighlightProps = {
items: TocItem[];
/** 알약 색 */
color?: string;
/** 한 줄 높이 (px) */
row?: number;
};
// 실제 쓰임: IntersectionObserver 로 지금 보이는 제목을 찾아 알약을 그 줄로 옮긴다.
export default function TocHighlight({ items, color = "#2f6df6", row = 36 }: TocHighlightProps) {
const [active, setActive] = useState(0);
useEffect(() => {
const els = items.map((it) => document.getElementById(it.id)).filter(Boolean) as HTMLElement[];
const io = new IntersectionObserver(
(entries) => {
const top = entries.filter((e) => e.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (top) setActive(els.indexOf(top.target as HTMLElement));
},
{ threshold: [0.3, 0.6] }
);
els.forEach((el) => io.observe(el));
return () => io.disconnect();
}, [items]);
return (
<nav className={styles.toc} aria-label="Contents" style={{ width: 220 }}>
<span className={styles.pill} style={{ borderLeftColor: color, height: row, transform: `translateY(${active * row}px)` }} />
{items.map((it) => (
<a key={it.id} href={`#${it.id}`} className={styles.item} style={{ height: row }}>
{it.label}
</a>
))}
</nav>
);
}08 · TOC highlight — Measured area 21.896% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why the solid fill had to go
Swapping it for a 14% tint with a left border fixed the contrast without losing the highlight.
.toc__pill {
position: absolute;
background: rgba(47, 109, 246, .14);
border-left: 3px solid $color;
animation: move $dur-loop ease-in-out infinite;
}
09Reading time left
Subtracting from the estimate
A "N min left" readout counts down as the page scrolls, subtracting from the total estimated reading time in proportion to how much has already gone by.

<main class="stage">
<p class="time is-demo" role="status">
<span class="time__label">left</span>
<span class="time__num" aria-hidden="true"></span>
<span class="time__label">min</span>
</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$total: 12; // 전체 예상 분 (두 자릿수로 시작해야 자릿수가 바뀌는 순간도 보인다)
$color: $subject-cream; // 숫자 색
$duration: $dur-loop;
@include stage($stage-ink);
@property --min {
syntax: '<integer>';
initial-value: #{$total};
inherits: false;
}
.time {
display: flex;
align-items: baseline;
gap: $sp-2;
margin: 0;
color: rgba(255, 255, 255, .7);
font-weight: 600;
font-size: 16px;
line-height: 1;
font-family: inherit;
}
.time__num {
--min: #{$total};
counter-reset: min var(--min);
color: $color;
font-weight: 800;
font-size: clamp(34px, 11vw, 46px);
line-height: 1;
font-family: inherit;
font-variant-numeric: tabular-nums;
&::before { content: counter(min); }
// 실제 사용: 남은 분수 = 전체 분수 × (1 − 스크롤 진행률)
@supports (animation-timeline: scroll()) {
animation: count-down linear both;
animation-timeline: scroll(root);
}
}
@keyframes count-down { from { --min: #{$total}; } to { --min: 0; } }
.time.is-demo .time__num { animation: count-down-loop $duration linear infinite; }
@keyframes count-down-loop {
0% { --min: #{$total}; }
92% { --min: 0; }
100% { --min: 0; }
}
@media (prefers-reduced-motion: reduce) {
.time__num, .time.is-demo .time__num { animation: none !important; --min: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --min {
syntax: "<integer>";
initial-value: 12;
inherits: false;
}
.time {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
color: rgba(255, 255, 255, 0.7);
font-weight: 600;
font-size: 16px;
line-height: 1;
font-family: inherit;
}
.time__num {
--min: 12;
counter-reset: min var(--min);
color: #fff7e6;
font-weight: 800;
font-size: clamp(34px, 11vw, 46px);
line-height: 1;
font-family: inherit;
font-variant-numeric: tabular-nums;
}
.time__num::before {
content: counter(min);
}
@supports (animation-timeline: scroll()) {
.time__num {
animation: count-down linear both;
animation-timeline: scroll(root);
}
}
@keyframes count-down {
from {
--min: 12;
}
to {
--min: 0;
}
}
.time.is-demo .time__num {
animation: count-down-loop 2s linear infinite;
}
@keyframes count-down-loop {
0% {
--min: 12;
}
92% {
--min: 0;
}
100% {
--min: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.time__num, .time.is-demo .time__num {
animation: none !important;
--min: 0;
}
}import { useEffect, useState } from "react";
import styles from "./ReadingTime.module.scss";
export type ReadingTimeProps = {
/** 전체 예상 분 */
totalMinutes: number;
/** 숫자 색 */
color?: string;
};
// 실제 쓰임: 남은 분 = 전체 분 × (1 − 스크롤 진행률).
export default function ReadingTime({ totalMinutes, color = "#fff7e6" }: ReadingTimeProps) {
const [left, setLeft] = useState(totalMinutes);
useEffect(() => {
const onScroll = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
const p = max > 0 ? Math.min(1, window.scrollY / max) : 0;
setLeft(Math.max(0, Math.round(totalMinutes * (1 - p))));
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [totalMinutes]);
return (
<p className={styles.time} role="status">
<span>left</span>
<span className={styles.num} style={{ color }}>{left}</span>
<span>min</span>
</p>
);
}09 · Reading time left — Measured area 1.545% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
A promise instead of a fill
Newsletter web versions and long article headers use it instead of a bar, since a shrinking number reads as a concrete promise rather than an abstract fill.
@property --min {
syntax: '<integer>';
initial-value: 12;
inherits: false;
}
.time__num {
--min: 12;
counter-reset: min var(--min);
&::before { content: counter(min); }
@supports (animation-timeline: scroll()) {
animation: count-down linear both;
animation-timeline: scroll(root);
}
}
@keyframes count-down { from { --min: 12; } to { --min: 0; } }
Where does this break?
Support is still a moving target
Turning that finished state into a celebration, as the confetti effects do, inherits the very same trap: mistime the moment the bar reaches 100% and the burst fires at an empty screen.
animation-timeline: scroll() is still a moving target. Measured against caniuse on 2026-09-10, it ships in Chrome and Edge from version 115 and in Safari from version 26 (released 2025-09), while Firefox only carries version 158 behind a flag as of this writing — MDN's animation-timeline reference tracks the current state. Six of the nine files wrap their moving rule in @supports (animation-timeline: scroll()). The other three — 03, 07, and 08 — read the scroll position with plain JavaScript instead, since each tracks which section, chapter, or heading is active rather than one continuous percentage. The zip's react/ folder ships a JS-driven twin of every item regardless: a scroll listener for the six built on animation-timeline, Math.floor on the scroll ratio for 07, and an IntersectionObserver for 03 and 08 — so a Firefox visitor still sees the number climb even where the CSS-only version would sit frozen. One trap no css scroll animation tutorial warned about turned up while rendering these files for real rather than just reading the code: building item 07's chapter fill as a single instant steps(1) swap left only four frames out of twenty-three with any visible difference between them, low enough that the motion measurement script flagged it as barely moving at all. Adding the scale(1.25) pop at each threshold — instead of a flat color swap — spread that change across sixteen of the twenty-three frames, which is what made the step actually read as movement rather than a flicker. The nine files with that fix already applied sit in the archive, and it opens with 3rmg234a typed just as this sentence prints it, with nothing added before or after.
Support at a glance
| Feature | Chrome / Edge | Safari | Firefox |
|---|---|---|---|
animation-timeline: scroll() |
115+ | 26+ (2025-09) | 158+, flagged as of 2026-09 |
@property |
85+ | 16.4+ | 128+ |
Accessibility
Where each one settles
Seven of the nine answer prefers-reduced-motion: reduce by snapping to the finished state — fully read — rather than freezing partway through, since a progress indicator stuck at 40% under reduced motion would misreport where the page actually stands. The two that track a specific section instead — 03's dot nav and 08's TOC pill — settle on the first stop rather than the last. That's the natural resting point for an indicator that was never a percentage to begin with. Items 06 and 09 print their numbers through counter(), which screen readers skip over entirely. So the digit itself carries aria-hidden while the wrapper around it carries role="status" instead, announcing the change without asking a screen reader to parse a moving digit. Item 08 doubles as real navigation, built from actual <a href="#section-id"> links rather than plain divs, so keyboard focus reaches every heading a mouse click would. Item 03's dots stay purely decorative instead, marked aria-hidden inside a labeled nav, since an unlabeled row of dots would announce as confusing buttons rather than named stops.
@media (prefers-reduced-motion: reduce) {
.bar, .rail__fill, .headline, .counter__num, .time__num {
animation: none !important;
}
.bar { transform: scaleX(1); }
.rail__fill { transform: scaleY(1); }
.headline { --p: 100%; }
.counter__num { --num: 100; }
.time__num { --min: 0; }
}
Which settings flip the query
MDN's prefers-reduced-motion page documents which operating-system settings flip this media query on.
Where the rest lives
More scroll-driven CSS lives on the CSS category hub, and what this site does and doesn't sell is on the about page.
FAQ
Do any of these nine actually need JavaScript?
Only indirectly. Every demo above runs on animation-timeline: scroll() alone, with zero lines of JS in the CSS-only files themselves. The zip's react/ folder adds a JS-driven twin of each one anyway, since 03's section detection and browsers that haven't shipped scroll timelines yet both need a script fallback to keep moving.
Which css page scroll progress bar fits a page with only three headings?
01's top bar or 04's side rail, since both read the whole document's scroll position rather than distinct sections. Items 03, 07, and 08 all depend on dividing the page into several chunks, and with only three headings the jumps between them read as sudden rather than gradual.
Can I combine more than one of these on the same page?
Yes — none of the nine claim the same spot. 06's number and 02's ring badge are the only two anchored to a screen corner, and they sit in opposite ones, top-right and bottom-right, so even those two pair cleanly. 01 and 04 run along opposite edges, and the rest sit inline in the content, so nothing here forces you to pick just one. Variants that mark position with dots or a table of contents instead of a bar are gathered in the reading position indicators.









