04
Scrollytelling · Pinned Scene
스크롤리텔링
01
장면 하나
02
장면 둘
03
장면 셋
04
장면 넷
STEP 1 / 4
텍스트가 올라오는 동안 왼쪽 그림은 고정되어 있습니다.
STEP 2 / 4
스텝이 제 구간에 들어오면 장면이 교체됩니다.
STEP 3 / 4
밝기를 매 프레임 목표값으로 이징 — 스르륵 전환됩니다.
STEP 4 / 4
이 구조가 스크롤리텔링의 전부입니다: 고정 + 교체.
데모 오른쪽 컬럼을 스크롤해보세요
↑ 데모 오른쪽 컬럼을 스크롤하면 왼쪽 장면이 교체됩니다
게임 소개 페이지처럼 스크롤을 내리면 화면 한쪽은 고정된 채 장면만 차례로 바뀌며 이야기가 전개됩니다. 'scroll + storytelling'의 합성어인 스크롤리텔링의 뼈대는 의외로 단순합니다 — 그림을 고정(pin)하고, 지나가는 텍스트 스텝이 어디까지 왔는지에 따라 장면을 갈아끼우는 것이 전부입니다.
- 스크롤 진행률(scrollTop ÷ 스크롤 가능 높이)로 현재 스텝 번호를 계산합니다 — IntersectionObserver 없이도 됩니다.
- 장면 전환은 opacity를 매 프레임 목표값으로 이징해 스르륵 교체 — 딱딱 끊기지 않습니다.
- prefers-reduced-motion이면 이징 없이 즉시 전환합니다(접근성).
section.tsx — 사용법
// 1) 아래 scrollytelling-demo.tsx 파일을 프로젝트에 넣습니다.
// 2) 고정 높이 컨테이너 안에서 오른쪽 컬럼이 스크롤되며 왼쪽 장면이 교체됩니다.
import { ScrollytellingDemo } from "@/components/scrollytelling-demo";
export function Section() {
return (
<section className="relative h-[80vh] overflow-hidden rounded-xl bg-[#08090d]">
<ScrollytellingDemo />
</section>
);
}scrollytelling-demo.tsx — 전체 컴포넌트 (복사해서 그대로 사용)
"use client";
import { type RefObject, useEffect, useRef } from "react";
/**
* 스크롤리텔링 — the technique behind pages like silverpalace's features page:
* a pinned visual stays put while text steps scroll past, and each step that
* comes into range swaps the scene with an eased crossfade.
*
* Frame-driven like the other effects: a rAF loop reads the scroll position
* (or, in auto mode, drives a virtual scroll from time so the index thumbnail
* plays itself) and eases every scene's opacity toward its target — no React
* state per frame. Under prefers-reduced-motion scenes swap instantly.
*/
export interface ScrollytellingConfig {
/** Per-frame blend toward the target opacity (higher = snappier). */
ease: number;
/** Scene scale while inactive — 1 disables the zoom-in feel. */
fromScale: number;
}
export const SCROLLYTELLING_DEFAULTS: ScrollytellingConfig = {
ease: 0.14,
fromScale: 0.92,
};
const SCENES = [
{
color: "#34d399",
title: "장면 하나",
body: "텍스트가 올라오는 동안 왼쪽 그림은 고정되어 있습니다.",
},
{
color: "#38bdf8",
title: "장면 둘",
body: "스텝이 제 구간에 들어오면 장면이 교체됩니다.",
},
{
color: "#fbbf24",
title: "장면 셋",
body: "밝기를 매 프레임 목표값으로 이징 — 스르륵 전환됩니다.",
},
{
color: "#f472b6",
title: "장면 넷",
body: "이 구조가 스크롤리텔링의 전부입니다: 고정 + 교체.",
},
] as const;
export function ScrollytellingDemo({
configRef,
auto = false,
}: {
configRef?: RefObject<Partial<ScrollytellingConfig>>;
auto?: boolean;
}) {
const scrollerRef = useRef<HTMLDivElement>(null);
const sceneRefs = useRef<(HTMLDivElement | null)[]>([]);
const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const cur = SCENES.map((_, i) => (i === 0 ? 1 : 0));
let raf = 0;
const t0 = performance.now();
const frame = (now: number) => {
const cfg = { ...SCROLLYTELLING_DEFAULTS, ...configRef?.current };
const max = scroller.scrollHeight - scroller.clientHeight;
if (auto && !reduced && max > 0) {
// ping-pong through the story on a fixed period
const p = ((now - t0) / 9000) % 2;
scroller.scrollTop = (p < 1 ? p : 2 - p) * max;
}
const progress = max > 0 ? scroller.scrollTop / max : 0;
const active = Math.min(
SCENES.length - 1,
Math.round(progress * (SCENES.length - 1)),
);
const ease = reduced ? 1 : cfg.ease;
SCENES.forEach((_, i) => {
const target = i === active ? 1 : 0;
cur[i] += (target - cur[i]) * ease;
const scene = sceneRefs.current[i];
if (scene) {
scene.style.opacity = String(cur[i]);
const s = cfg.fromScale + (1 - cfg.fromScale) * cur[i];
scene.style.transform = `scale(${s})`;
}
const step = stepRefs.current[i];
if (step) step.style.opacity = i === active ? "1" : "0.35";
});
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [configRef, auto]);
return (
<div className="absolute inset-0 flex">
{/* Pinned visual — never scrolls; scenes crossfade in place. */}
<div className="relative w-1/2 shrink-0">
{SCENES.map((scene, i) => (
<div
key={scene.title}
ref={(el) => {
sceneRefs.current[i] = el;
}}
className="absolute inset-0 flex flex-col items-center justify-center will-change-transform"
style={{ opacity: i === 0 ? 1 : 0 }}
>
<div
className="flex size-20 items-center justify-center rounded-full font-mono text-xl font-bold text-black sm:size-24"
style={{
background: scene.color,
boxShadow: `0 0 60px ${scene.color}55`,
}}
>
{String(i + 1).padStart(2, "0")}
</div>
<p className="mt-4 text-sm font-semibold text-white">
{scene.title}
</p>
</div>
))}
</div>
{/* Scrolling step column — drives the story. */}
<div
ref={scrollerRef}
className="h-full w-1/2 overflow-y-auto"
style={{ scrollbarWidth: "thin" }}
>
{SCENES.map((scene, i) => (
<div
key={scene.title}
ref={(el) => {
stepRefs.current[i] = el;
}}
className="flex h-full flex-col justify-center pl-1 pr-6"
>
<p className="font-mono text-[11px]" style={{ color: scene.color }}>
STEP {i + 1} / {SCENES.length}
</p>
<p className="mt-2 text-sm leading-relaxed text-white/80">
{scene.body}
</p>
</div>
))}
</div>
</div>
);
}