스크롤 스토리텔링 섹션 9종 — 복붙하는 CSS 장면 구성
스크롤 스토리텔링은 스크롤 위치를 시간축 삼아 한 구간 안의 장면을 차례로 바꾸는 구성입니다. 여기 아홉 개는 효과가 아니라 장면을 짜는 방식이 서로 다르고, 전부 라이브러리 없이 CSS만으로 돕니다.
01 scrolly-sticky-figure-steps
02 scrolly-caption-over-pinned-bg
04 scrolly-before-after-wipe
05 scrolly-data-bars-grow
06 scrolly-exploded-parts
07 scrolly-route-map-progress
08 scrolly-page-turn-chapters
09 scrolly-endcap-cta-rise
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 고정 그림 · 스텝 텍스트
- 02 고정 배경 위 캡션 통과
- 03 프레임 시퀀스 스크럽
- 04 전후 비교 와이프
- 05 진입하면 자라는 막대
- 06 부품 분해 조립
- 07 경로 지도 따라가기
- 08 챕터 페이지 넘김
- 09 끝에서 올라오는 CTA
순서는 인기순이 아니라 한 편의 이야기가 지나가는 순서입니다. 먼저 무대를 세웁니다 — 그림 하나를 붙여 두고 글이 지나가게 하거나(01), 배경을 통째로 붙여 두고 글 카드만 통과시킵니다(02). 무대가 서면 그 안에서 시간을 돌립니다. 한 장면을 여덟 컷으로 잘라 스크롤 양만큼 감고(03), 두 장면을 겹쳐 두고 경계를 밀어 바꿉니다(04). 그다음이 숫자와 물건입니다. 막대가 자라며 수치를 보여주고(05), 부품이 흩어졌다 모이며 속을 보여줍니다(06). 마지막 셋은 독자를 데리고 나갑니다 — 어디를 거쳤는지 지도로 보여주고(07), 장을 넘겨 다음 이야기로 이어 가고(08), 다 읽은 자리에서만 다음 행동을 권합니다(09). 아홉 개 전부 자동 재생 미리보기를 쓰지만, 손을 대는 순간 그 루프가 떨어지고 진짜 스크롤이 운전대를 잡습니다. 랜딩 페이지 스크롤 애니메이션과 갈리는 지점이 여기인데, 하나하나가 장식이 아니라 이야기의 다음 문장을 여는 장치로 놓여 있기 때문입니다. 같은 축에서 홈페이지 전체의 움직임을 모은 판은 홈페이지 스크롤 효과에 있고, 지금 어느 장면인지 재는 도구는 스크롤 인디케이터가 따로 있습니다.
01고정 그림 · 스텝 텍스트
무엇이 보이나
화면을 둘로 갈라 왼쪽 그림만 제자리에 붙여 두고, 오른쪽 문단이 올라오는 동안 그림이 한 프레임에 딱 끊어져 다음 장으로 바뀝니다. 두 장이 겹쳐 비치는 중간 프레임이 없어야 "설명이 바뀌었다"가 분명하게 읽힙니다.

<main class="stage">
<div class="sf is-demo">
<div class="sf__scroller">
<div class="sf__grid">
<div class="sf__figure">
<div class="sf__panel sf__panel--1">
<svg viewBox="0 0 256 256" width="40" height="40" aria-hidden="true"><g fill="currentColor"><path d="M128 129.09V232a8 8 0 0 1-3.84-1l-88-48.18a8 8 0 0 1-4.16-7V80.18a8 8 0 0 1 .7-3.25Z" opacity=".2"/><path d="m223.68 66.15l-88-48.15a15.88 15.88 0 0 0-15.36 0l-88 48.17a16 16 0 0 0-8.32 14v95.64a16 16 0 0 0 8.32 14l88 48.17a15.88 15.88 0 0 0 15.36 0l88-48.17a16 16 0 0 0 8.32-14V80.18a16 16 0 0 0-8.32-14.03M128 32l80.34 44l-29.77 16.3l-80.35-44Zm0 88L47.66 76l33.9-18.56l80.34 44ZM40 90l80 43.78v85.79l-80-43.75Zm176 85.78l-80 43.79v-85.75l32-17.51V152a8 8 0 0 0 16 0v-44.45L216 90v85.77Z"/></g></svg>
<span class="sf__cap">개봉</span>
</div>
<div class="sf__panel sf__panel--2">
<svg viewBox="0 0 256 256" width="40" height="40" aria-hidden="true"><g fill="currentColor"><path d="m207.86 123.18l16.78-21a99 99 0 0 0-10.07-24.29l-26.7-3a81 81 0 0 0-6.81-6.81l-3-26.71a99.4 99.4 0 0 0-24.3-10l-21 16.77a82 82 0 0 0-9.64 0l-21-16.78a99 99 0 0 0-24.21 10.07l-3 26.7a81 81 0 0 0-6.81 6.81l-26.71 3a99.4 99.4 0 0 0-10 24.3l16.77 21a82 82 0 0 0 0 9.64l-16.78 21a99 99 0 0 0 10.07 24.29l26.7 3a81 81 0 0 0 6.81 6.81l3 26.71a99.4 99.4 0 0 0 24.3 10l21-16.77a82 82 0 0 0 9.64 0l21 16.78a99 99 0 0 0 24.29-10.07l3-26.7a81 81 0 0 0 6.81-6.81l26.71-3a99.4 99.4 0 0 0 10-24.3l-16.77-21a82 82 0 0 0-.08-9.64M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m88-29.84q.06-2.16 0-4.32l14.92-18.64a8 8 0 0 0 1.48-7.06a107.6 107.6 0 0 0-10.88-26.25a8 8 0 0 0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186 40.54a8 8 0 0 0-3.94-6a107.3 107.3 0 0 0-26.25-10.86a8 8 0 0 0-7.06 1.48L130.16 40h-4.32L107.2 25.11a8 8 0 0 0-7.06-1.48a107.6 107.6 0 0 0-26.25 10.88a8 8 0 0 0-3.93 6l-2.64 23.76q-1.56 1.49-3 3L40.54 70a8 8 0 0 0-6 3.94a107.7 107.7 0 0 0-10.87 26.25a8 8 0 0 0 1.49 7.06L40 125.84v4.32L25.11 148.8a8 8 0 0 0-1.48 7.06a107.6 107.6 0 0 0 10.88 26.25a8 8 0 0 0 6 3.93l23.72 2.64q1.49 1.56 3 3L70 215.46a8 8 0 0 0 3.94 6a107.7 107.7 0 0 0 26.25 10.87a8 8 0 0 0 7.06-1.49L125.84 216q2.16.06 4.32 0l18.64 14.92a8 8 0 0 0 7.06 1.48a107.2 107.2 0 0 0 26.25-10.88a8 8 0 0 0 3.93-6l2.64-23.72q1.56-1.48 3-3l23.78-2.8a8 8 0 0 0 6-3.94a107.7 107.7 0 0 0 10.87-26.25a8 8 0 0 0-1.49-7.06Zm-16.1-6.5a74 74 0 0 1 0 8.68a8 8 0 0 0 1.74 5.48l14.19 17.73a91.6 91.6 0 0 1-6.23 15l-22.6 2.56a8 8 0 0 0-5.1 2.64a74 74 0 0 1-6.14 6.14a8 8 0 0 0-2.64 5.1l-2.51 22.58a91.3 91.3 0 0 1-15 6.23l-17.74-14.19a8 8 0 0 0-5-1.75h-.48a74 74 0 0 1-8.68 0a8.06 8.06 0 0 0-5.48 1.74l-17.78 14.2a91.6 91.6 0 0 1-15-6.23L82.89 187a8 8 0 0 0-2.64-5.1a74 74 0 0 1-6.14-6.14a8 8 0 0 0-5.1-2.64l-22.58-2.52a91.3 91.3 0 0 1-6.23-15l14.19-17.74a8 8 0 0 0 1.74-5.48a74 74 0 0 1 0-8.68a8 8 0 0 0-1.74-5.48L40.2 100.45a91.6 91.6 0 0 1 6.23-15L69 82.89a8 8 0 0 0 5.1-2.64a74 74 0 0 1 6.14-6.14A8 8 0 0 0 82.89 69l2.51-22.57a91.3 91.3 0 0 1 15-6.23l17.74 14.19a8 8 0 0 0 5.48 1.74a74 74 0 0 1 8.68 0a8.06 8.06 0 0 0 5.48-1.74l17.77-14.19a91.6 91.6 0 0 1 15 6.23L173.11 69a8 8 0 0 0 2.64 5.1a74 74 0 0 1 6.14 6.14a8 8 0 0 0 5.1 2.64l22.58 2.51a91.3 91.3 0 0 1 6.23 15l-14.19 17.74a8 8 0 0 0-1.74 5.53Z"/></g></svg>
<span class="sf__cap">설정</span>
</div>
<div class="sf__panel sf__panel--3">
<svg viewBox="0 0 256 256" width="40" height="40" aria-hidden="true"><g fill="currentColor"><path d="M184 120v61.65a8 8 0 0 1-2.34 5.65l-34.35 34.35a8 8 0 0 1-13.57-4.53L128 176Zm-48-48H74.35a8 8 0 0 0-5.65 2.34l-34.35 34.35a8 8 0 0 0 4.53 13.57L80 128ZM40 216c37.65 0 50.69-19.69 54.56-28.18l-26.38-26.38C59.69 165.31 40 178.35 40 216" opacity=".2"/><path d="M223.85 47.12a16 16 0 0 0-15-15c-12.58-.75-44.73.4-71.41 27.07L132.69 64H74.36A15.9 15.9 0 0 0 63 68.68L28.7 103a16 16 0 0 0 9.07 27.16l38.47 5.37l44.21 44.21l5.37 38.49a15.94 15.94 0 0 0 10.78 12.92a16.1 16.1 0 0 0 5.1.83a15.9 15.9 0 0 0 11.3-4.68l34.32-34.3a15.9 15.9 0 0 0 4.68-11.36v-58.33l4.77-4.77c26.68-26.68 27.83-58.83 27.08-71.42M74.36 80h42.33l-39.53 39.52L40 114.34Zm74.41-9.45a76.65 76.65 0 0 1 59.11-22.47a76.46 76.46 0 0 1-22.42 59.16L128 164.68L91.32 128ZM176 181.64L141.67 216l-5.19-37.17L176 139.31Zm-74.16 9.5C97.34 201 82.29 224 40 224a8 8 0 0 1-8-8c0-42.29 23-57.34 32.86-61.85a8 8 0 0 1 6.64 14.56c-6.43 2.93-20.62 12.36-23.12 38.91c26.55-2.5 36-16.69 38.91-23.12a8 8 0 1 1 14.56 6.64Z"/></g></svg>
<span class="sf__cap">발사</span>
</div>
</div>
<div class="sf__steps">
<p class="sf__step"><b class="sf__n">1</b>상자를 열고 부품을 꺼냅니다</p>
<p class="sf__step"><b class="sf__n">2</b>전원을 켜고 설정을 맞춥니다</p>
<p class="sf__step"><b class="sf__n">3</b>준비가 끝나면 발사합니다</p>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // 미리보기 루프 한 쪽 방향. alternate 라 한 바퀴는 2s
$easing: linear; // 스크롤 스크럽은 등속이라야 스크롤 양과 화면이 1:1 로 맞는다
$color: $subject-blue-deep;
@include stage($stage-ink);
.sf { width: min(392px, 92vw); }
.sf__scroller {
height: clamp(148px, 58vh, 176px);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.sf__grid {
display: grid;
grid-template-columns: 104px 1fr;
gap: $sp-3;
}
// ── 실제 동작 ① 왼쪽 그림만 제자리에 붙는다 ──────────────
.sf__figure {
position: sticky;
top: 0;
align-self: start;
display: grid;
height: 104px;
border-radius: $r-card;
background: rgba(255, 247, 230, .08);
}
// ── 실제 동작 ② 스크롤 진행이 그림 번호를 고른다.
// steps(1, end) 라 두 장이 반투명하게 겹치는 중간 프레임이 없다 ──
.sf__panel {
grid-area: 1 / 1;
display: grid;
place-items: center;
gap: $sp-1;
color: $subject-cream;
animation-name: sf-p1;
animation-duration: 1s;
animation-timing-function: steps(1, end);
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
.sf__panel--2 { animation-name: sf-p2; }
.sf__panel--3 { animation-name: sf-p3; color: $stage-yellow; }
// 문단이 화면 한가운데를 지나는 지점 = 28% · 76% (문단 96px · 화면 176px 실측)
@keyframes sf-p1 { 0% { opacity: 1 } 28% { opacity: 0 } 100% { opacity: 0 } }
@keyframes sf-p2 { 0% { opacity: 0 } 28% { opacity: 1 } 76% { opacity: 0 } 100% { opacity: 0 } }
@keyframes sf-p3 { 0% { opacity: 0 } 76% { opacity: 1 } 100% { opacity: 1 } }
.sf__cap {
color: inherit;
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
// 마지막 문단도 화면 한가운데까지 올라오려면 뒤에 반 화면만큼 여백이 있어야 한다
.sf__steps {
display: grid;
padding-bottom: 88px;
}
.sf__step {
display: flex;
align-items: flex-start;
gap: $sp-2;
min-height: 96px;
margin: 0;
padding: $sp-3 0 0;
color: rgba(255, 247, 230, .82);
font-size: 13px;
line-height: 20px;
font-weight: 500;
}
.sf__n {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 20px;
height: 20px;
border-radius: 50%;
background: $color;
color: $subject-cream;
font-size: 11px;
line-height: 12px;
font-weight: 700;
}
// ── 격자 미리보기 루프 ────────────────────────────────
// 스크롤 상자(.sf__scroller) 자체가 아니라 **안쪽 트랙(.sf__steps)** 만 민다.
// 상자에 transform 을 걸면 100% 가 상자 자기 폭이라 내용이 통째로 날아간다(051 실측).
.sf.is-demo .sf__steps {
animation-name: sf-scrub;
animation-duration: $duration;
animation-timing-function: $easing;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.sf.is-demo .sf__panel {
animation-duration: $duration;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@keyframes sf-scrub {
from { transform: translateY(0); }
to { transform: translateY(-200px); }
}
@media (prefers-reduced-motion: reduce) {
.sf__panel { animation: none !important; opacity: 0; }
.sf__panel--1 { opacity: 1; }
.sf.is-demo .sf__steps { animation: none !important; 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);
}
.sf {
width: min(392px, 92vw);
}
.sf__scroller {
height: clamp(148px, 58vh, 176px);
overflow-y: auto;
scrollbar-width: none;
}
.sf__scroller::-webkit-scrollbar {
display: none;
}
.sf__grid {
display: grid;
grid-template-columns: 104px 1fr;
gap: 12px;
}
.sf__figure {
position: sticky;
top: 0;
align-self: start;
display: grid;
height: 104px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.08);
}
.sf__panel {
grid-area: 1/1;
display: grid;
place-items: center;
gap: 4px;
color: #fff7e6;
animation-name: sf-p1;
animation-duration: 1s;
animation-timing-function: steps(1, end);
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
.sf__panel--2 {
animation-name: sf-p2;
}
.sf__panel--3 {
animation-name: sf-p3;
color: #ffd23f;
}
@keyframes sf-p1 {
0% {
opacity: 1;
}
28% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes sf-p2 {
0% {
opacity: 0;
}
28% {
opacity: 1;
}
76% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes sf-p3 {
0% {
opacity: 0;
}
76% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.sf__cap {
color: inherit;
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
.sf__steps {
display: grid;
padding-bottom: 88px;
}
.sf__step {
display: flex;
align-items: flex-start;
gap: 8px;
min-height: 96px;
margin: 0;
padding: 12px 0 0;
color: rgba(255, 247, 230, 0.82);
font-size: 13px;
line-height: 20px;
font-weight: 500;
}
.sf__n {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 20px;
height: 20px;
border-radius: 50%;
background: #0a4fe8;
color: #fff7e6;
font-size: 11px;
line-height: 12px;
font-weight: 700;
}
.sf.is-demo .sf__steps {
animation-name: sf-scrub;
animation-duration: 1s;
animation-timing-function: linear;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.sf.is-demo .sf__panel {
animation-duration: 1s;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@keyframes sf-scrub {
from {
transform: translateY(0);
}
to {
transform: translateY(-200px);
}
}
@media (prefers-reduced-motion: reduce) {
.sf__panel {
animation: none !important;
opacity: 0;
}
.sf__panel--1 {
opacity: 1;
}
.sf.is-demo .sf__steps {
animation: none !important;
transform: none;
}
}// 실제 동작은 CSS 만으로 끝난다: 왼쪽은 position: sticky, 그림 교체는 animation-timeline: scroll().
// is-demo 는 격자 미리보기용 루프일 뿐이라 손을 대는 순간 뗀다.
var r = document.querySelector('.sf');
var s = r.querySelector('.sf__scroller');
function stopDemo(){ r.classList.remove('is-demo'); }
r.addEventListener('pointerdown', stopDemo);
s.addEventListener('scroll', stopDemo, { passive: true });import type { CSSProperties, ReactNode } from "react";
import styles from "./StickyFigureSteps.module.scss";
export type StickyFigureStepsProps = {
steps?: string[];
captions?: string[];
/** 격자 미리보기 루프. 실제 페이지에서는 false 로 두고 스크롤이 끌게 한다 */
preview?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconPackage = (): ReactNode => (
<svg viewBox="0 0 256 256" width="40" height="40" aria-hidden="true"><g fill="currentColor"><path d="M128 129.09V232a8 8 0 0 1-3.84-1l-88-48.18a8 8 0 0 1-4.16-7V80.18a8 8 0 0 1 .7-3.25Z" opacity=".2"/><path d="m223.68 66.15l-88-48.15a15.88 15.88 0 0 0-15.36 0l-88 48.17a16 16 0 0 0-8.32 14v95.64a16 16 0 0 0 8.32 14l88 48.17a15.88 15.88 0 0 0 15.36 0l88-48.17a16 16 0 0 0 8.32-14V80.18a16 16 0 0 0-8.32-14.03M128 32l80.34 44l-29.77 16.3l-80.35-44Zm0 88L47.66 76l33.9-18.56l80.34 44ZM40 90l80 43.78v85.79l-80-43.75Zm176 85.78l-80 43.79v-85.75l32-17.51V152a8 8 0 0 0 16 0v-44.45L216 90v85.77Z"/></g></svg>
);
const IconGear = (): ReactNode => (
<svg viewBox="0 0 256 256" width="40" height="40" aria-hidden="true"><g fill="currentColor"><path d="M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m88-29.84q.06-2.16 0-4.32l14.92-18.64a8 8 0 0 0 1.48-7.06a107.6 107.6 0 0 0-10.88-26.25a8 8 0 0 0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186 40.54a8 8 0 0 0-3.94-6a107.3 107.3 0 0 0-26.25-10.86a8 8 0 0 0-7.06 1.48L130.16 40h-4.32L107.2 25.11a8 8 0 0 0-7.06-1.48a107.6 107.6 0 0 0-26.25 10.88a8 8 0 0 0-3.93 6l-2.64 23.76q-1.56 1.49-3 3L40.54 70a8 8 0 0 0-6 3.94a107.7 107.7 0 0 0-10.87 26.25a8 8 0 0 0 1.49 7.06L40 125.84v4.32L25.11 148.8a8 8 0 0 0-1.48 7.06a107.6 107.6 0 0 0 10.88 26.25a8 8 0 0 0 6 3.93l23.72 2.64q1.49 1.56 3 3L70 215.46a8 8 0 0 0 3.94 6a107.7 107.7 0 0 0 26.25 10.87a8 8 0 0 0 7.06-1.49L125.84 216q2.16.06 4.32 0l18.64 14.92a8 8 0 0 0 7.06 1.48a107.2 107.2 0 0 0 26.25-10.88a8 8 0 0 0 3.93-6l2.64-23.72q1.56-1.48 3-3l23.78-2.8a8 8 0 0 0 6-3.94a107.7 107.7 0 0 0 10.87-26.25a8 8 0 0 0-1.49-7.06Z"/></g></svg>
);
const IconRocket = (): ReactNode => (
<svg viewBox="0 0 256 256" width="40" height="40" aria-hidden="true"><g fill="currentColor"><path d="M184 120v61.65a8 8 0 0 1-2.34 5.65l-34.35 34.35a8 8 0 0 1-13.57-4.53L128 176Zm-48-48H74.35a8 8 0 0 0-5.65 2.34l-34.35 34.35a8 8 0 0 0 4.53 13.57L80 128ZM40 216c37.65 0 50.69-19.69 54.56-28.18l-26.38-26.38C59.69 165.31 40 178.35 40 216" opacity=".2"/><path d="M223.85 47.12a16 16 0 0 0-15-15c-12.58-.75-44.73.4-71.41 27.07L132.69 64H74.36A15.9 15.9 0 0 0 63 68.68L28.7 103a16 16 0 0 0 9.07 27.16l38.47 5.37l44.21 44.21l5.37 38.49a15.94 15.94 0 0 0 10.78 12.92a16.1 16.1 0 0 0 5.1.83a15.9 15.9 0 0 0 11.3-4.68l34.32-34.3a15.9 15.9 0 0 0 4.68-11.36v-58.33l4.77-4.77c26.68-26.68 27.83-58.83 27.08-71.42M74.36 80h42.33l-39.53 39.52L40 114.34Zm74.41-9.45a76.65 76.65 0 0 1 59.11-22.47a76.46 76.46 0 0 1-22.42 59.16L128 164.68L91.32 128ZM176 181.64L141.67 216l-5.19-37.17L176 139.31Z"/></g></svg>
);
const ICONS = [IconPackage, IconGear, IconRocket];
export default function StickyFigureSteps({
steps = ["Open the box and take the parts out.", "Power it on and dial in the settings.", "Once it is ready, launch."],
captions = ["Unbox", "Set up", "Launch"],
preview = true,
duration = 1000,
easing = "linear",
color = "#0a4fe8",
}: StickyFigureStepsProps): ReactNode {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = preview ? `${styles.sf} ${styles.isDemo}` : styles.sf;
return (
<div className={cls} style={vars}>
<div className={styles.scroller}>
<div className={styles.grid}>
<div className={styles.figure}>
{captions.map((c: string, i: number): ReactNode => {
const Icon = ICONS[i % ICONS.length];
return (
<div className={`${styles.panel} ${styles[`panel${i + 1}`]}`} key={c}>
<Icon />
<span className={styles.cap}>{c}</span>
</div>
);
})}
</div>
<div className={styles.steps}>
{steps.map((s: string, i: number): ReactNode => (
<p className={styles.step} key={s}><b className={styles.n}>{i + 1}</b>{s}</p>
))}
</div>
</div>
</div>
</div>
);
}01 · 고정 그림 · 스텝 텍스트 — 측정 누적면적 16.031% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
문단이 화면 한가운데를 지나는 지점이 교체 시점이라, 문단 높이(96px)와 화면 높이(176px)에서 계산한 28%·76%가 키프레임 자리입니다.
.sf__figure {
position: sticky;
top: 0;
align-self: start;
display: grid;
height: 104px;
}
.sf__panel {
grid-area: 1 / 1;
animation-name: sf-p1;
animation-duration: 1s;
animation-timing-function: steps(1, end);
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
.sf__panel--2 { animation-name: sf-p2; }
@keyframes sf-p1 { 0% { opacity: 1 } 28% { opacity: 0 } 100% { opacity: 0 } }
@keyframes sf-p2 { 0% { opacity: 0 } 28% { opacity: 1 } 76% { opacity: 0 } 100% { opacity: 0 } }
02고정 배경 위 캡션 통과
패럴랙스 스크롤은 화면에서 어떻게 보이나
배경 장면은 한 발짝도 움직이지 않고 반투명 카드만 아래에서 올라와 위로 빠져나갑니다. 가려지는 만큼 배경이 어두워지도록 덮개 한 장의 불투명도를 스크롤 진행에 직접 물렸습니다.

<main class="stage">
<div class="cb is-demo">
<div class="cb__scroller">
<div class="cb__bg">
<div class="cb__scene" aria-hidden="true">
<span class="cb__sun"></span>
<svg class="cb__peaks" viewBox="0 0 256 256" width="120" height="120" aria-hidden="true"><g fill="currentColor"><path d="M144 52a20 20 0 1 1 20 20a20 20 0 0 1-20-20m49.44 55.92a8 8 0 0 0-13.77 0l-33 55.75l-21-35.67H50.35L8 200h240Z" opacity=".2"/><path d="M164 80a28 28 0 1 0-28-28a28 28 0 0 0 28 28m0-40a12 12 0 1 1-12 12a12 12 0 0 1 12-12m90.88 155.92l-54.56-92.08A15.87 15.87 0 0 0 186.55 96a15.85 15.85 0 0 0-13.76 7.84L146.63 148l-44.84-76.1a16 16 0 0 0-27.58 0L1.11 195.94A8 8 0 0 0 8 208h240a8 8 0 0 0 6.88-12.08M88 80l23.57 40H64.43ZM22 192l33-56h66l33 56Zm150.57 0l-16.66-28.28L186.55 112L234 192Z"/></g></svg>
</div>
<div class="cb__dim" aria-hidden="true"></div>
</div>
<div class="cb__caps">
<p class="cb__cap">첫 장면은 한 발짝도 움직이지 않습니다</p>
<p class="cb__cap">카드만 올라와서 위로 빠져나갑니다</p>
<p class="cb__cap">가려질수록 배경이 조금씩 어두워집니다</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // alternate 라 한 바퀴는 2s
$easing: linear;
$color: $subject-ink;
@include stage($stage-yellow);
.cb {
--h: clamp(148px, 58vh, 176px);
width: min(360px, 92vw);
}
// ⚠ 타임라인에 이름을 붙여 둔다. scroll(nearest) 는 `overflow: hidden` 인 상자도
// 스크롤 컨테이너로 쳐서, 배경 상자 안의 요소가 **절대 안 움직이는** 타임라인에 묶인다 (실측)
.cb__scroller {
height: var(--h);
overflow-y: auto;
scroll-timeline-name: --cb-scroll;
scroll-timeline-axis: block;
border-radius: $r-card;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
// ── 실제 동작 ① 배경 장면은 스크롤 상자 안에 붙어 제자리에 남는다 ──
.cb__bg {
position: sticky;
top: 0;
height: var(--h);
overflow: hidden;
}
.cb__scene {
display: grid;
place-items: center;
height: 100%;
background: linear-gradient(160deg, $subject-pink 0%, $stage-orange 58%, #aa6f54 100%);
color: rgba(255, 247, 230, .92);
}
.cb__sun {
grid-area: 1 / 1;
width: 72px;
height: 72px;
border-radius: 50%;
background: rgba($stage-yellow, .85);
transform: translate(64px, -32px);
}
.cb__peaks { grid-area: 1 / 1; transform: translateY(12px); }
// ── 실제 동작 ② 가려진 만큼 어두워진다 — 스크롤 진행이 곧 불투명도 ──
.cb__dim {
position: absolute;
inset: 0;
background: $color;
opacity: 0;
animation-name: cb-dim;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --cb-scroll;
}
@keyframes cb-dim {
from { opacity: 0; }
to { opacity: .46; }
}
// 캡션 줄은 배경 위로 올라탄다 — 음수 margin 이 배경 한 판을 통째로 덮는다
.cb__caps {
position: relative;
display: grid;
gap: calc(var(--h) * .62);
margin-top: calc(var(--h) * -1);
padding: calc(var(--h) * .44) $sp-4 calc(var(--h) * .5);
}
.cb__cap {
margin: 0;
padding: $sp-3;
border: 1px solid rgba(255, 255, 255, .72);
border-radius: $r-card;
// ⚠ 반투명 흰 판을 어두워지는 배경 위에 올리면 합성색이 같이 어두워져 먹색 글자 대비가 떨어진다.
// .34 로 뒀더니 마지막 카드가 읽히지 않아 .70 까지 올렸다 (실측)
background: rgba(255, 255, 255, .7);
backdrop-filter: blur(10px) saturate(150%);
-webkit-backdrop-filter: blur(10px) saturate(150%);
color: $color;
font-size: 13px;
line-height: 19px;
font-weight: 700;
}
// ── 격자 미리보기 루프 ────────────────────────────────
// 트랙(.cb__caps)만 민다. 스크롤 상자 자체에 transform 을 걸면 내용이 날아간다(051 실측)
.cb.is-demo .cb__caps {
animation-name: cb-scrub;
animation-duration: $duration;
animation-timing-function: $easing;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.cb.is-demo .cb__dim {
animation-duration: $duration;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@keyframes cb-scrub {
from { transform: translateY(0); }
to { transform: translateY(-328px); }
}
@media (prefers-reduced-motion: reduce) {
.cb__dim { animation: none !important; opacity: .3; }
.cb.is-demo .cb__caps { animation: none !important; 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);
}
.cb {
--h: clamp(148px, 58vh, 176px);
width: min(360px, 92vw);
}
.cb__scroller {
height: var(--h);
overflow-y: auto;
scroll-timeline-name: --cb-scroll;
scroll-timeline-axis: block;
border-radius: 16px;
scrollbar-width: none;
}
.cb__scroller::-webkit-scrollbar {
display: none;
}
.cb__bg {
position: sticky;
top: 0;
height: var(--h);
overflow: hidden;
}
.cb__scene {
display: grid;
place-items: center;
height: 100%;
background: linear-gradient(160deg, #ff9ebb 0%, #ff4d1f 58%, #aa6f54 100%);
color: rgba(255, 247, 230, 0.92);
}
.cb__sun {
grid-area: 1/1;
width: 72px;
height: 72px;
border-radius: 50%;
background: rgba(255, 210, 63, 0.85);
transform: translate(64px, -32px);
}
.cb__peaks {
grid-area: 1/1;
transform: translateY(12px);
}
.cb__dim {
position: absolute;
inset: 0;
background: #17141a;
opacity: 0;
animation-name: cb-dim;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --cb-scroll;
}
@keyframes cb-dim {
from {
opacity: 0;
}
to {
opacity: 0.46;
}
}
.cb__caps {
position: relative;
display: grid;
gap: calc(var(--h) * 0.62);
margin-top: calc(var(--h) * -1);
padding: calc(var(--h) * 0.44) 16px calc(var(--h) * 0.5);
}
.cb__cap {
margin: 0;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 16px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px) saturate(150%);
-webkit-backdrop-filter: blur(10px) saturate(150%);
color: #17141a;
font-size: 13px;
line-height: 19px;
font-weight: 700;
}
.cb.is-demo .cb__caps {
animation-name: cb-scrub;
animation-duration: 1s;
animation-timing-function: linear;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.cb.is-demo .cb__dim {
animation-duration: 1s;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@keyframes cb-scrub {
from {
transform: translateY(0);
}
to {
transform: translateY(-328px);
}
}
@media (prefers-reduced-motion: reduce) {
.cb__dim {
animation: none !important;
opacity: 0.3;
}
.cb.is-demo .cb__caps {
animation: none !important;
transform: none;
}
}// 실제 동작은 CSS 만: 배경은 position: sticky, 어두워지는 정도는 animation-timeline: scroll().
var r = document.querySelector('.cb');
var s = r.querySelector('.cb__scroller');
function stopDemo(){ r.classList.remove('is-demo'); }
r.addEventListener('pointerdown', stopDemo);
s.addEventListener('scroll', stopDemo, { passive: true });import type { CSSProperties, ReactNode } from "react";
import styles from "./CaptionOverPinnedBg.module.scss";
export type CaptionOverPinnedBgProps = {
captions?: string[];
preview?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconPeaks = (): ReactNode => (
<svg className={styles.peaks} viewBox="0 0 256 256" width="120" height="120" aria-hidden="true"><g fill="currentColor"><path d="M144 52a20 20 0 1 1 20 20a20 20 0 0 1-20-20m49.44 55.92a8 8 0 0 0-13.77 0l-33 55.75l-21-35.67H50.35L8 200h240Z" opacity=".2"/><path d="M164 80a28 28 0 1 0-28-28a28 28 0 0 0 28 28m0-40a12 12 0 1 1-12 12a12 12 0 0 1 12-12m90.88 155.92l-54.56-92.08A15.87 15.87 0 0 0 186.55 96a15.85 15.85 0 0 0-13.76 7.84L146.63 148l-44.84-76.1a16 16 0 0 0-27.58 0L1.11 195.94A8 8 0 0 0 8 208h240a8 8 0 0 0 6.88-12.08M88 80l23.57 40H64.43ZM22 192l33-56h66l33 56Zm150.57 0l-16.66-28.28L186.55 112L234 192Z"/></g></svg>
);
export default function CaptionOverPinnedBg({
captions = [
"The first scene never moves an inch.",
"Only the cards rise and leave through the top.",
"The more it is covered, the darker the backdrop gets.",
],
preview = true,
duration = 1000,
easing = "linear",
color = "#17141a",
}: CaptionOverPinnedBgProps): ReactNode {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = preview ? `${styles.cb} ${styles.isDemo}` : styles.cb;
return (
<div className={cls} style={vars}>
<div className={styles.scroller}>
<div className={styles.bg}>
<div className={styles.scene} aria-hidden="true">
<span className={styles.sun} />
<IconPeaks />
</div>
<div className={styles.dim} aria-hidden="true" />
</div>
<div className={styles.caps}>
{captions.map((c: string): ReactNode => (
<p className={styles.cap} key={c}>{c}</p>
))}
</div>
</div>
</div>
);
}02 · 고정 배경 위 캡션 통과 — 측정 누적면적 43.372% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
변한 픽셀이 화면의 43.372%로 아홉 개 중 가장 큰데, 이는 카드가 배경 전면을 쓸고 지나가기 때문입니다.
.cb__scroller {
height: var(--h);
overflow-y: auto;
scroll-timeline-name: --cb-scroll;
scroll-timeline-axis: block;
}
.cb__bg {
position: sticky;
top: 0;
height: var(--h);
overflow: hidden;
}
.cb__dim {
position: absolute;
inset: 0;
animation-name: cb-dim;
animation-fill-mode: both;
animation-timeline: --cb-scroll;
}
@keyframes cb-dim { from { opacity: 0 } to { opacity: .46 } }
03프레임 시퀀스 스크럽
어떤 움직임인가
여덟 컷을 한 줄로 이어 붙인 띠를 스크롤 양만큼 옆으로 밀면, 동영상을 스크럽하는 것과 같은 화면이 됩니다. steps(8, jump-none) 은 0과 1을 모두 포함해 여덟 컷이 정확히 한 번씩 서므로 중간 위치가 생기지 않습니다.
<main class="stage">
<div class="sp is-demo">
<div class="sp__scroller">
<div class="sp__pad">
<div class="sp__pin">
<div class="sp__window">
<div class="sp__strip" aria-hidden="true">
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
</div>
</div>
<p class="sp__hint">스크롤한 만큼 컷이 넘어갑니다</p>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // alternate 라 한 바퀴(앞으로 갔다 뒤로)는 2s
$easing: linear; // 스크럽은 등속이라야 스크롤 양과 컷 번호가 1:1
$color: $subject-cream;
@include stage($stage-orange);
.sp {
--h: clamp(148px, 58vh, 176px);
--frame: 168px;
width: min(360px, 92vw);
}
// ⚠ 타임라인에 이름을 붙인다. scroll(nearest) 는 창의 overflow: hidden 까지 스크롤 컨테이너로 쳐서
// 띠가 **절대 안 움직이는** 타임라인에 묶인다 (실측)
.sp__scroller {
height: var(--h);
overflow-y: auto;
scroll-timeline-name: --sp-scroll;
scroll-timeline-axis: block;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
// ⚠ 여기는 padding-bottom 이 아니라 height 다. sticky 가 붙어 있을 수 있는 범위는 부모의
// **콘텐츠 박스**라, padding 으로만 늘린 자리는 안 쳐준다 — 창이 곧바로 위로 흘러나간다 (실측)
.sp__pad { height: calc(var(--h) * 2.2); }
.sp__pin {
position: sticky;
top: 0;
display: grid;
justify-items: center;
gap: $sp-2;
}
// 창은 한 컷만 보여준다. 실제로 움직이는 건 안쪽 띠뿐이다
.sp__window {
width: var(--frame);
height: 104px;
overflow: hidden;
border-radius: $r-card;
background: rgba(23, 20, 26, .78);
}
// ── 실제 동작: 스크롤 진행이 컷 번호가 된다.
// steps(8, jump-none) 은 0 과 1 을 모두 포함해 여덟 컷이 정확히 한 번씩 선다 ──
.sp__strip {
display: flex;
width: max-content;
animation-name: sp-scrub;
animation-duration: 1s;
animation-timing-function: steps(8, jump-none);
animation-fill-mode: both;
animation-timeline: --sp-scroll;
}
@keyframes sp-scrub {
from { transform: translateX(0); }
to { transform: translateX(-87.5%); }
}
// 컷마다 overflow: hidden — 열린 뚜껑이 옆 컷 자리로 넘어가면 창 하나에 두 컷이 겹쳐 보인다
.sp__frame {
position: relative;
flex: 0 0 var(--frame);
width: var(--frame);
height: 104px;
overflow: hidden;
}
.sp__frame:nth-child(1) { --i: 0; }
.sp__frame:nth-child(2) { --i: 1; }
.sp__frame:nth-child(3) { --i: 2; }
.sp__frame:nth-child(4) { --i: 3; }
.sp__frame:nth-child(5) { --i: 4; }
.sp__frame:nth-child(6) { --i: 5; }
.sp__frame:nth-child(7) { --i: 6; }
.sp__frame:nth-child(8) { --i: 7; }
.sp__box {
position: absolute;
left: 40px;
bottom: 20px;
width: 88px;
height: 40px;
border-radius: $r-sm;
background: $color;
}
// 뚜껑은 컷마다 14도씩 더 열린다 — 애니메이션이 아니라 컷의 생김새 그 자체
.sp__lid {
position: absolute;
left: 40px;
bottom: 60px;
width: 88px;
height: 12px;
border-radius: $r-xs;
background: $stage-yellow;
transform-origin: left bottom; // 규칙8: 뚜껑 모서리 경첩
transform: rotate(calc(var(--i) * -14deg));
}
.sp__item {
position: absolute;
left: 72px;
bottom: 48px;
width: 24px;
height: 24px;
border-radius: 50%;
background: $subject-mint;
opacity: calc(var(--i) / 7);
transform: translateY(calc(var(--i) * -6px));
}
.sp__hint {
margin: 0;
color: $subject-ink; // 주황 무대 위 크림 2.83:1 → 잉크 5.50:1
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
// ── 격자 미리보기 루프 — 같은 띠를 시간으로 민다(앞으로 갔다 되감기) ──
.sp.is-demo .sp__strip {
animation-duration: $duration;
animation-timing-function: steps(8, jump-none);
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
// 한 컷 = 12.5% — 여섯 번째 컷에서 멈춘다
@media (prefers-reduced-motion: reduce) {
.sp__strip { animation: none !important; transform: translateX(-62.5%); }
}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);
}
.sp {
--h: clamp(148px, 58vh, 176px);
--frame: 168px;
width: min(360px, 92vw);
}
.sp__scroller {
height: var(--h);
overflow-y: auto;
scroll-timeline-name: --sp-scroll;
scroll-timeline-axis: block;
scrollbar-width: none;
}
.sp__scroller::-webkit-scrollbar {
display: none;
}
.sp__pad {
height: calc(var(--h) * 2.2);
}
.sp__pin {
position: sticky;
top: 0;
display: grid;
justify-items: center;
gap: 8px;
}
.sp__window {
width: var(--frame);
height: 104px;
overflow: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.78);
}
.sp__strip {
display: flex;
width: max-content;
animation-name: sp-scrub;
animation-duration: 1s;
animation-timing-function: steps(8, jump-none);
animation-fill-mode: both;
animation-timeline: --sp-scroll;
}
@keyframes sp-scrub {
from {
transform: translateX(0);
}
to {
transform: translateX(-87.5%);
}
}
.sp__frame {
position: relative;
flex: 0 0 var(--frame);
width: var(--frame);
height: 104px;
overflow: hidden;
}
.sp__frame:nth-child(1) {
--i: 0;
}
.sp__frame:nth-child(2) {
--i: 1;
}
.sp__frame:nth-child(3) {
--i: 2;
}
.sp__frame:nth-child(4) {
--i: 3;
}
.sp__frame:nth-child(5) {
--i: 4;
}
.sp__frame:nth-child(6) {
--i: 5;
}
.sp__frame:nth-child(7) {
--i: 6;
}
.sp__frame:nth-child(8) {
--i: 7;
}
.sp__box {
position: absolute;
left: 40px;
bottom: 20px;
width: 88px;
height: 40px;
border-radius: 8px;
background: #fff7e6;
}
.sp__lid {
position: absolute;
left: 40px;
bottom: 60px;
width: 88px;
height: 12px;
border-radius: 4px;
background: #ffd23f;
transform-origin: left bottom;
transform: rotate(calc(var(--i) * -14deg));
}
.sp__item {
position: absolute;
left: 72px;
bottom: 48px;
width: 24px;
height: 24px;
border-radius: 50%;
background: #4cd4a6;
opacity: calc(var(--i) / 7);
transform: translateY(calc(var(--i) * -6px));
}
.sp__hint {
margin: 0;
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
.sp.is-demo .sp__strip {
animation-duration: 1s;
animation-timing-function: steps(8, jump-none);
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@media (prefers-reduced-motion: reduce) {
.sp__strip {
animation: none !important;
transform: translateX(-62.5%);
}
}// 실제 동작은 CSS 만: 띠를 미는 건 animation-timeline: scroll() 이고 timing 은 steps(8, jump-none).
var r = document.querySelector('.sp');
var s = r.querySelector('.sp__scroller');
function stopDemo(){ r.classList.remove('is-demo'); }
r.addEventListener('pointerdown', stopDemo);
s.addEventListener('scroll', stopDemo, { passive: true });import type { CSSProperties, ReactNode } from "react";
import styles from "./FrameSequenceScrub.module.scss";
export type FrameSequenceScrubProps = {
frames?: number;
hint?: string;
preview?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type FrameVars = CSSProperties & { "--i"?: number };
export default function FrameSequenceScrub({
frames = 8,
hint = "Each bit of scroll advances one frame.",
preview = true,
duration = 1000,
easing = "linear",
color = "#fff7e6",
}: FrameSequenceScrubProps): ReactNode {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = preview ? `${styles.sp} ${styles.isDemo}` : styles.sp;
const list: number[] = [];
for (let i = 0; i < frames; i += 1) list.push(i);
return (
<div className={cls} style={vars}>
<div className={styles.scroller}>
<div className={styles.pad}>
<div className={styles.pin}>
<div className={styles.window}>
<div className={styles.strip} aria-hidden="true">
{list.map((i: number): ReactNode => {
const fv: FrameVars = { "--i": i };
return (
<div className={styles.frame} style={fv} key={i}>
<span className={styles.item} />
<span className={styles.lid} />
<span className={styles.box} />
</div>
);
})}
</div>
</div>
<p className={styles.hint}>{hint}</p>
</div>
</div>
</div>
</div>
);
}03 · 프레임 시퀀스 스크럽 — 측정 누적면적 2.855% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
컷 자체는 애니메이션이 아니라 생김새라, 컷 번호에 뚜껑 각도를 곱해 정적으로 그려 둡니다.
.sp__window {
width: var(--frame);
height: 104px;
overflow: hidden;
}
.sp__strip {
display: flex;
width: max-content;
animation-name: sp-scrub;
animation-timing-function: steps(8, jump-none);
animation-fill-mode: both;
animation-timeline: --sp-scroll;
}
@keyframes sp-scrub {
from { transform: translateX(0); }
to { transform: translateX(-87.5%); }
}
.sp__lid { transform: rotate(calc(var(--i) * -14deg)); }
04전후 비교 와이프
무엇이 보이나
같은 자리에 두 장면을 겹쳐 두고 위층만 오른쪽부터 잘라 내면 아래층이 그만큼 드러납니다. 자르는 일은 clip-path: inset() 의 오른쪽 값 하나가 전부이고, 손잡이 선은 같은 타임라인에 물려 잘린 경계를 따라갑니다.

<main class="stage">
<div class="bw is-demo">
<div class="bw__scroller">
<div class="bw__pad">
<div class="bw__frame">
<div class="bw__layer bw__layer--after" aria-hidden="true">
<span class="bw__bar bw__bar--a"></span>
<span class="bw__bar bw__bar--b"></span>
<span class="bw__bar bw__bar--c"></span>
<span class="bw__dot"></span>
</div>
<div class="bw__layer bw__layer--before" aria-hidden="true">
<span class="bw__bar bw__bar--a"></span>
<span class="bw__bar bw__bar--b"></span>
<span class="bw__bar bw__bar--c"></span>
<span class="bw__dot"></span>
</div>
<div class="bw__handle" aria-hidden="true">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M208 80v96H48V80Z" opacity=".2"/><path d="m213.66 181.66l-32 32a8 8 0 0 1-11.32-11.32L188.69 184H48a8 8 0 0 1 0-16h140.69l-18.35-18.34a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32m-139.32-64a8 8 0 0 0 11.32-11.32L67.31 88H208a8 8 0 0 0 0-16H67.31l18.35-18.34a8 8 0 0 0-11.32-11.32l-32 32a8 8 0 0 0 0 11.32Z"/></g></svg>
</div>
</div>
<p class="bw__tags"><span class="bw__tag bw__tag--old">이전</span><span class="bw__tag">이후</span></p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // alternate 라 한 바퀴는 2s
// 미리보기 곡선은 가운데가 가장 빠르다 — 대표 이미지(가장 크게 변한 장)가
// 반쯤 걷힌 장면으로 뽑혀야 전과 후가 한 장에 같이 보인다. 실제 스크롤 판은 linear
$easing: cubic-bezier(.65, 0, .35, 1);
$color: $subject-blue;
@include stage($stage-paper);
.bw {
--h: clamp(148px, 58vh, 176px);
--fw: min(272px, 82vw);
width: min(360px, 92vw);
}
.bw__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.bw__pad {
display: grid;
justify-items: center;
gap: $sp-2;
// 상자가 처음에는 화면 밖에 있어야 뷰 타임라인의 entry 구간이 실제로 지나간다
padding: calc(var(--h) * .95) 0 calc(var(--h) * .6);
}
// ⚠ 뷰 타임라인의 이름은 **관찰 대상**(이 상자)에 붙인다. 안쪽 층에서 view() 를 그냥 쓰면
// 이 상자의 overflow: hidden 이 스크롤포트로 잡혀 진행률이 0 에서 멈춘다 (실측)
.bw__frame {
view-timeline-name: --bw-view;
view-timeline-axis: block;
position: relative;
width: var(--fw);
height: 104px;
overflow: hidden;
border-radius: $r-card;
}
.bw__layer {
position: absolute;
inset: 0;
display: grid;
align-content: center;
gap: $sp-2;
padding: $sp-4;
}
.bw__layer--after { background: #eef3ff; }
.bw__layer--after .bw__bar--a { background: $color; }
.bw__layer--after .bw__bar--b { background: $subject-mint; }
.bw__layer--after .bw__bar--c { background: $subject-lilac; }
.bw__layer--after .bw__dot { background: $stage-orange; }
// ── 실제 동작: 위층만 오른쪽부터 잘려 나간다.
// inset() 의 오른쪽 값이 0 → 100% 로 자라면 아래층이 그만큼 드러난다 ──
.bw__layer--before {
background: #e7e5ea;
clip-path: inset(0 0 0 0);
animation-name: bw-cut;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --bw-view;
}
.bw__layer--before .bw__bar--a,
.bw__layer--before .bw__bar--b,
.bw__layer--before .bw__bar--c { background: #b6b2bd; }
.bw__layer--before .bw__dot { background: #8d8895; }
@keyframes bw-cut {
from { clip-path: inset(0 0 0 0); }
to { clip-path: inset(0 100% 0 0); }
}
.bw__bar {
height: 12px;
border-radius: $r-pill;
}
.bw__bar--a { width: 64%; }
.bw__bar--b { width: 88%; }
.bw__bar--c { width: 46%; }
.bw__dot {
position: absolute;
top: $sp-4;
right: $sp-4;
width: 28px;
height: 28px;
border-radius: 50%;
}
// 손잡이는 잘린 경계에 붙어 같은 진행률로 움직인다
.bw__handle {
position: absolute;
top: 0;
bottom: 0;
left: 0;
display: grid;
place-items: center;
width: 4px;
background: $subject-ink;
color: $subject-ink;
animation-name: bw-handle;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --bw-view;
svg {
padding: $sp-1;
border-radius: 50%;
background: $subject-ink;
color: $subject-cream;
}
}
@keyframes bw-handle {
from { transform: translateX(calc(var(--fw) - 4px)); }
to { transform: translateX(0); }
}
.bw__tags {
display: flex;
gap: $sp-2;
margin: 0;
}
.bw__tag {
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $color;
color: #fff;
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.bw__tag--old { background: #b6b2bd; }
// 미리보기 루프는 실제로 스크롤하지 않으니 위 여백을 줄여 상자를 화면 안에 둔다
.bw.is-demo .bw__pad { padding-top: calc(var(--h) * .1); }
// ── 격자 미리보기 루프 — 같은 두 애니메이션을 시간으로 되감는다 ──
.bw.is-demo .bw__layer--before,
.bw.is-demo .bw__handle {
animation-duration: $duration;
animation-timing-function: $easing;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@media (prefers-reduced-motion: reduce) {
.bw__layer--before { animation: none !important; clip-path: inset(0 50% 0 0); }
.bw__handle { animation: none !important; transform: translateX(calc(var(--fw) * .5)); }
}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);
}
.bw {
--h: clamp(148px, 58vh, 176px);
--fw: min(272px, 82vw);
width: min(360px, 92vw);
}
.bw__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
}
.bw__scroller::-webkit-scrollbar {
display: none;
}
.bw__pad {
display: grid;
justify-items: center;
gap: 8px;
padding: calc(var(--h) * 0.95) 0 calc(var(--h) * 0.6);
}
.bw__frame {
view-timeline-name: --bw-view;
view-timeline-axis: block;
position: relative;
width: var(--fw);
height: 104px;
overflow: hidden;
border-radius: 16px;
}
.bw__layer {
position: absolute;
inset: 0;
display: grid;
align-content: center;
gap: 8px;
padding: 16px;
}
.bw__layer--after {
background: #eef3ff;
}
.bw__layer--after .bw__bar--a {
background: #2f6df6;
}
.bw__layer--after .bw__bar--b {
background: #4cd4a6;
}
.bw__layer--after .bw__bar--c {
background: #b9a0ff;
}
.bw__layer--after .bw__dot {
background: #ff4d1f;
}
.bw__layer--before {
background: #e7e5ea;
clip-path: inset(0 0 0 0);
animation-name: bw-cut;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --bw-view;
}
.bw__layer--before .bw__bar--a,
.bw__layer--before .bw__bar--b,
.bw__layer--before .bw__bar--c {
background: #b6b2bd;
}
.bw__layer--before .bw__dot {
background: #8d8895;
}
@keyframes bw-cut {
from {
clip-path: inset(0 0 0 0);
}
to {
clip-path: inset(0 100% 0 0);
}
}
.bw__bar {
height: 12px;
border-radius: 999px;
}
.bw__bar--a {
width: 64%;
}
.bw__bar--b {
width: 88%;
}
.bw__bar--c {
width: 46%;
}
.bw__dot {
position: absolute;
top: 16px;
right: 16px;
width: 28px;
height: 28px;
border-radius: 50%;
}
.bw__handle {
position: absolute;
top: 0;
bottom: 0;
left: 0;
display: grid;
place-items: center;
width: 4px;
background: #17141a;
color: #17141a;
animation-name: bw-handle;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --bw-view;
}
.bw__handle svg {
padding: 4px;
border-radius: 50%;
background: #17141a;
color: #fff7e6;
}
@keyframes bw-handle {
from {
transform: translateX(calc(var(--fw) - 4px));
}
to {
transform: translateX(0);
}
}
.bw__tags {
display: flex;
gap: 8px;
margin: 0;
}
.bw__tag {
padding: 4px 8px;
border-radius: 999px;
background: #2f6df6;
color: #fff;
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.bw__tag--old {
background: #b6b2bd;
}
.bw.is-demo .bw__pad {
padding-top: calc(var(--h) * 0.1);
}
.bw.is-demo .bw__layer--before,
.bw.is-demo .bw__handle {
animation-duration: 1s;
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@media (prefers-reduced-motion: reduce) {
.bw__layer--before {
animation: none !important;
clip-path: inset(0 50% 0 0);
}
.bw__handle {
animation: none !important;
transform: translateX(calc(var(--fw) * 0.5));
}
}// 실제 동작은 CSS 만: 위층의 clip-path 와 손잡이 위치를 animation-timeline: view() 가 함께 끌고 간다.
var r = document.querySelector('.bw');
var s = r.querySelector('.bw__scroller');
function stopDemo(){ r.classList.remove('is-demo'); }
r.addEventListener('pointerdown', stopDemo);
s.addEventListener('scroll', stopDemo, { passive: true });import type { CSSProperties, ReactNode } from "react";
import styles from "./BeforeAfterWipe.module.scss";
export type BeforeAfterWipeProps = {
beforeLabel?: string;
afterLabel?: string;
preview?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconArrows = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M208 80v96H48V80Z" opacity=".2"/><path d="m213.66 181.66l-32 32a8 8 0 0 1-11.32-11.32L188.69 184H48a8 8 0 0 1 0-16h140.69l-18.35-18.34a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32m-139.32-64a8 8 0 0 0 11.32-11.32L67.31 88H208a8 8 0 0 0 0-16H67.31l18.35-18.34a8 8 0 0 0-11.32-11.32l-32 32a8 8 0 0 0 0 11.32Z"/></g></svg>
);
const Layer = ({ kind }: { kind: string }): ReactNode => (
<div className={`${styles.layer} ${styles[kind]}`} aria-hidden="true">
<span className={`${styles.bar} ${styles.barA}`} />
<span className={`${styles.bar} ${styles.barB}`} />
<span className={`${styles.bar} ${styles.barC}`} />
<span className={styles.dot} />
</div>
);
export default function BeforeAfterWipe({
beforeLabel = "Before",
afterLabel = "After",
preview = true,
duration = 1000,
easing = "cubic-bezier(.65, 0, .35, 1)",
color = "#2f6df6",
}: BeforeAfterWipeProps): ReactNode {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = preview ? `${styles.bw} ${styles.isDemo}` : styles.bw;
return (
<div className={cls} style={vars}>
<div className={styles.scroller}>
<div className={styles.pad}>
<div className={styles.frame}>
<Layer kind="after" />
<Layer kind="before" />
<div className={styles.handle} aria-hidden="true"><IconArrows /></div>
</div>
<p className={styles.tags}>
<span className={`${styles.tag} ${styles.tagOld}`}>{beforeLabel}</span>
<span className={styles.tag}>{afterLabel}</span>
</p>
</div>
</div>
</div>
);
}04 · 전후 비교 와이프 — 측정 누적면적 19.369% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
미리보기 곡선만 가운데가 가장 빠르도록 잡아 대표 이미지가 반쯤 걷힌 장면으로 뽑히게 했습니다.
.bw__frame {
view-timeline-name: --bw-view;
view-timeline-axis: block;
position: relative;
overflow: hidden;
}
.bw__layer--before {
clip-path: inset(0 0 0 0);
animation-name: bw-cut;
animation-fill-mode: both;
animation-timeline: --bw-view;
}
@keyframes bw-cut {
from { clip-path: inset(0 0 0 0); }
to { clip-path: inset(0 100% 0 0); }
}
@keyframes bw-handle {
from { transform: translateX(calc(var(--fw) - 4px)); }
to { transform: translateX(0); }
}
05진입하면 자라는 막대
화면에서 일어나는 일
막대 다섯 개가 바닥에 눌려 있다가 구간이 화면에 들어온 만큼 펴집니다. 높이를 직접 바꾸지 않고 바닥을 축으로 눌러 두었다 펴기 때문에 레이아웃 계산이 다시 돌지 않습니다.

<main class="stage">
<div class="db is-demo">
<div class="db__scroller">
<div class="db__pad">
<p class="db__cap">재방문율</p>
<div class="db__chart">
<div class="db__col"><span class="db__bar"></span><span class="db__axis">Q1</span></div>
<div class="db__col"><span class="db__bar"></span><span class="db__axis">Q2</span></div>
<div class="db__col"><span class="db__bar"></span><span class="db__axis">Q3</span></div>
<div class="db__col"><span class="db__bar"></span><span class="db__axis">Q4</span></div>
<div class="db__col"><span class="db__bar"></span><span class="db__axis">Q5</span></div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
// 가운데가 가장 빠른 곡선 — ease-out 은 첫 프레임이 가장 빨라서
// 대표 이미지가 아직 다 납작한 차트로 뽑힌다 (실측)
$easing: cubic-bezier(.5, 0, .5, 1);
$color: $subject-mint;
@include stage($stage-ink);
.db {
--h: clamp(148px, 58vh, 176px);
width: min(320px, 92vw);
}
.db__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.db__pad {
display: grid;
gap: $sp-3;
// 차트가 처음에는 화면 **밖**에 있어야 entry 구간이 실제로 지나간다.
// 처음부터 보이면 구간이 이미 끝난 상태라 스크롤해도 아무 일이 안 일어난다 (실측)
padding: calc(var(--h) * 1.05) 0 calc(var(--h) * .6);
}
.db__cap {
margin: 0;
color: rgba(255, 247, 230, .88);
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
// 게이지 칸은 grid 로 나눈다 — flex 균등분배보다 폭이 정확하다
.db__chart {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: $sp-2;
align-items: end;
height: 96px;
}
.db__col {
display: grid;
align-content: end;
justify-items: center;
gap: $sp-1;
}
// ── 실제 동작: 구간이 화면에 들어온 만큼 막대가 자란다.
// 바닥을 축으로 눌러 두었다가 펴는 것이라 레이아웃을 다시 계산하지 않는다 ──
.db__bar {
width: 100%;
border-radius: $r-xs;
background: $color;
transform: scaleY(.04);
transform-origin: bottom; // 규칙8: 차트막대 bottom에서 자람
// ⚠ 스크롤판과 루프판은 키프레임을 나눠 쓴다. 루프용(자랐다 다시 눕는)을 그대로 스크롤에
// 물리면 구간 끝(=다 읽은 뒤)에 막대가 도로 납작해진다 — 스크롤판은 한 방향뿐이다 (실측)
animation-name: db-grow-in;
animation-duration: 1s;
animation-timing-function: $easing;
// both — backwards 만 두면 구간이 끝난 뒤(다 읽고 더 내렸을 때) 작성 상태로 돌아가 납작해진다
animation-fill-mode: both;
animation-timeline: view(block);
}
@keyframes db-grow-in {
from { transform: scaleY(.04); }
to { transform: scaleY(1); }
}
// 막대마다 구간을 조금씩 늦춰 물결처럼 올라오게 한다
.db__col:nth-child(1) .db__bar { height: 34px; animation-range: entry 8% cover 38%; }
.db__col:nth-child(2) .db__bar { height: 52px; animation-range: entry 14% cover 44%; }
.db__col:nth-child(3) .db__bar { height: 44px; animation-range: entry 20% cover 50%; }
.db__col:nth-child(4) .db__bar { height: 68px; animation-range: entry 26% cover 56%; }
.db__col:nth-child(5) .db__bar { height: 80px; animation-range: entry 32% cover 62%; }
// 자라는 구간(22%)을 줄어드는 구간(26%)보다 짧게 잡는다 — 대표 이미지는 가장 크게
// 변한 장이라, 줄어드는 쪽이 더 빠르면 다 줄어든 빈 차트가 대표로 뽑힌다.
// 마지막 막대의 시차(100ms = 5%)까지 더해도 복귀가 89% 안에서 끝난다
@keyframes db-grow {
0% { transform: scaleY(.04); }
22%, 58% { transform: scaleY(1); }
84%, 100% { transform: scaleY(.04); }
}
.db__axis {
color: rgba(255, 247, 230, .62);
font-size: 10px;
line-height: 14px;
font-weight: 500;
}
// 미리보기 루프는 실제로 스크롤하지 않으니 위 여백을 원래대로 줄여 차트를 화면 안에 둔다
.db.is-demo .db__pad { padding-top: calc(var(--h) * .22); }
// ── 격자 미리보기 루프 — 같은 곡선을 시간 축에서 시차만 주고 돌린다.
// 시차에는 animation-fill-mode: backwards 가 반드시 붙는다.
// 없으면 지연 구간에 작성 상태가 첫 프레임에 한 번 번쩍인다(252 실측) ──
.db.is-demo .db__bar {
animation-name: db-grow;
animation-duration: $duration;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-timeline: auto;
}
.db.is-demo .db__col:nth-child(2) .db__bar { animation-delay: 25ms; }
.db.is-demo .db__col:nth-child(3) .db__bar { animation-delay: 50ms; }
.db.is-demo .db__col:nth-child(4) .db__bar { animation-delay: 75ms; }
.db.is-demo .db__col:nth-child(5) .db__bar { animation-delay: 100ms; }
@media (prefers-reduced-motion: reduce) {
.db__bar { animation: none !important; transform: scaleY(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.db {
--h: clamp(148px, 58vh, 176px);
width: min(320px, 92vw);
}
.db__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
}
.db__scroller::-webkit-scrollbar {
display: none;
}
.db__pad {
display: grid;
gap: 12px;
padding: calc(var(--h) * 1.05) 0 calc(var(--h) * 0.6);
}
.db__cap {
margin: 0;
color: rgba(255, 247, 230, 0.88);
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
.db__chart {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
align-items: end;
height: 96px;
}
.db__col {
display: grid;
align-content: end;
justify-items: center;
gap: 4px;
}
.db__bar {
width: 100%;
border-radius: 4px;
background: #4cd4a6;
transform: scaleY(0.04);
transform-origin: bottom;
animation-name: db-grow-in;
animation-duration: 1s;
animation-timing-function: cubic-bezier(0.5, 0, 0.5, 1);
animation-fill-mode: both;
animation-timeline: view(block);
}
@keyframes db-grow-in {
from {
transform: scaleY(0.04);
}
to {
transform: scaleY(1);
}
}
.db__col:nth-child(1) .db__bar {
height: 34px;
animation-range: entry 8% cover 38%;
}
.db__col:nth-child(2) .db__bar {
height: 52px;
animation-range: entry 14% cover 44%;
}
.db__col:nth-child(3) .db__bar {
height: 44px;
animation-range: entry 20% cover 50%;
}
.db__col:nth-child(4) .db__bar {
height: 68px;
animation-range: entry 26% cover 56%;
}
.db__col:nth-child(5) .db__bar {
height: 80px;
animation-range: entry 32% cover 62%;
}
@keyframes db-grow {
0% {
transform: scaleY(0.04);
}
22%, 58% {
transform: scaleY(1);
}
84%, 100% {
transform: scaleY(0.04);
}
}
.db__axis {
color: rgba(255, 247, 230, 0.62);
font-size: 10px;
line-height: 14px;
font-weight: 500;
}
.db.is-demo .db__pad {
padding-top: calc(var(--h) * 0.22);
}
.db.is-demo .db__bar {
animation-name: db-grow;
animation-duration: 2s;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-timeline: auto;
}
.db.is-demo .db__col:nth-child(2) .db__bar {
animation-delay: 25ms;
}
.db.is-demo .db__col:nth-child(3) .db__bar {
animation-delay: 50ms;
}
.db.is-demo .db__col:nth-child(4) .db__bar {
animation-delay: 75ms;
}
.db.is-demo .db__col:nth-child(5) .db__bar {
animation-delay: 100ms;
}
@media (prefers-reduced-motion: reduce) {
.db__bar {
animation: none !important;
transform: scaleY(1);
}
}// 실제 동작은 CSS 만: 막대마다 animation-timeline: view() 와 서로 다른 animation-range 가 걸려 있다.
var r = document.querySelector('.db');
var s = r.querySelector('.db__scroller');
function stopDemo(){ r.classList.remove('is-demo'); }
r.addEventListener('pointerdown', stopDemo);
s.addEventListener('scroll', stopDemo, { passive: true });import type { CSSProperties, ReactNode } from "react";
import styles from "./DataBarsGrow.module.scss";
export type DataBarsGrowProps = {
caption?: string;
labels?: string[];
preview?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function DataBarsGrow({
caption = "Return visits",
labels = ["Q1", "Q2", "Q3", "Q4", "Q5"],
preview = true,
duration = 2000,
easing = "cubic-bezier(.5, 0, .5, 1)",
color = "#4cd4a6",
}: DataBarsGrowProps): ReactNode {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = preview ? `${styles.db} ${styles.isDemo}` : styles.db;
return (
<div className={cls} style={vars}>
<div className={styles.scroller}>
<div className={styles.pad}>
<p className={styles.cap}>{caption}</p>
<div className={styles.chart}>
{labels.map((l: string): ReactNode => (
<div className={styles.col} key={l}>
<span className={styles.bar} />
<span className={styles.axis}>{l}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}05 · 진입하면 자라는 막대 — 측정 누적면적 11.046% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
시차는 지연이 아니라 막대마다 animation-range 를 조금씩 어긋나게 줘서 만들었습니다 — 스크롤 타임라인에는 시간이 없으니 지연이 아무 뜻도 갖지 못합니다.
.db__chart {
display: grid;
grid-template-columns: repeat(5, 1fr);
align-items: end;
height: 96px;
}
.db__bar {
transform: scaleY(.04);
transform-origin: bottom;
animation-name: db-grow-in;
animation-fill-mode: both;
animation-timeline: view(block);
}
.db__col:nth-child(1) .db__bar { height: 34px; animation-range: entry 8% cover 38%; }
.db__col:nth-child(5) .db__bar { height: 80px; animation-range: entry 32% cover 62%; }
@keyframes db-grow-in {
from { transform: scaleY(.04); }
to { transform: scaleY(1); }
}
06부품 분해 조립
어떤 움직임인가
네 겹으로 포개진 판이 사방으로 벌어져 속을 보여주다가 다시 한자리로 모입니다. 판마다 목적지가 달라 키프레임을 넷으로 나눴고, 이름표는 판이 충분히 벌어진 뒤에야 나타나 글자가 겹치지 않습니다.

<main class="stage">
<div class="ex is-demo">
<div class="ex__scroller">
<div class="ex__pad">
<div class="ex__pin">
<div class="ex__stack">
<div class="ex__part ex__part--1"><span class="ex__tag">상판</span></div>
<div class="ex__part ex__part--2">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M200 48H56a8 8 0 0 0-8 8v144a8 8 0 0 0 8 8h144a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8m-48 104h-48v-48h48Z" opacity=".2"/><path d="M152 96h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8m-8 48h-32v-32h32Zm88 0h-16v-32h16a8 8 0 0 0 0-16h-16V56a16 16 0 0 0-16-16h-40V24a8 8 0 0 0-16 0v16h-32V24a8 8 0 0 0-16 0v16H56a16 16 0 0 0-16 16v40H24a8 8 0 0 0 0 16h16v32H24a8 8 0 0 0 0 16h16v40a16 16 0 0 0 16 16h40v16a8 8 0 0 0 16 0v-16h32v16a8 8 0 0 0 16 0v-16h40a16 16 0 0 0 16-16v-40h16a8 8 0 0 0 0-16m-32 56H56V56h144z"/></g></svg>
<span class="ex__tag">기판</span>
</div>
<div class="ex__part ex__part--3"><span class="ex__tag">프레임</span></div>
<div class="ex__part ex__part--4"><span class="ex__tag">바닥판</span></div>
</div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // alternate 라 한 바퀴(분해 → 조립)는 2s
// 가운데가 가장 빠른 곡선 — 대표 이미지가 반쯤 벌어진(이름표도 보이는) 장면으로 뽑힌다
$easing: cubic-bezier(.5, 0, .5, 1);
$color: $subject-ink;
@include stage($stage-yellow);
.ex {
--h: clamp(148px, 58vh, 176px);
width: min(340px, 92vw);
}
.ex__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
// ⚠ 여기는 padding-bottom 이 아니라 height 다. sticky 가 붙어 있을 수 있는 범위는 부모의
// **콘텐츠 박스**라, padding 으로만 늘린 자리는 안 쳐준다 — 창이 곧바로 위로 흘러나간다 (실측)
.ex__pad { height: calc(var(--h) * 2.2); }
.ex__pin {
position: sticky;
top: 0;
display: grid;
place-items: center;
height: var(--h);
}
.ex__stack {
position: relative;
width: 184px;
height: 120px;
}
.ex__part {
position: absolute;
top: 48px;
left: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
width: 184px;
height: 24px;
border: 2px solid $color;
border-radius: $r-sm;
background: $subject-cream;
color: $color;
animation-duration: 1s;
animation-timing-function: $easing;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
// ── 실제 동작: 스크롤 진행이 곧 분해 정도. 네 판이 서로 다른 방향으로 벌어진다 ──
.ex__part--1 { animation-name: ex-p1; background: $subject-pink; }
.ex__part--2 { animation-name: ex-p2; }
.ex__part--3 { animation-name: ex-p3; }
.ex__part--4 { animation-name: ex-p4; background: $subject-sky; }
@keyframes ex-p1 {
from { transform: translate(0, -6px) rotate(0deg); }
to { transform: translate(-16px, -54px) rotate(-7deg); }
}
@keyframes ex-p2 {
from { transform: translate(0, -2px) rotate(0deg); }
to { transform: translate(12px, -18px) rotate(4deg); }
}
@keyframes ex-p3 {
from { transform: translate(0, 2px) rotate(0deg); }
to { transform: translate(-12px, 18px) rotate(-4deg); }
}
@keyframes ex-p4 {
from { transform: translate(0, 6px) rotate(0deg); }
to { transform: translate(16px, 54px) rotate(7deg); }
}
// 이름표는 판이 벌어진 만큼만 보인다 — 포개져 있을 땐 글자가 겹치지 않는다
.ex__tag {
font-size: 11px;
line-height: 14px;
font-weight: 700;
opacity: 0;
animation-name: ex-tag;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes ex-tag {
0%, 10% { opacity: 0; }
38%, 100% { opacity: 1; }
}
// ── 격자 미리보기 루프 — 같은 분해를 시간으로 돌렸다 되감는다 ──
.ex.is-demo .ex__part,
.ex.is-demo .ex__tag {
animation-duration: $duration;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@media (prefers-reduced-motion: reduce) {
.ex__part { animation: none !important; }
.ex__part--1 { transform: translate(-16px, -54px); }
.ex__part--2 { transform: translate(12px, -18px); }
.ex__part--3 { transform: translate(-12px, 18px); }
.ex__part--4 { transform: translate(16px, 54px); }
.ex__tag { animation: none !important; opacity: 1; }
}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);
}
.ex {
--h: clamp(148px, 58vh, 176px);
width: min(340px, 92vw);
}
.ex__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
}
.ex__scroller::-webkit-scrollbar {
display: none;
}
.ex__pad {
height: calc(var(--h) * 2.2);
}
.ex__pin {
position: sticky;
top: 0;
display: grid;
place-items: center;
height: var(--h);
}
.ex__stack {
position: relative;
width: 184px;
height: 120px;
}
.ex__part {
position: absolute;
top: 48px;
left: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 184px;
height: 24px;
border: 2px solid #17141a;
border-radius: 8px;
background: #fff7e6;
color: #17141a;
animation-duration: 1s;
animation-timing-function: cubic-bezier(0.5, 0, 0.5, 1);
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
.ex__part--1 {
animation-name: ex-p1;
background: #ff9ebb;
}
.ex__part--2 {
animation-name: ex-p2;
}
.ex__part--3 {
animation-name: ex-p3;
}
.ex__part--4 {
animation-name: ex-p4;
background: #6ec8ff;
}
@keyframes ex-p1 {
from {
transform: translate(0, -6px) rotate(0deg);
}
to {
transform: translate(-16px, -54px) rotate(-7deg);
}
}
@keyframes ex-p2 {
from {
transform: translate(0, -2px) rotate(0deg);
}
to {
transform: translate(12px, -18px) rotate(4deg);
}
}
@keyframes ex-p3 {
from {
transform: translate(0, 2px) rotate(0deg);
}
to {
transform: translate(-12px, 18px) rotate(-4deg);
}
}
@keyframes ex-p4 {
from {
transform: translate(0, 6px) rotate(0deg);
}
to {
transform: translate(16px, 54px) rotate(7deg);
}
}
.ex__tag {
font-size: 11px;
line-height: 14px;
font-weight: 700;
opacity: 0;
animation-name: ex-tag;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes ex-tag {
0%, 10% {
opacity: 0;
}
38%, 100% {
opacity: 1;
}
}
.ex.is-demo .ex__part,
.ex.is-demo .ex__tag {
animation-duration: 1s;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@media (prefers-reduced-motion: reduce) {
.ex__part {
animation: none !important;
}
.ex__part--1 {
transform: translate(-16px, -54px);
}
.ex__part--2 {
transform: translate(12px, -18px);
}
.ex__part--3 {
transform: translate(-12px, 18px);
}
.ex__part--4 {
transform: translate(16px, 54px);
}
.ex__tag {
animation: none !important;
opacity: 1;
}
}// 실제 동작은 CSS 만: 부품마다 animation-timeline: scroll() 이 걸려 있어 스크롤 진행이 곧 분해 정도다.
var r = document.querySelector('.ex');
var s = r.querySelector('.ex__scroller');
function stopDemo(){ r.classList.remove('is-demo'); }
r.addEventListener('pointerdown', stopDemo);
s.addEventListener('scroll', stopDemo, { passive: true });import type { CSSProperties, ReactNode } from "react";
import styles from "./ExplodedParts.module.scss";
export type ExplodedPartsProps = {
parts?: string[];
preview?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconCpu = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M200 48H56a8 8 0 0 0-8 8v144a8 8 0 0 0 8 8h144a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8m-48 104h-48v-48h48Z" opacity=".2"/><path d="M152 96h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8m-8 48h-32v-32h32Zm88 0h-16v-32h16a8 8 0 0 0 0-16h-16V56a16 16 0 0 0-16-16h-40V24a8 8 0 0 0-16 0v16h-32V24a8 8 0 0 0-16 0v16H56a16 16 0 0 0-16 16v40H24a8 8 0 0 0 0 16h16v32H24a8 8 0 0 0 0 16h16v40a16 16 0 0 0 16 16h40v16a8 8 0 0 0 16 0v-16h32v16a8 8 0 0 0 16 0v-16h40a16 16 0 0 0 16-16v-40h16a8 8 0 0 0 0-16m-32 56H56V56h144z"/></g></svg>
);
export default function ExplodedParts({
parts = ["Top plate", "Board", "Frame", "Base plate"],
preview = true,
duration = 1000,
easing = "cubic-bezier(.5, 0, .5, 1)",
color = "#17141a",
}: ExplodedPartsProps): ReactNode {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = preview ? `${styles.ex} ${styles.isDemo}` : styles.ex;
return (
<div className={cls} style={vars}>
<div className={styles.scroller}>
<div className={styles.pad}>
<div className={styles.pin}>
<div className={styles.stack}>
{parts.map((p: string, i: number): ReactNode => (
<div className={`${styles.part} ${styles[`part${i + 1}`]}`} key={p}>
{i === 1 ? <IconCpu /> : null}
<span className={styles.tag}>{p}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}06 · 부품 분해 조립 — 측정 누적면적 18.662% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
애니메이션 여덟 개가 한 타임라인에 물려 있어 어느 지점에서 멈춰도 한 장의 분해도가 됩니다.
.ex__part {
position: absolute;
top: 48px;
animation-duration: 1s;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
.ex__part--1 { animation-name: ex-p1; }
.ex__part--4 { animation-name: ex-p4; }
@keyframes ex-p1 {
from { transform: translate(0, -6px) rotate(0deg); }
to { transform: translate(-16px, -54px) rotate(-7deg); }
}
@keyframes ex-p4 {
from { transform: translate(0, 6px) rotate(0deg); }
to { transform: translate(16px, 54px) rotate(7deg); }
}
@keyframes ex-tag { 0%, 10% { opacity: 0 } 38%, 100% { opacity: 1 } }
07경로 지도 따라가기
무엇이 보이나
흐린 점선 위로 진한 선이 그어지고 같은 곡선을 표식이 따라 달립니다. 선 길이를 몰라도 되도록 pathLength="100" 을 박아 두었고, 덕분에 stroke-dashoffset 은 100에서 0으로 쓰기만 하면 됩니다.

<main class="stage">
<div class="rm is-demo">
<div class="rm__scroller">
<div class="rm__pad">
<div class="rm__pin">
<div class="rm__map">
<svg class="rm__svg" viewBox="0 0 248 120" width="248" height="120" aria-hidden="true">
<path class="rm__guide" d="M20 96C62 96 58 34 100 34C142 34 138 92 180 92C206 92 214 60 228 36" pathLength="100"/>
<path class="rm__line" d="M20 96C62 96 58 34 100 34C142 34 138 92 180 92C206 92 214 60 228 36" pathLength="100"/>
</svg>
<span class="rm__stop rm__stop--1"></span>
<span class="rm__stop rm__stop--2"></span>
<span class="rm__stop rm__stop--3"></span>
<span class="rm__mark">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
</span>
<span class="rm__name rm__name--1">출발</span>
<span class="rm__name rm__name--2">경유</span>
<span class="rm__name rm__name--3">도착</span>
</div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // alternate 라 한 바퀴는 2s
$easing: linear;
$color: $subject-cream;
$route: "M20 96C62 96 58 34 100 34C142 34 138 92 180 92C206 92 214 60 228 36";
@include stage($stage-orange);
.rm {
--h: clamp(148px, 58vh, 176px);
width: min(340px, 92vw);
}
.rm__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
// ⚠ 여기는 padding-bottom 이 아니라 height 다. sticky 가 붙어 있을 수 있는 범위는 부모의
// **콘텐츠 박스**라, padding 으로만 늘린 자리는 안 쳐준다 — 창이 곧바로 위로 흘러나간다 (실측)
.rm__pad { height: calc(var(--h) * 2.2); }
.rm__pin {
position: sticky;
top: 0;
display: grid;
place-items: center;
height: var(--h);
}
.rm__map {
position: relative;
width: 248px;
height: 120px;
border-radius: $r-card;
background: rgba(23, 20, 26, .72);
}
.rm__svg { position: absolute; top: 0; left: 0; }
.rm__guide {
fill: none;
stroke: rgba(255, 247, 230, .3);
stroke-width: 2;
stroke-dasharray: 3 3;
}
// ── 실제 동작 ① 경로가 그어진다.
// pathLength="100" 을 박아 두면 길이를 몰라도 dashoffset 을 100 → 0 으로 쓸 수 있다 ──
.rm__line {
fill: none;
stroke: $stage-yellow;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation-name: rm-draw;
animation-duration: 1s;
animation-timing-function: $easing;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes rm-draw {
from { stroke-dashoffset: 100; }
to { stroke-dashoffset: 0; }
}
// ── 실제 동작 ② 표식은 같은 곡선 위를 달린다.
// offset-path 가 경로를 받고 offset-distance 가 그 위의 위치다 ──
.rm__mark {
position: absolute;
top: 0;
left: 0;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: $stage-yellow;
color: $subject-ink;
offset-path: path($route);
offset-distance: 0%;
offset-rotate: 0deg;
animation-name: rm-run;
animation-duration: 1s;
animation-timing-function: $easing;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes rm-run {
from { offset-distance: 0%; }
to { offset-distance: 100%; }
}
// 지나친 지점만 불이 켜진다 — 구간을 어긋나게 줘서 순서대로 들어온다
.rm__stop {
position: absolute;
width: 12px;
height: 12px;
border: 2px solid rgba(255, 247, 230, .5);
border-radius: 50%;
background: rgba(23, 20, 26, .9);
animation-name: rm-lit;
animation-duration: 1s;
animation-timing-function: $easing;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
.rm__stop--1 { left: 12px; top: 88px; animation-range: 0% 12%; }
.rm__stop--2 { left: 92px; top: 26px; animation-range: 34% 46%; }
.rm__stop--3 { left: 220px; top: 28px; animation-range: 84% 96%; }
@keyframes rm-lit {
from { border-color: rgba(255, 247, 230, .5); background: rgba(23, 20, 26, .9); }
to { border-color: $subject-mint; background: $subject-mint; }
}
.rm__name {
position: absolute;
color: $color;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.rm__name--1 { left: 32px; top: 90px; }
.rm__name--2 { left: 82px; top: 4px; }
.rm__name--3 { left: 198px; top: 6px; }
// ── 격자 미리보기 루프 — 같은 진행을 시간으로 돌렸다 되감는다 ──
.rm.is-demo .rm__line,
.rm.is-demo .rm__mark,
.rm.is-demo .rm__stop {
animation-duration: $duration;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
// 미리보기에서는 구간 대신 키프레임 자리로 순서를 준다 — 시차를 delay 로 주면
// 2초 캡처 구간과 주기가 어긋나 gif 가 이어지지 않는다
.rm.is-demo .rm__stop--1 { animation-name: rm-lit-a; }
.rm.is-demo .rm__stop--2 { animation-name: rm-lit-b; }
.rm.is-demo .rm__stop--3 { animation-name: rm-lit-c; }
@keyframes rm-lit-a {
0%, 8% { border-color: rgba(255, 247, 230, .5); background: rgba(23, 20, 26, .9); }
12%, 100% { border-color: $subject-mint; background: $subject-mint; }
}
@keyframes rm-lit-b {
0%, 36% { border-color: rgba(255, 247, 230, .5); background: rgba(23, 20, 26, .9); }
42%, 100% { border-color: $subject-mint; background: $subject-mint; }
}
@keyframes rm-lit-c {
0%, 84% { border-color: rgba(255, 247, 230, .5); background: rgba(23, 20, 26, .9); }
90%, 100% { border-color: $subject-mint; background: $subject-mint; }
}
@media (prefers-reduced-motion: reduce) {
.rm__line { animation: none !important; stroke-dashoffset: 0; }
.rm__mark { animation: none !important; offset-distance: 100%; }
.rm__stop { animation: none !important; border-color: $subject-mint; background: $subject-mint; }
}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);
}
.rm {
--h: clamp(148px, 58vh, 176px);
width: min(340px, 92vw);
}
.rm__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
}
.rm__scroller::-webkit-scrollbar {
display: none;
}
.rm__pad {
height: calc(var(--h) * 2.2);
}
.rm__pin {
position: sticky;
top: 0;
display: grid;
place-items: center;
height: var(--h);
}
.rm__map {
position: relative;
width: 248px;
height: 120px;
border-radius: 16px;
background: rgba(23, 20, 26, 0.72);
}
.rm__svg {
position: absolute;
top: 0;
left: 0;
}
.rm__guide {
fill: none;
stroke: rgba(255, 247, 230, 0.3);
stroke-width: 2;
stroke-dasharray: 3 3;
}
.rm__line {
fill: none;
stroke: #ffd23f;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation-name: rm-draw;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes rm-draw {
from {
stroke-dashoffset: 100;
}
to {
stroke-dashoffset: 0;
}
}
.rm__mark {
position: absolute;
top: 0;
left: 0;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #ffd23f;
color: #17141a;
offset-path: path("M20 96C62 96 58 34 100 34C142 34 138 92 180 92C206 92 214 60 228 36");
offset-distance: 0%;
offset-rotate: 0deg;
animation-name: rm-run;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes rm-run {
from {
offset-distance: 0%;
}
to {
offset-distance: 100%;
}
}
.rm__stop {
position: absolute;
width: 12px;
height: 12px;
border: 2px solid rgba(255, 247, 230, 0.5);
border-radius: 50%;
background: rgba(23, 20, 26, 0.9);
animation-name: rm-lit;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
.rm__stop--1 {
left: 12px;
top: 88px;
animation-range: 0% 12%;
}
.rm__stop--2 {
left: 92px;
top: 26px;
animation-range: 34% 46%;
}
.rm__stop--3 {
left: 220px;
top: 28px;
animation-range: 84% 96%;
}
@keyframes rm-lit {
from {
border-color: rgba(255, 247, 230, 0.5);
background: rgba(23, 20, 26, 0.9);
}
to {
border-color: #4cd4a6;
background: #4cd4a6;
}
}
.rm__name {
position: absolute;
color: #fff7e6;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.rm__name--1 {
left: 32px;
top: 90px;
}
.rm__name--2 {
left: 82px;
top: 4px;
}
.rm__name--3 {
left: 198px;
top: 6px;
}
.rm.is-demo .rm__line,
.rm.is-demo .rm__mark,
.rm.is-demo .rm__stop {
animation-duration: 1s;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
.rm.is-demo .rm__stop--1 {
animation-name: rm-lit-a;
}
.rm.is-demo .rm__stop--2 {
animation-name: rm-lit-b;
}
.rm.is-demo .rm__stop--3 {
animation-name: rm-lit-c;
}
@keyframes rm-lit-a {
0%, 8% {
border-color: rgba(255, 247, 230, 0.5);
background: rgba(23, 20, 26, 0.9);
}
12%, 100% {
border-color: #4cd4a6;
background: #4cd4a6;
}
}
@keyframes rm-lit-b {
0%, 36% {
border-color: rgba(255, 247, 230, 0.5);
background: rgba(23, 20, 26, 0.9);
}
42%, 100% {
border-color: #4cd4a6;
background: #4cd4a6;
}
}
@keyframes rm-lit-c {
0%, 84% {
border-color: rgba(255, 247, 230, 0.5);
background: rgba(23, 20, 26, 0.9);
}
90%, 100% {
border-color: #4cd4a6;
background: #4cd4a6;
}
}
@media (prefers-reduced-motion: reduce) {
.rm__line {
animation: none !important;
stroke-dashoffset: 0;
}
.rm__mark {
animation: none !important;
offset-distance: 100%;
}
.rm__stop {
animation: none !important;
border-color: #4cd4a6;
background: #4cd4a6;
}
}// 실제 동작은 CSS 만: 선은 stroke-dashoffset, 표식은 offset-path 위의 offset-distance,
// 둘 다 animation-timeline: scroll() 이 끌고 간다.
var r = document.querySelector('.rm');
var s = r.querySelector('.rm__scroller');
function stopDemo(){ r.classList.remove('is-demo'); }
r.addEventListener('pointerdown', stopDemo);
s.addEventListener('scroll', stopDemo, { passive: true });import type { CSSProperties, ReactNode } from "react";
import styles from "./RouteMapProgress.module.scss";
export type RouteMapProgressProps = {
stops?: string[];
preview?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ROUTE = "M20 96C62 96 58 34 100 34C142 34 138 92 180 92C206 92 214 60 228 36";
const IconPin = (): ReactNode => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
);
export default function RouteMapProgress({
stops = ["Start", "Stop", "Arrive"],
preview = true,
duration = 1000,
easing = "linear",
color = "#fff7e6",
}: RouteMapProgressProps): ReactNode {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = preview ? `${styles.rm} ${styles.isDemo}` : styles.rm;
return (
<div className={cls} style={vars}>
<div className={styles.scroller}>
<div className={styles.pad}>
<div className={styles.pin}>
<div className={styles.map}>
<svg className={styles.svg} viewBox="0 0 248 120" width="248" height="120" aria-hidden="true">
<path className={styles.guide} d={ROUTE} pathLength="100" />
<path className={styles.line} d={ROUTE} pathLength="100" />
</svg>
{stops.map((s: string, i: number): ReactNode => (
<span className={`${styles.stop} ${styles[`stop${i + 1}`]}`} key={s} />
))}
<span className={styles.mark}><IconPin /></span>
{stops.map((s: string, i: number): ReactNode => (
<span className={`${styles.name} ${styles[`name${i + 1}`]}`} key={s}>{s}</span>
))}
</div>
</div>
</div>
</div>
</div>
);
}07 · 경로 지도 따라가기 — 측정 누적면적 3.755% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
표식은 같은 곡선을 offset-path 로 받아 달리므로 선과 표식이 어긋날 수가 없습니다.
.rm__line {
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation-name: rm-draw;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes rm-draw {
from { stroke-dashoffset: 100; }
to { stroke-dashoffset: 0; }
}
.rm__mark {
offset-path: path("M20 96C62 96 58 34 100 34C142 34 138 92 180 92C206 92 214 60 228 36");
offset-distance: 0%;
offset-rotate: 0deg;
animation-name: rm-run;
animation-timeline: scroll(nearest block);
}
@keyframes rm-run { from { offset-distance: 0% } to { offset-distance: 100% } }
08챕터 페이지 넘김
화면에서 일어나는 일
왼쪽 모서리를 축으로 종이 한 장이 넘어가 다음 장이 드러납니다. 회전각을 스크롤 진행에 등속으로 물려야 스크롤 양과 넘어간 정도가 1:1로 맞고, 뒷면을 가려 둬야 앞면 글자가 비쳐 겹치지 않습니다.

<main class="stage">
<div class="pt is-demo">
<div class="pt__scroller">
<div class="pt__pad">
<div class="pt__pin">
<div class="pt__book">
<div class="pt__half pt__half--left">
<svg viewBox="0 0 256 256" width="28" height="28" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144h-72a32 32 0 0 0-32 32a32 32 0 0 0-32-32H24V56h72a32 32 0 0 1 32 32a32 32 0 0 1 32-32Z" opacity=".2"/><path d="M232 48h-72a40 40 0 0 0-32 16a40 40 0 0 0-32-16H24a8 8 0 0 0-8 8v144a8 8 0 0 0 8 8h72a24 24 0 0 1 24 24a8 8 0 0 0 16 0a24 24 0 0 1 24-24h72a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8M96 192H32V64h64a24 24 0 0 1 24 24v112a39.8 39.8 0 0 0-24-8m128 0h-64a39.8 39.8 0 0 0-24 8V88a24 24 0 0 1 24-24h64Z"/></g></svg>
<span class="pt__small">차례</span>
</div>
<div class="pt__half pt__half--right">
<span class="pt__no">02</span>
<span class="pt__title">성장</span>
</div>
<div class="pt__page">
<div class="pt__face pt__face--front">
<span class="pt__no">01</span>
<span class="pt__title">시작</span>
</div>
<div class="pt__face pt__face--back" aria-hidden="true"></div>
</div>
</div>
<div class="pt__rail" aria-hidden="true"><span class="pt__fill"></span></div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // alternate 라 한 바퀴(넘김 → 되넘김)는 2s
$easing: linear; // 스크롤 스크럽은 등속이라야 스크롤 양과 회전각이 1:1 로 맞는다
$color: $subject-blue;
@include stage($stage-paper);
.pt {
--h: clamp(148px, 58vh, 176px);
width: min(340px, 92vw);
}
.pt__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
// ⚠ 여기는 padding-bottom 이 아니라 height 다. sticky 가 붙어 있을 수 있는 범위는 부모의
// **콘텐츠 박스**라, padding 으로만 늘린 자리는 안 쳐준다 — 창이 곧바로 위로 흘러나간다 (실측)
.pt__pad { height: calc(var(--h) * 2.2); }
.pt__pin {
position: sticky;
top: 0;
display: grid;
justify-items: center;
align-content: center;
gap: $sp-3;
height: var(--h);
}
.pt__book {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
width: 240px;
height: 112px;
border-radius: $r-card;
background: #f2eff5;
perspective: 900px;
}
.pt__half {
display: grid;
place-content: center;
justify-items: center;
gap: $sp-1;
color: rgba(23, 20, 26, .72);
}
.pt__half--left { border-right: 1px dashed rgba(23, 20, 26, .2); }
.pt__no {
color: $subject-blue-deep; // 크림 면 위 파란 글자 4.25:1 → deep 5.98:1
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.pt__title {
color: $subject-ink;
font-size: 18px;
line-height: 24px;
font-weight: 800;
}
.pt__small {
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
// ── 실제 동작: 오른쪽 장이 왼쪽 모서리를 축으로 넘어간다.
// 스크롤 진행이 곧 회전각이고, 뒷면은 가려 둬서 앞면 글자가 비치지 않는다 ──
.pt__page {
position: absolute;
top: 0;
right: 0;
width: 120px;
height: 112px;
transform-origin: left center; // 규칙8: 책장 왼쪽 경첩 회전
transform-style: preserve-3d;
animation-name: pt-turn;
animation-duration: 1s;
animation-timing-function: $easing;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes pt-turn {
from { transform: rotateY(0deg); }
to { transform: rotateY(-180deg); }
}
.pt__face {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: $sp-1;
border-radius: 0 $r-card $r-card 0;
backface-visibility: hidden;
box-shadow: $shadow-press;
}
.pt__face--front { background: $subject-cream; }
.pt__face--back {
background: repeating-linear-gradient(135deg, #e3ddef 0 8px, #d6cee6 8px 16px);
transform: rotateY(180deg);
}
.pt__rail {
width: 240px;
height: 4px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
}
.pt__fill {
display: block;
height: 4px;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 읽기막대 left 채움
animation-name: pt-read;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes pt-read {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
// ── 격자 미리보기 루프 ────────────────────────────────
// 넘김만 steps(1, end) 로 끊는다. 회전한 면의 폭은 |cos θ| 라 프레임당 변화율이 90° 에서
// 언제나 최대라, 부드럽게 두면 대표 이미지가 늘 모서리뷰로 뽑힌다(252 실측).
// 진행 막대는 그대로 이어져 프레임을 채운다 — transform 이라 반투명 유령이 안 생긴다.
.pt.is-demo .pt__page {
animation-name: pt-cut;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
.pt.is-demo .pt__fill {
animation-duration: $duration;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@keyframes pt-cut {
0%, 49% { transform: rotateY(0deg); }
50%, 100% { transform: rotateY(-180deg); }
}
@media (prefers-reduced-motion: reduce) {
.pt__page { animation: none !important; transform: rotateY(0deg); }
.pt__fill { animation: none !important; transform: scaleX(.5); }
}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);
}
.pt {
--h: clamp(148px, 58vh, 176px);
width: min(340px, 92vw);
}
.pt__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
}
.pt__scroller::-webkit-scrollbar {
display: none;
}
.pt__pad {
height: calc(var(--h) * 2.2);
}
.pt__pin {
position: sticky;
top: 0;
display: grid;
justify-items: center;
align-content: center;
gap: 12px;
height: var(--h);
}
.pt__book {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
width: 240px;
height: 112px;
border-radius: 16px;
background: #f2eff5;
perspective: 900px;
}
.pt__half {
display: grid;
place-content: center;
justify-items: center;
gap: 4px;
color: rgba(23, 20, 26, 0.72);
}
.pt__half--left {
border-right: 1px dashed rgba(23, 20, 26, 0.2);
}
.pt__no {
color: #0a4fe8;
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.pt__title {
color: #17141a;
font-size: 18px;
line-height: 24px;
font-weight: 800;
}
.pt__small {
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.pt__page {
position: absolute;
top: 0;
right: 0;
width: 120px;
height: 112px;
transform-origin: left center;
transform-style: preserve-3d;
animation-name: pt-turn;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes pt-turn {
from {
transform: rotateY(0deg);
}
to {
transform: rotateY(-180deg);
}
}
.pt__face {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: 4px;
border-radius: 0 16px 16px 0;
backface-visibility: hidden;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.pt__face--front {
background: #fff7e6;
}
.pt__face--back {
background: repeating-linear-gradient(135deg, #e3ddef 0 8px, #d6cee6 8px 16px);
transform: rotateY(180deg);
}
.pt__rail {
width: 240px;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
}
.pt__fill {
display: block;
height: 4px;
border-radius: 999px;
background: #2f6df6;
transform: scaleX(0);
transform-origin: left center;
animation-name: pt-read;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes pt-read {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.pt.is-demo .pt__page {
animation-name: pt-cut;
animation-duration: 1s;
animation-timing-function: steps(1, end);
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
.pt.is-demo .pt__fill {
animation-duration: 1s;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@keyframes pt-cut {
0%, 49% {
transform: rotateY(0deg);
}
50%, 100% {
transform: rotateY(-180deg);
}
}
@media (prefers-reduced-motion: reduce) {
.pt__page {
animation: none !important;
transform: rotateY(0deg);
}
.pt__fill {
animation: none !important;
transform: scaleX(0.5);
}
}// 실제 동작: 스크롤이 rotateY 를 0 → -180deg 로 끌고 간다(animation-timeline: scroll()).
// 격자 미리보기 루프만 steps(1, end) 로 끊는다 — 3D 플립은 90° 부근이 항상 최대 변화라
// 그대로 두면 대표 이미지가 늘 모서리뷰(선 한 줄)가 된다.
var r = document.querySelector('.pt');
var s = r.querySelector('.pt__scroller');
function stopDemo(){ r.classList.remove('is-demo'); }
r.addEventListener('pointerdown', stopDemo);
s.addEventListener('scroll', stopDemo, { passive: true });import type { CSSProperties, ReactNode } from "react";
import styles from "./PageTurnChapters.module.scss";
export type PageTurnChaptersProps = {
tocLabel?: string;
frontTitle?: string;
backTitle?: string;
preview?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconBook = (): ReactNode => (
<svg viewBox="0 0 256 256" width="28" height="28" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144h-72a32 32 0 0 0-32 32a32 32 0 0 0-32-32H24V56h72a32 32 0 0 1 32 32a32 32 0 0 1 32-32Z" opacity=".2"/><path d="M232 48h-72a40 40 0 0 0-32 16a40 40 0 0 0-32-16H24a8 8 0 0 0-8 8v144a8 8 0 0 0 8 8h72a24 24 0 0 1 24 24a8 8 0 0 0 16 0a24 24 0 0 1 24-24h72a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8M96 192H32V64h64a24 24 0 0 1 24 24v112a39.8 39.8 0 0 0-24-8m128 0h-64a39.8 39.8 0 0 0-24 8V88a24 24 0 0 1 24-24h64Z"/></g></svg>
);
export default function PageTurnChapters({
tocLabel = "Contents",
frontTitle = "Beginning",
backTitle = "Growth",
preview = true,
duration = 1000,
easing = "linear",
color = "#2f6df6",
}: PageTurnChaptersProps): ReactNode {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = preview ? `${styles.pt} ${styles.isDemo}` : styles.pt;
return (
<div className={cls} style={vars}>
<div className={styles.scroller}>
<div className={styles.pad}>
<div className={styles.pin}>
<div className={styles.book}>
<div className={`${styles.half} ${styles.halfLeft}`}>
<IconBook />
<span className={styles.small}>{tocLabel}</span>
</div>
<div className={`${styles.half} ${styles.halfRight}`}>
<span className={styles.no}>02</span>
<span className={styles.title}>{backTitle}</span>
</div>
<div className={styles.page}>
<div className={`${styles.face} ${styles.faceFront}`}>
<span className={styles.no}>01</span>
<span className={styles.title}>{frontTitle}</span>
</div>
<div className={`${styles.face} ${styles.faceBack}`} aria-hidden="true" />
</div>
</div>
<div className={styles.rail} aria-hidden="true"><span className={styles.fill} /></div>
</div>
</div>
</div>
</div>
);
}08 · 챕터 페이지 넘김 — 측정 누적면적 21.651% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
변한 픽셀의 한 장 최대치가 21.014%로 아홉 개 중 가장 큰데, 이는 종이 한 장이 반대편 반쪽을 통째로 덮기 때문입니다.
.pt__page {
position: absolute;
right: 0;
width: 120px;
transform-origin: left center;
transform-style: preserve-3d;
animation-name: pt-turn;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes pt-turn {
from { transform: rotateY(0deg); }
to { transform: rotateY(-180deg); }
}
.pt__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.pt__face--back { transform: rotateY(180deg); }
09끝에서 올라오는 CTA
어떤 움직임인가
본문을 읽는 동안에는 자리조차 없다가, 마지막 구간이 화면에 들어오는 만큼 아래쪽 막대가 올라옵니다. 구간 자신이 이름 붙은 뷰 타임라인을 내놓고 그 안에 붙은 막대가 그 진행률을 쓰므로, 화면에 보이는지 검사하는 코드가 한 줄도 없습니다.

<main class="stage">
<div class="ec is-demo">
<div class="ec__scroller">
<div class="ec__doc" aria-hidden="true">
<span class="ec__line"></span>
<span class="ec__line ec__line--short"></span>
<span class="ec__line"></span>
<span class="ec__line ec__line--mid"></span>
<span class="ec__line"></span>
<span class="ec__line ec__line--short"></span>
<span class="ec__line ec__line--mid"></span>
<span class="ec__line"></span>
</div>
<div class="ec__end">
<div class="ec__bar">
<span class="ec__txt">지금 시작하세요</span>
<span class="ec__btn">
<span class="ec__btnlabel">신청하기</span>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
</span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // alternate 라 한 바퀴는 2s
$easing: $ease-pop;
$color: $subject-mint;
@include stage($stage-ink);
.ec {
--h: clamp(148px, 58vh, 176px);
width: min(300px, 90vw);
}
.ec__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.ec__doc {
display: grid;
gap: $sp-3;
padding: $sp-2 0;
}
.ec__line {
height: 8px;
border-radius: $r-pill;
background: rgba(255, 247, 230, .22);
}
.ec__line--short { width: 52%; }
.ec__line--mid { width: 78%; }
// ── 실제 동작: 마지막 구간이 자기 진행률을 타임라인으로 내놓는다.
// 조상이 만든 view-timeline 은 후손이 그대로 쓸 수 있다 ──
.ec__end {
display: flex;
align-items: flex-end;
height: var(--h);
view-timeline-name: --ec-end;
view-timeline-axis: block;
}
// 막대는 마지막 구간 안에서만 화면 바닥에 붙는다 — 읽는 동안에는 자리조차 없다
.ec__bar {
position: sticky;
bottom: $sp-2;
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
width: 100%;
padding: $sp-2 $sp-3;
border-radius: $r-card;
background: $subject-cream;
transform: translateY(140%);
animation-name: ec-rise;
animation-duration: 1s;
animation-timing-function: $easing;
animation-fill-mode: both;
animation-timeline: --ec-end;
animation-range: entry 0% entry 80%;
}
@keyframes ec-rise {
from { transform: translateY(140%); }
to { transform: translateY(0); }
}
.ec__txt {
color: $subject-ink;
font-size: 13px;
line-height: 18px;
font-weight: 800;
}
.ec__btn {
display: inline-flex;
align-items: center;
gap: $sp-1;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $color;
color: $subject-ink;
font-size: 12px;
line-height: 16px;
font-weight: 700;
transform: scale(.82);
animation-name: ec-pop;
animation-duration: 1s;
animation-timing-function: $easing;
animation-fill-mode: both;
animation-timeline: --ec-end;
animation-range: entry 24% entry 100%;
}
@keyframes ec-pop {
from { transform: scale(.82); }
to { transform: scale(1); }
}
// ── 격자 미리보기 루프 — 본문은 가만히 두고 막대만 올라온다.
// 본문 줄을 같이 굴리면 8줄이 한꺼번에 움직여 변한 픽셀 면적이 막대보다 커지고,
// 대표 이미지(면적이 가장 크게 변한 장)가 정작 주인공 없는 본문 장면으로 뽑힌다(실측) ──
// 미리보기에서는 스크롤을 실제로 하지 않으니 sticky 가 걸릴 일이 없다 —
// 그대로 두면 막대가 마지막 구간과 함께 화면 아래에 남아 한 프레임도 안 보인다.
// 루프 동안만 화면 바닥에 절대 배치로 세워 둔다
.ec.is-demo .ec__scroller { position: relative; }
.ec.is-demo .ec__bar {
position: absolute;
left: 0;
right: 0;
bottom: $sp-2;
width: auto;
animation-name: ec-rise-demo;
animation-duration: $duration;
// 끝으로 갈수록 빨라지는 곡선이라 가장 크게 변한 장 = 막대가 거의 다 올라온 순간이다
animation-timing-function: ease-in;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
.ec.is-demo .ec__btn {
animation-name: ec-pop-demo;
animation-duration: $duration;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
// 본문이 먼저 다 올라가 멈춘 **뒤에** 막대가 혼자 올라온다.
// 둘이 겹치면 대표 이미지(앞 프레임과 가장 크게 달라진 장)가 본문만 움직이는
// 장면으로 뽑혀 정작 주인공인 CTA 가 없는 그림이 된다
@keyframes ec-rise-demo {
0%, 16% { transform: translateY(220%); }
70%, 100% { transform: translateY(0); }
}
@keyframes ec-pop-demo {
0%, 72% { transform: scale(.82); }
88%, 100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.ec__bar { animation: none !important; transform: translateY(0); }
.ec__btn { animation: none !important; 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);
}
.ec {
--h: clamp(148px, 58vh, 176px);
width: min(300px, 90vw);
}
.ec__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
}
.ec__scroller::-webkit-scrollbar {
display: none;
}
.ec__doc {
display: grid;
gap: 12px;
padding: 8px 0;
}
.ec__line {
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.22);
}
.ec__line--short {
width: 52%;
}
.ec__line--mid {
width: 78%;
}
.ec__end {
display: flex;
align-items: flex-end;
height: var(--h);
view-timeline-name: --ec-end;
view-timeline-axis: block;
}
.ec__bar {
position: sticky;
bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 8px 12px;
border-radius: 16px;
background: #fff7e6;
transform: translateY(140%);
animation-name: ec-rise;
animation-duration: 1s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-fill-mode: both;
animation-timeline: --ec-end;
animation-range: entry 0% entry 80%;
}
@keyframes ec-rise {
from {
transform: translateY(140%);
}
to {
transform: translateY(0);
}
}
.ec__txt {
color: #17141a;
font-size: 13px;
line-height: 18px;
font-weight: 800;
}
.ec__btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: #4cd4a6;
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 700;
transform: scale(0.82);
animation-name: ec-pop;
animation-duration: 1s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-fill-mode: both;
animation-timeline: --ec-end;
animation-range: entry 24% entry 100%;
}
@keyframes ec-pop {
from {
transform: scale(0.82);
}
to {
transform: scale(1);
}
}
.ec.is-demo .ec__scroller {
position: relative;
}
.ec.is-demo .ec__bar {
position: absolute;
left: 0;
right: 0;
bottom: 8px;
width: auto;
animation-name: ec-rise-demo;
animation-duration: 1s;
animation-timing-function: ease-in;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
.ec.is-demo .ec__btn {
animation-name: ec-pop-demo;
animation-duration: 1s;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@keyframes ec-rise-demo {
0%, 16% {
transform: translateY(220%);
}
70%, 100% {
transform: translateY(0);
}
}
@keyframes ec-pop-demo {
0%, 72% {
transform: scale(0.82);
}
88%, 100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.ec__bar {
animation: none !important;
transform: translateY(0);
}
.ec__btn {
animation: none !important;
transform: scale(1);
}
}// 실제 동작은 CSS 만: 마지막 구간(.ec__end)이 view-timeline 을 만들고,
// 그 안에 position: sticky 로 붙은 막대가 그 진행률만큼 올라온다.
var r = document.querySelector('.ec');
var s = r.querySelector('.ec__scroller');
function stopDemo(){ r.classList.remove('is-demo'); }
r.addEventListener('pointerdown', stopDemo);
s.addEventListener('scroll', stopDemo, { passive: true });import type { CSSProperties, ReactNode } from "react";
import styles from "./EndcapCtaRise.module.scss";
export type EndcapCtaRiseProps = {
headline?: string;
button?: string;
lines?: number;
preview?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconArrow = (): ReactNode => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
);
const WIDTHS = ["", "short", "", "mid", "", "short", "mid", ""];
export default function EndcapCtaRise({
headline = "Start now",
button = "Sign up",
lines = 8,
preview = true,
duration = 1000,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#4cd4a6",
}: EndcapCtaRiseProps): ReactNode {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = preview ? `${styles.ec} ${styles.isDemo}` : styles.ec;
const list: number[] = [];
for (let i = 0; i < lines; i += 1) list.push(i);
return (
<div className={cls} style={vars}>
<div className={styles.scroller}>
<div className={styles.doc} aria-hidden="true">
{list.map((i: number): ReactNode => {
const w = WIDTHS[i % WIDTHS.length];
return <span className={w ? `${styles.line} ${styles[w]}` : styles.line} key={i} />;
})}
</div>
<div className={styles.end}>
<div className={styles.bar}>
<span className={styles.txt}>{headline}</span>
<span className={styles.btn}>
<span className={styles.btnlabel}>{button}</span>
<IconArrow />
</span>
</div>
</div>
</div>
</div>
);
}09 · 끝에서 올라오는 CTA — 측정 누적면적 9.936% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
안쪽 버튼은 animation-range 를 24%부터 잡아 한 박자 늦게 커집니다.
.ec__end {
display: flex;
align-items: flex-end;
height: var(--h);
view-timeline-name: --ec-end;
view-timeline-axis: block;
}
.ec__bar {
position: sticky;
bottom: $sp-2;
transform: translateY(140%);
animation-name: ec-rise;
animation-fill-mode: both;
animation-timeline: --ec-end;
animation-range: entry 0% entry 80%;
}
@keyframes ec-rise {
from { transform: translateY(140%); }
to { transform: translateY(0); }
}
어디서 깨지는가 — 함정 하나
이 편의 함정은 타임라인을 붙였다고 생각한 곳에 안 붙는 것이었습니다. scroll(nearest) 가 고르는 "가장 가까운 스크롤 상자"에는 overflow: hidden 인 상자도 포함됩니다. 03번 띠는 컷을 잘라 보여주는 창(overflow: hidden) 안에 있었고, 02번 덮개는 배경을 가두는 상자(같은 속성) 안에 있었습니다. 두 상자 다 스크롤이 0에서 0까지라, 선언은 멀쩡한데 진행률이 영원히 0이었습니다. 컴파일도 되고 콘솔도 조용해서 "왜 아무 일도 안 일어나지"만 남습니다. view() 도 같은 규칙을 쓰기 때문에 04번 위층이 자기를 감싼 상자를 스크롤포트로 잡았습니다. 셋 다 scroll-timeline-name 과 view-timeline-name 으로 이름을 붙여 고쳤습니다 — 이름 붙인 타임라인은 그 요소의 후손이면 어디서든 집어 쓸 수 있습니다.
붙는 자리 문제는 position: sticky 에서도 한 번 더 나왔습니다. 03·06·07·08번은 창을 붙여 두고 그 아래를 비워 스크롤 여유를 만들었는데, 처음에 그 여유를 부모의 padding-bottom 으로 줬습니다. 붙어 있을 수 있는 범위는 부모의 콘텐츠 박스라서 패딩으로 늘린 자리는 계산에 안 들어갑니다. 창이 붙자마자 위로 흘러나갔고, 실제로 스크롤해 보기 전에는 미리보기 루프가 멀쩡해서 눈치채지 못했습니다. 여유를 height 로 바꾸자 네 개가 한꺼번에 고쳐졌습니다.
미리보기와 실제 스크롤이 서로 다른 코드를 타는 것도 여기서 나온 함정입니다. 05번 막대는 루프용 키프레임이 다 자란 뒤 도로 눕히도록 돼 있었는데, 그 키프레임을 그대로 스크롤에 물리자 다 읽고 더 내리면 차트가 납작해졌습니다. animation-fill-mode 를 backwards 로 둔 것도 같이 물렸습니다 — 구간이 끝난 뒤를 안 채우므로 막대가 작성 상태로 돌아갔습니다. 스크롤판은 db-grow-in 이라는 한 방향 키프레임에 both 로, 루프판은 원래 키프레임으로 갈라 놓았습니다. 09번은 그 반대였습니다. 막대가 position: sticky 로 붙는데 미리보기에서는 아무도 스크롤을 안 하니 sticky 가 걸릴 일이 없어, 24장 중 한 장에도 안 나왔습니다. 미리보기 동안만 절대 배치로 세워 뒀습니다. 아홉 개 전부 .is-demo 를 뗀 상태로 0%·50%·100% 를 따로 찍어 본 뒤에야 이 세 가지가 드러났습니다.
브라우저 지원은 함정이 아니라 조건입니다. 스크롤 타임라인은 2026년 9월 기준 Chrome·Edge 115 이상, Safari 26 이상, Firefox 158 이상에서 돌고(caniuse 집계 전 세계 87.22%), 그 아래 판에서는 선언이 통째로 무시됩니다. 무시되면 요소는 작성 상태 그대로 남으므로, 작성 상태를 "안 보이는 상태"로 두면 옛 브라우저에서 내용이 사라집니다. 자바스크립트 폴백은 넣지 않았습니다. 대신 아홉 개 모두 스크롤 타임라인이 없어도 읽히도록 짰습니다 — 01·02번은 position: sticky 만으로 배치가 서고, 05번 막대는 높이가 원래 값이고 눌린 상태만 못 펴집니다.
접근성
prefers-reduced-motion: reduce 에서는 아홉 개가 연출은 끄고 결과는 남깁니다. 무엇을 남기는지가 항목마다 다릅니다.
| 항목 | 끄는 것 | 남기는 것 |
|---|---|---|
| 01 고정 그림 | 그림 교체 · 문단 스크럽 | 첫 장 그림과 세 문단 전부 |
| 02 고정 배경 | 배경 어두워짐 | 배경 장면과 캡션 세 장 |
| 03 컷 스크럽 | 띠 이동 | 여섯 번째 컷 한 장 |
| 04 전후 와이프 | 경계 이동 | 반씩 나뉜 전·후 두 장면 |
| 05 자라는 막대 | 자라는 동작 | 다 자란 막대 다섯 |
| 06 부품 분해 | 벌어짐 · 이름표 등장 | 벌어진 분해도와 이름표 |
| 07 경로 지도 | 선 그리기 · 표식 이동 | 다 그어진 경로와 켜진 지점 |
| 08 페이지 넘김 | 회전 | 첫 장과 진행 막대 절반 |
| 09 끝 CTA | 올라옴 · 버튼 커짐 | 올라와 있는 막대와 버튼 |
표의 "남기는 것"이 곧 정적 상태이고, 그 상태가 화면에서 말이 되는지를 아홉 개 모두 따로 찍어 확인했습니다. 05번이 처음에 scaleY(.04) 로 눌린 채 남아 빈 차트가 됐던 것도 이 확인에서 잡혔습니다. 장면이 바뀌는 순간의 미세한 움직임은 CSS 밑줄 애니메이션처럼 한 줄짜리 전환으로도 세울 수 있습니다. 세 언어 판과 React 판까지 같은 값을 쓰는 아홉 벌 소스는 압축 비밀번호 rgaqtmah 만 넣으면 열리는 zip 안에 있습니다.
읽는 순서도 그대로입니다. 아홉 개 어디에도 내용을 가로채는 스크롤 하이재킹이 없고, 모든 글자가 문서 순서대로 남아 있어 화면 낭독기는 애니메이션과 무관하게 위에서 아래로 읽습니다. 장식용 도형과 아이콘에는 aria-hidden 을 걸어 낭독에서 뺐습니다. 속성별 지원 범위와 값의 뜻은 MDN 의 animation-timeline 문서에 정리돼 있습니다.
스크롤 계열을 더 찾는다면 홈페이지 스크롤 효과 9가지와 스크롤 읽기 진행 표시 9종이 이웃입니다.
FAQ
스크롤리텔링은 스크롤 타임라인이 안 먹는 브라우저에서 어떻게 보이나요?
선언이 무시되고 요소가 작성 상태 그대로 그려집니다. 그래서 작성 상태를 "완성된 모습"에 가깝게 두는 것이 폴백입니다. 05번 막대는 높이를 원래 값으로 적고 transform 으로만 눌러 두었기 때문에, 눌림이 안 풀려도 칸과 라벨은 제자리에 있습니다. 반대로 opacity: 0 이나 display: none 으로 숨겨 두면 지원 없는 브라우저에서 내용이 통째로 사라지므로 그렇게 짜지 않았습니다. 이야기가 끝나고 독자를 데려가야 할 때는 랜딩페이지 전환율을 끌어올리는 섹션이 다음 장면을 준비합니다.
animation-timeline: scroll() 과 view() 는 뭐가 다른가요?
scroll() 은 스크롤 상자가 위에서 아래까지 얼마나 굴렀는지를 0에서 1로 봅니다. 구간 전체를 하나의 진행률로 쓰고 싶을 때, 그러니까 01·02·03·06·07·08번처럼 창을 붙여 두고 그 안을 굴릴 때 씁니다. view() 는 그 요소 자신이 화면에 얼마나 들어왔는지를 봅니다. 04·05·09번처럼 "이게 보이기 시작하면"이 조건일 때 씁니다. view() 쪽은 animation-range 로 entry·cover 같은 구간 이름을 써서 언제부터 언제까지인지 따로 정합니다.
미리보기 루프와 실제 스크롤 동작을 왜 따로 짜나요?
격자에 박히는 데모는 iframe 안에서 아무도 스크롤하지 않기 때문입니다. 스크롤이 0이면 스크롤 타임라인의 진행률도 0이라 화면이 멈춘 그림이 됩니다. 그래서 아홉 개 모두 .is-demo 클래스가 붙어 있는 동안만 시간 축에서 돌리고, 손가락이 닿거나 실제로 스크롤되는 순간 그 클래스를 떼어 스크롤 타임라인에 넘깁니다. 01~04·06번은 같은 키프레임을 그대로 쓰고, 05·07·08·09번만 미리보기용 키프레임을 따로 두었습니다 — 넘길 때 화면이 튀지 않을 만큼 두 경로를 붙여 두는 것이 요령입니다.









