04固定画面のシーン切替
画面は固定したまま、スクロール量に応じてシーンが交互にゆっくり切り替わる、スクロールテリングの核心動作です。
使いどころ3ステップの使い方案内、製品ストーリー
animation-timeline: scroll()키프레임 구간opacity

<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 に同梱 · スクロール アニメーション CSSで作る9種 — コピペでApple風
このデモが属する記事
スクロール アニメーション CSSで作る9種 — コピペでApple風スクロール アニメーション CSSは、指でページを下へスクロールする動きに画面が応える仕組みで、Appleの製品ページがその基準です。スクロール用ライブラリ一切なしのCSSだけで、横流しパネル・固定画面の切替・単語の着色・背景色の入れ替えま この記事のデモをすべて見る


