9 CSS Timeline Animation Ideas, Copy-Paste
A CSS timeline animation is a line of dots, bars, or cards that draws or fills itself to show steps in order — a company's history, an order's four
01 timeline-stepper-animation-vertical-line-draw
02 timeline-stepper-animation-dot-pop-stagger
03 timeline-stepper-animation-horizontal-steps-fill
04 timeline-stepper-animation-active-step-pulse
05 timeline-stepper-animation-card-alternate-slide
06 timeline-stepper-animation-checkout-stepper
07 timeline-stepper-animation-scroll-progress-line
08 timeline-stepper-animation-view-reveal-events
09 timeline-stepper-animation-sibling-index-numbering
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Vertical line draw
- 02 Dot stagger pop
- 03 Horizontal steps fill
- 04 Active step pulse
- 05 Alternating card slide
- 06 Checkout stepper
- 07 Scroll-drawn progress line (scroll())
- 08 Events reveal via view()
- 09 Auto numbering via sibling-index()
These nine aren't ranked by how flashy they look — they follow the order a timeline actually gets built in. First come the two ways the line itself gets drawn (01–02), then two ways a horizontal stepper shows progress (03–04), then two ways cards or checks layer onto content already on the page (05–06), and last, three that hand scroll position and list order straight to CSS instead of a script (07–09). Dressing a page's opening scene in time order belongs to the hero section animations, and the button that sends readers back to the top once the story ends is the back to top button.
01Vertical line draw
What the user notices
Scroll down to a vertical timeline — a company history, a résumé's work history — and instead of the line just sitting there, it grows from top to bottom like a pen stroke as the list comes into view.

<main class="stage">
<ul class="ts01-timeline is-demo">
<li class="ts01-step"><span class="ts01-dot"></span>Plan</li>
<li class="ts01-step"><span class="ts01-dot"></span>Design</li>
<li class="ts01-step"><span class="ts01-dot"></span>Build</li>
<li class="ts01-step"><span class="ts01-dot"></span>Launch</li>
<li class="ts01-line" aria-hidden="true"></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.ts01-timeline {
position: relative;
margin: 0;
padding: 0 0 0 32px;
list-style: none;
color: $subject-cream;
font: 500 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ts01-step {
position: relative;
padding: $sp-1 0;
margin-bottom: clamp($sp-4, 6vw, $sp-6);
&:last-of-type { margin-bottom: 0; }
}
.ts01-dot {
position: absolute;
left: -28px;
top: 3px;
width: 12px;
height: 12px;
border-radius: 50%;
background: $subject-cream;
border: 2px solid $stage-ink;
box-shadow: 0 0 0 2px $color;
}
// 세로선: 위에서 아래로 그려진다 (실제 사용 코드 — 뷰포트 진입 시 한 번)
.ts01-line {
position: absolute;
left: -23px;
top: 4px;
width: 3px;
height: calc(100% - 12px);
background: $color;
border-radius: $r-xs;
transform: scaleY(1);
transform-origin: top; // 규칙8: 세로선 위에서 그려짐
@supports (animation-timeline: view()) {
transform: scaleY(0);
animation: ts01-draw linear both;
animation-timeline: view();
animation-range: entry 0% cover 70%;
}
}
@keyframes ts01-draw {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
// 데모 루프: 뷰포트 진입을 2초 주기로 흉내
.ts01-timeline.is-demo .ts01-line {
animation: ts01-draw-loop $duration $easing infinite;
}
@keyframes ts01-draw-loop {
0%, 8% { transform: scaleY(0); }
75%, 100% { transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
.ts01-line, .ts01-timeline.is-demo .ts01-line { animation: none !important; transform: scaleY(1) !important; }
}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);
}
.ts01-timeline {
position: relative;
margin: 0;
padding: 0 0 0 32px;
list-style: none;
color: #fff7e6;
font: 500 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ts01-step {
position: relative;
padding: 4px 0;
margin-bottom: clamp(16px, 6vw, 24px);
}
.ts01-step:last-of-type {
margin-bottom: 0;
}
.ts01-dot {
position: absolute;
left: -28px;
top: 3px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff7e6;
border: 2px solid #17141a;
box-shadow: 0 0 0 2px #2f6df6;
}
.ts01-line {
position: absolute;
left: -23px;
top: 4px;
width: 3px;
height: calc(100% - 12px);
background: #2f6df6;
border-radius: 4px;
transform: scaleY(1);
transform-origin: top;
}
@supports (animation-timeline: view()) {
.ts01-line {
transform: scaleY(0);
animation: ts01-draw linear both;
animation-timeline: view();
animation-range: entry 0% cover 70%;
}
}
@keyframes ts01-draw {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.ts01-timeline.is-demo .ts01-line {
animation: ts01-draw-loop 600ms ease-out infinite;
}
@keyframes ts01-draw-loop {
0%, 8% {
transform: scaleY(0);
}
75%, 100% {
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.ts01-line, .ts01-timeline.is-demo .ts01-line {
animation: none !important;
transform: scaleY(1) !important;
}
}import { CSSProperties } from "react";
import styles from "./VerticalLineDraw.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface VerticalLineDrawProps {
duration?: string;
easing?: string;
color?: string;
steps?: string[];
}
export default function VerticalLineDraw({
duration = "600ms",
easing = "ease-out",
color = "#2f6df6",
steps = ["기획", "디자인", "개발", "출시"],
}: VerticalLineDrawProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<ul className={styles.timeline} style={style}>
{steps.map((s) => (
<li key={s} className={styles.step}>
<span className={styles.dot} />
{s}
</li>
))}
<li className={styles.line} aria-hidden="true" />
</ul>
);
}01 · Vertical line draw — Measured area 0.368% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to wire it up
The whole effect is one transform: scaleY(0) growing to scaleY(1) with transform-origin: top.
.rail {
width: 3px;
height: 100%;
background: $color;
transform-origin: top;
transform: scaleY(1);
@supports (animation-timeline: view()) {
transform: scaleY(0);
animation: draw linear both;
animation-timeline: view();
animation-range: entry 0% cover 70%;
}
}
@keyframes draw {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
02Dot stagger pop
Where this shows up
The dots sitting along that line pop in one after another from top to bottom, each one bouncing past full size before it settles — a fit for onboarding checklists or order-status steps.

<main class="stage">
<div class="ts02-row is-demo">
<span class="ts02-dot"></span>
<span class="ts02-dot"></span>
<span class="ts02-dot"></span>
<span class="ts02-dot"></span>
<span class="ts02-dot"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-pop;
$color: $stage-ink;
@include stage($stage-yellow);
.ts02-row {
display: flex;
align-items: center;
gap: $sp-4;
}
.ts02-dot {
width: 28px;
height: 28px;
border-radius: 50%;
background: $color;
transform: scale(0);
opacity: 0;
}
// 실제 사용 코드: 뷰포트에 들어오면 순서대로 팝 (nth-child 델레이만 다르다)
@for $i from 1 through 5 {
.ts02-row .ts02-dot:nth-child(#{$i}) {
animation-delay: #{($i - 1) * 140}ms;
}
}
.ts02-row.is-demo .ts02-dot {
animation: ts02-pop $duration $easing infinite;
}
@keyframes ts02-pop {
0% { transform: scale(0); opacity: 0; }
10% { transform: scale(1.25); opacity: 1; }
20% { transform: scale(1); opacity: 1; }
85% { transform: scale(1); opacity: 1; }
92% { transform: scale(0); opacity: 0; }
100% { transform: scale(0); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ts02-dot, .ts02-row.is-demo .ts02-dot { animation: none !important; transform: scale(1) !important; opacity: 1 !important; }
}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);
}
.ts02-row {
display: flex;
align-items: center;
gap: 16px;
}
.ts02-dot {
width: 28px;
height: 28px;
border-radius: 50%;
background: #17141a;
transform: scale(0);
opacity: 0;
}
.ts02-row .ts02-dot:nth-child(1) {
animation-delay: 0ms;
}
.ts02-row .ts02-dot:nth-child(2) {
animation-delay: 140ms;
}
.ts02-row .ts02-dot:nth-child(3) {
animation-delay: 280ms;
}
.ts02-row .ts02-dot:nth-child(4) {
animation-delay: 420ms;
}
.ts02-row .ts02-dot:nth-child(5) {
animation-delay: 560ms;
}
.ts02-row.is-demo .ts02-dot {
animation: ts02-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes ts02-pop {
0% {
transform: scale(0);
opacity: 0;
}
10% {
transform: scale(1.25);
opacity: 1;
}
20% {
transform: scale(1);
opacity: 1;
}
85% {
transform: scale(1);
opacity: 1;
}
92% {
transform: scale(0);
opacity: 0;
}
100% {
transform: scale(0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ts02-dot, .ts02-row.is-demo .ts02-dot {
animation: none !important;
transform: scale(1) !important;
opacity: 1 !important;
}
}import { CSSProperties } from "react";
import styles from "./DotPopStagger.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface DotPopStaggerProps {
duration?: string;
easing?: string;
color?: string;
count?: number;
stepDelay?: number;
}
export default function DotPopStagger({
duration = "2s",
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#17141a",
count = 5,
stepDelay = 140,
}: DotPopStaggerProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.row} style={style}>
{Array.from({ length: count }).map((_, i) => (
<span key={i} className={styles.dot} style={{ animationDelay: `${i * stepDelay}ms` }} />
))}
</div>
);
}02 · Dot stagger pop — Measured area 4.049% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password
The core of it
Every dot shares one keyframe; only animation-delay differs, generated per nth-child by an SCSS @for loop instead of nine hand-written rules.
.dot {
width: 28px;
height: 28px;
border-radius: 50%;
background: $color;
transform: scale(0);
opacity: 0;
animation: pop 2s cubic-bezier(.2, 1.4, .4, 1) both;
}
@for $i from 1 through 5 {
.dot:nth-child(#{$i}) { animation-delay: #{($i - 1) * 140}ms; }
}
@keyframes pop {
0% { transform: scale(0); opacity: 0; }
60% { transform: scale(1.25); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
03Horizontal steps fill
Where a css stepper animation fits
In a sign-up flow or survey laid out left to right, the connector between two steps fills in color the moment the step before it finishes, so one glance at the bar shows exactly how far someone got.

<main class="stage">
<div class="ts03-steps is-demo">
<div class="ts03-step"><span class="ts03-circle">1</span><span class="ts03-label">Sign up</span></div>
<span class="ts03-bar"><span class="ts03-fill"></span></span>
<div class="ts03-step"><span class="ts03-circle">2</span><span class="ts03-label">Profile</span></div>
<span class="ts03-bar"><span class="ts03-fill"></span></span>
<div class="ts03-step"><span class="ts03-circle">3</span><span class="ts03-label">Done</span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-ink; // 주황 무대 위 전경은 잉크 — 크림은 3.11:1
@include stage($stage-orange);
.ts03-steps {
display: flex;
align-items: flex-start;
width: min(320px, 88vw);
}
.ts03-step {
flex-grow: 0;
flex-shrink: 0;
flex-basis: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
color: $color;
font: 600 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ts03-circle {
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, .18);
color: $color;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
// 연결선: 왼쪽부터 채워진다 (transform-origin: left, scaleX)
.ts03-bar {
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
height: 3px;
margin-top: 16px;
background: rgba(255, 255, 255, .22);
border-radius: $r-xs;
overflow: hidden;
display: block;
}
.ts03-fill {
display: block;
width: 100%;
height: 100%;
background: $color;
transform: scaleX(0);
transform-origin: left; // 규칙8: 연결선 왼쪽부터 채움
}
// 실제 사용 코드: 단계가 끝나면 JS 가 .is-done 을 붙이고 채워진다
.ts03-step.is-done .ts03-circle { background: $subject-cream; color: $subject-orange-deep; } // 채워진 원 안 숫자 — 크림 면 위 deep 5.04:1
.ts03-step.is-done + .ts03-bar .ts03-fill { transform: scaleX(1); }
// 데모 루프: 1 → 2 → 3 순서로 채워지는 걸 2초에 한 번
.ts03-steps.is-demo .ts03-step:nth-child(1) .ts03-circle { animation: ts03-done $duration $easing infinite; }
.ts03-steps.is-demo .ts03-bar:nth-child(2) .ts03-fill { animation: ts03-fill $duration $easing infinite; animation-delay: .3s; }
.ts03-steps.is-demo .ts03-step:nth-child(3) .ts03-circle { animation: ts03-done $duration $easing infinite; animation-delay: .9s; }
.ts03-steps.is-demo .ts03-bar:nth-child(4) .ts03-fill { animation: ts03-fill $duration $easing infinite; animation-delay: 1.2s; }
@keyframes ts03-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes ts03-done { from { background: rgba(255, 255, 255, .18); } to { background: $subject-cream; } }
@media (prefers-reduced-motion: reduce) {
.ts03-fill, .ts03-steps.is-demo .ts03-fill { animation: none !important; transform: scaleX(1) !important; }
.ts03-steps.is-demo .ts03-circle { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ts03-steps {
display: flex;
align-items: flex-start;
width: min(320px, 88vw);
}
.ts03-step {
flex-grow: 0;
flex-shrink: 0;
flex-basis: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #17141a;
font: 600 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ts03-circle {
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.18);
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
.ts03-bar {
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
height: 3px;
margin-top: 16px;
background: rgba(255, 255, 255, 0.22);
border-radius: 4px;
overflow: hidden;
display: block;
}
.ts03-fill {
display: block;
width: 100%;
height: 100%;
background: #17141a;
transform: scaleX(0);
transform-origin: left;
}
.ts03-step.is-done .ts03-circle {
background: #fff7e6;
color: #c93000;
}
.ts03-step.is-done + .ts03-bar .ts03-fill {
transform: scaleX(1);
}
.ts03-steps.is-demo .ts03-step:nth-child(1) .ts03-circle {
animation: ts03-done 300ms ease-out infinite;
}
.ts03-steps.is-demo .ts03-bar:nth-child(2) .ts03-fill {
animation: ts03-fill 300ms ease-out infinite;
animation-delay: 0.3s;
}
.ts03-steps.is-demo .ts03-step:nth-child(3) .ts03-circle {
animation: ts03-done 300ms ease-out infinite;
animation-delay: 0.9s;
}
.ts03-steps.is-demo .ts03-bar:nth-child(4) .ts03-fill {
animation: ts03-fill 300ms ease-out infinite;
animation-delay: 1.2s;
}
@keyframes ts03-fill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes ts03-done {
from {
background: rgba(255, 255, 255, 0.18);
}
to {
background: #fff7e6;
}
}
@media (prefers-reduced-motion: reduce) {
.ts03-fill, .ts03-steps.is-demo .ts03-fill {
animation: none !important;
transform: scaleX(1) !important;
}
.ts03-steps.is-demo .ts03-circle {
animation: none !important;
}
}import { CSSProperties } from "react";
import styles from "./HorizontalStepsFill.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface HorizontalStepsFillProps {
duration?: string;
easing?: string;
color?: string;
labels?: string[];
doneCount?: number;
}
export default function HorizontalStepsFill({
duration = "600ms",
easing = "ease-out",
color = "#17141a",
labels = ["가입", "프로필", "완료"],
doneCount = 2,
}: HorizontalStepsFillProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.steps} style={style}>
{labels.map((label, i) => (
<div key={label} style={{ display: "contents" }}>
<div className={styles.step}>
<span className={`${styles.circle} ${i < doneCount ? styles.done : ""}`}>{i + 1}</span>
<span>{label}</span>
</div>
{i < labels.length - 1 && (
<span className={styles.bar}>
<span className={`${styles.fill} ${i < doneCount - 1 ? styles.filled : ""}`} />
</span>
)}
</div>
))}
</div>
);
}03 · Horizontal steps fill — Measured area 1.597% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The idea behind it
scaleX grows against the connector's own width, so neither step needs to know how wide its neighbor is.
.bar {
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
height: 3px;
overflow: hidden;
}
.fill {
width: 100%;
height: 100%;
background: $color;
transform: scaleX(0);
transform-origin: left;
}
.step.is-done + .bar .fill { transform: scaleX(1); }
04Active step pulse
What it looks like
Behind the step currently being worked on — an order being packed, a live notification badge — a ring positioned behind the circle expands outward and fades on a loop, reading like a radar ping that marks "this one, right now." It's a separate .ring element sitting behind the circle, not a pseudo-element on the circle itself, so the number inside stays sharp while only the ring fades and grows.

<main class="stage">
<div class="ts04-track">
<span class="ts04-node is-done"></span>
<span class="ts04-line is-done"></span>
<span class="ts04-step is-demo">
<span class="ts04-ring"></span>
<span class="ts04-circle">3</span>
</span>
<span class="ts04-line"></span>
<span class="ts04-node"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.ts04-track {
display: flex;
align-items: center;
gap: 0;
}
.ts04-node {
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(23, 20, 26, .16);
flex: 0 0 auto;
&.is-done { background: $color; }
}
.ts04-line {
width: 40px;
height: 3px;
background: rgba(23, 20, 26, .16);
flex: 0 0 auto;
&.is-done { background: $color; }
}
.ts04-step {
position: relative;
width: 40px;
height: 40px;
flex: 0 0 auto;
display: grid;
place-items: center;
}
// 지금 처리 중인 단계: 뒤에서 링이 번지는 펄스
.ts04-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid $color;
opacity: 0;
}
.ts04-step.is-demo .ts04-ring {
animation: ts04-pulse $duration $easing infinite;
}
@keyframes ts04-pulse {
0% { transform: scale(.7); opacity: .8; }
70% { transform: scale(1.8); opacity: 0; }
100% { transform: scale(1.8); opacity: 0; }
}
.ts04-circle {
width: 32px;
height: 32px;
border-radius: 50%;
background: $subject-blue-deep; // 크림 글자가 얹히므로 진한 파랑
color: $subject-cream;
display: grid;
place-items: center;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.ts04-ring, .ts04-step.is-demo .ts04-ring { animation: none !important; opacity: .6 !important; transform: scale(1) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ts04-track {
display: flex;
align-items: center;
gap: 0;
}
.ts04-node {
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.16);
flex: 0 0 auto;
}
.ts04-node.is-done {
background: #2f6df6;
}
.ts04-line {
width: 40px;
height: 3px;
background: rgba(23, 20, 26, 0.16);
flex: 0 0 auto;
}
.ts04-line.is-done {
background: #2f6df6;
}
.ts04-step {
position: relative;
width: 40px;
height: 40px;
flex: 0 0 auto;
display: grid;
place-items: center;
}
.ts04-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid #2f6df6;
opacity: 0;
}
.ts04-step.is-demo .ts04-ring {
animation: ts04-pulse 2s ease-out infinite;
}
@keyframes ts04-pulse {
0% {
transform: scale(0.7);
opacity: 0.8;
}
70% {
transform: scale(1.8);
opacity: 0;
}
100% {
transform: scale(1.8);
opacity: 0;
}
}
.ts04-circle {
width: 32px;
height: 32px;
border-radius: 50%;
background: #0a4fe8;
color: #fff7e6;
display: grid;
place-items: center;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.ts04-ring, .ts04-step.is-demo .ts04-ring {
animation: none !important;
opacity: 0.6 !important;
transform: scale(1) !important;
}
}import { CSSProperties } from "react";
import styles from "./ActiveStepPulse.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface ActiveStepPulseProps {
duration?: string;
easing?: string;
color?: string;
activeNumber?: number;
}
export default function ActiveStepPulse({
duration = "1.6s",
easing = "ease-out",
color = "#2f6df6",
activeNumber = 3,
}: ActiveStepPulseProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.track} style={style}>
<span className={`${styles.node} ${styles.done}`} />
<span className={`${styles.line} ${styles.done}`} />
<span className={styles.step}>
<span className={styles.ring} />
<span className={styles.circle}>{activeNumber}</span>
</span>
<span className={styles.line} />
<span className={styles.node} />
</div>
);
}04 · Active step pulse — Measured area 2.033% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it is done this way
.ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid $color;
animation: pulse 1.6s ease-out infinite;
}
@keyframes pulse {
0% { transform: scale(.7); opacity: .8; }
70% { transform: scale(1.8); opacity: 0; }
100% { transform: scale(1.8); opacity: 0; }
}
05Alternating card slide
When to use it
On a project-milestone page or a company-history layout, cards line up on alternating sides of a center rail and slide in as each one scrolls into view — odd cards arriving from the left, even cards from the right, fading in at the same time.

<main class="stage">
<div class="ts05-timeline is-demo">
<div class="ts05-row ts05-row--l"><div class="ts05-card">2024.01</div></div>
<div class="ts05-row ts05-row--r"><div class="ts05-card">2024.06</div></div>
<div class="ts05-row ts05-row--l"><div class="ts05-card">2024.12</div></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue-deep; // 크림 글자가 얹히므로 진한 파랑
@include stage($stage-ink);
.ts05-timeline {
position: relative;
width: min(280px, 84vw);
display: flex;
flex-direction: column;
gap: $sp-4;
// 가운데 세로선
&::before {
content: "";
position: absolute;
left: 50%;
top: 0;
width: 2px;
height: 100%;
background: rgba(255, 247, 230, .18);
transform: translateX(-50%);
}
}
.ts05-row {
display: flex;
&--l { justify-content: flex-start; }
&--r { justify-content: flex-end; }
}
.ts05-card {
width: 44%;
box-sizing: border-box;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: $color;
color: $subject-cream;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
opacity: 0;
}
// 실제 사용 코드: 뷰포트에 들어오면 홀수는 왼쪽, 짝수는 오른쪽에서 모인다
.ts05-row--l .ts05-card { transform: translateX(-24px); }
.ts05-row--r .ts05-card { transform: translateX(24px); }
@supports (animation-timeline: view()) {
.ts05-row--l .ts05-card { animation: ts05-in-l $duration $easing both; animation-timeline: view(); animation-range: entry 0% cover 60%; }
.ts05-row--r .ts05-card { animation: ts05-in-r $duration $easing both; animation-timeline: view(); animation-range: entry 0% cover 60%; }
}
@supports not (animation-timeline: view()) {
.ts05-card { opacity: 1; transform: none; }
}
@keyframes ts05-in-l { from { opacity: 0; transform: translateX(-24px); } to { opacity: 1; transform: translateX(0); } }
@keyframes ts05-in-r { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: translateX(0); } }
// 데모 루프: 위에서부터 순서대로
.ts05-timeline.is-demo .ts05-row--l .ts05-card { animation: ts05-in-l $duration $easing infinite; }
.ts05-timeline.is-demo .ts05-row--r .ts05-card { animation: ts05-in-r $duration $easing infinite; animation-delay: .18s; }
.ts05-timeline.is-demo .ts05-row:nth-child(3) .ts05-card { animation-delay: .36s; }
@media (prefers-reduced-motion: reduce) {
.ts05-card, .ts05-timeline.is-demo .ts05-card { animation: none !important; opacity: 1 !important; transform: none !important; }
}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);
}
.ts05-timeline {
position: relative;
width: min(280px, 84vw);
display: flex;
flex-direction: column;
gap: 16px;
}
.ts05-timeline::before {
content: "";
position: absolute;
left: 50%;
top: 0;
width: 2px;
height: 100%;
background: rgba(255, 247, 230, 0.18);
transform: translateX(-50%);
}
.ts05-row {
display: flex;
}
.ts05-row--l {
justify-content: flex-start;
}
.ts05-row--r {
justify-content: flex-end;
}
.ts05-card {
width: 44%;
box-sizing: border-box;
padding: 8px 12px;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
opacity: 0;
}
.ts05-row--l .ts05-card {
transform: translateX(-24px);
}
.ts05-row--r .ts05-card {
transform: translateX(24px);
}
@supports (animation-timeline: view()) {
.ts05-row--l .ts05-card {
animation: ts05-in-l 2s cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-timeline: view();
animation-range: entry 0% cover 60%;
}
.ts05-row--r .ts05-card {
animation: ts05-in-r 2s cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-timeline: view();
animation-range: entry 0% cover 60%;
}
}
@supports not (animation-timeline: view()) {
.ts05-card {
opacity: 1;
transform: none;
}
}
@keyframes ts05-in-l {
from {
opacity: 0;
transform: translateX(-24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes ts05-in-r {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.ts05-timeline.is-demo .ts05-row--l .ts05-card {
animation: ts05-in-l 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ts05-timeline.is-demo .ts05-row--r .ts05-card {
animation: ts05-in-r 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 0.18s;
}
.ts05-timeline.is-demo .ts05-row:nth-child(3) .ts05-card {
animation-delay: 0.36s;
}
@media (prefers-reduced-motion: reduce) {
.ts05-card, .ts05-timeline.is-demo .ts05-card {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}import { CSSProperties } from "react";
import styles from "./AlternatingCardSlide.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface AlternatingCardSlideProps {
duration?: string;
easing?: string;
color?: string;
dates?: string[];
}
export default function AlternatingCardSlide({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#0a4fe8",
dates = ["2024.01", "2024.06", "2024.12"],
}: AlternatingCardSlideProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.timeline} style={style}>
{dates.map((d, i) => (
<div key={d} className={`${styles.row} ${i % 2 === 0 ? styles.rowL : styles.rowR}`}>
<div className={styles.card} style={{ animationDelay: `${i * 180}ms` }}>{d}</div>
</div>
))}
</div>
);
}05 · Alternating card slide — Measured area 8.283% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Worth noting
Each side gets its own keyframe rather than one shared one, which turns out to matter — the trap section below explains why.
.row--l .card { transform: translateX(-24px); }
.row--r .card { transform: translateX(24px); }
@supports (animation-timeline: view()) {
.row--l .card { animation: in-l ease-out both; animation-timeline: view(); animation-range: entry 0% cover 60%; }
.row--r .card { animation: in-r ease-out both; animation-timeline: view(); animation-range: entry 0% cover 60%; }
}
@keyframes in-l { from { opacity: 0; transform: translateX(-24px); } to { opacity: 1; transform: translateX(0); } }
@keyframes in-r { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: translateX(0); } }
06Checkout stepper
What you see
In a four-step checkout or booking form, a finished step swaps its number for a checkmark that draws itself stroke by stroke, and the connector to the next step fills in right after.

<main class="stage">
<div class="ts06-steps is-demo">
<div class="ts06-step">
<span class="ts06-circle" aria-label="Done">
<svg class="ts06-check" viewBox="0 0 16 16" fill="none" aria-hidden="true"><polyline points="3,8 7,12 13,4" /></svg>
<span class="ts06-num">1</span>
</span>
<span class="ts06-label">Cart</span>
</div>
<span class="ts06-bar"><span class="ts06-fill"></span></span>
<div class="ts06-step">
<span class="ts06-circle" aria-label="Done">
<svg class="ts06-check" viewBox="0 0 16 16" fill="none" aria-hidden="true"><polyline points="3,8 7,12 13,4" /></svg>
<span class="ts06-num">2</span>
</span>
<span class="ts06-label">Shipping</span>
</div>
<span class="ts06-bar"><span class="ts06-fill"></span></span>
<div class="ts06-step ts06-step--active">
<span class="ts06-circle"><span class="ts06-num">3</span></span>
<span class="ts06-label">Payment</span>
</div>
<span class="ts06-bar"></span>
<div class="ts06-step">
<span class="ts06-circle"><span class="ts06-num">4</span></span>
<span class="ts06-label">Done</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $stage-ink;
@include stage($stage-yellow);
.ts06-steps {
display: flex;
align-items: flex-start;
width: min(340px, 90vw);
}
.ts06-step {
flex-grow: 0;
flex-shrink: 0;
flex-basis: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
color: $color;
font: 600 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ts06-circle {
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(23, 20, 26, .12);
display: grid;
place-items: center;
}
.ts06-num { font-weight: 700; font-size: 13px; line-height: 1; font-family: inherit; }
// 체크 아이콘: SVG polyline 을 stroke-dashoffset 으로 그린다 (BD16: 막대 두 개 사선 대신 안전한 방법)
.ts06-check {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 8px;
box-sizing: border-box;
opacity: 0;
polyline {
fill: none;
stroke: $subject-cream;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 16;
stroke-dashoffset: 16;
}
}
.ts06-num { opacity: 0; }
// 실제 사용 코드: 끝난 단계는 JS 가 .is-checked 를 붙이면 체크가 그려지고 숫자는 사라진다
.ts06-step.is-checked .ts06-circle { background: $color; }
.ts06-step.is-checked .ts06-check { opacity: 1; }
.ts06-step.is-checked .ts06-check polyline { animation: ts06-draw .5s $easing forwards; }
.ts06-step.is-checked .ts06-num { display: none; }
.ts06-step:not(.is-checked) .ts06-num { opacity: 1; }
.ts06-step--active .ts06-circle { background: $color; color: $subject-cream; }
.ts06-step--active .ts06-num { opacity: 1; color: $subject-cream; }
.ts06-step.is-checked + .ts06-bar .ts06-fill { transform: scaleX(1); }
.ts06-bar {
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
height: 3px;
margin-top: 16px;
background: rgba(23, 20, 26, .14);
border-radius: $r-xs;
overflow: hidden;
display: block;
}
.ts06-fill {
display: block;
width: 100%;
height: 100%;
background: $color;
transform: scaleX(0);
transform-origin: left; // 규칙8: 체크아웃 연결선 좌측 채움
}
@keyframes ts06-draw { from { stroke-dashoffset: 16; } to { stroke-dashoffset: 0; } }
// 데모 루프: 1·2 는 이미 완료, 3(결제)이 활성 상태로 펄스
.ts06-steps.is-demo .ts06-step--active .ts06-circle { animation: ts06-active-pulse 1.2s $easing infinite; }
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-circle,
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-circle { background: $color; }
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-check,
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-check { opacity: 1; }
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-num,
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-num { display: none; }
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-check polyline { animation: ts06-draw-loop $duration $easing infinite; }
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-check polyline { animation: ts06-draw-loop $duration $easing infinite; animation-delay: .35s; }
.ts06-steps.is-demo .ts06-bar:nth-child(2) .ts06-fill { animation: ts06-fill-loop $duration $easing infinite; }
.ts06-steps.is-demo .ts06-bar:nth-child(4) .ts06-fill { animation: ts06-fill-loop $duration $easing infinite; animation-delay: .35s; }
@keyframes ts06-draw-loop {
0%, 5% { stroke-dashoffset: 16; }
35%, 100% { stroke-dashoffset: 0; }
}
@keyframes ts06-fill-loop {
0%, 8% { transform: scaleX(0); }
40%, 100% { transform: scaleX(1); }
}
@keyframes ts06-active-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(23, 20, 26, .35); }
50% { box-shadow: 0 0 0 6px rgba(23, 20, 26, 0); }
}
@media (prefers-reduced-motion: reduce) {
.ts06-check polyline, .ts06-steps.is-demo .ts06-check polyline { animation: none !important; stroke-dashoffset: 0 !important; }
.ts06-fill, .ts06-steps.is-demo .ts06-fill { animation: none !important; }
.ts06-steps.is-demo .ts06-step--active .ts06-circle { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ts06-steps {
display: flex;
align-items: flex-start;
width: min(340px, 90vw);
}
.ts06-step {
flex-grow: 0;
flex-shrink: 0;
flex-basis: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #17141a;
font: 600 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ts06-circle {
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.12);
display: grid;
place-items: center;
}
.ts06-num {
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
.ts06-check {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 8px;
box-sizing: border-box;
opacity: 0;
}
.ts06-check polyline {
fill: none;
stroke: #fff7e6;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 16;
stroke-dashoffset: 16;
}
.ts06-num {
opacity: 0;
}
.ts06-step.is-checked .ts06-circle {
background: #17141a;
}
.ts06-step.is-checked .ts06-check {
opacity: 1;
}
.ts06-step.is-checked .ts06-check polyline {
animation: ts06-draw 0.5s ease-out forwards;
}
.ts06-step.is-checked .ts06-num {
display: none;
}
.ts06-step:not(.is-checked) .ts06-num {
opacity: 1;
}
.ts06-step--active .ts06-circle {
background: #17141a;
color: #fff7e6;
}
.ts06-step--active .ts06-num {
opacity: 1;
color: #fff7e6;
}
.ts06-step.is-checked + .ts06-bar .ts06-fill {
transform: scaleX(1);
}
.ts06-bar {
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
height: 3px;
margin-top: 16px;
background: rgba(23, 20, 26, 0.14);
border-radius: 4px;
overflow: hidden;
display: block;
}
.ts06-fill {
display: block;
width: 100%;
height: 100%;
background: #17141a;
transform: scaleX(0);
transform-origin: left;
}
@keyframes ts06-draw {
from {
stroke-dashoffset: 16;
}
to {
stroke-dashoffset: 0;
}
}
.ts06-steps.is-demo .ts06-step--active .ts06-circle {
animation: ts06-active-pulse 1.2s ease-out infinite;
}
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-circle,
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-circle {
background: #17141a;
}
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-check,
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-check {
opacity: 1;
}
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-num,
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-num {
display: none;
}
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-check polyline {
animation: ts06-draw-loop 2s ease-out infinite;
}
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-check polyline {
animation: ts06-draw-loop 2s ease-out infinite;
animation-delay: 0.35s;
}
.ts06-steps.is-demo .ts06-bar:nth-child(2) .ts06-fill {
animation: ts06-fill-loop 2s ease-out infinite;
}
.ts06-steps.is-demo .ts06-bar:nth-child(4) .ts06-fill {
animation: ts06-fill-loop 2s ease-out infinite;
animation-delay: 0.35s;
}
@keyframes ts06-draw-loop {
0%, 5% {
stroke-dashoffset: 16;
}
35%, 100% {
stroke-dashoffset: 0;
}
}
@keyframes ts06-fill-loop {
0%, 8% {
transform: scaleX(0);
}
40%, 100% {
transform: scaleX(1);
}
}
@keyframes ts06-active-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(23, 20, 26, 0.35);
}
50% {
box-shadow: 0 0 0 6px rgba(23, 20, 26, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.ts06-check polyline, .ts06-steps.is-demo .ts06-check polyline {
animation: none !important;
stroke-dashoffset: 0 !important;
}
.ts06-fill, .ts06-steps.is-demo .ts06-fill {
animation: none !important;
}
.ts06-steps.is-demo .ts06-step--active .ts06-circle {
animation: none !important;
}
}import { CSSProperties } from "react";
import styles from "./CheckoutStepper.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface CheckoutStepperProps {
duration?: string;
easing?: string;
color?: string;
labels?: string[];
activeIndex?: number;
}
export default function CheckoutStepper({
duration = "500ms",
easing = "ease-out",
color = "#17141a",
labels = ["장바구니", "배송", "결제", "완료"],
activeIndex = 2,
}: CheckoutStepperProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.steps} style={style}>
{labels.map((label, i) => (
<div key={label} style={{ display: "contents" }}>
<div className={styles.step}>
<span className={`${styles.circle} ${i < activeIndex ? styles.checked : ""} ${i === activeIndex ? styles.active : ""}`} aria-label={i < activeIndex ? "완료" : undefined}>
{i < activeIndex ? (
<svg className={styles.check} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<polyline points="3,8 7,12 13,4" />
</svg>
) : (
<span className={styles.num}>{i + 1}</span>
)}
</span>
<span>{label}</span>
</div>
{i < labels.length - 1 && (
<span className={styles.bar}>
<span className={`${styles.fill} ${i < activeIndex - 1 ? styles.filled : ""}`} />
</span>
)}
</div>
))}
</div>
);
}06 · Checkout stepper — Measured area 0.77% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
The key property
The check is a real SVG polyline animated with stroke-dashoffset, not two rotated bars, so there's no seam where the strokes could land out of alignment.
.check polyline {
stroke-dasharray: 16;
stroke-dashoffset: 16;
}
.step.is-checked .check polyline {
animation: draw .5s ease-out forwards;
}
@keyframes draw { to { stroke-dashoffset: 0; } }
.step.is-checked + .bar .fill { transform: scaleX(1); }
07Scroll-drawn progress line (scroll())
Where it fits
Inside a long article's side rail or a scrollable timeline box, the vertical line draws itself in exact lockstep with how far the reader has scrolled — no scroll listener, no requestAnimationFrame loop, just one declaration: animation-timeline: scroll(nearest block).

<main class="stage">
<div class="ts07-box">
<span class="ts07-rail is-demo"></span>
<div class="ts07-content">
<div class="ts07-item">09:00</div>
<div class="ts07-item">09:20</div>
<div class="ts07-item">10:00</div>
<div class="ts07-item">10:40</div>
<div class="ts07-item">11:10</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink; // 주황 무대 위 전경은 잉크 — 크림은 3.11:1
@include stage($stage-orange);
.ts07-box {
position: relative;
width: min(220px, 80vw);
height: clamp(120px, 62vh, 200px);
overflow-y: auto;
padding: $sp-2 0 $sp-2 $sp-6;
box-sizing: border-box;
background: rgba(255, 247, 230, .25); // 잉크 글자를 받는 상자 — 먹 틴트(4.32:1) 대신 크림 틴트 6.98:1
border-radius: $r-sm;
}
.ts07-content { position: relative; }
.ts07-rail {
position: absolute;
left: $sp-2;
top: 0;
width: 3px;
height: 100%;
background: $color;
border-radius: $r-xs;
transform-origin: top; // 규칙8: 스크롤 진행선 위 기준 채움
transform: scaleY(0);
}
// 실제 사용 코드: 상자를 안에서 스크롤하면 JS 없이 진행률만큼 그려진다
@supports (animation-timeline: scroll()) {
.ts07-rail:not(.is-demo) {
animation: ts07-draw linear both;
animation-timeline: scroll(nearest block);
}
}
// 미지원 브라우저(Firefox·Safari): 다 그려진 최종 상태로 고정
@supports not (animation-timeline: scroll()) {
.ts07-rail:not(.is-demo) { transform: scaleY(1); }
}
@keyframes ts07-draw { from { transform: scaleY(0); } to { transform: scaleY(1); } }
.ts07-item {
padding: $sp-2 0;
color: $color;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
border-bottom: 1px solid rgba(23, 20, 26, .14);
}
// 데모 루프: 실제 스크롤 대신 2초 주기로 진행률을 흉내(캡처용)
.ts07-rail.is-demo { animation: ts07-draw-loop $duration $easing infinite; }
@keyframes ts07-draw-loop { 0% { transform: scaleY(0); } 100% { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) {
.ts07-rail, .ts07-rail.is-demo { animation: none !important; transform: scaleY(1) !important; }
}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);
}
.ts07-box {
position: relative;
width: min(220px, 80vw);
height: clamp(120px, 62vh, 200px);
overflow-y: auto;
padding: 8px 0 8px 24px;
box-sizing: border-box;
background: rgba(255, 247, 230, 0.25);
border-radius: 8px;
}
.ts07-content {
position: relative;
}
.ts07-rail {
position: absolute;
left: 8px;
top: 0;
width: 3px;
height: 100%;
background: #17141a;
border-radius: 4px;
transform-origin: top;
transform: scaleY(0);
}
@supports (animation-timeline: scroll()) {
.ts07-rail:not(.is-demo) {
animation: ts07-draw linear both;
animation-timeline: scroll(nearest block);
}
}
@supports not (animation-timeline: scroll()) {
.ts07-rail:not(.is-demo) {
transform: scaleY(1);
}
}
@keyframes ts07-draw {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.ts07-item {
padding: 8px 0;
color: #17141a;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
border-bottom: 1px solid rgba(23, 20, 26, 0.14);
}
.ts07-rail.is-demo {
animation: ts07-draw-loop 2s linear infinite;
}
@keyframes ts07-draw-loop {
0% {
transform: scaleY(0);
}
100% {
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.ts07-rail, .ts07-rail.is-demo {
animation: none !important;
transform: scaleY(1) !important;
}
}import { CSSProperties } from "react";
import styles from "./ScrollProgressLine.module.scss";
type Vars = CSSProperties & { "--color"?: string };
export interface ScrollProgressLineProps {
color?: string;
times?: string[];
}
// animation-timeline: scroll() 는 브라우저 지원이 갈린다 — Firefox·Safari 는 아직 없다.
// 지원 브라우저에서는 이 상자를 안에서 스크롤하면 JS 없이 옆 선이 진행률만큼 그려진다.
export default function ScrollProgressLine({
color = "#17141a",
times = ["09:00", "09:20", "10:00", "10:40", "11:10"],
}: ScrollProgressLineProps) {
const style: Vars = { "--color": color };
return (
<div className={styles.box} style={style}>
<span className={styles.rail} />
<div className={styles.content}>
{times.map((t) => (
<div key={t} className={styles.item}>{t}</div>
))}
</div>
</div>
);
}07 · Scroll-drawn progress line (scroll()) — Measured area 0.373% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to read in the code
.rail {
width: 3px;
height: 100%;
background: $color;
transform-origin: top;
transform: scaleY(1);
}
@supports (animation-timeline: scroll()) {
.rail { transform: scaleY(0); animation: draw linear both; animation-timeline: scroll(nearest block); }
}
@supports not (animation-timeline: scroll()) {
.rail { transform: scaleY(1); }
}
@keyframes draw { to { transform: scaleY(1); } }
08Events reveal via view()
What moves
In a scroll-revealed event timeline or a long-form article's section breaks, each card sets its own animation-timeline: view() with an animation-range, so opacity and translateY move automatically as that one card crosses into the viewport — no IntersectionObserver, no per-card JS at all.

<main class="stage">
<div class="ts08-box">
<div class="ts08-card is-demo">09:00</div>
<div class="ts08-card is-demo">09:20</div>
<div class="ts08-card is-demo">10:00</div>
<div class="ts08-card is-demo">10:40</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep; // 크림 글자가 얹히므로 진한 파랑
@include stage($stage-paper);
.ts08-box {
width: min(240px, 84vw);
height: clamp(120px, 66vh, 220px);
overflow-y: auto;
padding: $sp-2;
box-sizing: border-box;
background: rgba(23, 20, 26, .05);
border-radius: $r-sm;
}
.ts08-card {
padding: $sp-4;
margin-bottom: $sp-2;
border-radius: $r-sm;
background: $color;
color: $subject-cream;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
opacity: 0;
transform: translateY(16px);
&:last-of-type { margin-bottom: 0; }
}
// 실제 사용 코드: 카드마다 자기가 스크롤로 보이는 구간에 맞춰 자동으로 드러난다
@supports (animation-timeline: view()) {
.ts08-card:not(.is-demo) {
animation: ts08-reveal $easing both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
// 미지원 브라우저(Firefox·Safari): 다 드러난 최종 상태로 고정
@supports not (animation-timeline: view()) {
.ts08-card:not(.is-demo) { opacity: 1; transform: none; }
}
@keyframes ts08-reveal { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
// 데모 루프: 실제 스크롤 대신, 카드가 하나씩 드러나는 걸 순서대로 흉내(캡처용)
.ts08-card.is-demo { animation: ts08-reveal-loop $duration $easing infinite; }
.ts08-card.is-demo:nth-child(1) { animation-delay: 0ms; }
.ts08-card.is-demo:nth-child(2) { animation-delay: 220ms; }
.ts08-card.is-demo:nth-child(3) { animation-delay: 440ms; }
.ts08-card.is-demo:nth-child(4) { animation-delay: 660ms; }
@keyframes ts08-reveal-loop {
0% { opacity: 0; transform: translateY(16px); }
18% { opacity: 1; transform: translateY(0); }
85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(16px); }
}
@media (prefers-reduced-motion: reduce) {
.ts08-card, .ts08-card.is-demo { animation: none !important; opacity: 1 !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ts08-box {
width: min(240px, 84vw);
height: clamp(120px, 66vh, 220px);
overflow-y: auto;
padding: 8px;
box-sizing: border-box;
background: rgba(23, 20, 26, 0.05);
border-radius: 8px;
}
.ts08-card {
padding: 16px;
margin-bottom: 8px;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
opacity: 0;
transform: translateY(16px);
}
.ts08-card:last-of-type {
margin-bottom: 0;
}
@supports (animation-timeline: view()) {
.ts08-card:not(.is-demo) {
animation: ts08-reveal ease-out both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
@supports not (animation-timeline: view()) {
.ts08-card:not(.is-demo) {
opacity: 1;
transform: none;
}
}
@keyframes ts08-reveal {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ts08-card.is-demo {
animation: ts08-reveal-loop 2s ease-out infinite;
}
.ts08-card.is-demo:nth-child(1) {
animation-delay: 0ms;
}
.ts08-card.is-demo:nth-child(2) {
animation-delay: 220ms;
}
.ts08-card.is-demo:nth-child(3) {
animation-delay: 440ms;
}
.ts08-card.is-demo:nth-child(4) {
animation-delay: 660ms;
}
@keyframes ts08-reveal-loop {
0% {
opacity: 0;
transform: translateY(16px);
}
18% {
opacity: 1;
transform: translateY(0);
}
85% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(16px);
}
}
@media (prefers-reduced-motion: reduce) {
.ts08-card, .ts08-card.is-demo {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}import { CSSProperties } from "react";
import styles from "./ViewRevealEvents.module.scss";
type Vars = CSSProperties & { "--color"?: string };
export interface ViewRevealEventsProps {
color?: string;
times?: string[];
}
// animation-timeline: view() 도 scroll() 과 같은 지원 범위다 — IntersectionObserver 없이
// 카드마다 자기가 뷰포트에 들어오는 구간에서 opacity·translateY 가 자동으로 움직인다.
export default function ViewRevealEvents({
color = "#0a4fe8",
times = ["09:00", "09:20", "10:00", "10:40"],
}: ViewRevealEventsProps) {
const style: Vars = { "--color": color };
return (
<div className={styles.box} style={style}>
{times.map((t) => (
<div key={t} className={styles.card}>{t}</div>
))}
</div>
);
}08 · Events reveal via view() — Measured area 29.524% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it is built
.card {
opacity: 1;
}
@supports (animation-timeline: view()) {
.card {
opacity: 0;
transform: translateY(16px);
animation: reveal ease-out both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
@supports not (animation-timeline: view()) {
.card { opacity: 1; transform: none; }
}
@keyframes reveal { to { opacity: 1; transform: translateY(0); } }
09Auto numbering via sibling-index()
The moment it fires
In a drag-to-reorder checklist, or any step list where items get added and removed often, each step sets counter-set: n calc(sibling-index()) so its ::before content numbers itself off the DOM's actual sibling order — no nth-child rule to rewrite every time the list changes.

<main class="stage">
<ul class="ts09-list is-demo">
<li class="ts09-step" aria-label="Step 1: Sign up">Sign up</li>
<li class="ts09-step" aria-label="Step 2: Shipping">Shipping</li>
<li class="ts09-step" aria-label="Step 3: Payment">Payment</li>
<li class="ts09-step" aria-label="Step 4: Done">Done</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.ts09-list {
margin: 0;
padding: 0;
list-style: none;
width: min(200px, 76vw);
counter-reset: n 0;
}
.ts09-step {
position: relative;
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-2 0 $sp-2 $sp-10;
color: $subject-cream;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
opacity: 0;
transform: scale(.7);
// 기본(폴백): 순서대로 자동 증가하는 CSS 카운터
counter-increment: n;
&::before {
content: counter(n);
position: absolute;
left: 0;
width: 28px;
height: 28px;
border-radius: 50%;
background: $color;
color: $subject-cream;
display: grid;
place-items: center;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
}
// 실제 사용 코드: sibling-index() 를 지원하면 순서가 바뀌어도(드래그 정렬) nth-child 없이 번호가 맞는다
@supports (z-index: calc(sibling-index())) {
.ts09-step {
counter-increment: none;
counter-set: n calc(sibling-index());
animation-delay: calc((sibling-index() - 1) * 120ms);
}
}
.ts09-list.is-demo .ts09-step {
animation: ts09-in $duration $easing infinite;
}
@keyframes ts09-in {
0% { opacity: 0; transform: scale(.7); }
20% { opacity: 1; transform: scale(1.08); }
32% { opacity: 1; transform: scale(1); }
90% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(.7); }
}
@media (prefers-reduced-motion: reduce) {
.ts09-step, .ts09-list.is-demo .ts09-step { animation: none !important; opacity: 1 !important; transform: none !important; }
}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);
}
.ts09-list {
margin: 0;
padding: 0;
list-style: none;
width: min(200px, 76vw);
counter-reset: n 0;
}
.ts09-step {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0 8px 40px;
color: #fff7e6;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
opacity: 0;
transform: scale(0.7);
counter-increment: n;
}
.ts09-step::before {
content: counter(n);
position: absolute;
left: 0;
width: 28px;
height: 28px;
border-radius: 50%;
background: #2f6df6;
color: #fff7e6;
display: grid;
place-items: center;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
@supports (z-index: calc(sibling-index())) {
.ts09-step {
counter-increment: none;
counter-set: n calc(sibling-index());
animation-delay: calc((sibling-index() - 1) * 120ms);
}
}
.ts09-list.is-demo .ts09-step {
animation: ts09-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes ts09-in {
0% {
opacity: 0;
transform: scale(0.7);
}
20% {
opacity: 1;
transform: scale(1.08);
}
32% {
opacity: 1;
transform: scale(1);
}
90% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.7);
}
}
@media (prefers-reduced-motion: reduce) {
.ts09-step, .ts09-list.is-demo .ts09-step {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}import { CSSProperties } from "react";
import styles from "./SiblingIndexNumbering.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface SiblingIndexNumberingProps {
duration?: string;
easing?: string;
color?: string;
steps?: string[];
}
export default function SiblingIndexNumbering({
duration = "2s",
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
steps = ["가입", "배송", "결제", "완료"],
}: SiblingIndexNumberingProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<ul className={styles.list} style={style}>
{steps.map((s, i) => (
<li key={s} className={styles.step} aria-label={`${i + 1}번째 단계: ${s}`}>{s}</li>
))}
</ul>
);
}09 · Auto numbering via sibling-index() — Measured area 4.478% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it works
animation-delay is computed from that same sibling-index(), so the stagger stays correct too.
.step {
counter-increment: n;
&::before { content: counter(n); }
}
@supports (z-index: calc(sibling-index())) {
.step {
counter-increment: none;
counter-set: n calc(sibling-index());
animation-delay: calc((sibling-index() - 1) * 120ms);
}
}
Where does this break?
What goes wrong
The real bug in this set wasn't a css animation timeline scroll support gap — it was a typo that left item 05 invisible in actual use while its own demo gif looked completely normal. The live @supports (animation-timeline: view()) rule pointed every card at one keyframe name, in, that was never defined anywhere in the file; only in-l and in-r existed. A browser given an animation-name that resolves to nothing just skips that rule, so .card's base opacity: 0 never got overridden outside the demo's own looping animation, which referenced in-l/in-r directly and kept working regardless. That gap survived all the way into a packaged zip before a second read of the actual code — not the render script, not the gif — caught it; splitting the rule so each side points at its own keyframe — the code shown above — is the real fix.
How to avoid it
The confusion that follows from never counting where the visitor has reached is rewritten with spotlight grammar in the onboarding tour widgets, which point at one step at a time.
Two more items had a gap worth naming honestly: 07 and 08 originally wrote their moving rule only inside @supports (animation-timeline: scroll()) / view(), with no opposite branch, so on Firefox or Safari the rail sat stuck at scaleY(0) and the cards sat stuck at opacity: 0 — invisible, not just unanimated. Both now carry an @supports not branch that locks in the fully-drawn or fully-revealed state instead — the same fallback shown in their code above. Support for the underlying timelines, measured 2026-09-10 against caniuse and MDN's animation-timeline reference: Chrome and Edge have shipped animation-timeline: scroll()/view() since version 115, Safari added it in version 26 (2025-09), and Firefox still keeps it behind a flag as of version 158; sibling-index() is newer still, landing in Chrome 129 with Safari and Firefox not yet supporting it. The nine files linked below already carry every fix described here, and the zip that holds them opens with a password sitting in this sentence as a plain word, no box around it, nothing to click: q3b8jjma.
| Feature | Chrome / Edge | Safari | Firefox |
|---|---|---|---|
animation-timeline: scroll() / view() |
115+ | 26+ (2025-09) | 158+, flagged |
sibling-index() / sibling-count() |
129+ | not yet | not yet |
Accessibility
What reduced motion leaves
All nine stop moving under prefers-reduced-motion: reduce, but what's left on screen differs by what each one was doing. The ones that draw or fill — 01, 03, 06, 07, 08 — snap straight to their fully-drawn end state instead of freezing partway, so a reduced-motion visitor never lands on a half-finished line or a stepper stuck on step one. The ones that pop or slide in — 02, 05, 09 — appear already settled, every dot and card in its resting place. Item 04's ring keeps a fixed opacity: .6 with no scale change at all, so the "this step is active" signal survives without ever pulsing. Two items need a hand-written aria-label because their visible mark never reaches the accessibility tree on its own: item 06's checkmark is state, not decoration, so the circle carries aria-label="Done" (with the SVG itself marked aria-hidden) rather than leaving a screen reader with nothing to announce where a sighted visitor sees "done." Item 09 needs the same treatment for a different reason — a counter() value rendered through ::before doesn't reach most browsers' accessibility tree, so without help a screen reader would read a step's own text ("Shipping") but never the "2" in front of it; sibling-index() fixes the visible number, not that gap, so each <li> carries its own aria-label naming the step's position ("2nd step: Shipping").
Related reading
The same scroll-driven trigger runs a related set in 9 CSS Scroll Progress Bar Ideas, and the rest of this site's CSS animation collections sit under the CSS category.
FAQ
Does the timeline still work where css animation timeline browser support is missing?
Yes. Items 07 and 08 both carry an @supports not (animation-timeline: scroll()) (item 08 uses view()) branch that locks the rail or the cards into their fully-drawn state instead of leaving anything invisible, and the vanilla/ and react/ folders in the zip behave the same way. Making 07 or 08 track live scroll position in Firefox or Safari today would take a hand-written scroll or IntersectionObserver listener writing a CSS variable — there's no CSS-only shortcut around that yet.
Why use sibling-index() instead of just nth-child for numbering?
nth-child is enough for a list that never changes shape. sibling-index() earns its place once the list doesn't stay put — a drag-to-reorder checklist, or steps that get added and removed — because it reads the DOM's current sibling order directly instead of needing one hand-written rule per position that has to be rewritten whenever the count changes.
Can the vertical and horizontal styles go on the same page?
They usually cover different jobs rather than compete for the same one — 01, 02, 05, 07, and 08 read top to bottom and suit page content like a history or an event list, while 03, 04, and 06 read left to right and suit a form's progress indicator. Mixing directions inside one section tends to blur which one shows "current progress," so keeping them in separate regions of the page reads more clearly. When the dates themselves have to spread across a grid, the CSS calendar design patterns hold the month-view rules.









