CSS 스크롤 애니메이션 9종 — AOS·GSAP 없이 JS 0줄로
CSS 스크롤 애니메이션 아홉 개를 모았습니다. AOS·GSAP 없이 브라우저 내장 animation-timeline 하나로 스크롤 위치 자체를 애니메이션 시계로 씁니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 발견되는 순서가 아니라 배워야 하는 순서로 놓았습니다. css 스크롤 내리면 나타나는 애니메이션인 01~04는 요소 하나가 뷰포트에 들어오는 순간을 다루는 view() 타임라인이고, 05~07은 문서 전체 스크롤 진행률을 다루는 scroll() 타임라인입니다. 이 둘을 섞어 쓰면 뭐가 뭘 움직이는지 헷갈리므로 항목마다 둘 중 하나만 씁니다. 08~09는 앞의 두 타임라인 위에 카운터·다중 요소 같은 응용을 얹은 것이라 맨 뒤에 뒀습니다. 07 패럴랙스 한 항목만으로 모자라 층을 더 깊게 쌓고 싶으면 CSS 패럴랙스 스크롤 9종이 그 항목만 아홉 배로 늘린 편이고, 03 글자 닦아내기에서 출발해 문장 자체를 다루고 싶다면 CSS 텍스트 애니메이션 효과로 건너가면 됩니다.
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
css 등장 애니메이션은 어디에 쓰나
섹션 제목이나 카드처럼 거의 모든 등장에 씁니다.
.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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
언제 쓰나
항목마다 같은 view() 규칙 하나를 걸면, 세로로 쌓인 배치 자체가 시차를 만들어 별도의 지연 값 없이도 순서대로 보입니다.
.list__item {
@supports (animation-timeline: view()) {
animation: stagger-in ease-out both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
@keyframes stagger-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
03글자 닦아내기
무엇이 보이나
제목이 왼쪽에서 오른쪽으로 닦여 나옵니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
clip-path 하나만 바뀌므로 레이아웃을 다시 계산하지 않습니다.
.headline {
@supports (animation-timeline: view()) {
animation: wipe-in cubic-bezier(.2, .8, .2, 1) 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이미지 커튼
화면에서 일어나는 일
이미지를 덮은 색 판이 오른쪽으로 걷히며 이미지가 드러납니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
쓰는 자리
포트폴리오나 상품 사진 앞에 씁니다.
.frame__curtain {
transform: scaleX(1);
transform-origin: right;
@supports (animation-timeline: view()) {
animation: curtain-open cubic-bezier(.2, .8, .2, 1) both;
animation-timeline: view();
animation-range: entry 10% cover 50%;
}
}
@keyframes curtain-open {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
05읽기 진행 바
어떤 움직임인가
문서를 얼마나 읽었는지 상단 바가 채워집니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
언제 쓰나
여기서만 view()가 아니라 scroll()을 씁니다. "들어왔나"가 아니라 "얼마나 지나왔나"를 재기 때문입니다.
.bar {
transform: scaleX(0);
transform-origin: left;
@supports (animation-timeline: scroll()) {
animation: fill-bar linear both;
animation-timeline: scroll(root);
}
}
@keyframes fill-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
06헤더 줄어들기
무엇이 보이나
스크롤하면 고정 헤더가 얇아집니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
height가 아니라 transform: scaleY로 줄이므로 옆 요소가 밀리지 않습니다.
.head {
transform-origin: top;
@supports (animation-timeline: scroll()) {
animation: shrink-header linear both;
animation-timeline: scroll(root);
animation-range: 0 200px;
}
}
@keyframes shrink-header {
from { transform: scaleY(1); }
to { transform: scaleY(.75); }
}
07패럴랙스
화면에서 일어나는 일
뒤 배경이 앞 내용보다 느리게 움직여 깊이가 생깁니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
쓰는 자리
두 레이어를 각각 scroll(root)에 걸고 이동량만 다르게 줍니다.
.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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
언제 쓰나
@property로 정수 타입을 등록해 JS 없이도 소수점 없이 뚝뚝 올라갑니다.
@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; }
}
09선이 그어지는 타임라인
무엇이 보이나
스크롤한 만큼 세로 선이 그어지고, 지나간 점이 밝아집니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
점은 배경색을 바꾸지 않고 opacity와 transform: scale만 씁니다.
.timeline__dot {
opacity: .28;
@supports (animation-timeline: view()) {
animation: dot-light 600ms ease-out both;
animation-timeline: view();
animation-range: entry 30% cover 60%;
}
}
@keyframes dot-light {
from { opacity: .28; transform: scale(1); }
to { opacity: 1; transform: scale(1.3); }
}
어디서 깨지는가 — 함정 셋
브라우저 지원
첫 번째는 브라우저 지원입니다. 스크롤 타임라인은 아직 모든 브라우저가 같지 않습니다. Chrome·Edge는 115부터, Safari는 26부터 animation-timeline: view()와 scroll()을 정식 지원하고, Firefox는 2026년 9월 현재 정식판에서 플래그 뒤에 있습니다. caniuse의 지원 현황에서 최신 상태를 확인할 수 있습니다. 그래서 아홉 개 전부 @supports not (animation-timeline: view()) 블록으로 미지원 브라우저에서는 움직이지 않고 그냥 보여주는 규칙을 넣었습니다. 감추는 게 아니라 지금은 그게 진짜 전부입니다.
조상의 overflow hidden
두 번째는 조상 요소의 overflow: hidden입니다. view() 타임라인은 요소가 스크롤 가능한 조상 안에서 지나가는 위치를 재는데, 애니메이션이 걸린 요소와 뷰포트 사이 어딘가에 overflow: hidden이 끼면 컨테이너가 끊겨 타임라인이 시작하지 않거나 멈춘 것처럼 보입니다. 이 zip의 데모 무대에 있는 overflow: hidden은 화면 밖 html·body에만 걸었고, 애니메이션이 걸린 요소와 그 부모 사이에는 걸지 않았습니다.
모바일
css 마우스 스크롤 애니메이션은 마우스 휠이 있는 데스크톱 이야기고, 세 번째는 모바일입니다. iOS 26 이전의 Safari는 animation-timeline을 지원하지 않아 대체 규칙이 그대로 최종 화면으로 남고, 안드로이드 크롬은 지원하지만 관성 스크롤 중에는 값이 듬성듬성 갱신돼 데스크톱만큼 매끄럽지 않습니다. 아홉 개 파일 모두 이 세 가지를 실제로 렌더해 겪고 다듬었고, 압축 비밀번호는 x5xz5uh4입니다.
08 카운트업처럼 숫자를 스크롤에 묶으면 이 세 함정이 한꺼번에 걸리는데, 스크롤과 떼어 놓고 숫자만 올리는 방법은 CSS 숫자 증가 애니메이션에 따로 모아 뒀습니다.
접근성
동작 줄이기를 켰을 때
시스템에서 "동작 줄이기"를 켠 사용자를 위해 아홉 개 전부 prefers-reduced-motion: reduce에서 애니메이션을 끄고, 움직이지 않아도 뜻이 통하는 최종 상태만 남깁니다. 01·03·04는 결과(등장·닦임·이미지)를 즉시 보여주고, 05·06은 진행 바와 헤더를 이미 채워진 모습으로 고정하며, 08은 숫자를 목표값으로 고정하고, 09는 선과 점을 이미 켜진 모습으로 둡니다. 이 미디어 쿼리의 세부는 MDN prefers-reduced-motion에서 볼 수 있습니다.
@media (prefers-reduced-motion: reduce) {
.card, .headline, .frame__curtain, .bar, .head,
.stat__num, .timeline__line, .timeline__dot {
animation: none !important;
}
}
다른 CSS 스크롤 애니메이션·SCSS 주제는 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 뭘 하는 곳인지는 소개에 있습니다.
FAQ
css 스크롤 애니메이션 효과에 왜 IntersectionObserver 대신 CSS를 쓰나요?
IntersectionObserver는 JS로 요소의 등장을 감지해 클래스를 토글하는 방식이라 스크립트가 로드되기 전에는 아무 일도 없거나, 반대로 처음부터 숨겨 둔 요소가 스크립트 실패 시 영영 안 보일 위험이 있습니다. animation-timeline은 브라우저가 스크롤 위치를 직접 애니메이션 값으로 쓰므로 JS가 아예 없어도 항상 같은 결과를 냅니다. 다만 지원 브라우저가 한정적이라 @supports 대체 규칙은 꼭 같이 둬야 합니다. 스크롤이 아니라 열림·닫힘이 방아쇠라면 JS를 최소로 줄이는 길이 또 다른데, 그 경우는 @starting-style을 쓰는 CSS 모달 팝업 애니메이션이 같은 문제를 다른 문법으로 풉니다.
Safari에서도 그대로 움직이나요?
Safari 26부터는 그대로 움직입니다. 그 이전 버전은 animation-timeline을 모르기 때문에, 이 zip의 아홉 개는 모두 미지원 시 "움직이지 않고 그냥 보여주기" 대체 규칙을 갖고 있습니다. 지원 폭은 계속 바뀌므로 배포 전 caniuse에서 최신 상태를 다시 확인하는 걸 권합니다.
React 컴포넌트에서도 같은 스크롤 효과가 되나요?
됩니다. 스크롤 타임라인은 JS가 아니라 CSS 엔진이 계산하므로 react/ 폴더의 컴포넌트도 SCSS 모듈 안에 같은 animation-timeline·animation-range 규칙을 그대로 두면 됩니다. 컴포넌트는 $duration·$color처럼 변수 3개만 props로 받아 CSS 변수로 넘기고, 스크롤 감지 로직은 하나도 새로 만들 필요가 없습니다.









