【コピペOK】スクロール アニメーション CSS のみ 9選
スクロール アニメーション css は、要素が画面に入った量やページ全体の読了率を animation-timeline に渡し、JavaScript を1行も書かずに動かす仕組みです。この記事では9種類を選び、view() と scroll(root) という2つのタイムラインだけで実装しました。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
並び順は効果の種類ではなく、実際にページを開いてから閉じるまでの順番です。最初に見出しやカードが現れ(01〜04)、読んでいる間はページ全体の進み具合を伝え(05〜07)、最後は数字と年表で結果を残します(08〜09)。HTML と CSS だけでスクロール アニメーションを完結させるため、JavaScript のファイルは zip に1つも入っていません。項目ごとに要点となるコードだけを載せ、完全なファイルは zip の中です。 07 のパララックス1項目では層が足りないときは、それだけを9通りに広げたCSS パララックス スクロール9選があり、03 の文字ワイプから進んで文そのものを動かしたい場合はCSS 文字 アニメーション9選が続きになります。
01ふわっと表示
動きの内容
スクロールで要素が画面に入ると、下から24pxふわっと持ち上がりながらフェードインします。

<main class="stage">
<article class="card is-demo">
<p class="card__eyebrow">SECTION</p>
<h2 class="card__title">ふわっと浮かび上がる</h2>
<p class="card__body">スクロールして表示されると、下からふわっと浮かび上がって現れます。</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 · ふわっと表示 — 計測 累積面積 38.606% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
html css スクロール アニメーションの使いどころ
IntersectionObserver ではなく animation-timeline: view() だけで動くので、JavaScript は0行です。見出しやカード、段落などほぼすべての登場に使えます。
.card {
@supports (animation-timeline: view()) {
animation: fade-up-reveal $ease-out 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(24px); }
to { opacity: 1; transform: translateY(0); }
}
02順番に表示
どんな動きか
リストの各項目に同じアニメーションを指定するだけで、スクロールに合わせて上から順番に現れます。

<main class="stage">
<ul class="list is-demo">
<li class="list__item" style="--i:0">スピード配送</li>
<li class="list__item" style="--i:1">かんたん決済</li>
<li class="list__item" style="--i:2">リアルタイム在庫</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 · 順番に表示 — 計測 累積面積 34.529% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
JavaScript の setTimeout で遅延を作る必要はありません。機能一覧や料金表、メンバー紹介に向いています。
.list__item {
@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(16px); }
to { opacity: 1; transform: translateY(0); }
}
03文字ワイプ
何が起きるか
見出しが clip-path ひとつで、左から右へワイプするように現れます。

<main class="stage">
<h1 class="headline is-demo">文字がすっと現れる</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 · 文字ワイプ — 計測 累積面積 2.99% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
width や left ではなく clip-path だけを動かすので、レイアウトの再計算が起きません。ヒーローの見出しや大きな中見出しに向いています。
.headline {
@supports (animation-timeline: view()) {
animation: wipe-in $ease-spring 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); }
}
04画像カーテン
動きの内容
画像を覆う色板が scaleX で右へたたまれるように消え、下の画像が現れます。

<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 · 画像カーテン — 計測 累積面積 40.756% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使う場面
ポートフォリオや商品写真の一覧で、読み込みの一瞬を隠す効果です。
.frame__curtain {
transform: scaleX(1);
transform-origin: right;
@supports (animation-timeline: view()) {
animation: curtain-open $ease-spring 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); }
}
05読了プログレスバー
どんな動きか
ページ上部の3pxバーが、読んだ分だけ左から伸びていきます。

<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 · 読了プログレスバー — 計測 累積面積 1.0% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
view() ではなく scroll(root) を使うのが要点で、「画面に入ったか」ではなく「文書全体のどこまで読んだか」を表します。長い記事やドキュメントに向いています。
.bar {
position: fixed;
top: 0; left: 0;
width: 100%;
height: 3px;
transform: scaleX(0);
transform-origin: left;
@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); }
}
06縮むヘッダー
何が起きるか
position: sticky のヘッダーが、スクロールが進むほど scaleY で薄くなります。

<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 · 縮むヘッダー — 計測 累積面積 7.342% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
height ではなく transform を動かすのは、height のアニメーションがブラウザに毎フレームのレイアウト計算をさせるためです。LP やブログのヘッダーに向いています。
.head {
position: sticky;
top: 0;
transform-origin: top;
@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(.75); }
}
07パララックス
動きの内容
背景の円が0.3倍、前景のタイトルが0.5倍の速さで動き、奥行きが生まれます。

<main class="stage is-demo">
<div class="parallax">
<div class="parallax__back" aria-hidden="true"></div>
<div class="parallax__front">
<h2 class="parallax__title">奥行きが生まれる</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 · パララックス — 計測 累積面積 13.925% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使う場面
同じ scroll(root) を使いながら移動量だけを変えるので、レイヤーが増えても仕組みは1つのままです。
.parallax__back {
@supports (animation-timeline: scroll()) {
animation: drift-back linear both;
animation-timeline: scroll(root);
}
}
.parallax__front {
@supports (animation-timeline: scroll()) {
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); } }
08カウントアップ
どんな動きか
数字が見えた瞬間に0から目標値まで上がります。

<main class="stage">
<div class="stat is-demo">
<p class="stat__num"></p>
<p class="stat__label">累計ダウンロード数</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 · カウントアップ — 計測 累積面積 3.302% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
@property で --num を整数として登録するので、JavaScript なしで小数点のない数字がひとつずつ増えます。実績や統計セクションに向いています。
@property --num {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
.stat__num {
--num: 0;
counter-reset: num var(--num);
&::after { content: counter(num); }
animation: count-up 1.2s linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
@keyframes count-up {
from { --num: 0; }
to { --num: 1280; }
}
09線が伸びるタイムライン
何が起きるか
縦線は scroll(root) で文書の進み具合に合わせて伸び、通過した点は view() で点灯します。

<main class="stage">
<ul class="timeline is-demo">
<li class="timeline__step"><span class="timeline__dot"></span>企画</li>
<li class="timeline__step"><span class="timeline__dot"></span>デザイン</li>
<li class="timeline__step"><span class="timeline__dot"></span>リリース</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 · 線が伸びるタイムライン — 計測 累積面積 0.553% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
1つの効果の中で scroll() と view() を組み合わせているのがここだけの特徴です。沿革や手順、ロードマップに向いています。
.timeline__line {
transform: scaleY(0);
transform-origin: top;
@supports (animation-timeline: scroll()) {
animation: line-draw linear both;
animation-timeline: scroll(root);
animation-range: 0 200px;
}
}
.timeline__dot {
@supports (animation-timeline: view()) {
animation: dot-light 320ms 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; transform: scale(1); } to { opacity: 1; transform: scale(1.3); } }
どこで壊れるか — 落とし穴
なぜ animation-timeline だけなのか
css アニメーション フェードイン スクロールを含め、9つとも animation-timeline だけで動く理由は、IntersectionObserver を使うと JavaScript がスクロールのたびに要素の位置を再計算し、メインスレッドが混雑するからです。MDN の CSS scroll-driven animations にあるとおり、animation-timeline を使えばこの計算はブラウザのコンポジター側で行われ、メインスレッドをふさぎません。1つ目は05と06でやりがちなことで、animation-range を entry 0% cover 100% のように広く取ると、要素が画面の外にいる間もアニメーションが進んでしまい、スクロールを戻すと変な位置で止まって見えます。
2つ目 — 線と点のずれ
2つ目は、css アニメーション 線が伸びる スクロールの09を最初に作ったときのことです。線と点を同じ animation-range に合わせたら、線が伸び切る前に最後の点だけ先に光ってしまいました。線は scroll(root) で文書全体の進み具合に、点は view() で自分の位置に合わせるよう分けたところ、この崩れは直りました。
3つ目 — ブラウザ対応
3つ目はブラウザです。animation-timeline: view() と scroll() は Chrome・Edge 115 以降と Safari 26 以降が対応済みで、Firefox は 2026 年 9 月時点の安定版ではフラグの裏にあり、@supports で分岐していない実装だと非対応ブラウザで要素が透明なまま止まってしまいます。zip のファイルはすべて @supports not (animation-timeline: view()) のような分岐で最終状態をそのまま表示するようにしてあり、対応状況は MDN の animation-timeline 対応表 で確認できます。修正済みの9ファイルを開くには x5xz5uh4 という文字列が必要です。下のカードから zip を取ってください。
08 のカウントアップのように数字をスクロールへ結び付けると、この3つの落とし穴が一度に効いてきます。スクロールから切り離して数字だけを増やす方法は、CSS 数字 カウントアップ アニメーション9選にまとめてあります。
アクセシビリティ
動きを減らす設定のとき
9つとも prefers-reduced-motion: reduce で animation: none になり、スクロールしても動きません。ただし05のプログレスバーと09の線は「動きを止める」と「情報を消す」が別物なので、transform: scaleX(1) や scaleY(1) を残して、進捗や到達済みの状態だけは見えるようにしてあります。08のカウントアップだけは例外的に、動きを止めると同時に目標の数字をそのまま表示します。
@media (prefers-reduced-motion: reduce) {
.card, .headline, .stat__num { animation: none !important; }
.bar, .timeline__line { animation: none !important; transform: scaleX(1); }
}
スクロール以外の CSS アニメーションは CSS アニメーションのカテゴリ に、このサイトについては 紹介ページ にまとめています。
FAQ
スクロール アニメーション css のみで9つすべてを1つのページに入れても大丈夫ですか?
入れられますが、animation-timeline: scroll(root) を使う05と07は1つの文書につき1つが基本です。scroll(root) は文書全体の進み具合を見ているので、同じ考え方の効果を何個も置くと、どれがどこまで進んだか分かりにくくなります。view() を使う01〜04・08・09は要素ごとに独立しているので、いくつ置いても構いません。
React ではクラスの付け外しの代わりに何を使いますか?
zip の react/ フォルダのコンポーネントは IntersectionObserver ではなく、CSS の animation-timeline をそのまま使っています。08のカウントアップだけは目標値を --num という CSS カスタムプロパティに props で渡し、JavaScript は数値を計算しません。それ以外の8つはクラス名を1つ付けるだけで、状態管理は不要です。 きっかけがスクロールではなく開閉になる場面では、同じ「動きをJSに任せない」考え方が@starting-styleの側へ移り、その道筋はCSS モーダル アニメーション9選に書いてあります。
スマホでも同じように動きますか?
はい、動きます。iOS の Safari と Android の Chrome はどちらも animation-timeline: view() と scroll() に対応しているので、タッチでスクロールしても同じアニメーションが再生されます。ただし06の縮むヘッダーはアドレスバーの表示・非表示でビューポートの高さが変わる端末があるため、animation-range の数値をそのままにすると縮むタイミングが数十ピクセルずれることがあります。









