Artoke
04

Scrollytelling · Pinned Scene

스크롤리텔링

01

장면 하나

02

장면 둘

03

장면 셋

04

장면 넷

STEP 1 / 4

텍스트가 올라오는 동안 왼쪽 그림은 고정되어 있습니다.

STEP 2 / 4

스텝이 제 구간에 들어오면 장면이 교체됩니다.

STEP 3 / 4

밝기를 매 프레임 목표값으로 이징 — 스르륵 전환됩니다.

STEP 4 / 4

이 구조가 스크롤리텔링의 전부입니다: 고정 + 교체.

데모 오른쪽 컬럼을 스크롤해보세요

↑ 데모 오른쪽 컬럼을 스크롤하면 왼쪽 장면이 교체됩니다

게임 소개 페이지처럼 스크롤을 내리면 화면 한쪽은 고정된 채 장면만 차례로 바뀌며 이야기가 전개됩니다. 'scroll + storytelling'의 합성어인 스크롤리텔링의 뼈대는 의외로 단순합니다 — 그림을 고정(pin)하고, 지나가는 텍스트 스텝이 어디까지 왔는지에 따라 장면을 갈아끼우는 것이 전부입니다.

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>
  );
}