홈페이지 스크롤 효과 9가지 — 애플 페이지처럼 되는 CSS
홈페이지 스크롤 효과는 페이지를 내리는 손가락 하나에 화면이 반응하게 만드는 장치로, 애플 제품 페이지가 이 분야의 기준입니다. 자바스크립트 라이브러리 하나도 없이 CSS만으로 가로 패널·고정 장면 교체·단어 채움·배경색 전환까지 아홉 가지를 실제로 돌려본 코드로 모았습니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 가로로 넘어가는 이야기 패널
- 02 끝에서 드러나는 커튼 푸터
- 03 겹쳐 쌓이는 카드
- 04 고정 화면 장면 교체
- 05 단어마다 차오르는 문장
- 06 형광펜이 그어지는 강조
- 07 구간마다 바뀌는 배경색
- 08 스크롤 속도에 기우는 목록
- 09 스포트라이트 서클
아홉 개는 화면에 나오는 순서입니다. 페이지를 열자마자 보이는 것(01)에서 시작해, 읽던 본문이 끝나며 만나는 것(02), 본문 흐름 속에서 카드가 쌓이고 문장이 차오르는 것(03~07), 그리고 목록이 반응하거나 한 장이 공개되는 마무리(08~09) 순서입니다. 부드러운 스크롤 효과로 애플식 페이지를 위에서 아래로 한 번 훑는 길과 같습니다. 같은 축에서 상단 바가 붙어 있고 떨어지는 쪽은 CSS 헤더 고정 9가지 응용이고, 장면 하나하나가 이야기를 끌어당기는 쪽은 스크롤 스토리텔링 섹션 9종입니다.
01가로로 넘어가는 이야기 패널
스크롤 애니메이션 css 로 무엇이 보이나
세로로 스크롤하면 고정된 화면 안에서 패널이 가로로 넘어가는, 애플 제품 페이지의 대표 구성입니다. 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
제품 특징 소개나 포트폴리오 스토리에 씁니다.
.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%); } // 두 판 너비 ÷ 트랙 폭
}
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
연락처·저작권 푸터나 마무리 인사에 어울립니다.
.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 값을 한 칸씩 깊어지게 주면 포개지는 간격이 생깁니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
서비스 소개 단계나 요금제 비교 카드에 씁니다.
.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고정 화면 장면 교체
무엇이 보이나
화면은 제자리에 고정한 채 스크롤한 만큼 장면이 서로 번갈아 부드럽게 바뀌는, 스크롤텔링의 핵심 동작입니다. 장면 셋이 스크롤 타임라인 한 줄을 공유하고 키프레임 구간으로 나눠 갖습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
사용법 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단어마다 차오르는 문장
화면에서 일어나는 일
문장의 단어가 스크롤 위치를 따라 하나씩 선명해지는, 애플 소개 문장에서 볼 수 있는 효과입니다. 단어마다 각자의 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
랜딩 첫 문장이나 브랜드 선언 문구에 씁니다.
.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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
섹션 제목 강조나 세일 문구에 씁니다.
.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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
서사형 랜딩이나 루틴·요금 안내에 씁니다.
.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">다시 올려도 돼요</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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
갤러리 피드나 뉴스 목록에 씁니다.
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
제품 공개 순간이나 아티스트 소개에 씁니다.
.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로 두었더니 배경이 흐름에서 화면 한 칸을 통째로 차지해서 섹션 칩들이 화면 밑에 밀려 캡처에 한 번도 잡히지 않았고, margin-bottom: -100%로 끌어올린 뒤에야 배경 위에 올라앉았습니다. 또 하나, 이 편의 스크롤 타임라인(scroll()·view())은 설치된 크롬에서 직접 렌더해 확인했지만 파이어폭스·사파리는 아직 지원하지 않아서, 지원하지 않는 브라우저에서는 05의 문장이 처음부터 선명하고 09의 원이 처음부터 열려 있는 상태로 고정됩니다. 그래서 스크롤 타임라인을 쓰는 여섯 개는 @supports not이나 기본값으로 최종 모습을 보여 주고, 스크롤 자체는 어떤 브라우저에서도 막히지 않게 했습니다. 스크롤과 무관하게 글자가 스스로 흘러나오는 쪽은 CSS 타이핑 효과 9종에서 다룹니다. 아홉 폴더를 이렇게 다시 확인한 뒤 압축했고, ry7akxw2을 안내 문장 그대로 입력하면 이 결과물이 통째로 열립니다.
접근성
아홉 개 모두 prefers-reduced-motion: reduce에서 자동으로 움직이는 부분이 꺼집니다. 05의 단어는 처음부터 진한 색으로, 06의 형광펜은 처음부터 다 그어진 상태로, 09의 원은 활짝 열린 상태로 고정되고, 08은 속도 반응 자체를 잠가 카드가 기울지 않게 했습니다. 스크롤 상자 안은 탭 키로 요소를 차례로 방문할 수 있고, 04처럼 화면이 고정되는 구성에서도 건너뛸 수 있는 다음 섹션이 그 아래 그대로 이어져 키보드로 끝까지 읽을 수 있습니다.
FAQ
스크롤 애니메이션 효과를 넣으면 사이트가 느려지나요?
01·04·07·09의 스크롤 타임라인과 02·03의 스티키는 브라우저가 렌더 단계에서 직접 처리해서, 매 프레임 자바스크립트가 계산하는 방식보다 오히려 가볍습니다. 08만 자바스크립트가 매 프레임 값을 갱신하지만 하는 일은 변수 하나에 숫자를 넣는 것뿐이라 부담이 작습니다. 포인터의 움직임을 좌표로 읽는 쪽은 마우스 이펙트 9가지가 담당합니다.
파이어폭스나 사파리에서는 어떻게 보이나요?
01·04·05·06·07·09는 스크롤 타임라인을 쓰지 못하는 브라우저에서 각각 최종 모습으로 고정됩니다. 패널 1이 보이고, 문장은 선명하고, 형광펜은 그어져 있고, 원은 열려 있습니다. 02·03·08은 이 기능과 무관해서 어느 브라우저에서도 같게 동작합니다.
아이폰에서도 되나요?
02·03의 스티키와 08의 속도 반응은 아이폰 사파리에서도 그대로 됩니다. 스크롤 타임라인을 쓰는 나머지는 아이폰 사파리가 아직 지원하지 않아 최종 모습으로 보입니다. 다만 화면 구조 자체는 그대로라 내용을 읽는 데는 지장이 없습니다.
스크롤 하위 효과는 CSS 애니메이션 카테고리에 더 있고, 시차를 주는 패럴랙스 모음과 헤더가 반응하는 스티키 헤더 모음이 이 글과 이어집니다. animation-timeline의 정식 명세는 MDN animation-timeline 문서에서, position: sticky의 동작은 MDN position 문서에서 확인할 수 있습니다.









