スクロール アニメーション CSSで作る9種 — コピペでApple風
スクロール アニメーション CSSは、指でページを下へスクロールする動きに画面が応える仕組みで、Appleの製品ページがその基準です。スクロール用ライブラリ一切なしのCSSだけで、横流しパネル・固定画面の切替・単語の着色・背景色の入れ替えまで9種を、実際にレンダリングして確かめたコードでまとめました。
01 scrollytelling-horizontal-panel-track
02 footer-reveal-curtain-scroll
03 sticky-stack-cards-scale
04 pinned-scene-swap-steps
05 text-word-fill-scroll
06 text-marker-sweep-scroll
07 theme-shift-section-bg
08 velocity-skew-cards
09 pinned-spotlight-circle
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 横へ流れるストーリーパネル
- 02 終わりで現れるカーテンフッター
- 03 重ねて積まれるカード
- 04 固定画面のシーン切替
- 05 単語ごとに満ちる文章
- 06 蛍光ペンが引かれる強調
- 07 区間ごとに変わる背景色
- 08 スクロール速度で傾く一覧
- 09 スポットライトサークル
9種は画面に現れる順番です。ページを開いた瞬間に見えるもの(01)から始まり、本文が終わって出会うもの(02)、本文の流れの中でカードが積まれ文が満ちていくもの(03〜07)、そして一覧が速度に応じたり一枚の画像が公開されたりする締め(08〜09)の順です。スクロール アニメーション css のみで、Apple風ページを上から下へひと息になぞる道と同じです。同じ軸で、上の帯が貼りついて離れる側がCSS スティッキーヘッダー追加9選、場面ひとつひとつが物語を引っ張る側がスクロールテリング 作り方 9選です。
01横へ流れるストーリーパネル
スクロール アニメーション 作り方として何が見えるか
縦にスクロールすると、固定された画面の中でパネルが横へ流れていく、Apple製品ページの定番構成です。scroll-timelineがスクロール箱の位置をそのまま読んでトラックを押し、position: stickyが画面を固定します。

<main class="stage">
<div class="hp is-demo">
<div class="hp__scroller">
<div class="hp__inner">
<div class="hp__pin">
<div class="hp__track">
<section class="hp__panel hp__panel--1"><span class="hp__tag">物語 1</span></section>
<section class="hp__panel hp__panel--2"><span class="hp__tag">物語 2</span></section>
<section class="hp__panel hp__panel--3"><span class="hp__tag">物語 3</span></section>
</div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.hp {
width: min(400px, 88vw);
height: clamp(150px, 62vh, 224px);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
// 세로로 스크롤하는 상자 — 이 상자의 스크롤 위치가 곧 타임라인
.hp__scroller {
height: 100%;
overflow-y: auto;
scroll-timeline: --hp-scroll block;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
// 스티키 영역이 세 번 화면만큼 깊이 차면 패널 세 장이 정확히 한 바퀴 돈다
.hp__inner { height: 300%; }
.hp__pin {
position: sticky;
top: 0;
height: calc(100% / 3);
overflow: hidden;
}
// 트랙 폭 = 패널 3장 = 화면의 300%. translateX % 는 트랙 자기 폭 기준이라
// 두 판 너비 이동은 -66.6667%
.hp__track { display: flex; height: 100%; width: 300%; }
.hp__panel {
flex: 0 0 33.3333%;
height: 100%;
display: grid;
place-items: center;
box-sizing: border-box;
}
.hp__panel--1 { background: linear-gradient(135deg, $color, #d6ddeb); }
.hp__panel--2 { background: linear-gradient(135deg, #c2ae82, $stage-yellow); }
.hp__panel--3 { background: linear-gradient(135deg, $stage-orange, #ebdbd6); }
.hp__tag {
padding: $sp-1 $sp-4;
border-radius: $r-pill;
background: rgba(23, 20, 26, .45);
color: $subject-cream;
font: 700 14px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 실제 사용 코드: 세로 스크롤 위치를 읽어 트랙을 가로로 민다
@supports (animation-timeline: scroll()) {
.hp__track:not(.is-demo) {
animation: hp-slide linear both;
animation-timeline: --hp-scroll;
}
}
@supports not (animation-timeline: scroll()) {
// 미지원 브라우저(Firefox·Safari): 세로 스크롤이 그냥 통과되도 패널 1이 그대로 보인다
.hp__track:not(.is-demo) { transform: none; }
}
@keyframes hp-slide {
from { transform: translateX(0); }
to { transform: translateX(-66.6667%); } // (트랙 300% − 화면 100%) ÷ 트랙 300%
}
// 데모 루프: 실제 세로 스크롤 대신 패널이 차례로 넘어가는 걸 흉내(캡처용)
.hp__track.is-demo { animation: hp-loop $duration $easing infinite; }
@keyframes hp-loop {
0%, 8% { transform: translateX(0); }
33%, 41% { transform: translateX(-33.3333%); }
66%, 74% { transform: translateX(-66.6667%); }
100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.hp__track.is-demo { animation: none !important; }
.hp__scroller { scroll-behavior: auto; }
}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);
}
.hp {
width: min(400px, 88vw);
height: clamp(150px, 62vh, 224px);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.hp__scroller {
height: 100%;
overflow-y: auto;
scroll-timeline: --hp-scroll block;
scrollbar-width: none;
}
.hp__scroller::-webkit-scrollbar {
display: none;
}
.hp__inner {
height: 300%;
}
.hp__pin {
position: sticky;
top: 0;
height: 33.3333333333%;
overflow: hidden;
}
.hp__track {
display: flex;
height: 100%;
width: 300%;
}
.hp__panel {
flex: 0 0 33.3333%;
height: 100%;
display: grid;
place-items: center;
box-sizing: border-box;
}
.hp__panel--1 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.hp__panel--2 {
background: linear-gradient(135deg, #c2ae82, #ffd23f);
}
.hp__panel--3 {
background: linear-gradient(135deg, #ff4d1f, #ebdbd6);
}
.hp__tag {
padding: 4px 16px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.45);
color: #fff7e6;
font: 700 14px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@supports (animation-timeline: scroll()) {
.hp__track:not(.is-demo) {
animation: hp-slide linear both;
animation-timeline: --hp-scroll;
}
}
@supports not (animation-timeline: scroll()) {
.hp__track:not(.is-demo) {
transform: none;
}
}
@keyframes hp-slide {
from {
transform: translateX(0);
}
to {
transform: translateX(-66.6667%);
}
}
.hp__track.is-demo {
animation: hp-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes hp-loop {
0%, 8% {
transform: translateX(0);
}
33%, 41% {
transform: translateX(-33.3333%);
}
66%, 74% {
transform: translateX(-66.6667%);
}
100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.hp__track.is-demo {
animation: none !important;
}
.hp__scroller {
scroll-behavior: auto;
}
}// 실제 동작은 scroll-timeline + animation-timeline + position: sticky 만으로 끝난다(0줄).
// 이 3줄은 미리보기 자동 재생만 멈춘다 — 실제 세로 스크롤은 그대로 살아있다.
document.querySelector('.hp__scroller').addEventListener('pointerdown', function () {
document.querySelector('.hp').classList.remove('is-demo');
});import type { CSSProperties } from "react";
import styles from "./HorizontalPanels.module.scss";
export type HorizontalPanelsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function HorizontalPanels({ duration = 300, easing = "linear", color = "#2f6df6" }: HorizontalPanelsProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.hp} style={vars}>
{/* 세로 스크롤 위치가 곧 타임라인 — 스크롤 상자에 scroll-timeline, 트랙에 animation-timeline */}
<div className={styles.scroller}>
<div className={styles.inner}>
<div className={styles.pin}>
<div className={styles.track}>
{[1, 2, 3].map((n) => (
<section key={n} className={`${styles.panel} ${styles["panel" + n]}`}>
<span className={styles.tag}>{n}</span>
</section>
))}
</div>
</div>
</div>
</div>
</div>
);
}01 · 横へ流れるストーリーパネル — 計測 累積面積 51.539% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
製品の特徴紹介やポートフォリオのストーリーに使います。
.hp__scroller {
overflow-y: auto;
scroll-timeline: --hp-scroll block;
}
.hp__inner { height: 300%; } // スティキ領域が画面3枚分
.hp__pin {
position: sticky;
top: 0;
height: calc(100% / 3);
overflow: hidden;
}
.hp__track {
display: flex;
width: 300%; // パネル3枚 = 画面の300%
animation: hp-slide linear both;
animation-timeline: --hp-scroll;
}
@keyframes hp-slide {
from { transform: translateX(0); }
to { transform: translateX(-66.6667%); } // 2枚分 ÷ トラック幅
}
02終わりで現れるカーテンフッター
画面で起きること
本文パネルがフッターの上を進み、スクロールの終わりで脇に退いてフッターをカーテンのように現します。フッターは文書の一番最後に置きつつ、position: sticky; bottom: 0で最初からビューポートの底に貼り付けておきます。

<main class="stage">
<div class="fc is-demo">
<div class="fc__scroll">
<div class="fc__main">
<p class="fc__line">本文はここで終わり</p>
<p class="fc__line">その下に挨拶が待っています</p>
<p class="fc__line">もう一段だけ下へ</p>
</div>
<footer class="fc__foot"><span class="fc__cheer">最後まで来てくれてありがとう</span></footer>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.fc {
width: min(380px, 88vw);
height: clamp(150px, 62vh, 216px);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.fc__scroll {
height: 100%;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
// ── 실제 동작: 푸터가 문서 맨 끝에 있되 sticky(bottom) 로 뷰포트 바닥에 미리 붙어 있고,
// 본문이 z-index 위를 지나가다 끝에서 비켜주면 푸터가 드러난다 ──
.fc__main {
position: relative;
z-index: 1; // 본문이 푸터 위를 지나간다
min-height: calc(100% + 64px); // 뷰포트보다 한 칸 깊어야 시작부터 푸터를 완전히 덮는다
margin-bottom: 64px; // 스크롤 끝에서 푸터 칸이 드러난다
padding: $sp-6 $sp-5;
background: $stage-paper;
box-sizing: border-box;
}
.fc__line {
margin: 0 0 $sp-4;
color: #3c4750;
font: 600 13px/1.6 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
&:last-child { margin-bottom: 0; }
}
.fc__foot {
position: sticky;
bottom: 0;
z-index: 0;
height: 64px;
display: grid;
place-items: center;
background: linear-gradient(120deg, #b9f0d8, #ffd6e8);
}
.fc__cheer {
padding: $sp-1 $sp-4;
border-radius: $r-pill;
background: rgba(255, 255, 255, .78);
color: #216a52;
font: 700 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 데모 루프: 실제 스크롤 대신 본문 패널이 위로 미끄러져 푸터를 드러내는 걸 흉내(캡처용)
.fc.is-demo .fc__main { animation: fc-lift $duration $easing infinite; }
@keyframes fc-lift {
0%, 12% { transform: translateY(0); }
55%, 88% { transform: translateY(-104px); }
100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.fc.is-demo .fc__main { animation: none !important; }
.fc__scroll { scroll-behavior: auto; }
}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);
}
.fc {
width: min(380px, 88vw);
height: clamp(150px, 62vh, 216px);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.fc__scroll {
height: 100%;
overflow-y: auto;
scrollbar-width: none;
}
.fc__scroll::-webkit-scrollbar {
display: none;
}
.fc__main {
position: relative;
z-index: 1;
min-height: calc(100% + 64px);
margin-bottom: 64px;
padding: 24px 20px;
background: #ffffff;
box-sizing: border-box;
}
.fc__line {
margin: 0 0 16px;
color: #3c4750;
font: 600 13px/1.6 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.fc__line:last-child {
margin-bottom: 0;
}
.fc__foot {
position: sticky;
bottom: 0;
z-index: 0;
height: 64px;
display: grid;
place-items: center;
background: linear-gradient(120deg, #b9f0d8, #ffd6e8);
}
.fc__cheer {
padding: 4px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
color: #216a52;
font: 700 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.fc.is-demo .fc__main {
animation: fc-lift 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes fc-lift {
0%, 12% {
transform: translateY(0);
}
55%, 88% {
transform: translateY(-104px);
}
100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.fc.is-demo .fc__main {
animation: none !important;
}
.fc__scroll {
scroll-behavior: auto;
}
}// 실제 동작은 position: sticky(bottom) + z-index 만으로 끝난다(0줄).
// 이 3줄은 미리보기 자동 재생만 멈춘다 — 실제 스크롤은 그대로 살아있다.
document.querySelector('.fc__scroll').addEventListener('pointerdown', function () {
document.querySelector('.fc').classList.remove('is-demo');
});import type { CSSProperties } from "react";
import styles from "./CurtainFooter.module.scss";
export type CurtainFooterProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CurtainFooter({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: CurtainFooterProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fc} style={vars}>
{/* 푸터가 문서 맨 끝(sticky bottom) — 본문이 z-index 위를 지나가다 끝에서 비켜준다 */}
<div className={styles.scroll}>
<div className={styles.main}>
<p>본문은 여기서 끝나고</p>
<p>그 아래에 인사가 기다립니다</p>
<p>한 칸만 더 내려보세요</p>
</div>
<footer className={styles.foot}>
<span className={styles.cheer}>끝까지 와줘서 고마워요</span>
</footer>
</div>
</div>
);
}02 · 終わりで現れるカーテンフッター — 計測 累積面積 16.541% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
連絡先・著作権のフッターや締めの挨拶に合います。
.fc__main {
position: relative;
z-index: 1; // 本文がフッターの上を進む
min-height: calc(100% + 64px); // 最初からフッターを完全に覆う
margin-bottom: 64px; // 終わりでフッターの枠が開く
background: #fff;
}
.fc__foot {
position: sticky;
bottom: 0;
z-index: 0;
height: 64px;
background: linear-gradient(120deg, #b9f0d8, #ffd6e8);
}
03重ねて積まれるカード
どんな動きか
カードごとにposition: stickyが効いていて、次のカードが覆いかぶさり、過ぎたカードは積まれたまま残ります。topの値を1枚ずつ深くすると、重なる間隔が生まれます。

<main class="stage">
<div class="sk">
<div class="sk__scroller">
<div class="sk__card sk__card--1 is-demo"><span class="sk__step">1</span><span class="sk__label">登録する</span></div>
<div class="sk__card sk__card--2 is-demo"><span class="sk__step">2</span><span class="sk__label">プロフィールを埋める</span></div>
<div class="sk__card sk__card--3 is-demo"><span class="sk__step">3</span><span class="sk__label">好みを選ぶ</span></div>
<div class="sk__card sk__card--4 is-demo"><span class="sk__step">4</span><span class="sk__label">完成</span></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.sk { width: min(380px, 88vw); }
.sk__scroller {
height: clamp(160px, 66vh, 232px);
overflow-y: auto;
padding: $sp-2 $sp-4 $sp-3;
box-sizing: border-box;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
// ── 실제 동작: 카드마다 sticky. top 이 한 칸씩 깊어져 다음 카드가 위에 포개진다 ──
.sk__card {
position: sticky;
display: flex;
align-items: center;
gap: $sp-3;
min-height: 40px;
margin-bottom: $sp-2;
padding: $sp-2 $sp-4;
border-radius: $r-card;
box-shadow: $shadow-press;
box-sizing: border-box;
&:last-child { margin-bottom: 0; }
}
.sk__card--1 { top: $sp-2; background: #ffd6e8; }
.sk__card--2 { top: calc($sp-2 + 8px); background: #d3ecff; }
.sk__card--3 { top: calc($sp-2 + 16px); background: #e4f7d9; }
.sk__card--4 { top: calc($sp-2 + 24px); background: #fff3c9; }
.sk__step {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: $subject-ink;
color: $subject-cream;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sk__label {
color: #3c4750;
font: 700 14px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 데모 루프: 다음 카드가 올라와 앞 카드 위에 포개지는 걸 순서대로 흉내(캡처용)
.sk__card--2.is-demo { animation: sk-in-2 $duration $easing infinite; }
.sk__card--3.is-demo { animation: sk-in-3 $duration $easing infinite; }
.sk__card--4.is-demo { animation: sk-in-4 $duration $easing infinite; }
@keyframes sk-in-2 {
0%, 10% { transform: translateY(30px); }
28%, 100% { transform: translateY(0); }
}
@keyframes sk-in-3 {
0%, 36% { transform: translateY(30px); }
54%, 100% { transform: translateY(0); }
}
@keyframes sk-in-4 {
0%, 62% { transform: translateY(30px); }
80%, 100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.sk__card.is-demo { animation: none !important; transform: none !important; }
.sk__scroller { scroll-behavior: auto; }
}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);
}
.sk {
width: min(380px, 88vw);
}
.sk__scroller {
height: clamp(160px, 66vh, 232px);
overflow-y: auto;
padding: 8px 16px 12px;
box-sizing: border-box;
scrollbar-width: none;
}
.sk__scroller::-webkit-scrollbar {
display: none;
}
.sk__card {
position: sticky;
display: flex;
align-items: center;
gap: 12px;
min-height: 40px;
margin-bottom: 8px;
padding: 8px 16px;
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.sk__card:last-child {
margin-bottom: 0;
}
.sk__card--1 {
top: 8px;
background: #ffd6e8;
}
.sk__card--2 {
top: 16px;
background: #d3ecff;
}
.sk__card--3 {
top: 24px;
background: #e4f7d9;
}
.sk__card--4 {
top: 32px;
background: #fff3c9;
}
.sk__step {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: #17141a;
color: #fff7e6;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sk__label {
color: #3c4750;
font: 700 14px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sk__card--2.is-demo {
animation: sk-in-2 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.sk__card--3.is-demo {
animation: sk-in-3 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.sk__card--4.is-demo {
animation: sk-in-4 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes sk-in-2 {
0%, 10% {
transform: translateY(30px);
}
28%, 100% {
transform: translateY(0);
}
}
@keyframes sk-in-3 {
0%, 36% {
transform: translateY(30px);
}
54%, 100% {
transform: translateY(0);
}
}
@keyframes sk-in-4 {
0%, 62% {
transform: translateY(30px);
}
80%, 100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.sk__card.is-demo {
animation: none !important;
transform: none !important;
}
.sk__scroller {
scroll-behavior: auto;
}
}// 실제 동작은 position: sticky(top 간격)만으로 끝난다(0줄). is-demo 클래스는
// 캡처용 미리보기 루프일 뿐 — 스크롤과 상관없이 사용자가 그대로 스크롤해도 쌓인다.import type { CSSProperties } from "react";
import styles from "./StackingCards.module.scss";
export type StackingCardsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const STEPS = [
{ n: 1, label: "가입하기" },
{ n: 2, label: "프로필 채우기" },
{ n: 3, label: "취향 고르기" },
{ n: 4, label: "완성" },
];
export default function StackingCards({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: StackingCardsProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.sk} style={vars}>
{/* 카드마다 position: sticky — top 이 한 칸씩 깊어져 다음 카드가 위에 포개진다 */}
<div className={styles.scroller}>
{STEPS.map((s) => (
<div key={s.n} className={`${styles.card} ${styles["card" + s.n]}`}>
<span className={styles.step}>{s.n}</span>
<span className={styles.label}>{s.label}</span>
</div>
))}
</div>
</div>
);
}03 · 重ねて積まれるカード — 計測 累積面積 30.731% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
サービス紹介のステップや料金プランの比較カードに使います。
.sk__card {
position: sticky;
min-height: 40px;
margin-bottom: 8px;
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, .32);
}
.sk__card--1 { top: 8px; background: #ffd6e8; }
.sk__card--2 { top: 16px; background: #d3ecff; }
.sk__card--3 { top: 24px; background: #e4f7d9; }
.sk__card--4 { top: 32px; background: #fff3c9; }
04固定画面のシーン切替
何が見えるか
画面は固定したまま、スクロール量に応じてシーンが交互にゆっくり切り替わる、スクロールテリングの核心動作です。シーン3枚がスクロールタイムライン1本を共有し、キーフレームの区間で分け合います。

<main class="stage">
<div class="ps">
<div class="ps__scroll">
<div class="ps__inner">
<div class="ps__pin">
<div class="ps__scene ps__scene--1"><span class="ps__cap">シーン 1 ・はじまり</span></div>
<div class="ps__scene ps__scene--2 is-demo"><span class="ps__cap">シーン 2 ・慣れてきた頃</span></div>
<div class="ps__scene ps__scene--3 is-demo"><span class="ps__cap">シーン 3 ・完成</span></div>
<div class="ps__hint">上下にスクロール</div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.ps {
width: min(400px, 88vw);
height: clamp(150px, 62vh, 216px);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
// 세로 스크롤 상자 자체가 타임라인 — 장면 셋이 이 한 줄을 공유한다
.ps__scroll {
height: 100%;
overflow-y: auto;
scroll-timeline: --ps-scroll block;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.ps__inner { height: 300%; }
// ── 실제 동작: 화면(핀)은 고정, 스크롤 구간마다 장면이 서로 바뀐다 ──
.ps__pin {
position: sticky;
top: 0;
height: calc(100% / 3);
display: grid;
}
.ps__scene {
grid-area: 1 / 1;
display: grid;
place-items: center;
}
.ps__scene--1 { background: linear-gradient(135deg, $subject-ink, #4a3f5c); }
.ps__scene--2 { background: linear-gradient(135deg, $color, #d6ddeb); }
.ps__scene--3 { background: linear-gradient(135deg, #c2ae82, $stage-yellow); }
.ps__cap {
padding: $sp-1 $sp-4;
border-radius: $r-pill;
background: rgba(23, 20, 26, .4);
color: $subject-cream;
font: 700 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ps__hint {
grid-area: 1 / 1;
align-self: end;
justify-self: center;
margin-bottom: $sp-3;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .5);
color: rgba(255, 255, 255, .92);
font: 600 11px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@supports (animation-timeline: scroll()) {
.ps__scene--1:not(.is-demo) { animation: ps-1 linear both; animation-timeline: --ps-scroll; }
.ps__scene--2:not(.is-demo) { animation: ps-2 linear both; animation-timeline: --ps-scroll; }
.ps__scene--3:not(.is-demo) { animation: ps-3 linear both; animation-timeline: --ps-scroll; }
}
@supports not (animation-timeline: scroll()) {
// 미지원 브라우저(Firefox·Safari): 장면 1이 그대로 보인다
.ps__scene--2:not(.is-demo), .ps__scene--3:not(.is-demo) { opacity: 0; }
}
// 전체 스크롤을 100% 로 보고 장면마다 제 구간을 차지한다
@keyframes ps-1 { 0% { opacity: 1; } 33%, 100% { opacity: 0; } }
@keyframes ps-2 { 0%, 30% { opacity: 0; } 38% { opacity: 1; } 62% { opacity: 1; } 70%, 100% { opacity: 0; } }
@keyframes ps-3 { 0%, 64% { opacity: 0; } 74%, 100% { opacity: 1; } }
// 데모 루프: 실제 스크롤 대신 장면이 순서대로 바뀌는 걸 흉내(캡처용)
.ps__scene--2.is-demo { animation: ps-loop-2 $duration linear infinite; }
.ps__scene--3.is-demo { animation: ps-loop-3 $duration linear infinite; }
@keyframes ps-loop-2 {
0%, 14% { opacity: 0; }
26%, 46% { opacity: 1; }
58%, 100% { opacity: 0; }
}
@keyframes ps-loop-3 {
0%, 58% { opacity: 0; }
70%, 92% { opacity: 1; }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ps__scene.is-demo { animation: none !important; }
.ps__scene--2.is-demo, .ps__scene--3.is-demo { opacity: 0 !important; }
.ps__scroll { scroll-behavior: auto; }
}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);
}
.ps {
width: min(400px, 88vw);
height: clamp(150px, 62vh, 216px);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ps__scroll {
height: 100%;
overflow-y: auto;
scroll-timeline: --ps-scroll block;
scrollbar-width: none;
}
.ps__scroll::-webkit-scrollbar {
display: none;
}
.ps__inner {
height: 300%;
}
.ps__pin {
position: sticky;
top: 0;
height: 33.3333333333%;
display: grid;
}
.ps__scene {
grid-area: 1/1;
display: grid;
place-items: center;
}
.ps__scene--1 {
background: linear-gradient(135deg, #17141a, #4a3f5c);
}
.ps__scene--2 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.ps__scene--3 {
background: linear-gradient(135deg, #c2ae82, #ffd23f);
}
.ps__cap {
padding: 4px 16px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.4);
color: #fff7e6;
font: 700 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ps__hint {
grid-area: 1/1;
align-self: end;
justify-self: center;
margin-bottom: 12px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.5);
color: rgba(255, 255, 255, 0.92);
font: 600 11px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@supports (animation-timeline: scroll()) {
.ps__scene--1:not(.is-demo) {
animation: ps-1 linear both;
animation-timeline: --ps-scroll;
}
.ps__scene--2:not(.is-demo) {
animation: ps-2 linear both;
animation-timeline: --ps-scroll;
}
.ps__scene--3:not(.is-demo) {
animation: ps-3 linear both;
animation-timeline: --ps-scroll;
}
}
@supports not (animation-timeline: scroll()) {
.ps__scene--2:not(.is-demo), .ps__scene--3:not(.is-demo) {
opacity: 0;
}
}
@keyframes ps-1 {
0% {
opacity: 1;
}
33%, 100% {
opacity: 0;
}
}
@keyframes ps-2 {
0%, 30% {
opacity: 0;
}
38% {
opacity: 1;
}
62% {
opacity: 1;
}
70%, 100% {
opacity: 0;
}
}
@keyframes ps-3 {
0%, 64% {
opacity: 0;
}
74%, 100% {
opacity: 1;
}
}
.ps__scene--2.is-demo {
animation: ps-loop-2 2s linear infinite;
}
.ps__scene--3.is-demo {
animation: ps-loop-3 2s linear infinite;
}
@keyframes ps-loop-2 {
0%, 14% {
opacity: 0;
}
26%, 46% {
opacity: 1;
}
58%, 100% {
opacity: 0;
}
}
@keyframes ps-loop-3 {
0%, 58% {
opacity: 0;
}
70%, 92% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ps__scene.is-demo {
animation: none !important;
}
.ps__scene--2.is-demo, .ps__scene--3.is-demo {
opacity: 0 !important;
}
.ps__scroll {
scroll-behavior: auto;
}
}// 실제 동작은 scroll-timeline + animation-timeline(장면마다 키프레임 구간)만으로 끝난다(0줄).
// 장면 2·3의 is-demo 클래스는 캡처용 미리보기 루프일 뿐 — 실제 스크롤 반응은 그대로 살아있다.import type { CSSProperties } from "react";
import styles from "./SceneSwap.module.scss";
export type SceneSwapProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SceneSwap({ duration = 300, easing = "linear", color = "#2f6df6" }: SceneSwapProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.ps} style={vars}>
{/* 스크롤 상자 자체가 타임라인 — 장면 셋이 이 한 줄을 공유하고 키프레임 구간으로 나눠 갖는다 */}
<div className={styles.scroll}>
<div className={styles.inner}>
<div className={styles.pin}>
<div className={`${styles.scene} ${styles.scene1}`}>장면 1 · 처음</div>
<div className={`${styles.scene} ${styles.scene2}`}>장면 2 · 익숙해질 때</div>
<div className={`${styles.scene} ${styles.scene3}`}>장면 3 · 완성</div>
<div className={styles.hint}>위아래로 스크롤</div>
</div>
</div>
</div>
</div>
);
}04 · 固定画面のシーン切替 — 計測 累積面積 51.54% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
3ステップの使い方案内や製品ストーリーに使います。
.ps__scroll {
overflow-y: auto;
scroll-timeline: --ps-scroll block;
}
.ps__pin {
position: sticky;
top: 0;
height: calc(100% / 3);
display: grid;
}
.ps__scene--1 { animation: ps-1 linear both; animation-timeline: --ps-scroll; }
.ps__scene--2 { animation: ps-2 linear both; animation-timeline: --ps-scroll; }
.ps__scene--3 { animation: ps-3 linear both; animation-timeline: --ps-scroll; }
@keyframes ps-1 { 0% { opacity: 1; } 33%, 100% { opacity: 0; } }
@keyframes ps-2 { 0%, 30% { opacity: 0; } 38%, 62% { opacity: 1; } 70%, 100% { opacity: 0; } }
@keyframes ps-3 { 0%, 64% { opacity: 0; } 74%, 100% { opacity: 1; } }
05単語ごとに満ちる文章
画面で起きること
文中の単語がスクロール位置に合わせてひとつずつはっきり浮かび上がる、Appleの紹介文で見かける演出です。単語ごとに自分のview()タイムラインが付くので、遅延を別に与えなくても位置の関係が自然に順番を作ります。

<main class="stage">
<div class="wf">
<div class="wf__scroll">
<div class="wf__spacer"></div>
<p class="wf__line">
<span class="wf__w is-demo">スクロール</span>
<span class="wf__w is-demo">が</span>
<span class="wf__w is-demo">届く</span><br>
<span class="wf__w is-demo">言葉</span>
<span class="wf__w is-demo">ごとに</span>
<span class="wf__w is-demo">鮮やかに</span>
</p>
<div class="wf__spacer"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.wf { width: min(400px, 88vw); }
.wf__scroll {
height: clamp(150px, 62vh, 216px);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.wf__spacer { height: 32%; }
.wf__line {
margin: 0;
padding: 0 $sp-4;
text-align: center;
font: 700 clamp(24px, 6vw, 34px)/1.5 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
letter-spacing: -.01em;
}
// ── 실제 동작: 단어마다 자기 자신의 view() 타임라인. delay 가 아니라
// 지오메트리(단어가 각자 제 구간을 지나가는 순서)가 저절로 스태거를 만든다 ──
.wf__w { color: rgba(23, 20, 26, .16); }
@supports (animation-timeline: view()) {
.wf__w:not(.is-demo) {
animation: wf-in linear both;
animation-timeline: view();
animation-range: entry 0% cover 42%;
}
}
@supports not (animation-timeline: view()) {
// 미지원 브라우저(Firefox·Safari): 문장 전체가 처음부터 선명하다
.wf__w:not(.is-demo) { color: $subject-ink; }
}
@keyframes wf-in {
from { color: rgba(23, 20, 26, .16); }
to { color: $subject-ink; }
}
// 데모 루프: 실제 스크롤 대신 단어가 시간 차로 밝아지는 걸 흉내(캡처용)
.wf__w.is-demo { animation: wf-loop $duration linear infinite; }
.wf__w.is-demo:nth-of-type(1) { animation-delay: 0ms; }
.wf__w.is-demo:nth-of-type(2) { animation-delay: 140ms; }
.wf__w.is-demo:nth-of-type(3) { animation-delay: 280ms; }
.wf__w.is-demo:nth-of-type(4) { animation-delay: 420ms; }
.wf__w.is-demo:nth-of-type(5) { animation-delay: 560ms; }
.wf__w.is-demo:nth-of-type(6) { animation-delay: 700ms; }
@keyframes wf-loop {
0% { color: rgba(23, 20, 26, .16); }
18% { color: $subject-ink; }
82% { color: $subject-ink; }
100% { color: rgba(23, 20, 26, .16); }
}
@media (prefers-reduced-motion: reduce) {
.wf__w.is-demo { animation: none !important; color: $subject-ink !important; }
.wf__scroll { scroll-behavior: auto; }
}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);
}
.wf {
width: min(400px, 88vw);
}
.wf__scroll {
height: clamp(150px, 62vh, 216px);
overflow-y: auto;
scrollbar-width: none;
}
.wf__scroll::-webkit-scrollbar {
display: none;
}
.wf__spacer {
height: 32%;
}
.wf__line {
margin: 0;
padding: 0 16px;
text-align: center;
font: 700 clamp(24px, 6vw, 34px)/1.5 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
letter-spacing: -0.01em;
}
.wf__w {
color: rgba(23, 20, 26, 0.16);
}
@supports (animation-timeline: view()) {
.wf__w:not(.is-demo) {
animation: wf-in linear both;
animation-timeline: view();
animation-range: entry 0% cover 42%;
}
}
@supports not (animation-timeline: view()) {
.wf__w:not(.is-demo) {
color: #17141a;
}
}
@keyframes wf-in {
from {
color: rgba(23, 20, 26, 0.16);
}
to {
color: #17141a;
}
}
.wf__w.is-demo {
animation: wf-loop 2s linear infinite;
}
.wf__w.is-demo:nth-of-type(1) {
animation-delay: 0ms;
}
.wf__w.is-demo:nth-of-type(2) {
animation-delay: 140ms;
}
.wf__w.is-demo:nth-of-type(3) {
animation-delay: 280ms;
}
.wf__w.is-demo:nth-of-type(4) {
animation-delay: 420ms;
}
.wf__w.is-demo:nth-of-type(5) {
animation-delay: 560ms;
}
.wf__w.is-demo:nth-of-type(6) {
animation-delay: 700ms;
}
@keyframes wf-loop {
0% {
color: rgba(23, 20, 26, 0.16);
}
18% {
color: #17141a;
}
82% {
color: #17141a;
}
100% {
color: rgba(23, 20, 26, 0.16);
}
}
@media (prefers-reduced-motion: reduce) {
.wf__w.is-demo {
animation: none !important;
color: #17141a !important;
}
.wf__scroll {
scroll-behavior: auto;
}
}// 실제 동작: 단어마다 제 view() 타임라인이 붙는다(0줄). is-demo 클래스는
// 캡처용 미리보기 루프일 뿐 — 실제 스크롤에서는 각 단어가 제 구간을 지날 때 밝아진다.import type { CSSProperties } from "react";
import styles from "./WordFill.module.scss";
export type WordFillProps = {
duration?: number;
easing?: string;
color?: string;
words?: string[];
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function WordFill({
duration = 300,
easing = "linear",
color = "#2f6df6",
words = ["스크롤이", "닿는", "곳마다", "한", "단어씩", "선명해집니다"],
}: WordFillProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.wf} style={vars}>
{/* 단어마다 자기 자신의 view() 타임라인 — 지오메트리가 저절로 스태거를 만든다 */}
<div className={styles.scroll}>
<div className={styles.spacer} />
<p className={styles.line}>
{words.map((w, i) => (
<span key={i} className={styles.w}>
{w}
{i === 2 ? <br /> : " "}
</span>
))}
</p>
<div className={styles.spacer} />
</div>
</div>
);
}05 · 単語ごとに満ちる文章 — 計測 累積面積 4.778% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
ランディングの最初の一文やブランドの宣言コピーに使います。
.wf__w {
color: rgba(23, 20, 26, .16); // 薄い状態が基本
animation: wf-in linear both;
animation-timeline: view(); // 単語それぞれのタイムライン
animation-range: entry 0% cover 42%;
}
@keyframes wf-in {
from { color: rgba(23, 20, 26, .16); }
to { color: #17141a; }
}
06蛍光ペンが引かれる強調
どんな動きか
スクロールが届くと、強調したい言葉の後ろに蛍光ペンの色が左から右へと塗られていきます。文字はそのままに、背景グラデーションの幅(background-size)だけを伸ばすのが要点です。

<main class="stage">
<div class="mk">
<div class="mk__scroll">
<div class="mk__spacer"></div>
<h2 class="mk__title">今シーズン <span class="mk__hl is-demo">たった一つのセール</span></h2>
<p class="mk__sub">蛍光ペンを引くように強調が満ちます</p>
<div class="mk__spacer"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
.mk { width: min(400px, 88vw); }
.mk__scroll {
height: clamp(150px, 62vh, 216px);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.mk__spacer { height: 30%; }
.mk__title {
margin: 0;
padding: 0 $sp-4;
text-align: center;
color: $subject-ink;
font: 800 clamp(22px, 5.5vw, 30px)/1.5 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
letter-spacing: -.01em;
}
.mk__sub {
margin: $sp-3 0 0;
text-align: center;
color: rgba(23, 20, 26, .72);
font: 600 13px/1.5 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// ── 실제 동작: 형광펜 = 배경 그라디언트의 폭. 글자는 그대로 두고 배경만 자란다 ──
@supports (animation-timeline: view()) {
.mk__hl:not(.is-demo) {
animation: mk-sw linear both;
animation-timeline: view();
animation-range: entry 30% cover 60%;
}
}
@supports not (animation-timeline: view()) {
// 미지원 브라우저(Firefox·Safari): 강조가 처음부터 끝까지 그어져 있다
.mk__hl:not(.is-demo) { background-size: 100% 58%; }
}
.mk__hl {
background: linear-gradient($color, $color) no-repeat 0 62%;
background-size: 0% 58%;
border-radius: $r-xs;
padding: 0 $sp-1;
}
@keyframes mk-sw {
from { background-size: 0% 58%; }
to { background-size: 100% 58%; }
}
// 데모 루프: 실제 스크롤 대신 형광펜이 좌에서 우로 그어지는 걸 흉내(캡처용)
.mk__hl.is-demo { animation: mk-loop $duration $easing infinite; }
@keyframes mk-loop {
0%, 10% { background-size: 0% 58%; }
55%, 88% { background-size: 100% 58%; }
100% { background-size: 0% 58%; }
}
@media (prefers-reduced-motion: reduce) {
.mk__hl.is-demo { animation: none !important; background-size: 100% 58% !important; }
.mk__scroll { scroll-behavior: auto; }
}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);
}
.mk {
width: min(400px, 88vw);
}
.mk__scroll {
height: clamp(150px, 62vh, 216px);
overflow-y: auto;
scrollbar-width: none;
}
.mk__scroll::-webkit-scrollbar {
display: none;
}
.mk__spacer {
height: 30%;
}
.mk__title {
margin: 0;
padding: 0 16px;
text-align: center;
color: #17141a;
font: 800 clamp(22px, 5.5vw, 30px)/1.5 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
letter-spacing: -0.01em;
}
.mk__sub {
margin: 12px 0 0;
text-align: center;
color: rgba(23, 20, 26, 0.72);
font: 600 13px/1.5 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@supports (animation-timeline: view()) {
.mk__hl:not(.is-demo) {
animation: mk-sw linear both;
animation-timeline: view();
animation-range: entry 30% cover 60%;
}
}
@supports not (animation-timeline: view()) {
.mk__hl:not(.is-demo) {
background-size: 100% 58%;
}
}
.mk__hl {
background: linear-gradient(#2f6df6, #2f6df6) no-repeat 0 62%;
background-size: 0% 58%;
border-radius: 4px;
padding: 0 4px;
}
@keyframes mk-sw {
from {
background-size: 0% 58%;
}
to {
background-size: 100% 58%;
}
}
.mk__hl.is-demo {
animation: mk-loop 2s ease-out infinite;
}
@keyframes mk-loop {
0%, 10% {
background-size: 0% 58%;
}
55%, 88% {
background-size: 100% 58%;
}
100% {
background-size: 0% 58%;
}
}
@media (prefers-reduced-motion: reduce) {
.mk__hl.is-demo {
animation: none !important;
background-size: 100% 58% !important;
}
.mk__scroll {
scroll-behavior: auto;
}
}// 실제 동작: view() 타임라인이 background-size 를 0% → 100% 로 늘린다(0줄).
// is-demo 클래스는 캡처용 미리보기 루프일 뿐 — 실제 스크롤 반응은 그대로 살아있다.import type { CSSProperties } from "react";
import styles from "./HighlighterSweep.module.scss";
export type HighlighterSweepProps = {
duration?: number;
easing?: string;
color?: string;
before?: string;
highlight?: string;
after?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function HighlighterSweep({
duration = 300,
easing = "linear",
color = "#2f6df6",
before = "이번 시즌",
highlight = "단 하나의 세일",
after,
}: HighlighterSweepProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.mk} style={vars}>
{/* 형광펜 = 배경 그라디언트의 폭(view() 타임라인). 글자는 그대로 두고 배경만 자란다 */}
<div className={styles.scroll}>
<div className={styles.spacer} />
<h2 className={styles.title}>
{before} <span className={styles.hl}>{highlight}</span> {after}
</h2>
<div className={styles.spacer} />
</div>
</div>
);
}06 · 蛍光ペンが引かれる強調 — 計測 累積面積 1.592% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
セクション見出しの強調やセールのコピーに使います。
.mk__hl {
background: linear-gradient(#2f6df6, #2f6df6) no-repeat 0 62%;
background-size: 0% 58%;
padding: 0 4px;
animation: mk-sw linear both;
animation-timeline: view();
animation-range: entry 30% cover 60%;
}
@keyframes mk-sw {
from { background-size: 0% 58%; }
to { background-size: 100% 58%; }
}
07区間ごとに変わる背景色
何が見えるか
ページを下へスクロールする間、背景色がセクションごとに滑らかに入れ替わり、場面が変わるような感覚を与えます。背景レイヤーをposition: stickyで後ろに貼り、スクロールタイムラインが色のキーフレームを回します。

<main class="stage">
<div class="ts">
<div class="ts__scroll">
<div class="ts__bg is-demo"></div>
<section class="ts__sec"><span class="ts__chip">第一幕 ・あいさつ</span></section>
<section class="ts__sec"><span class="ts__chip">第二幕 ・物語</span></section>
<section class="ts__sec"><span class="ts__chip">第三幕 ・しまい</span></section>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue;
@include stage($stage-paper);
.ts {
width: min(380px, 88vw);
height: clamp(150px, 62vh, 216px);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
// 세로 스크롤 상자의 위치가 곧 배경색. 배경은 뒤에 붙어 있고 글은 앞에 있다
.ts__scroll {
position: relative;
height: 100%;
overflow-y: auto;
scroll-timeline: --ts-scroll block;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
// ── 실제 동작: 스크롤 처음부터 끝까지를 배경색 세 개가 이어 받는다 ──
.ts__bg {
position: sticky;
top: 0;
height: 100%;
margin-bottom: -100%; // 배경 칸만큼 다음 콘텐츠를 끌어올려 섹션이 배경 위에 오게 한다
background: $subject-cream;
}
@supports (animation-timeline: scroll()) {
.ts__bg:not(.is-demo) {
animation: ts-bg linear both;
animation-timeline: --ts-scroll;
}
}
@keyframes ts-bg {
0% { background-color: $subject-cream; } // 크림
50% { background-color: #b8f0d0; } // 민트
100% { background-color: #ff9db8; } // 피치
}
.ts__sec {
position: relative;
z-index: 1;
height: 50%;
display: grid;
place-items: center;
}
.ts__chip {
padding: $sp-2 $sp-4;
border-radius: $r-pill;
background: rgba(255, 255, 255, .8);
color: #3c4750;
font: 700 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 데모 루프: 실제 스크롤 대신 배경색이 세 파스텔을 순회하는 걸 흉내(캡처용).
// 색 전환을 짧게 잡은 이유: 프레임 사이 차이가 픽셀 문턱(12)을 넘어야 움직임으로 잡히기 때문
.ts__bg.is-demo { animation: ts-loop $duration $easing infinite; }
@keyframes ts-loop {
0%, 14% { background-color: $subject-cream; }
24%, 44% { background-color: #b8f0d0; }
54%, 90% { background-color: #ff9db8; }
100% { background-color: $subject-cream; }
}
@media (prefers-reduced-motion: reduce) {
.ts__bg.is-demo { animation: none !important; background-color: $subject-cream !important; }
.ts__scroll { scroll-behavior: auto; }
}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);
}
.ts {
width: min(380px, 88vw);
height: clamp(150px, 62vh, 216px);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ts__scroll {
position: relative;
height: 100%;
overflow-y: auto;
scroll-timeline: --ts-scroll block;
scrollbar-width: none;
}
.ts__scroll::-webkit-scrollbar {
display: none;
}
.ts__bg {
position: sticky;
top: 0;
height: 100%;
margin-bottom: -100%;
background: #fff7e6;
}
@supports (animation-timeline: scroll()) {
.ts__bg:not(.is-demo) {
animation: ts-bg linear both;
animation-timeline: --ts-scroll;
}
}
@keyframes ts-bg {
0% {
background-color: #fff7e6;
}
50% {
background-color: #b8f0d0;
}
100% {
background-color: #ff9db8;
}
}
.ts__sec {
position: relative;
z-index: 1;
height: 50%;
display: grid;
place-items: center;
}
.ts__chip {
padding: 8px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.8);
color: #3c4750;
font: 700 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ts__bg.is-demo {
animation: ts-loop 2s linear infinite;
}
@keyframes ts-loop {
0%, 14% {
background-color: #fff7e6;
}
24%, 44% {
background-color: #b8f0d0;
}
54%, 90% {
background-color: #ff9db8;
}
100% {
background-color: #fff7e6;
}
}
@media (prefers-reduced-motion: reduce) {
.ts__bg.is-demo {
animation: none !important;
background-color: #fff7e6 !important;
}
.ts__scroll {
scroll-behavior: auto;
}
}// 실제 동작: 스크롤 상자의 scroll-timeline 이 배경색 키프레임을 직접 돌린다(0줄).
// is-demo 클래스는 캡처용 미리보기 루프일 뿐 — 실제 스크롤 반응은 그대로 살아있다.import type { CSSProperties } from "react";
import styles from "./ThemeShift.module.scss";
export type ThemeShiftProps = {
duration?: number;
easing?: string;
color?: string;
sections?: string[];
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ThemeShift({
duration = 300,
easing = "linear",
color = "#2f6df6",
sections = ["첫째 마당 · 인사", "둘째 마당 · 이야기", "셋째 마당 · 마무리"],
}: ThemeShiftProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.ts} style={vars}>
{/* 스크롤 상자의 위치가 곧 배경색 — sticky 배경이 뒤에 붙고 섹션이 앞을 지나간다 */}
<div className={styles.scroll}>
<div className={styles.bg} />
{sections.map((s, i) => (
<section key={i} className={styles.sec}>
<span className={styles.chip}>{s}</span>
</section>
))}
</div>
</div>
);
}07 · 区間ごとに変わる背景色 — 計測 累積面積 48.633% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
ストーリー型ランディングやルーティン・料金案内に使います。
.ts__scroll {
overflow-y: auto;
scroll-timeline: --ts-scroll block;
}
.ts__bg {
position: sticky;
top: 0;
height: 100%;
margin-bottom: -100%; // セクションを背景の上へ引き上げる
animation: ts-bg linear both;
animation-timeline: --ts-scroll;
}
@keyframes ts-bg {
0% { background-color: #fff7e6; }
50% { background-color: #b8f0d0; }
100% { background-color: #ff9db8; }
}
08スクロール速度で傾く一覧
画面で起きること
スクロールを速くすると一覧カードがその速度ぶん傾き、止まると元の姿に戻ります。速度を測る短いスクリプトが--vel変数に値を入れ、傾きの計算はCSSのcalcが担います。

<main class="stage">
<div class="vs is-demo">
<div class="vs__scroll">
<div class="vs__row"><span class="vs__thumb vs__thumb--1"></span><span class="vs__t">すばやく下へ</span></div>
<div class="vs__row"><span class="vs__thumb vs__thumb--2"></span><span class="vs__t">カードが速さを感じます</span></div>
<div class="vs__row"><span class="vs__thumb vs__thumb--3"></span><span class="vs__t">止まると元の姿</span></div>
<div class="vs__row"><span class="vs__thumb vs__thumb--4"></span><span class="vs__t">上へ戻してもOK</span></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.vs {
width: min(390px, 88vw);
height: clamp(150px, 62vh, 216px);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vs__scroll {
height: 100%;
overflow-y: auto;
padding: $sp-3;
box-sizing: border-box;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.vs__row {
display: flex;
align-items: center;
gap: $sp-3;
margin-bottom: $sp-3;
padding: $sp-3;
border-radius: $r-card;
background: rgba(255, 247, 230, .96);
box-sizing: border-box;
// ── 실제 동작: JS 가 --vel(스크롤 속도)을 넣으면 skewY 가 그 값을 받아 기울고,
// 속도가 0 으로 줄면 같은 계산식이 저절로 제자리로 돌려놓는다 ──
transform: skewY(calc(var(--vel, 0) * -0.18deg));
&:last-child { margin-bottom: 0; }
}
.vs__thumb {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: $r-sm;
}
.vs__thumb--1 { background: linear-gradient(135deg, $color, #d6ddeb); }
.vs__thumb--2 { background: linear-gradient(135deg, #c2ae82, $stage-yellow); }
.vs__thumb--3 { background: linear-gradient(135deg, $stage-orange, #ebdbd6); }
.vs__thumb--4 { background: linear-gradient(135deg, #37b786, #d6ebe2); }
.vs__t {
color: #3c4750;
font: 700 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 데모 루프: 실제 스크롤 대신 카드가 왕복으로 기울었다 펴지는 걸 흉내(캡처용)
.vs.is-demo .vs__row { animation: vs-skew $duration $easing infinite; }
.vs.is-demo .vs__row:nth-child(2) { animation-delay: 40ms; }
.vs.is-demo .vs__row:nth-child(3) { animation-delay: 80ms; }
.vs.is-demo .vs__row:nth-child(4) { animation-delay: 120ms; }
@keyframes vs-skew {
0%, 100% { transform: skewY(0deg); }
30% { transform: skewY(-4.4deg); }
62% { transform: skewY(2.2deg); }
}
@media (prefers-reduced-motion: reduce) {
.vs.is-demo .vs__row { animation: none !important; }
.vs__row { transform: none !important; } // 움직임 잠금: 속도 반응도 함께 끈다
.vs__scroll { scroll-behavior: auto; }
}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);
}
.vs {
width: min(390px, 88vw);
height: clamp(150px, 62vh, 216px);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vs__scroll {
height: 100%;
overflow-y: auto;
padding: 12px;
box-sizing: border-box;
scrollbar-width: none;
}
.vs__scroll::-webkit-scrollbar {
display: none;
}
.vs__row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
padding: 12px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.96);
box-sizing: border-box;
transform: skewY(calc(var(--vel, 0) * -0.18deg));
}
.vs__row:last-child {
margin-bottom: 0;
}
.vs__thumb {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 8px;
}
.vs__thumb--1 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.vs__thumb--2 {
background: linear-gradient(135deg, #c2ae82, #ffd23f);
}
.vs__thumb--3 {
background: linear-gradient(135deg, #ff4d1f, #ebdbd6);
}
.vs__thumb--4 {
background: linear-gradient(135deg, #37b786, #d6ebe2);
}
.vs__t {
color: #3c4750;
font: 700 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.vs.is-demo .vs__row {
animation: vs-skew 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.vs.is-demo .vs__row:nth-child(2) {
animation-delay: 40ms;
}
.vs.is-demo .vs__row:nth-child(3) {
animation-delay: 80ms;
}
.vs.is-demo .vs__row:nth-child(4) {
animation-delay: 120ms;
}
@keyframes vs-skew {
0%, 100% {
transform: skewY(0deg);
}
30% {
transform: skewY(-4.4deg);
}
62% {
transform: skewY(2.2deg);
}
}
@media (prefers-reduced-motion: reduce) {
.vs.is-demo .vs__row {
animation: none !important;
}
.vs__row {
transform: none !important;
}
.vs__scroll {
scroll-behavior: auto;
}
}// 스크롤 속도를 재서 --vel 변수에 넣는다. 나머지는 CSS 계산(calc)이 한다.
// 미리보기(.is-demo) 중에는 CSS 키프레임이 움직임을 맡는다.
(function () {
var box = document.querySelector('.vs__scroll');
var last = box.scrollTop, vel = 0;
box.addEventListener('scroll', function () {
document.querySelector('.vs').classList.remove('is-demo');
vel = Math.max(-24, Math.min(24, box.scrollTop - last));
last = box.scrollTop;
});
(function decay() {
vel *= 0.86;
box.style.setProperty('--vel', vel.toFixed(2));
requestAnimationFrame(decay);
})();
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./VelocitySkew.module.scss";
export type VelocitySkewProps = {
duration?: number;
easing?: string;
color?: string;
rows?: string[];
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function VelocitySkew({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
rows = ["빠르게 내려보세요", "카드가 속도를 느낍니다", "멈추면 제자리", "다시 올려도 돼요"],
}: VelocitySkewProps) {
const boxRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 스크롤 속도를 재서 --vel 에 넣는다. 나머지는 CSS calc(skewY) 가 한다
useEffect(() => {
const box = boxRef.current;
if (!box) return;
let last = box.scrollTop;
let vel = 0;
let raf = 0;
const onScroll = () => {
vel = Math.max(-24, Math.min(24, box.scrollTop - last));
last = box.scrollTop;
};
const decay = () => {
vel *= 0.86;
box.style.setProperty("--vel", vel.toFixed(2));
raf = requestAnimationFrame(decay);
};
box.addEventListener("scroll", onScroll, { passive: true });
raf = requestAnimationFrame(decay);
return () => {
box.removeEventListener("scroll", onScroll);
cancelAnimationFrame(raf);
};
}, []);
return (
<div className={styles.vs} style={vars}>
<div ref={boxRef} className={styles.scroll}>
{rows.map((t, i) => (
<div key={i} className={styles.row}>
<span className={`${styles.thumb} ${styles["thumb" + ((i % 4) + 1)]}`} />
<span className={styles.t}>{t}</span>
</div>
))}
</div>
</div>
);
}08 · スクロール速度で傾く一覧 — 計測 累積面積 18.627% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
ギャラリーフィードやニュース・マガジン一覧に使います。
var box = document.querySelector('.feed');
var last = box.scrollTop, vel = 0;
box.addEventListener('scroll', function () {
vel = Math.max(-24, Math.min(24, box.scrollTop - last));
last = box.scrollTop;
});
(function decay() {
vel *= 0.86;
box.style.setProperty('--vel', vel.toFixed(2));
requestAnimationFrame(decay);
})();
.feed__row {
transform: skewY(calc(var(--vel, 0) * -0.18deg));
}
09スポットライトサークル
どんな動きか
固定画面で丸い窓がスクロールに合わせて大きくなり、隠していた画像を現します。明るい画面を上に重ね、clip-path: circle()の半径をスクロールタイムラインで広げます。

<main class="stage">
<div class="sl">
<div class="sl__scroll">
<div class="sl__inner">
<div class="sl__pin">
<div class="sl__dim"><span class="sl__dimm">まだ秘密</span></div>
<div class="sl__spot is-demo"><span class="sl__cap">いま公開</span></div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.sl {
width: min(390px, 88vw);
height: clamp(150px, 62vh, 216px);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.sl__scroll {
height: 100%;
overflow-y: auto;
scroll-timeline: --sl-scroll block;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.sl__inner { height: 280%; }
.sl__pin {
position: sticky;
top: 0;
height: calc(100% / 2.8);
display: grid;
overflow: hidden;
}
// 감춰둔 화면(어두운 데 갈무리 무늬) — 스포트라이트가 자라면 삼켜지는 자리
.sl__dim {
grid-area: 1 / 1;
display: grid;
background: repeating-linear-gradient(45deg, #241f2b 0 12px, #1d1924 12px 24px);
}
.sl__dimm {
align-self: start;
justify-self: center;
margin-top: 14%;
padding: $sp-1 $sp-4;
border-radius: $r-pill;
color: rgba(255, 247, 230, .5);
border: 1px dashed rgba(255, 247, 230, .35);
font: 700 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// ── 실제 동작: 스포트라이트 = 위에 얹은 밝은 화면의 원형 창. 스크롤이 반지름을 키운다 ──
.sl__spot {
grid-area: 1 / 1;
display: grid;
place-items: center;
background:
radial-gradient(circle at 50% 42%, rgba(255, 247, 230, .28), transparent 58%),
linear-gradient(135deg, $color, #d6ddeb);
clip-path: circle(14% at 50% 50%);
}
.sl__cap {
padding: $sp-1 $sp-4;
border-radius: $r-pill;
background: rgba(23, 20, 26, .45);
color: $subject-cream;
font: 700 14px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@supports (animation-timeline: scroll()) {
.sl__spot:not(.is-demo) {
animation: sl-open linear both;
animation-timeline: --sl-scroll;
}
}
@supports not (animation-timeline: scroll()) {
// 미지원 브라우저(Firefox·Safari): 원이 처음부터 활짝 열려 있다
.sl__spot:not(.is-demo) { clip-path: circle(75% at 50% 50%); }
}
@keyframes sl-open {
from { clip-path: circle(14% at 50% 50%); }
to { clip-path: circle(75% at 50% 50%); }
}
// 데모 루프: 실제 스크롤 대신 원이 열렸다 닫히는 걸 흉내(캡처용)
.sl__spot.is-demo { animation: sl-loop $duration $easing infinite; }
@keyframes sl-loop {
0%, 10% { clip-path: circle(14% at 50% 50%); }
55%, 88% { clip-path: circle(75% at 50% 50%); }
100% { clip-path: circle(14% at 50% 50%); }
}
@media (prefers-reduced-motion: reduce) {
.sl__spot.is-demo { animation: none !important; clip-path: circle(75% at 50% 50%) !important; }
.sl__scroll { scroll-behavior: auto; }
}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);
}
.sl {
width: min(390px, 88vw);
height: clamp(150px, 62vh, 216px);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sl__scroll {
height: 100%;
overflow-y: auto;
scroll-timeline: --sl-scroll block;
scrollbar-width: none;
}
.sl__scroll::-webkit-scrollbar {
display: none;
}
.sl__inner {
height: 280%;
}
.sl__pin {
position: sticky;
top: 0;
height: 35.7142857143%;
display: grid;
overflow: hidden;
}
.sl__dim {
grid-area: 1/1;
display: grid;
background: repeating-linear-gradient(45deg, #241f2b 0 12px, #1d1924 12px 24px);
}
.sl__dimm {
align-self: start;
justify-self: center;
margin-top: 14%;
padding: 4px 16px;
border-radius: 999px;
color: rgba(255, 247, 230, 0.5);
border: 1px dashed rgba(255, 247, 230, 0.35);
font: 700 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sl__spot {
grid-area: 1/1;
display: grid;
place-items: center;
background: radial-gradient(circle at 50% 42%, rgba(255, 247, 230, 0.28), transparent 58%), linear-gradient(135deg, #2f6df6, #d6ddeb);
clip-path: circle(14% at 50% 50%);
}
.sl__cap {
padding: 4px 16px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.45);
color: #fff7e6;
font: 700 14px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@supports (animation-timeline: scroll()) {
.sl__spot:not(.is-demo) {
animation: sl-open linear both;
animation-timeline: --sl-scroll;
}
}
@supports not (animation-timeline: scroll()) {
.sl__spot:not(.is-demo) {
clip-path: circle(75% at 50% 50%);
}
}
@keyframes sl-open {
from {
clip-path: circle(14% at 50% 50%);
}
to {
clip-path: circle(75% at 50% 50%);
}
}
.sl__spot.is-demo {
animation: sl-loop 2s ease-out infinite;
}
@keyframes sl-loop {
0%, 10% {
clip-path: circle(14% at 50% 50%);
}
55%, 88% {
clip-path: circle(75% at 50% 50%);
}
100% {
clip-path: circle(14% at 50% 50%);
}
}
@media (prefers-reduced-motion: reduce) {
.sl__spot.is-demo {
animation: none !important;
clip-path: circle(75% at 50% 50%) !important;
}
.sl__scroll {
scroll-behavior: auto;
}
}// 실제 동작: 스크롤 상자의 scroll-timeline 이 clip-path 원 반지름을 키운다(0줄).
// is-demo 클래스는 캡처용 미리보기 루프일 뿐 — 실제 스크롤 반응은 그대로 살아있다.import type { CSSProperties } from "react";
import styles from "./SpotlightCircle.module.scss";
export type SpotlightCircleProps = {
duration?: number;
easing?: string;
color?: string;
secret?: string;
reveal?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SpotlightCircle({
duration = 300,
easing = "linear",
color = "#2f6df6",
secret = "아직 비밀",
reveal = "지금 공개",
}: SpotlightCircleProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.sl} style={vars}>
{/* 스포트라이트 = 위에 얹은 밝은 화면의 원형 창. 스크롤이 반지름을 키운다 */}
<div className={styles.scroll}>
<div className={styles.inner}>
<div className={styles.pin}>
<div className={styles.dim}>
<span className={styles.dimm}>{secret}</span>
</div>
<div className={styles.spot}>
<span className={styles.cap}>{reveal}</span>
</div>
</div>
</div>
</div>
</div>
);
}09 · スポットライトサークル — 計測 累積面積 46.333% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
製品のお披露目シーンやアーティスト紹介に使います。
.sl__spot {
grid-area: 1 / 1;
background: linear-gradient(135deg, #2f6df6, #8fb4ff);
clip-path: circle(14% at 50% 50%);
animation: sl-open linear both;
animation-timeline: --sl-scroll;
}
@keyframes sl-open {
from { clip-path: circle(14% at 50% 50%); }
to { clip-path: circle(75% at 50% 50%); }
}
どこで壊れるか — 落とし穴ひとつ
いちばん刺さった落とし穴は、position: stickyが流れの中で自分の枠を取り続けるという点でした。02を最初に作ったときフッターを本文の前に置いたところ、最後までスクロールしてもフッターが一度も現れませんでした。フッターを文書の一番最後に移し、本文にmargin-bottomを与えて初めて、本文がフッターの上を進み終わりで脇に退く絵ができました。07も同じ病にかかりました。背景レイヤーをstickyにしただけで流れの中で画面1枚分を占領し、セクションのチップが画面の下に押しやられて1枚もキャプチャに映らず、margin-bottom: -100%で引き上げて初めて背景の上に乗りました。もうひとつ、この記事のスクロールタイムライン(scroll()・view())はインストールされたChromeで直接レンダリングして確かめましたが、FirefoxとSafariはまだ対応していません。対応していないブラウザでは、05の文章は最初からはっきりし、09の丸い窓は最初から開いた状態で固定されます。だからスクロールタイムラインを使う6種(01・04・05・06・07・09)には@supports notなどのフォールバックを用意し、9種すべてでスクロール自体はどのブラウザでも止まらないようにしました。スクロールと無関係に文字が流れ出す側はCSS タイピング アニメーション 9選が担います。9つのフォルダをこうして確かめ直してから圧縮し、案内どおりにry7akxw2を入力すると成果物がそのまま開きます。
アクセシビリティ
9種ともprefers-reduced-motion: reduceで自動的に動く部分が止まります。05の単語は最初から濃い色に、06の蛍光ペンは最初から引き終わった状態に、09の円は大きく開いた状態に固定され、08は速度反応そのものを鍵にかけてカードが傾かないようにしました。スクロール箱の中はTabキーで要素を順に訪れられ、04のように画面が固定される構成でも、次のセクションがすぐ下に続くのでキーボードで最後まで読めます。
FAQ
スクロール アニメーション サンプルを入れるとサイトは重くなりますか?
01・04・05・06・07・09のスクロールタイムラインと02・03のスティキは、ブラウザがレンダリング段階で直接処理するので、毎フレームJavaScriptが計算する方式よりむしろ軽いです。08だけはスクリプトが毎フレーム値を更新しますが、していることは変数に数字を1つ入れるだけなので負担は小さいです。ポインタの動きを座標として読む側はマウス カーソル エフェクト 9選が受け持ちます。
FirefoxやSafariではどう見えますか?
スクロールタイムラインを使う01・04・05・06・07・09は、対応できないブラウザではそれぞれ最終形で固定されます。パネル1が見え、文章ははっきりし、蛍光ペンは引かれ、円は開いています。02・03・08はこの機能と無関係なので、どのブラウザでも同じように動きます。
iPhoneでも動きますか?
02・03のスティキと06の背景の伸長は、iPhoneのSafariでもそのまま動きます。残りはiPhoneのSafariがまだ対応していないスクロールタイムラインを使うため、最終形で見えます。ただし画面の構造自体は崩れないので、内容を読む支障はありません。
スクロール系の演出はCSSアニメーションカテゴリにさらにあり、この記事はパララックス集やスティキヘッダー集へ続きます。animation-timelineの正式な仕様はMDNのanimation-timelineで、position: stickyの動作はMDNのpositionで確認できます。









