スクロールテリング 作り方 9選 — コピペOKのCSS
スクロールテリングとは、スクロール位置を時計の代わりに使い、ひとつの区間の中で場面を順番に切り替える組み立て方です。ここにある9つは効果の違いではなく、場面の組み立て方そのものが違います。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 固定図版とステップ文
- 02 固定背景を通るキャプション
- 03 フレームシーケンス スクラブ
- 04 ビフォーアフター ワイプ
- 05 入ると伸びるバー
- 06 パーツ分解・組み立て
- 07 ルートマップの描画
- 08 チャプターのページめくり
- 09 最後に現れるCTA
並び順は人気順ではなく、ひとつの話が流れていく順番です。まず舞台を立てます。図版を1枚その場に留めて文章を流すか(01)、背景ごと留めて文章カードだけを通すか(02)です。舞台が立ったら、その中で時間を回します。ひとつの場面を8コマに切ってスクロール量の分だけ巻き(03)、2つの場面を重ねて境目を押して入れ替えます(04)。次は数字と物です。バーが伸びて数値を見せ(05)、パーツが散らばっては集まり、中身を見せます(06)。最後の3つは読者を外へ連れ出します。どこを通ってきたかを地図で見せ(07)、章をめくって次の話へつなぎ(08)、読み終わった場所でだけ次の行動を勧めます(09)。9つとも自動再生のプレビューを使っていますが、指が触れた瞬間にそのループを外し、本物のスクロールに操作を渡します。スクロールテリング 作り方 で迷いやすいのは見せ方そのものではなく、留めた舞台が次の一文をどう開くかという順番のほうです。同じ軸でサイト全体の動きを集めた版はスクロール アニメーション CSSにあり、今どの場面かを測る道具はスクロール インジケーターに別にあります。
01固定図版とステップ文
何が見えるか
画面を2つに割り、左の図版だけをその場に留めたまま、右の段落が上がっていきます。2枚が半透明に重なる中間フレームを作らないことで、「説明が切り替わった」ことがはっきり伝わります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
切り替えの位置は段落が画面の中央を通る地点なので、段落の高さ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固定背景を通るキャプション
スクロール 演出 は画面でどう見えるか
背景の場面はまったく動かず、半透明のカードだけが下から上がって上へ抜けていきます。覆われた分だけ背景が暗くなるよう、オーバーレイ1枚の不透明度をスクロールの進み具合に直接つないでいます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
カードが背景の全面を通り過ぎるため、変化したピクセルは画面の43.372%と9つの中で最も大きくなりました。
.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フレームシーケンス スクラブ
どんな動きか
8コマを一列につないだ帯をスクロール量の分だけ横に送ると、動画をスクラブしているのと同じ画面になります。steps(8, jump-none) は0と1の両方を含むので、8コマがちょうど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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
コマの中身はアニメーションではなく絵柄そのものなので、ふたの角度をコマ番号に掛けて静的に描いてあります。
.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ビフォーアフター ワイプ
何が見えるか
同じ位置に2つの場面を重ね、上の層だけを右から切り取ると、下の層がその分だけ現れます。切り取りは 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
プレビューの曲線だけ真ん中を最も速くして、代表画像が半分ほど切り取られた場面から選ばれるようにしています。
.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入ると伸びるバー
画面で起きること
5本のバーが床に押しつぶされていて、区間が画面に入った分だけ伸びます。高さを直接変えるのではなく、底を軸に潰しておいて戻すだけなので、レイアウトの再計算が起きません。時間差は遅延ではなく、バーごとに animation-range を少しずつずらして作りました。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
スクロールのタイムラインには時間の概念がないので、遅延を指定しても意味がないからです。
.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パーツ分解・組み立て
どんな動きか
4枚重なった板が四方に開いて中身を見せ、また一か所に集まります。板ごとに行き先が違うのでキーフレームを4つに分け、名前のラベルは板が十分に開いてから現れるので文字が重なりません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
8つのアニメーションがひとつのタイムラインにつながっているため、どの位置で止めても1枚の分解図になります。
.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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
マーカーは同じ曲線を 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対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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
紙1枚が反対側の半分を丸ごと覆うため、1フレームあたりの変化量の最大値は21.014%と9つの中で最も大きくなりました。
.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
どんな動きか
本文を読んでいる間は置き場所さえなく、最後の区間が画面に入った分だけ下のバーが上がってきます。区間そのものが名前付きのビュータイムラインを持ち、その中に入っているバーが進み具合を受け取るので、要素が画面に入ったかを調べるコードは1行も要りません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
中のボタンは 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の帯はコマを切り出して見せる窓の中にあり、02のオーバーレイは背景を閉じ込める箱の中にありました。どちらの箱もスクロールが0から0までなので、宣言は正しいのに進み具合がいつまでも0のままです。コンパイルも通り、コンソールも静かなので、「なぜ何も起きないのか」だけが残ります。view() も同じ規則を使うため、04の上の層は自分を包む箱をスクロールポートとして拾っていました。3つとも scroll-timeline-name と view-timeline-name で名前を付けて直しました。名前を付けたタイムラインは、その要素の子孫であればどこからでも拾えます。
つながる場所の問題は position: sticky でもう一度出ました。03・06・07・08は窓を留めてその下を空けてスクロールの余裕を作るのですが、最初はその余裕を親の padding-bottom で与えていました。留まっていられる範囲は親のコンテンツボックスなので、パディングで伸ばした場所は計算に入りません。窓は留まるどころかすぐ上へ流れていきましたが、プレビューのループは正常だったので、実際にスクロールしてみるまで気づきませんでした。余裕を height に変えたら4つが一度に直りました。
プレビューと本物のスクロールが別々のコードを通ることも落とし穴になりました。05のバーはループ用のキーフレームが伸びきったあとまた潰れるようになっていて、そのキーフレームをそのままスクロールにつないだところ、読み終えてさらにスクロールするとグラフが平らになりました。animation-fill-mode を backwards にしていたことも重なっていました。区間が終わったあとを埋めないので、バーが記述どおりの状態に戻ってしまいます。そこでスクロール版は db-grow-in という一方向のキーフレームと both を使い、ループ版は元のキーフレームを使うように分けました。09は逆でした。バーは position: sticky で留まるのに、プレビューでは誰もスクロールしないので sticky が効く場面がなく、撮影した24フレームのどれにも写りませんでした。プレビューの間だけ絶対配置で立てておくことにしました。9つとも .is-demo を外した状態で0%・50%・100%を別々に撮ってみて、はじめてこの3つに気づきました。
ブラウザー対応は落とし穴ではなく条件です。スクロールのタイムラインは2026年9月時点で Chrome・Edge 115以上、Safari 26以上、Firefox 158以上で動き(caniuse の集計では全世界87.22%)、それより前のバージョンでは宣言が丸ごと無視されます。無視されると要素は記述どおりの状態のまま残るので、記述どおりの状態を「見えない状態」にしておくと古いブラウザーで内容が消えます。JavaScript のフォールバックは入れていません。代わりに9つとも、スクロールのタイムラインがなくても読めるように書いてあります。01・02は position: sticky だけで配置が決まり、05のバーは高さが本来の値のままで、潰れた状態が解けないだけです。
アクセシビリティ
prefers-reduced-motion: reduce では9つとも演出を止めて結果を残します。何を残すかは項目ごとに違います。
| 項目 | 止めるもの | 残るもの |
|---|---|---|
| 01 固定図版 | 図版の切り替え・段落の送り | 最初の図版と3つの段落すべて |
| 02 固定背景 | 背景が暗くなること | 背景の場面とキャプション3枚 |
| 03 コマ送り | 帯の移動 | 6コマ目が1枚 |
| 04 ワイプ | 境目の移動 | 半分ずつ分かれた前と後の場面 |
| 05 伸びるバー | 伸びる動き | 伸びきったバー5本 |
| 06 パーツ分解 | 開くこと・ラベルの登場 | 開いた分解図とラベル |
| 07 ルートマップ | 線の描画・マーカーの移動 | 引き終わった経路と点灯した地点 |
| 08 ページめくり | 回転 | 最初の面と半分まで進んだバー |
| 09 最後のCTA | せり上がり・ボタンの拡大 | 上がりきったバーとボタン |
表の「残るもの」がそのまま静止状態になります。その状態が画面として成り立っているかを、9つとも別々に撮って確かめました。05が最初 scaleY(.04) で潰れたまま残り、空のグラフになっていたのもこの確認で見つかりました。場面が変わる瞬間の微細な動きはCSS リンク 下線 アニメーションのような一行の切替でも立てられます。3言語版と React 版まで同じ値をそろえた9組のソースは、圧縮パスワード rgaqtmah を入れると開く zip の中にあります。
読む順番もそのままです。9つのどこにも内容を横取りするスクロールジャックがなく、すべての文字が文書の順番どおりに残っているので、スクリーンリーダーはアニメーションと関係なく上から下へ読みます。飾りの図形とアイコンには 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になり、画面が止まった絵になります。そこで9つとも .is-demo クラスが付いている間だけ時間軸で回し、指が触れるか実際にスクロールされた瞬間にそのクラスを外してスクロールのタイムラインに渡します。01〜04と06は同じキーフレームをそのまま使い、05・07・08・09だけプレビュー用のキーフレームを別に持たせています。渡すときに画面が飛ばない程度に2つの経路を近づけておくのがコツです。









