9 Website Scroll Effects — Apple-Style, CSS Only
Website scroll effects, done with no scroll library at all, feel the way Apple's product pages do.
This collection covers horizontal panels, pinned scene swaps, word-by-word fills, and section theme shifts. Copy, paste, and it runs.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Horizontal story panels
- 02 Curtain-reveal footer
- 03 Stacking cards
- 04 Pinned scene swap
- 05 Word-by-word fill
- 06 Highlighter sweep
- 07 Section-by-section theme shift
- 08 Velocity-skewed list
- 09 Spotlight circle
The nine follow the order you meet them on the page. It starts with what appears the moment the page opens (01), moves to what you find when the article ends (02), continues through cards stacking and sentences filling in as you read (03–07), and closes with a list that reacts to speed and a single image waiting to be revealed (08–09). It is the same path an Apple-style page takes from top to bottom, css parallax scrolling and all. On the same axis, the bar that clings and drops is 9 CSS Sticky Header Extras, and scenes that pull a story along are 9 Scrollytelling CSS Section Patterns.
01Horizontal story panels
What this css scroll animation shows
Scrolling vertically slides panels horizontally inside a pinned screen — the signature Apple product-page layout. A scroll-timeline reads the scroller's position and pushes the track, while position: sticky holds the screen.

<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">Story 1</span></section>
<section class="hp__panel hp__panel--2"><span class="hp__tag">Story 2</span></section>
<section class="hp__panel hp__panel--3"><span class="hp__tag">Story 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 · Horizontal story panels — Measured area 51.539% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Use it for product feature stories or portfolio walkthroughs.
.hp__scroller {
overflow-y: auto;
scroll-timeline: --hp-scroll block;
}
.hp__inner { height: 300%; } // sticky area three screens deep
.hp__pin {
position: sticky;
top: 0;
height: calc(100% / 3);
overflow: hidden;
}
.hp__track {
display: flex;
width: 300%; // 3 panels = 300% of the screen
animation: hp-slide linear both;
animation-timeline: --hp-scroll;
}
@keyframes hp-slide {
from { transform: translateX(0); }
to { transform: translateX(-66.6667%); } // two panels over track width
}
02Curtain-reveal footer
What happens on screen
The content panel travels over the footer and steps aside at the end of the scroll, revealing the footer like a curtain. The footer sits last in the document but sticks to the viewport bottom from the start with position: sticky; bottom: 0.

<main class="stage">
<div class="fc is-demo">
<div class="fc__scroll">
<div class="fc__main">
<p class="fc__line">The article ends here</p>
<p class="fc__line">A greeting waits beneath it</p>
<p class="fc__line">Scroll one more notch</p>
</div>
<footer class="fc__foot"><span class="fc__cheer">Thanks for scrolling all the way</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 · Curtain-reveal footer — Measured area 16.541% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Use it for contact footers or closing greetings.
.fc__main {
position: relative;
z-index: 1; // content travels above the footer
min-height: calc(100% + 64px); // covers the footer from the start
margin-bottom: 64px; // opens the footer slot at the end
background: #fff;
}
.fc__foot {
position: sticky;
bottom: 0;
z-index: 0;
height: 64px;
background: linear-gradient(120deg, #b9f0d8, #ffd6e8);
}
03Stacking cards
What moves
Every card is sticky, so each new one slides up and covers the last while earlier cards stay stacked in place. Stepping the top value one notch deeper per card leaves room for the overlap.

<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">Sign up</span></div>
<div class="sk__card sk__card--2 is-demo"><span class="sk__step">2</span><span class="sk__label">Fill your profile</span></div>
<div class="sk__card sk__card--3 is-demo"><span class="sk__step">3</span><span class="sk__label">Pick your taste</span></div>
<div class="sk__card sk__card--4 is-demo"><span class="sk__step">4</span><span class="sk__label">Done</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 · Stacking cards — Measured area 30.731% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Use it for service intro steps or pricing comparison cards.
.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; }
04Pinned scene swap
What you see
The screen stays pinned while scenes cross-fade in place according to how far you have scrolled — the core motion of scrollytelling. Three scenes share one scroll timeline and split it with keyframe ranges.

<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">Scene 1 · The start</span></div>
<div class="ps__scene ps__scene--2 is-demo"><span class="ps__cap">Scene 2 · Getting used to it</span></div>
<div class="ps__scene ps__scene--3 is-demo"><span class="ps__cap">Scene 3 · Complete</span></div>
<div class="ps__hint">Scroll up and down</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 · Pinned scene swap — Measured area 51.54% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Use it for three-step how-tos or product stories.
.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; } }
05Word-by-word fill
What happens on screen
Words in a sentence sharpen one by one as you scroll — the effect seen across Apple's copy blocks. Each word carries its own view() timeline, so geometry itself creates the stagger without any delay tricks.

<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">Scroll</span>
<span class="wf__w is-demo">touches</span>
<span class="wf__w is-demo">each</span><br>
<span class="wf__w is-demo">word</span>
<span class="wf__w is-demo">into</span>
<span class="wf__w is-demo">focus</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 · Word-by-word fill — Measured area 4.778% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Use it for landing openers or brand statements.
.wf__w {
color: rgba(23, 20, 26, .16); // light by default
animation: wf-in linear both;
animation-timeline: view(); // one timeline per word
animation-range: entry 0% cover 42%;
}
@keyframes wf-in {
from { color: rgba(23, 20, 26, .16); }
to { color: #17141a; }
}
06Highlighter sweep
What moves
As you scroll to it, a highlighter color sweeps in behind the key phrase from left to right. The trick is growing only the background gradient's width (background-size) while the letters stay untouched.

<main class="stage">
<div class="mk">
<div class="mk__scroll">
<div class="mk__spacer"></div>
<h2 class="mk__title">This season <span class="mk__hl is-demo">one single sale</span></h2>
<p class="mk__sub">The highlight sweeps in like a marker</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 · Highlighter sweep — Measured area 1.592% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Use it for section headline emphasis or sale copy.
.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%; }
}
07Section-by-section theme shift
What you see
The page background blends gently between colors section by section as you scroll, like a change of scene. A sticky background layer sits behind the content, and a scroll timeline drives its color keyframes.

<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">Act 1 · Greeting</span></section>
<section class="ts__sec"><span class="ts__chip">Act 2 · Story</span></section>
<section class="ts__sec"><span class="ts__chip">Act 3 · Closing</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 · Section-by-section theme shift — Measured area 48.633% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Use it for narrative landings or routine and pricing guides.
.ts__scroll {
overflow-y: auto;
scroll-timeline: --ts-scroll block;
}
.ts__bg {
position: sticky;
top: 0;
height: 100%;
margin-bottom: -100%; // pulls sections up over the background
animation: ts-bg linear both;
animation-timeline: --ts-scroll;
}
@keyframes ts-bg {
0% { background-color: #fff7e6; }
50% { background-color: #b8f0d0; }
100% { background-color: #ff9db8; }
}
08Velocity-skewed list
What happens on screen
Fast scrolling tilts the list cards in proportion to the speed; stopping settles them back upright. A short script tracks the speed and writes it into a --vel variable, and CSS calc does all the tilting math.

<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">Scroll down fast</span></div>
<div class="vs__row"><span class="vs__thumb vs__thumb--2"></span><span class="vs__t">Cards feel the speed</span></div>
<div class="vs__row"><span class="vs__thumb vs__thumb--3"></span><span class="vs__t">Stop and they settle</span></div>
<div class="vs__row"><span class="vs__thumb vs__thumb--4"></span><span class="vs__t">Scroll up too</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 · Velocity-skewed list — Measured area 18.627% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Use it for gallery feeds or news and magazine lists.
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));
}
09Spotlight circle
What moves
On a pinned screen, a circular window grows as you scroll to uncover a hidden image. A bright layer sits on top, and a scroll timeline grows its clip-path: circle() radius.

<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">Still a secret</span></div>
<div class="sl__spot is-demo"><span class="sl__cap">Now revealed</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 · Spotlight circle — Measured area 46.333% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Use it for product reveal moments or artist intros.
.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%); }
}
Where it breaks — one trap
The trap that bit hardest was that position: sticky still occupies its slot in the flow. The first build of 02 placed the footer before the content, and no matter how far you scrolled, the footer never once appeared. Moving the footer to the very end of the document and giving the content a margin-bottom finally produced the picture of the panel traveling over the footer and stepping aside at the end. Item 07 suffered the same issue: its sticky background layer claimed a full screen of the flow, pushing the section chips below the fold so they never showed up in a single capture, until margin-bottom: -100% pulled the sections back on top. One more thing: the scroll timelines here (scroll() and view()) were verified by rendering in the installed Chrome, but Firefox and Safari do not support them yet — in those browsers the sentence in 05 stays sharp from the start and the circle in 09 stays open. That is why all nine ship an @supports not fallback showing the finished state, while scrolling itself never breaks. Type that spills out with no scroll involved at all is the side 9 CSS Typing Animation handles. All nine folders were re-checked this way before zipping, and entering ry7akxw2 exactly as written in the guide opens the whole thing.
Accessibility
All nine switch off their automatic motion under prefers-reduced-motion: reduce. The words in 05 stay dark from the start, the highlighter in 06 arrives fully drawn, the circle in 09 stays wide open, and 08 locks its speed reaction so cards never tilt. Every scroll box remains reachable with the Tab key, and even in the pinned layout of 04 the next section still sits right below, so keyboard users can read to the end.
FAQ
Do website scroll animation effects slow a site down?
The scroll timelines in 01, 04, 07, and 09 and the sticky tricks in 02 and 03 are handled directly by the browser's rendering stage, which is lighter than having JavaScript recalculate every frame. Only 08 runs a script each frame, and its whole job is writing one number into a variable. Reading the pointer as coordinates belongs to 9 Mouse Effects CSS Tricks instead.
How does it look in Firefox or Safari?
Items using scroll timelines (01, 04, 05, 06, 07, 09) freeze at their finished state in browsers without support: panel 1 shows, the sentence is sharp, the highlighter is drawn, the circle is open. Items 02, 03, and 08 do not rely on them and behave the same everywhere.
Does it work on iPhone?
The sticky footer and stacking cards in 02 and 03 and the background sweep in 06 work as-is in iPhone Safari. The rest rely on scroll timelines that iPhone Safari does not support yet, so they show their finished state — the layout itself stays intact and everything remains readable.
More scroll pieces live in the CSS animations category, and this article continues into the parallax collection and the sticky header collection. The formal spec of animation-timeline is on MDN's animation-timeline page, and sticky positioning is explained on MDN's position page.









