9 CSS Animation on Scroll Effects, No JavaScript
A css animation on scroll ties motion to how far the page has scrolled, using CSS's own animation-timeline instead of a scroll listener.
01 fade-up-reveal
02 stagger-list
03 clip-text-reveal
04 image-curtain
05 scroll-progress-bar
06 sticky-header-shrink
07 parallax-layers
08 count-up-on-view
09 line-draw-timeline
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Fade up
- 02 Staggered list
- 03 Text wipe
- 04 Image curtain
- 05 Reading progress
- 06 Shrinking header
- 07 Parallax layers
- 08 Count up
- 09 Line-draw timeline
The order follows how a page reveals itself as you scroll. 01 through 04 fire once, the moment an element enters the viewport. 05 through 07 read your position across the whole document rather than a single element. 08 and 09 combine both — a single trigger, timed to the surrounding scroll. Watch them loop in the grid above — every section below keeps only the essential lines, and the untrimmed files live in the zip. When one parallax entry like 07 isn't enough depth, the CSS parallax scrolling set is that single item stretched across nine, and if 03's wipe leaves you wanting to move the sentence itself, the CSS text animation effects take it from there.
01Fade up
The motion
Rises slightly and fades in the moment it enters the viewport, using animation-timeline: view() instead of an IntersectionObserver.

<main class="stage">
<article class="card is-demo">
<p class="card__eyebrow">SECTION</p>
<h2 class="card__title">Rise into view</h2>
<p class="card__body">As it scrolls into view, it rises gently from below.</p>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$distance: 24px; // 올라오는 거리. 멀리 두려면 48px
$duration: $dur-enter; // 등장이 다 끝나는 데 걸리는 진행 구간
$easing: $ease-out; // 처음 빠르고 끝은 느리게
// ── 데모 무대 (zip 에서 지워도 된다) ──────────────────────────
@include stage($stage-ink);
// ── 카드 (피사체: ink 무대라 blue) ───────────────────────────
.card {
box-sizing: border-box;
width: min(300px, 100%);
padding: clamp(16px, 6vw, 26px) clamp(16px, 6vw, 24px);
border-radius: $r-card;
background: $subject-blue-deep;
color: #fff;
text-align: left;
&__eyebrow { margin: 0 0 6px; font-weight: 700; font-size: 11px; line-height: 1; font-family: inherit; letter-spacing: .12em; color: $subject-cream; }
&__title { margin: 0 0 8px; font-weight: 700; font-size: clamp(16px, 5vw, 20px); line-height: 1.3; font-family: inherit; }
&__body { margin: 0; font-weight: 400; font-size: 14px; line-height: 1.5; font-family: inherit; color: $subject-cream; }
// ── 실제 사용 코드: 스크롤이 곧 애니메이션 시계다 (zip 그대로) ──
// 요소가 뷰포트로 들어오는 구간(entry)만큼 진행된다. JS 0줄.
@supports (animation-timeline: view()) {
animation: fade-up-reveal $easing both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
// ── 미지원 브라우저: 움직이지 않고 그냥 보여준다 (정직한 대체) ──
@supports not (animation-timeline: view()) {
opacity: 1;
transform: none;
}
}
@keyframes fade-up-reveal {
from { opacity: 0; transform: translateY($distance); }
to { opacity: 1; transform: translateY(0); }
}
// ── 데모(.is-demo): 렌더러가 프레임을 뽑도록 스크롤 대신 2초 루프로 흉내 ──
.card.is-demo {
animation: fade-up-loop $dur-loop $easing infinite;
}
@keyframes fade-up-loop {
0% { opacity: 0; transform: translateY($distance * 2); }
30% { opacity: 1; transform: translateY(0); }
75% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY($distance * 2); }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 보여주기만 ────────────
@media (prefers-reduced-motion: reduce) {
.card, .card.is-demo { animation: none !important; opacity: 1; transform: none; }
}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);
}
.card {
box-sizing: border-box;
width: min(300px, 100%);
padding: clamp(16px, 6vw, 26px) clamp(16px, 6vw, 24px);
border-radius: 16px;
background: #0a4fe8;
color: #fff;
text-align: left;
}
.card__eyebrow {
margin: 0 0 6px;
font-weight: 700;
font-size: 11px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.12em;
color: #fff7e6;
}
.card__title {
margin: 0 0 8px;
font-weight: 700;
font-size: clamp(16px, 5vw, 20px);
line-height: 1.3;
font-family: inherit;
}
.card__body {
margin: 0;
font-weight: 400;
font-size: 14px;
line-height: 1.5;
font-family: inherit;
color: #fff7e6;
}
@supports (animation-timeline: view()) {
.card {
animation: fade-up-reveal ease-out both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
}
@supports not (animation-timeline: view()) {
.card {
opacity: 1;
transform: none;
}
}
@keyframes fade-up-reveal {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card.is-demo {
animation: fade-up-loop 2s ease-out infinite;
}
@keyframes fade-up-loop {
0% {
opacity: 0;
transform: translateY(48px);
}
30% {
opacity: 1;
transform: translateY(0);
}
75% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(48px);
}
}
@media (prefers-reduced-motion: reduce) {
.card, .card.is-demo {
animation: none !important;
opacity: 1;
transform: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./FadeUpReveal.module.scss";
export type FadeUpRevealProps = {
eyebrow?: string;
title: string;
children: ReactNode;
/** 올라오는 거리 (기본 24px) */
distance?: number;
/** 등장이 끝나는 구간 (기본 700ms) — animation-range 계산에는 안 쓰이고 문서용 */
duration?: number;
/** easing (기본 ease-out) */
easing?: string;
className?: string;
};
type Vars = CSSProperties & {
"--distance"?: string;
"--duration"?: string;
"--easing"?: string;
};
export default function FadeUpReveal({
eyebrow = "SECTION",
title,
children,
distance = 24,
duration = 700,
easing = "ease-out",
className = "",
}: FadeUpRevealProps) {
const vars: Vars = {
"--distance": `${distance}px`,
"--duration": `${duration}ms`,
"--easing": easing,
};
return (
<article className={`${styles.card} ${className}`} style={vars}>
<p className={styles.eyebrow}>{eyebrow}</p>
<h2 className={styles.title}>{title}</h2>
<p className={styles.body}>{children}</p>
</article>
);
}01 · Fade up — Measured area 38.606% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a css scroll reveal animation belongs
It's the default entrance for section titles, cards, and paragraphs — almost anything that needs to announce itself once and get out of the way.
.card {
animation: fade-up $easing both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
@keyframes fade-up {
from { opacity: 0; transform: translateY($distance); }
to { opacity: 1; transform: translateY(0); }
}
02Staggered list
What it does
List items appear one after another as you scroll past them, roughly 80ms apart, without a single setTimeout.

<main class="stage">
<ul class="list is-demo">
<li class="list__item" style="--i:0">Fast shipping</li>
<li class="list__item" style="--i:1">Easy checkout</li>
<li class="list__item" style="--i:2">Live inventory</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$stagger: 80ms; // 항목 사이 간격. 빠르게 40ms
$distance: 16px; // 올라오는 거리
$color: $subject-ink; // 항목 배경 (yellow 무대라 ink)
// ── 데모 무대 (zip 에서 지워도 된다) ──────────────────────────
@include stage($stage-yellow);
// ── 목록 ──────────────────────────────────────────────
.list {
margin: 0;
padding: 0;
list-style: none;
width: min(280px, 100%);
display: grid;
gap: clamp(6px, 3vw, 10px); // 320×200 에서도 네 줄이 들어가게 줄인다
}
.list__item {
padding: clamp(8px, 3vw, 14px) clamp(12px, 4vw, 18px);
border-radius: $r-control;
background: $color;
color: #fff;
font-weight: 600;
font-size: clamp(13px, 4vw, 15px);
line-height: 1;
font-family: inherit;
// ── 실제 사용 코드: 세로로 쌓인 항목은 스크롤하면 제 위치에서
// 순서대로 뷰포트에 들어온다 — 항목마다 같은 규칙이면 자동으로 순서대로 보인다.
// JS 지연(setTimeout) 없이, 목록 배치 자체가 시차를 만든다.
@supports (animation-timeline: view()) {
animation: stagger-in $ease-out both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
@supports not (animation-timeline: view()) {
opacity: 1;
transform: none;
}
}
@keyframes stagger-in {
from { opacity: 0; transform: translateY($distance); }
to { opacity: 1; transform: translateY(0); }
}
// ── 데모(.is-demo): 항목마다 --i 순서로 지연을 줘 순서대로 등장 흉내 ──
.list.is-demo .list__item {
animation: stagger-loop $dur-loop $ease-out infinite;
animation-delay: calc(var(--i) * #{$stagger} * 4);
}
@keyframes stagger-loop {
0% { opacity: 0; transform: translateY($distance * 2); }
20% { opacity: 1; transform: translateY(0); }
70% { opacity: 1; transform: translateY(0); }
85% { opacity: 0; transform: translateY($distance * 2); }
100% { opacity: 0; transform: translateY($distance * 2); }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 보여주기만 ────────────
@media (prefers-reduced-motion: reduce) {
.list__item { animation: none !important; opacity: 1; transform: none; }
}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);
}
.list {
margin: 0;
padding: 0;
list-style: none;
width: min(280px, 100%);
display: grid;
gap: clamp(6px, 3vw, 10px);
}
.list__item {
padding: clamp(8px, 3vw, 14px) clamp(12px, 4vw, 18px);
border-radius: 12px;
background: #17141a;
color: #fff;
font-weight: 600;
font-size: clamp(13px, 4vw, 15px);
line-height: 1;
font-family: inherit;
}
@supports (animation-timeline: view()) {
.list__item {
animation: stagger-in ease-out both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
@supports not (animation-timeline: view()) {
.list__item {
opacity: 1;
transform: none;
}
}
@keyframes stagger-in {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.list.is-demo .list__item {
animation: stagger-loop 2s ease-out infinite;
animation-delay: calc(var(--i) * 80ms * 4);
}
@keyframes stagger-loop {
0% {
opacity: 0;
transform: translateY(32px);
}
20% {
opacity: 1;
transform: translateY(0);
}
70% {
opacity: 1;
transform: translateY(0);
}
85% {
opacity: 0;
transform: translateY(32px);
}
100% {
opacity: 0;
transform: translateY(32px);
}
}
@media (prefers-reduced-motion: reduce) {
.list__item {
animation: none !important;
opacity: 1;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./StaggerList.module.scss";
export type StaggerListProps = {
items: string[];
/** 항목 사이 진입 범위 간격 (기본 80ms, 문서용 — 실제 진행은 목록 배치가 만든다) */
stagger?: number;
/** 올라오는 거리 (기본 16px) */
distance?: number;
/** 글자 색 배경 (기본 #17141a) */
color?: string;
className?: string;
};
type ItemVars = CSSProperties & { "--i"?: number };
type ListVars = CSSProperties & { "--stagger"?: string; "--distance"?: string; "--color"?: string };
export default function StaggerList({
items,
stagger = 80,
distance = 16,
color = "#17141a",
className = "",
}: StaggerListProps) {
const vars: ListVars = { "--stagger": `${stagger}ms`, "--distance": `${distance}px`, "--color": color };
return (
<ul className={`${styles.list} ${className}`} style={vars}>
{items.map((label, i) => {
const itemVars: ItemVars = { "--i": i };
return (
<li key={label} className={styles.item} style={itemVars}>
{label}
</li>
);
})}
</ul>
);
}02 · Staggered list — Measured area 34.529% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
The stagger comes free from the list's own layout, since each item watches its own entry into the viewport. Use it for feature lists, pricing tables, and team grids.
.list__item {
animation: stagger-in $ease-out both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
@keyframes stagger-in {
from { opacity: 0; transform: translateY($distance); }
to { opacity: 1; transform: translateY(0); }
}
03Text wipe
What you see
A headline wipes in from the left, revealed by a single animated clip-path instead of a moving width or left offset.

<main class="stage">
<h1 class="headline is-demo">Text wipes in</h1>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-base; // 닦여 나오는 시간
$easing: $ease-spring; // 처음 빠르고 끝은 부드럽게
$color: $subject-cream; // 글자 색 (orange 무대라 cream)
// ── 데모 무대 (zip 에서 지워도 된다) ──────────────────────────
@include stage($stage-orange);
// ── 제목 ──────────────────────────────────────────────
.headline {
margin: 0;
font-weight: 800;
font-size: clamp(22px, 7vw, 34px);
line-height: 1.3;
font-family: inherit;
color: $color;
text-align: center;
// ── 실제 사용 코드: clip-path 하나로 왼쪽에서 오른쪽으로 닦인다 ──
// width·left 대신 clip-path 만 바뀌므로 레이아웃 재계산이 없다.
@supports (animation-timeline: view()) {
animation: wipe-in $easing both;
animation-timeline: view();
animation-range: entry 20% cover 60%;
}
@supports not (animation-timeline: view()) {
clip-path: inset(0 0 0 0);
}
}
@keyframes wipe-in {
from { clip-path: inset(0 100% 0 0); }
to { clip-path: inset(0 0 0 0); }
}
// ── 데모(.is-demo): 스크롤 대신 2초 루프로 흉내 ────────────
.headline.is-demo {
animation: wipe-loop $dur-loop $easing infinite;
}
@keyframes wipe-loop {
0% { clip-path: inset(0 100% 0 0); }
35% { clip-path: inset(0 0 0 0); }
75% { clip-path: inset(0 0 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 다 보여주기만 ────────
@media (prefers-reduced-motion: reduce) {
.headline, .headline.is-demo { animation: none !important; clip-path: inset(0 0 0 0); }
}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);
}
.headline {
margin: 0;
font-weight: 800;
font-size: clamp(22px, 7vw, 34px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
text-align: center;
}
@supports (animation-timeline: view()) {
.headline {
animation: wipe-in cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-timeline: view();
animation-range: entry 20% cover 60%;
}
}
@supports not (animation-timeline: view()) {
.headline {
clip-path: inset(0 0 0 0);
}
}
@keyframes wipe-in {
from {
clip-path: inset(0 100% 0 0);
}
to {
clip-path: inset(0 0 0 0);
}
}
.headline.is-demo {
animation: wipe-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes wipe-loop {
0% {
clip-path: inset(0 100% 0 0);
}
35% {
clip-path: inset(0 0 0 0);
}
75% {
clip-path: inset(0 0 0 0);
}
100% {
clip-path: inset(0 100% 0 0);
}
}
@media (prefers-reduced-motion: reduce) {
.headline, .headline.is-demo {
animation: none !important;
clip-path: inset(0 0 0 0);
}
}import type { CSSProperties } from "react";
import styles from "./TextWipe.module.scss";
export type TextWipeProps = {
children: string;
/** 닦여 나오는 시간 (기본 600ms) */
duration?: number;
/** easing (기본 cubic-bezier(.2,.8,.2,1)) */
easing?: string;
/** 글자 색 (기본 #17141a) */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TextWipe({
children,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
className = "",
}: TextWipeProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<h1 className={`${styles.headline} ${className}`} style={vars}>
{children}
</h1>
);
}03 · Text wipe — Measured area 2.99% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
Use it for hero headlines and section titles that need one confident entrance.
.headline {
animation: wipe-in $easing both;
animation-timeline: view();
animation-range: entry 20% cover 60%;
}
@keyframes wipe-in {
from { clip-path: inset(0 100% 0 0); }
to { clip-path: inset(0 0 0 0); }
}
04Image curtain
The motion
A colored panel covers the image, then slides off to the right as the frame scrolls into view.

<main class="stage">
<div class="frame is-demo">
<div class="frame__image" aria-hidden="true"></div>
<div class="frame__curtain"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-enter; // 커튼이 걷히는 시간
$curtain: $subject-ink; // 커튼 색 (paper 무대라 ink). 주황 $subject 대체 가능
$easing: $ease-spring;
// ── 데모 무대 (zip 에서 지워도 된다) ──────────────────────────
@include stage($stage-paper);
// ── 이미지 자리 (실제로는 <img>, 여기선 그라데이션) ─────────────
.frame {
position: relative;
width: clamp(160px, 70vw, 280px); // 320×200 에서도 세로로 안 넘치게 폭에 따라 준다
aspect-ratio: 4 / 3; // 고정 height 대신 비율로 — 폭이 줄면 높이도 같이 준다
border-radius: $r-control;
overflow: hidden;
isolation: isolate;
&__image {
position: absolute;
inset: 0;
background: linear-gradient(135deg, $stage-yellow, $stage-orange 60%, $stage-ink);
}
&__curtain {
position: absolute;
inset: 0;
background: $curtain;
transform: scaleX(1);
transform-origin: right; // 오른쪽으로 접히며 걷힌다
// ── 실제 사용 코드: 뷰포트에 들어오는 동안 커튼이 걷힌다 ──
@supports (animation-timeline: view()) {
animation: curtain-open $easing both;
animation-timeline: view();
animation-range: entry 10% cover 50%;
}
// ── 미지원 브라우저: 커튼을 미리 걷어 이미지를 보여준다 ──
@supports not (animation-timeline: view()) {
transform: scaleX(0);
}
}
}
@keyframes curtain-open {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
// ── 데모(.is-demo): 스크롤 대신 2초 루프로 흉내 ────────────
.frame.is-demo .frame__curtain {
animation: curtain-loop $dur-loop $easing infinite;
}
@keyframes curtain-loop {
0% { transform: scaleX(1); }
35% { transform: scaleX(0); }
75% { transform: scaleX(0); }
100% { transform: scaleX(1); }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 열어서 보여주기만 ────
@media (prefers-reduced-motion: reduce) {
.frame__curtain, .frame.is-demo .frame__curtain { animation: none !important; transform: scaleX(0); }
}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);
}
.frame {
position: relative;
width: clamp(160px, 70vw, 280px);
aspect-ratio: 4/3;
border-radius: 12px;
overflow: hidden;
isolation: isolate;
}
.frame__image {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #ffd23f, #ff4d1f 60%, #17141a);
}
.frame__curtain {
position: absolute;
inset: 0;
background: #17141a;
transform: scaleX(1);
transform-origin: right;
}
@supports (animation-timeline: view()) {
.frame__curtain {
animation: curtain-open cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-timeline: view();
animation-range: entry 10% cover 50%;
}
}
@supports not (animation-timeline: view()) {
.frame__curtain {
transform: scaleX(0);
}
}
@keyframes curtain-open {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
.frame.is-demo .frame__curtain {
animation: curtain-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes curtain-loop {
0% {
transform: scaleX(1);
}
35% {
transform: scaleX(0);
}
75% {
transform: scaleX(0);
}
100% {
transform: scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
.frame__curtain, .frame.is-demo .frame__curtain {
animation: none !important;
transform: scaleX(0);
}
}import type { CSSProperties } from "react";
import styles from "./ImageCurtain.module.scss";
export type ImageCurtainProps = {
/** 이미지 URL. 비우면 데모용 그라데이션을 쓴다 */
src?: string;
alt?: string;
/** 커튼이 걷히는 시간 (기본 700ms) */
duration?: number;
/** 커튼 색 (기본 주황) */
curtain?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--curtain"?: string };
export default function ImageCurtain({
src,
alt = "",
duration = 700,
curtain = "#ff4d1f",
className = "",
}: ImageCurtainProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--curtain": curtain };
return (
<div className={`${styles.frame} ${className}`} style={vars}>
{src ? (
<img className={styles.image} src={src} alt={alt} />
) : (
<div className={styles.image} aria-hidden="true" />
)}
<div className={styles.curtain} />
</div>
);
}04 · Image curtain — Measured area 40.756% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It suits portfolios and product photos, where the reveal itself is part of the design.
.frame__curtain {
transform-origin: right;
animation: curtain-open $easing both;
animation-timeline: view();
animation-range: entry 10% cover 50%;
}
@keyframes curtain-open {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
05Reading progress
What it does
A bar fixed to the top of the page fills as you read, driven only by a scroll() timeline tied to the document instead of a scroll listener that recalculates on every frame.

<main class="stage">
<div class="bar is-demo" aria-hidden="true"></div>
<article class="doc">
<p class="doc__line"></p>
<p class="doc__line"></p>
<p class="doc__line short"></p>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$thickness: 3px; // 바 두께. 굵게 6px
$color: $subject-blue; // 바 색 (ink 무대라 blue)
$track: rgba(255, 255, 255, .12); // 안 읽은 구간 색
// ── 데모 무대 (zip 에서 지워도 된다) ──────────────────────────
html, body { margin: 0; overflow: hidden; }
.stage {
box-sizing: border-box;
position: relative;
min-height: 100vh;
margin: 0;
padding: 40px clamp(16px, 6vw, 32px) 0;
background: $stage-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// ── 진행 바: 문서 최상단에 고정, scroll() 타임라인만 쓴다 ─────
.bar {
position: fixed;
top: 0; left: 0;
width: 100%;
height: $thickness;
background: $color;
transform: scaleX(0);
transform-origin: left; // 규칙8: 진행바 left 채움
// ── 실제 사용 코드: 문서 전체 스크롤 진행률이 곧 바 길이다 ──
// view() 가 아니라 scroll() — "얼마나 들어왔나" 가 아니라 "얼마나 읽었나".
@supports (animation-timeline: scroll()) {
animation: fill-bar linear both;
animation-timeline: scroll(root);
}
@supports not (animation-timeline: scroll()) {
transform: scaleX(0);
}
}
@keyframes fill-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
// ── 데모(.is-demo): 스크롤 대신 2초 루프로 흉내 ────────────
.bar.is-demo {
animation: fill-loop $dur-loop linear infinite;
}
@keyframes fill-loop {
0% { transform: scaleX(0); }
90% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
// ── 배경: 문서처럼 보이는 자리 ─────────────────────────
.doc { max-width: 380px; color: #fff; }
.doc__line {
height: 12px;
margin: 0 0 14px;
border-radius: 4px;
background: $track;
&.short { width: 60%; }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 채워 보여주기만 ────────
@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;
position: relative;
min-height: 100vh;
margin: 0;
padding: 40px clamp(16px, 6vw, 32px) 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: #2f6df6;
transform: scaleX(0);
transform-origin: left;
}
@supports (animation-timeline: scroll()) {
.bar {
animation: fill-bar linear both;
animation-timeline: scroll(root);
}
}
@supports not (animation-timeline: scroll()) {
.bar {
transform: scaleX(0);
}
}
@keyframes fill-bar {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.bar.is-demo {
animation: fill-loop 2s linear infinite;
}
@keyframes fill-loop {
0% {
transform: scaleX(0);
}
90% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
.doc {
max-width: 380px;
color: #fff;
}
.doc__line {
height: 12px;
margin: 0 0 14px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.12);
}
.doc__line.short {
width: 60%;
}
@media (prefers-reduced-motion: reduce) {
.bar, .bar.is-demo {
animation: none !important;
transform: scaleX(1);
}
}import type { CSSProperties } from "react";
import styles from "./ProgressBar.module.scss";
export type ProgressBarProps = {
/** 바 두께 (기본 3px) */
thickness?: number;
/** 바 색 (기본 노랑) */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--thickness"?: string; "--color"?: string };
export default function ProgressBar({
thickness = 3,
color = "#ffd23f",
className = "",
}: ProgressBarProps) {
const vars: Vars = { "--thickness": `${thickness}px`, "--color": color };
return <div className={`${styles.bar} ${className}`} style={vars} aria-hidden="true" />;
}05 · Reading progress — Measured area 1.0% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
It suits long articles and documentation, where readers want to see how much is left.
.bar {
position: fixed;
top: 0;
left: 0;
transform-origin: left;
animation: fill-bar linear both;
animation-timeline: scroll(root);
}
@keyframes fill-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
06Shrinking header
What you see
The sticky header thins out and its logo scales down as the page scrolls, using transform: scaleY instead of animating height.

<main class="stage">
<header class="head is-demo">
<span class="head__logo">GODRICH</span>
<nav class="head__nav" aria-hidden="true">
<span></span><span></span><span></span>
</nav>
</header>
<div class="page">
<p class="page__line"></p>
<p class="page__line"></p>
<p class="page__line short"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$tall: 64px; // 처음 헤더 높이
$short-scale: .75; // 줄어든 비율 (64px → 48px ≈ .75)
$color: $subject-ink; // 헤더 배경 (yellow 무대라 ink)
// ── 데모 무대 (zip 에서 지워도 된다) ──────────────────────────
html, body { margin: 0; overflow: hidden; }
.stage {
min-height: 100vh;
margin: 0;
background: $stage-yellow;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// ── 헤더: 높이 대신 transform: scaleY 로 "줄어든다" ────────────
// (height 는 애니메이션하면 레이아웃을 다시 계산한다 — 함정 섹션 참고)
.head {
position: sticky;
top: 0;
height: $tall;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 clamp(16px, 5vw, 24px);
background: $color;
transform-origin: top; // 규칙8: 헤더가 top에서 얇아짐
&__logo { color: #fff; font-weight: 800; font-size: 18px; line-height: 1; font-family: inherit; letter-spacing: .04em; }
&__nav { display: flex; gap: 10px; span { width: 26px; height: 8px; border-radius: 4px; background: rgba(255, 255, 255, .35); } }
// ── 실제 사용 코드: 문서 스크롤이 진행되는 동안 얇아진다 ──
@supports (animation-timeline: scroll()) {
animation: shrink-header linear both;
animation-timeline: scroll(root);
animation-range: 0 200px;
}
@supports not (animation-timeline: scroll()) {
transform: none;
}
}
@keyframes shrink-header {
from { transform: scaleY(1); }
to { transform: scaleY($short-scale); }
}
// ── 데모(.is-demo): 스크롤 대신 2초 루프(줄었다 늘었다)로 흉내 ──
.head.is-demo {
animation: shrink-loop $dur-loop ease-in-out infinite;
}
@keyframes shrink-loop {
0% { transform: scaleY(1); }
50% { transform: scaleY($short-scale); }
100% { transform: scaleY(1); }
}
// ── 배경: 문서처럼 보이는 자리 ─────────────────────────
.page { padding: 28px 24px 0; }
.page__line {
height: 12px;
margin: 0 0 14px;
border-radius: 4px;
background: rgba(23, 20, 26, .14);
&.short { width: 60%; }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 줄인 상태로 고정 ────────
@media (prefers-reduced-motion: reduce) {
.head, .head.is-demo { animation: none !important; transform: scaleY($short-scale); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
min-height: 100vh;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.head {
position: sticky;
top: 0;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 clamp(16px, 5vw, 24px);
background: #17141a;
transform-origin: top;
}
.head__logo {
color: #fff;
font-weight: 800;
font-size: 18px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.head__nav {
display: flex;
gap: 10px;
}
.head__nav span {
width: 26px;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.35);
}
@supports (animation-timeline: scroll()) {
.head {
animation: shrink-header linear both;
animation-timeline: scroll(root);
animation-range: 0 200px;
}
}
@supports not (animation-timeline: scroll()) {
.head {
transform: none;
}
}
@keyframes shrink-header {
from {
transform: scaleY(1);
}
to {
transform: scaleY(0.75);
}
}
.head.is-demo {
animation: shrink-loop 2s ease-in-out infinite;
}
@keyframes shrink-loop {
0% {
transform: scaleY(1);
}
50% {
transform: scaleY(0.75);
}
100% {
transform: scaleY(1);
}
}
.page {
padding: 28px 24px 0;
}
.page__line {
height: 12px;
margin: 0 0 14px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.14);
}
.page__line.short {
width: 60%;
}
@media (prefers-reduced-motion: reduce) {
.head, .head.is-demo {
animation: none !important;
transform: scaleY(0.75);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ShrinkingHeader.module.scss";
export type ShrinkingHeaderProps = {
logo: string;
children?: ReactNode;
/** 처음 헤더 높이 (기본 64px) */
tall?: number;
/** 줄어든 비율 (기본 .75 → 64px 기준 48px) */
shortScale?: number;
/** 헤더 배경 (기본 어두운색) */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--tall"?: string; "--short-scale"?: number; "--color"?: string };
export default function ShrinkingHeader({
logo,
children,
tall = 64,
shortScale = 0.75,
color = "#17141a",
className = "",
}: ShrinkingHeaderProps) {
const vars: Vars = { "--tall": `${tall}px`, "--short-scale": shortScale, "--color": color };
return (
<header className={`${styles.head} ${className}`} style={vars}>
<span className={styles.logo}>{logo}</span>
<nav className={styles.nav}>{children}</nav>
</header>
);
}06 · Shrinking header — Measured area 7.342% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
Use it on landing pages and blog headers that need to stay pinned without eating the whole screen.
.head {
position: sticky;
top: 0;
transform-origin: top;
animation: shrink-header linear both;
animation-timeline: scroll(root);
animation-range: 0 200px;
}
@keyframes shrink-header {
from { transform: scaleY(1); }
to { transform: scaleY(.75); }
}
07Parallax layers
The motion
The background drifts slower than the foreground content as the page scrolls, and the gap between the two speeds is what reads as depth.

<main class="stage is-demo">
<div class="parallax">
<div class="parallax__back" aria-hidden="true"></div>
<div class="parallax__front">
<h2 class="parallax__title">Depth takes shape</h2>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$back-shift: 20px; // 뒤 배경 이동량 (0.3배 느낌)
$front-shift: 40px; // 앞 내용 이동량 (0.5배 느낌)
$color: $subject-cream; // 뒤 배경 원 색 (orange 무대라 cream)
// ── 데모 무대 (zip 에서 지워도 된다) ──────────────────────────
html, body { margin: 0; overflow: hidden; }
.stage {
position: relative;
min-height: 100vh;
margin: 0;
overflow: hidden;
background: $stage-orange;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.parallax {
position: absolute;
inset: 0;
display: grid;
place-items: center;
&__back {
position: absolute;
inset: -20% -20%;
background:
radial-gradient($color 0 60px, transparent 61px) 20% 30%,
radial-gradient($color 0 40px, transparent 41px) 75% 65%;
background-repeat: no-repeat;
opacity: .55;
// ── 실제 사용 코드: 뒤 배경은 스크롤보다 느리게(0.3배) 움직인다 ──
@supports (animation-timeline: scroll()) {
animation: drift-back linear both;
animation-timeline: scroll(root);
}
@supports not (animation-timeline: scroll()) {
transform: none;
}
}
&__front {
position: relative;
z-index: 1;
// ── 실제 사용 코드: 앞 내용은 뒤보다 빠르게(0.5배) 움직인다 ──
@supports (animation-timeline: scroll()) {
animation: drift-front linear both;
animation-timeline: scroll(root);
}
@supports not (animation-timeline: scroll()) {
transform: none;
}
}
&__title {
margin: 0;
padding: 14px 22px;
border-radius: $r-pill;
background: $stage-ink;
color: #fff;
font-weight: 700;
font-size: clamp(16px, 5vw, 20px);
line-height: 1;
font-family: inherit;
}
}
@keyframes drift-back { from { transform: translateY(0); } to { transform: translateY(-#{$back-shift}); } }
@keyframes drift-front { from { transform: translateY(0); } to { transform: translateY(-#{$front-shift}); } }
// ── 데모(.is-demo): 스크롤 대신 2초 왕복 루프로 흉내 ────────
.stage.is-demo .parallax__back {
animation: drift-back-loop $dur-loop ease-in-out infinite;
}
.stage.is-demo .parallax__front {
animation: drift-front-loop $dur-loop ease-in-out infinite;
}
@keyframes drift-back-loop {
0%, 100% { transform: translateY($back-shift); }
50% { transform: translateY(-$back-shift); }
}
@keyframes drift-front-loop {
0%, 100% { transform: translateY($front-shift); }
50% { transform: translateY(-$front-shift); }
}
// ── 접근성: 움직임 줄이기 설정이면 층을 그대로 고정 ────────────
@media (prefers-reduced-motion: reduce) {
.parallax__back, .parallax__front,
.stage.is-demo .parallax__back, .stage.is-demo .parallax__front {
animation: none !important;
transform: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
position: relative;
min-height: 100vh;
margin: 0;
overflow: hidden;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.parallax {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.parallax__back {
position: absolute;
inset: -20% -20%;
background: radial-gradient(#fff7e6 0 60px, transparent 61px) 20% 30%, radial-gradient(#fff7e6 0 40px, transparent 41px) 75% 65%;
background-repeat: no-repeat;
opacity: 0.55;
}
@supports (animation-timeline: scroll()) {
.parallax__back {
animation: drift-back linear both;
animation-timeline: scroll(root);
}
}
@supports not (animation-timeline: scroll()) {
.parallax__back {
transform: none;
}
}
.parallax__front {
position: relative;
z-index: 1;
}
@supports (animation-timeline: scroll()) {
.parallax__front {
animation: drift-front linear both;
animation-timeline: scroll(root);
}
}
@supports not (animation-timeline: scroll()) {
.parallax__front {
transform: none;
}
}
.parallax__title {
margin: 0;
padding: 14px 22px;
border-radius: 999px;
background: #17141a;
color: #fff;
font-weight: 700;
font-size: clamp(16px, 5vw, 20px);
line-height: 1;
font-family: inherit;
}
@keyframes drift-back {
from {
transform: translateY(0);
}
to {
transform: translateY(-20px);
}
}
@keyframes drift-front {
from {
transform: translateY(0);
}
to {
transform: translateY(-40px);
}
}
.stage.is-demo .parallax__back {
animation: drift-back-loop 2s ease-in-out infinite;
}
.stage.is-demo .parallax__front {
animation: drift-front-loop 2s ease-in-out infinite;
}
@keyframes drift-back-loop {
0%, 100% {
transform: translateY(20px);
}
50% {
transform: translateY(-20px);
}
}
@keyframes drift-front-loop {
0%, 100% {
transform: translateY(40px);
}
50% {
transform: translateY(-40px);
}
}
@media (prefers-reduced-motion: reduce) {
.parallax__back, .parallax__front,
.stage.is-demo .parallax__back, .stage.is-demo .parallax__front {
animation: none !important;
transform: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ParallaxLayers.module.scss";
export type ParallaxLayersProps = {
children: ReactNode;
/** 뒤 배경 이동량 (기본 20px, 0.3배 느낌) */
backShift?: number;
/** 앞 내용 이동량 (기본 40px, 0.5배 느낌) */
frontShift?: number;
/** 뒤 배경 원 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--back-shift"?: string; "--front-shift"?: string; "--color"?: string };
export default function ParallaxLayers({
children,
backShift = 20,
frontShift = 40,
color = "#fff7e6",
className = "",
}: ParallaxLayersProps) {
const vars: Vars = {
"--back-shift": `${backShift}px`,
"--front-shift": `${frontShift}px`,
"--color": color,
};
return (
<div className={`${styles.parallax} ${className}`} style={vars}>
<div className={styles.back} aria-hidden="true" />
<div className={styles.front}>{children}</div>
</div>
);
}07 · Parallax layers — Measured area 13.925% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
Use it for hero backgrounds and the breaks between sections.
.layer--back {
animation: drift-back linear both;
animation-timeline: scroll(root);
}
.layer--front {
animation: drift-front linear both;
animation-timeline: scroll(root);
}
@keyframes drift-back {
from { transform: translateY(0); }
to { transform: translateY(-20px); }
}
@keyframes drift-front {
from { transform: translateY(0); }
to { transform: translateY(-40px); }
}
08Count up
What it does
The number counts from 0 to its target the instant it's visible, using @property to register the custom value as an integer so the browser interpolates whole numbers instead of decimals.

<main class="stage">
<div class="stat is-demo">
<p class="stat__num"></p>
<p class="stat__label">Total downloads</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$target: 1280; // 목표 숫자. % 단위면 100
$duration: 1.2s; // 올라가는 시간
$color: $subject-blue; // 숫자 색 (paper 무대라 blue)
// ── 데모 무대 (zip 에서 지워도 된다) ──────────────────────────
@include stage($stage-paper);
// ── 숫자가 담긴 CSS 카운터. @property 로 정수로 등록해야
// 애니메이션 중간값이 정수로 보간된다(소수점 없이 뚝뚝 올라간다) ──
@property --num {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
.stat {
text-align: center;
&__num {
margin: 0;
--num: 0;
counter-reset: num var(--num);
font-weight: 800;
font-size: clamp(36px, 12vw, 56px);
line-height: 1;
font-family: inherit;
color: $color;
&::after { content: counter(num); }
}
&__label { margin: 6px 0 0; font-weight: 500; font-size: 14px; line-height: 1; font-family: inherit; color: $stage-ink; }
}
// ── 실제 사용 코드: 뷰포트에 들어오는 동안 0에서 목표까지 오른다 ──
@supports (animation-timeline: view()) {
.stat__num {
animation: count-up $duration linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
@supports not (animation-timeline: view()) {
.stat__num { --num: #{$target}; }
}
@keyframes count-up {
from { --num: 0; }
to { --num: #{$target}; }
}
// ── 데모(.is-demo): 스크롤 대신 2초 루프로 흉내 ────────────
.stat.is-demo .stat__num {
animation: count-up-loop $dur-loop linear infinite;
}
@keyframes count-up-loop {
0% { --num: 0; }
55% { --num: #{$target}; }
100% { --num: #{$target}; }
}
// ── 접근성: 움직임 줄이기 설정이면 목표 숫자를 바로 보여준다 ────
@media (prefers-reduced-motion: reduce) {
.stat__num, .stat.is-demo .stat__num { animation: none !important; --num: #{$target}; }
}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);
}
@property --num {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
.stat {
text-align: center;
}
.stat__num {
margin: 0;
--num: 0;
counter-reset: num var(--num);
font-weight: 800;
font-size: clamp(36px, 12vw, 56px);
line-height: 1;
font-family: inherit;
color: #2f6df6;
}
.stat__num::after {
content: counter(num);
}
.stat__label {
margin: 6px 0 0;
font-weight: 500;
font-size: 14px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
@supports (animation-timeline: view()) {
.stat__num {
animation: count-up 1.2s linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
@supports not (animation-timeline: view()) {
.stat__num {
--num: 1280;
}
}
@keyframes count-up {
from {
--num: 0;
}
to {
--num: 1280;
}
}
.stat.is-demo .stat__num {
animation: count-up-loop 2s linear infinite;
}
@keyframes count-up-loop {
0% {
--num: 0;
}
55% {
--num: 1280;
}
100% {
--num: 1280;
}
}
@media (prefers-reduced-motion: reduce) {
.stat__num, .stat.is-demo .stat__num {
animation: none !important;
--num: 1280;
}
}import type { CSSProperties } from "react";
import styles from "./CountUp.module.scss";
export type CountUpProps = {
label: string;
/** 목표 숫자 (기본 1280) */
target?: number;
/** 올라가는 시간 (기본 1.2s) */
duration?: string;
/** 숫자 색 (기본 파랑) */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--target"?: number; "--duration"?: string; "--color"?: string };
export default function CountUp({
label,
target = 1280,
duration = "1.2s",
color = "#2f6df6",
className = "",
}: CountUpProps) {
const vars: Vars = { "--target": target, "--duration": duration, "--color": color };
return (
<div className={`${styles.stat} ${className}`} style={vars}>
<p className={styles.num} />
<p className={styles.label}>{label}</p>
</div>
);
}08 · Count up — Measured area 3.302% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
No JavaScript reads the value; the CSS counter does. Use it for stats sections, anywhere a static number would look inert.
@property --num {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
.stat__num {
--num: 0;
counter-reset: num var(--num);
animation: count-up 1.2s linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
&::after { content: counter(num); }
}
@keyframes count-up {
from { --num: 0; }
to { --num: 1280; }
}
09Line-draw timeline
What you see
A vertical line draws itself as you scroll the page, and each dot lights up once the line has passed it.

<main class="stage">
<ul class="timeline is-demo">
<li class="timeline__step"><span class="timeline__dot"></span>Plan</li>
<li class="timeline__step"><span class="timeline__dot"></span>Design</li>
<li class="timeline__step"><span class="timeline__dot"></span>Launch</li>
<li class="timeline__line" aria-hidden="true"></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$thickness: 2px; // 선 두께
$color: $subject-cream; // 선·점 색 (ink 무대라 cream)
$duration: $dur-enter; // 점 하나가 켜지는 시간
// ── 데모 무대 (zip 에서 지워도 된다) ──────────────────────────
@include stage($stage-ink);
// ── 세로 타임라인 ────────────────────────────────────
.timeline {
position: relative;
margin: 0;
padding: 0 0 0 28px;
list-style: none;
color: #fff;
font-weight: 500;
font-size: 14px;
line-height: 1;
font-family: inherit;
&__step {
position: relative;
padding: clamp(4px, 2vw, 6px) 0;
margin-bottom: clamp(10px, 6vw, 26px); // 320×200 에서도 세 단계가 들어가게 줄인다
&:last-of-type { margin-bottom: 0; }
}
&__dot {
position: absolute;
left: -28px;
top: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
background: $color; // 색은 고정. 켜짐/꺼짐은 opacity 로만 표현
opacity: .28;
transform: scale(1);
// ── 실제 사용 코드: 점마다 제 위치에서 뷰포트에 들어오면 켜진다 ──
@supports (animation-timeline: view()) {
animation: dot-light $duration ease-out both;
animation-timeline: view();
animation-range: entry 30% cover 60%;
}
@supports not (animation-timeline: view()) {
opacity: 1;
}
}
// ── 선: 세로로 그어진다. 실제 사용 코드는 문서 스크롤 진행률에 맞춘다 ──
&__line {
position: absolute;
left: -23px;
top: 8px;
width: $thickness;
height: calc(100% - 16px);
background: $color;
transform: scaleY(0);
transform-origin: top; // 규칙8: 세로선 top에서 차오름
@supports (animation-timeline: scroll()) {
animation: line-draw linear both;
animation-timeline: scroll(root);
animation-range: 0 200px;
}
@supports not (animation-timeline: scroll()) {
transform: scaleY(1);
}
}
}
@keyframes dot-light { from { opacity: .28; transform: scale(1); } to { opacity: 1; transform: scale(1.3); } }
@keyframes line-draw { from { transform: scaleY(0); } to { transform: scaleY(1); } }
// ── 데모(.is-demo): 스크롤 대신 2초 루프로 흉내 (선이 먼저, 점은 순서대로) ──
.timeline.is-demo .timeline__line {
animation: line-draw-loop $dur-loop ease-in-out infinite;
}
.timeline.is-demo .timeline__step:nth-child(1) .timeline__dot { animation: dot-loop $dur-loop ease-out infinite; animation-delay: 0ms; }
.timeline.is-demo .timeline__step:nth-child(2) .timeline__dot { animation: dot-loop $dur-loop ease-out infinite; animation-delay: 160ms; }
.timeline.is-demo .timeline__step:nth-child(3) .timeline__dot { animation: dot-loop $dur-loop ease-out infinite; animation-delay: 320ms; }
@keyframes line-draw-loop {
0% { transform: scaleY(0); }
60% { transform: scaleY(1); }
100% { transform: scaleY(1); }
}
@keyframes dot-loop {
0% { opacity: .28; transform: scale(1); }
15% { opacity: 1; transform: scale(1.3); }
70% { opacity: 1; transform: scale(1); }
85% { opacity: .28; transform: scale(1); }
100% { opacity: .28; transform: scale(1); }
}
// ── 접근성: 움직임 줄이기 설정이면 다 켜진 상태로 고정 ────────
@media (prefers-reduced-motion: reduce) {
.timeline__line, .timeline.is-demo .timeline__line { animation: none !important; transform: scaleY(1); }
.timeline__dot, .timeline.is-demo .timeline__step .timeline__dot { animation: none !important; opacity: 1; transform: scale(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);
}
.timeline {
position: relative;
margin: 0;
padding: 0 0 0 28px;
list-style: none;
color: #fff;
font-weight: 500;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.timeline__step {
position: relative;
padding: clamp(4px, 2vw, 6px) 0;
margin-bottom: clamp(10px, 6vw, 26px);
}
.timeline__step:last-of-type {
margin-bottom: 0;
}
.timeline__dot {
position: absolute;
left: -28px;
top: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff7e6;
opacity: 0.28;
transform: scale(1);
}
@supports (animation-timeline: view()) {
.timeline__dot {
animation: dot-light 600ms ease-out both;
animation-timeline: view();
animation-range: entry 30% cover 60%;
}
}
@supports not (animation-timeline: view()) {
.timeline__dot {
opacity: 1;
}
}
.timeline__line {
position: absolute;
left: -23px;
top: 8px;
width: 2px;
height: calc(100% - 16px);
background: #fff7e6;
transform: scaleY(0);
transform-origin: top;
}
@supports (animation-timeline: scroll()) {
.timeline__line {
animation: line-draw linear both;
animation-timeline: scroll(root);
animation-range: 0 200px;
}
}
@supports not (animation-timeline: scroll()) {
.timeline__line {
transform: scaleY(1);
}
}
@keyframes dot-light {
from {
opacity: 0.28;
transform: scale(1);
}
to {
opacity: 1;
transform: scale(1.3);
}
}
@keyframes line-draw {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.timeline.is-demo .timeline__line {
animation: line-draw-loop 2s ease-in-out infinite;
}
.timeline.is-demo .timeline__step:nth-child(1) .timeline__dot {
animation: dot-loop 2s ease-out infinite;
animation-delay: 0ms;
}
.timeline.is-demo .timeline__step:nth-child(2) .timeline__dot {
animation: dot-loop 2s ease-out infinite;
animation-delay: 160ms;
}
.timeline.is-demo .timeline__step:nth-child(3) .timeline__dot {
animation: dot-loop 2s ease-out infinite;
animation-delay: 320ms;
}
@keyframes line-draw-loop {
0% {
transform: scaleY(0);
}
60% {
transform: scaleY(1);
}
100% {
transform: scaleY(1);
}
}
@keyframes dot-loop {
0% {
opacity: 0.28;
transform: scale(1);
}
15% {
opacity: 1;
transform: scale(1.3);
}
70% {
opacity: 1;
transform: scale(1);
}
85% {
opacity: 0.28;
transform: scale(1);
}
100% {
opacity: 0.28;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.timeline__line, .timeline.is-demo .timeline__line {
animation: none !important;
transform: scaleY(1);
}
.timeline__dot, .timeline.is-demo .timeline__step .timeline__dot {
animation: none !important;
opacity: 1;
transform: scale(1);
}
}import type { CSSProperties } from "react";
import styles from "./LineDrawTimeline.module.scss";
export type LineDrawTimelineProps = {
steps: string[];
/** 선 두께 (기본 2px) */
thickness?: number;
/** 선·점 색 (기본 cream) */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--thickness"?: string; "--color"?: string };
export default function LineDrawTimeline({
steps,
thickness = 2,
color = "#fff7e6",
className = "",
}: LineDrawTimelineProps) {
const vars: Vars = { "--thickness": `${thickness}px`, "--color": color };
return (
<ul className={`${styles.timeline} ${className}`} style={vars}>
{steps.map((step) => (
<li key={step} className={styles.step}>
<span className={styles.dot} />
{step}
</li>
))}
<li className={styles.line} aria-hidden="true" />
</ul>
);
}09 · Line-draw timeline — Measured area 0.553% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
Use it for company history, step-by-step guides, and roadmaps, where progress itself is the point.
.timeline__line {
transform-origin: top;
animation: line-draw linear both;
animation-timeline: scroll(root);
animation-range: 0 200px;
}
.timeline__dot {
animation: dot-light $duration ease-out both;
animation-timeline: view();
animation-range: entry 30% cover 60%;
}
@keyframes line-draw {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
@keyframes dot-light {
from { opacity: .28; }
to { opacity: 1; }
}
Where it breaks — the traps
Why animation-timeline, not a scroll listener
All nine use CSS's native animation-timeline instead of a scroll event listener, so nothing runs in JavaScript on every scroll frame, as MDN's guide to scroll-driven animations describes. The first trap is browser support: view() and scroll() timelines are newer than most of CSS, so every file wraps the moving rule in @supports (animation-timeline: view()) and a matching @supports not block. The fallback shows the finished state, which is the difference between a demo that degrades gracefully and one that just stays invisible forever in an older browser. Where the trigger is opening and closing rather than scrolling, that same fallback duty shifts onto @starting-style, and the CSS modal animation patterns work through what it does and doesn't cover.
Trap two — a pixel budget
The second trap is 06's animation-range: 0 200px. That's a fixed pixel budget, not a percentage of the page, so the header only finishes shrinking once you've scrolled past 200px total, and setting it larger than the page's own scrollable distance leaves the header stuck halfway. Those numbers are already tuned against the actual demo pages, and the zip's password is x5xz5uh4 if you'd rather stop reading and open it now.
Tying a number to scroll the way 08 does collects all three of these traps at once, which is why counting up on its own timing — no scroll involved — is gathered separately in the CSS number counter animations.
Trap three — the preview frame
The third trap is the preview above: the grid sits inside a fixed 480×300 iframe, which is too short to scroll for real, so every demo falls back to a two-second .is-demo loop instead of the genuine scroll-timeline. To see 05's bar or 09's line actually respond to your scroll position, open the vanilla file directly and scroll the page yourself; the loop is a stand-in, not the real behavior.
Accessibility
Reduced motion
Every demo answers prefers-reduced-motion, but the fallback isn't always "turn it off" — sometimes it's "skip straight to the result." 01 through 04 drop their animation and show the finished state immediately: opacity 1, the wipe fully open, the curtain already pulled back. 08 sets --num straight to its target number instead of counting up, and 09 draws the line and lights every dot without the sweep. 06 and 07 lock into their end state too, since a permanently shrunk header and still parallax layers aren't decorative enough to keep once motion is off. MDN's guide to prefers-reduced-motion covers browser support for the query.
@media (prefers-reduced-motion: reduce) {
.card, .list__item, .headline, .frame__curtain { animation: none !important; }
.head, .timeline__line { animation: none !important; transform: scaleY(1); }
.stat__num { animation: none !important; --num: 1280; }
}
More scroll animation css examples live in the CSS category, and what this site actually is gets explained on the about page.
FAQ
Is this really a css animation on scroll without javascript?
No. Every animation here runs on animation-timeline: view() or scroll(), which is a CSS property, not a scroll listener. The React folder in the zip only wraps the same SCSS in components; it doesn't drive any of the actual motion with JS.
What's the difference between view() and scroll()?
view() times an animation to one element's own trip through the viewport, so 01 through 04, 08, and the dots in 09 use it. scroll() instead reads how far the whole document has moved, which is what drives 05, 06, 07, and the line in 09. Pick view() when you're animating an element into place, and scroll() when the motion should track total scroll progress.
Can I use these with Tailwind or a React build?
Yes. Tailwind's utility classes and this SCSS don't conflict, since the motion lives entirely in animation-timeline and keyframes rather than in classes Tailwind would also want to control. The zip's react/ folder has one component per effect that takes the same variables as props, so nothing needs rewriting to fit a component library.









