03Frame sequence scrub
A strip of eight frames slides sideways by exactly as much as you scroll, so the scene winds forward and back like a clip you scrub with your finger.
Best forScenes where the in-between states matter, like a product opening and closing, and intro stretches too heavy for real video
scroll-timeline-namesteps(8, jump-none)translateX
<main class="stage">
<div class="sp is-demo">
<div class="sp__scroller">
<div class="sp__pad">
<div class="sp__pin">
<div class="sp__window">
<div class="sp__strip" aria-hidden="true">
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
<div class="sp__frame"><span class="sp__item"></span><span class="sp__lid"></span><span class="sp__box"></span></div>
</div>
</div>
<p class="sp__hint">Each bit of scroll advances one frame.</p>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // alternate 라 한 바퀴(앞으로 갔다 뒤로)는 2s
$easing: linear; // 스크럽은 등속이라야 스크롤 양과 컷 번호가 1:1
$color: $subject-cream;
@include stage($stage-orange);
.sp {
--h: clamp(148px, 58vh, 176px);
--frame: 168px;
width: min(360px, 92vw);
}
// ⚠ 타임라인에 이름을 붙인다. scroll(nearest) 는 창의 overflow: hidden 까지 스크롤 컨테이너로 쳐서
// 띠가 **절대 안 움직이는** 타임라인에 묶인다 (실측)
.sp__scroller {
height: var(--h);
overflow-y: auto;
scroll-timeline-name: --sp-scroll;
scroll-timeline-axis: block;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
// ⚠ 여기는 padding-bottom 이 아니라 height 다. sticky 가 붙어 있을 수 있는 범위는 부모의
// **콘텐츠 박스**라, padding 으로만 늘린 자리는 안 쳐준다 — 창이 곧바로 위로 흘러나간다 (실측)
.sp__pad { height: calc(var(--h) * 2.2); }
.sp__pin {
position: sticky;
top: 0;
display: grid;
justify-items: center;
gap: $sp-2;
}
// 창은 한 컷만 보여준다. 실제로 움직이는 건 안쪽 띠뿐이다
.sp__window {
width: var(--frame);
height: 104px;
overflow: hidden;
border-radius: $r-card;
background: rgba(23, 20, 26, .78);
}
// ── 실제 동작: 스크롤 진행이 컷 번호가 된다.
// steps(8, jump-none) 은 0 과 1 을 모두 포함해 여덟 컷이 정확히 한 번씩 선다 ──
.sp__strip {
display: flex;
width: max-content;
animation-name: sp-scrub;
animation-duration: 1s;
animation-timing-function: steps(8, jump-none);
animation-fill-mode: both;
animation-timeline: --sp-scroll;
}
@keyframes sp-scrub {
from { transform: translateX(0); }
to { transform: translateX(-87.5%); }
}
// 컷마다 overflow: hidden — 열린 뚜껑이 옆 컷 자리로 넘어가면 창 하나에 두 컷이 겹쳐 보인다
.sp__frame {
position: relative;
flex: 0 0 var(--frame);
width: var(--frame);
height: 104px;
overflow: hidden;
}
.sp__frame:nth-child(1) { --i: 0; }
.sp__frame:nth-child(2) { --i: 1; }
.sp__frame:nth-child(3) { --i: 2; }
.sp__frame:nth-child(4) { --i: 3; }
.sp__frame:nth-child(5) { --i: 4; }
.sp__frame:nth-child(6) { --i: 5; }
.sp__frame:nth-child(7) { --i: 6; }
.sp__frame:nth-child(8) { --i: 7; }
.sp__box {
position: absolute;
left: 40px;
bottom: 20px;
width: 88px;
height: 40px;
border-radius: $r-sm;
background: $color;
}
// 뚜껑은 컷마다 14도씩 더 열린다 — 애니메이션이 아니라 컷의 생김새 그 자체
.sp__lid {
position: absolute;
left: 40px;
bottom: 60px;
width: 88px;
height: 12px;
border-radius: $r-xs;
background: $stage-yellow;
transform-origin: left bottom; // 규칙8: 뚜껑 모서리 경첩
transform: rotate(calc(var(--i) * -14deg));
}
.sp__item {
position: absolute;
left: 72px;
bottom: 48px;
width: 24px;
height: 24px;
border-radius: 50%;
background: $subject-mint;
opacity: calc(var(--i) / 7);
transform: translateY(calc(var(--i) * -6px));
}
.sp__hint {
margin: 0;
color: $subject-ink; // 주황 무대 위 크림 2.83:1 → 잉크 5.50:1
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
// ── 격자 미리보기 루프 — 같은 띠를 시간으로 민다(앞으로 갔다 되감기) ──
.sp.is-demo .sp__strip {
animation-duration: $duration;
animation-timing-function: steps(8, jump-none);
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
// 한 컷 = 12.5% — 여섯 번째 컷에서 멈춘다
@media (prefers-reduced-motion: reduce) {
.sp__strip { animation: none !important; transform: translateX(-62.5%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
--h: clamp(148px, 58vh, 176px);
--frame: 168px;
width: min(360px, 92vw);
}
.sp__scroller {
height: var(--h);
overflow-y: auto;
scroll-timeline-name: --sp-scroll;
scroll-timeline-axis: block;
scrollbar-width: none;
}
.sp__scroller::-webkit-scrollbar {
display: none;
}
.sp__pad {
height: calc(var(--h) * 2.2);
}
.sp__pin {
position: sticky;
top: 0;
display: grid;
justify-items: center;
gap: 8px;
}
.sp__window {
width: var(--frame);
height: 104px;
overflow: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.78);
}
.sp__strip {
display: flex;
width: max-content;
animation-name: sp-scrub;
animation-duration: 1s;
animation-timing-function: steps(8, jump-none);
animation-fill-mode: both;
animation-timeline: --sp-scroll;
}
@keyframes sp-scrub {
from {
transform: translateX(0);
}
to {
transform: translateX(-87.5%);
}
}
.sp__frame {
position: relative;
flex: 0 0 var(--frame);
width: var(--frame);
height: 104px;
overflow: hidden;
}
.sp__frame:nth-child(1) {
--i: 0;
}
.sp__frame:nth-child(2) {
--i: 1;
}
.sp__frame:nth-child(3) {
--i: 2;
}
.sp__frame:nth-child(4) {
--i: 3;
}
.sp__frame:nth-child(5) {
--i: 4;
}
.sp__frame:nth-child(6) {
--i: 5;
}
.sp__frame:nth-child(7) {
--i: 6;
}
.sp__frame:nth-child(8) {
--i: 7;
}
.sp__box {
position: absolute;
left: 40px;
bottom: 20px;
width: 88px;
height: 40px;
border-radius: 8px;
background: #fff7e6;
}
.sp__lid {
position: absolute;
left: 40px;
bottom: 60px;
width: 88px;
height: 12px;
border-radius: 4px;
background: #ffd23f;
transform-origin: left bottom;
transform: rotate(calc(var(--i) * -14deg));
}
.sp__item {
position: absolute;
left: 72px;
bottom: 48px;
width: 24px;
height: 24px;
border-radius: 50%;
background: #4cd4a6;
opacity: calc(var(--i) / 7);
transform: translateY(calc(var(--i) * -6px));
}
.sp__hint {
margin: 0;
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
.sp.is-demo .sp__strip {
animation-duration: 1s;
animation-timing-function: steps(8, jump-none);
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timeline: auto;
}
@media (prefers-reduced-motion: reduce) {
.sp__strip {
animation: none !important;
transform: translateX(-62.5%);
}
}// 실제 동작은 CSS 만: 띠를 미는 건 animation-timeline: scroll() 이고 timing 은 steps(8, jump-none).
var r = document.querySelector('.sp');
var s = r.querySelector('.sp__scroller');
function stopDemo(){ r.classList.remove('is-demo'); }
r.addEventListener('pointerdown', stopDemo);
s.addEventListener('scroll', stopDemo, { passive: true });import type { CSSProperties, ReactNode } from "react";
import styles from "./FrameSequenceScrub.module.scss";
export type FrameSequenceScrubProps = {
frames?: number;
hint?: string;
preview?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type FrameVars = CSSProperties & { "--i"?: number };
export default function FrameSequenceScrub({
frames = 8,
hint = "Each bit of scroll advances one frame.",
preview = true,
duration = 1000,
easing = "linear",
color = "#fff7e6",
}: FrameSequenceScrubProps): ReactNode {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = preview ? `${styles.sp} ${styles.isDemo}` : styles.sp;
const list: number[] = [];
for (let i = 0; i < frames; i += 1) list.push(i);
return (
<div className={cls} style={vars}>
<div className={styles.scroller}>
<div className={styles.pad}>
<div className={styles.pin}>
<div className={styles.window}>
<div className={styles.strip} aria-hidden="true">
{list.map((i: number): ReactNode => {
const fv: FrameVars = { "--i": i };
return (
<div className={styles.frame} style={fv} key={i}>
<span className={styles.item} />
<span className={styles.lid} />
<span className={styles.box} />
</div>
);
})}
</div>
</div>
<p className={styles.hint}>{hint}</p>
</div>
</div>
</div>
</div>
);
}03 Frame sequence scrub — Measured area 2.855% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · 9 Scrollytelling CSS Section Patterns, Copy-Paste
The post this demo lives in
9 Scrollytelling CSS Section Patterns, Copy-PasteScrollytelling CSS makes scroll position the clock that drives a scene: one section, several states, advanced by how far See all demos in this post


