Artoke
06

Scroll-jacking · Section Snap

스크롤재킹

01

휠 한 번

02

섹션 하나

03

통째로 스냅

04

끝에선 돌려주기

데모 위에서 휠을 굴려보세요

↑ 데모 위에서 휠을 굴리면 섹션 단위로 넘어갑니다

브라우저의 기본 스크롤을 가로채(preventDefault) 휠 한 번을 '다음 섹션으로'라는 명령으로 바꿉니다. 애플 제품 페이지식 풀페이지 전환의 핵심이지만, 사용자가 스크롤 속도를 통제할 수 없어 약간 부정적인 뉘앙스로 '스크롤재킹'이라 불립니다 — 쓸 땐 끝에 도달하면 스크롤을 돌려주는 매너가 필수입니다.

section.tsx — 사용법
// 1) 아래 scroll-jacking-demo.tsx 파일을 프로젝트에 넣습니다.
// 2) 컨테이너 위에서 휠 한 번 = 섹션 하나. 끝 섹션에선 스크롤을 페이지에 돌려줍니다.
import { ScrollJackingDemo } from "@/components/scroll-jacking-demo";

export function Section() {
  return (
    <section className="relative h-[80vh] overflow-hidden rounded-xl bg-[#08090d]">
      <ScrollJackingDemo />
    </section>
  );
}
scroll-jacking-demo.tsx — 전체 컴포넌트 (복사해서 그대로 사용)
"use client";

import { type RefObject, useEffect, useRef, useState } from "react";

/**
 * 스크롤재킹 — full-pane section snap. The wheel event is captured with
 * `passive: false` so preventDefault can stop the browser's own scroll, and
 * one wheel gesture becomes one "go to the next section" command (a CSS
 * transition on translateY does the move; input is locked while it runs).
 *
 * The minimum scroll-jacking manner: at the first/last section, wheeling
 * outward is NOT intercepted — the page gets its scroll back.
 *
 * In auto mode (the index thumbnail) sections advance on a timer instead of
 * the wheel; prefers-reduced-motion disables the auto-play.
 */

export interface ScrollJackingConfig {
  /** Seconds per section transition. */
  duration: number;
  /** Easing preset: smooth | spring | linear. */
  easing: string;
}

export const SCROLL_JACKING_DEFAULTS: ScrollJackingConfig = {
  duration: 0.8,
  easing: "smooth",
};

const EASINGS: Record<string, string> = {
  smooth: "cubic-bezier(0.65, 0, 0.35, 1)",
  spring: "cubic-bezier(0.34, 1.56, 0.64, 1)",
  linear: "linear",
};

const SECTIONS = [
  { color: "#f87171", title: "휠 한 번" },
  { color: "#fbbf24", title: "섹션 하나" },
  { color: "#38bdf8", title: "통째로 스냅" },
  { color: "#34d399", title: "끝에선 돌려주기" },
];

export function ScrollJackingDemo({
  configRef,
  auto = false,
}: {
  configRef?: RefObject<Partial<ScrollJackingConfig>>;
  auto?: boolean;
}) {
  const [index, setIndex] = useState(0);
  const indexRef = useRef(0);
  const lockRef = useRef(false);
  const hostRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);

  // The wheel handler is bound once — it reads fresh values through refs.
  useEffect(() => {
    indexRef.current = index;
  }, [index]);

  // The move itself: set transition + transform imperatively so the config
  // ref is only read inside an effect (never during render).
  useEffect(() => {
    const track = trackRef.current;
    if (!track) return;
    const cfg = { ...SCROLL_JACKING_DEFAULTS, ...configRef?.current };
    track.style.transition = `transform ${cfg.duration}s ${
      EASINGS[cfg.easing] ?? EASINGS.smooth
    }`;
    track.style.transform = `translateY(-${index * 100}%)`;
  }, [index, configRef]);

  useEffect(() => {
    const host = hostRef.current;
    if (!host || auto) return;
    const onWheel = (e: WheelEvent) => {
      const dir = e.deltaY > 0 ? 1 : -1;
      const next = indexRef.current + dir;
      // Release the scroll at both ends — the page keeps scrolling naturally.
      if (next < 0 || next >= SECTIONS.length) return;
      e.preventDefault();
      if (lockRef.current || Math.abs(e.deltaY) < 4) return;
      lockRef.current = true;
      setIndex(next);
      const cfg = { ...SCROLL_JACKING_DEFAULTS, ...configRef?.current };
      window.setTimeout(
        () => {
          lockRef.current = false;
        },
        cfg.duration * 1000 + 150,
      );
    };
    host.addEventListener("wheel", onWheel, { passive: false });
    return () => host.removeEventListener("wheel", onWheel);
  }, [auto, configRef]);

  // Auto mode: advance on a timer so the thumbnail plays itself.
  useEffect(() => {
    if (!auto) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const id = window.setInterval(() => {
      setIndex((i) => (i + 1) % SECTIONS.length);
    }, 1800);
    return () => window.clearInterval(id);
  }, [auto]);

  return (
    <div ref={hostRef} className="absolute inset-0 overflow-hidden">
      <div ref={trackRef} className="h-full will-change-transform">
        {SECTIONS.map((section, i) => (
          <div
            key={section.title}
            className="flex h-full flex-col items-center justify-center"
            style={{
              background: `radial-gradient(ellipse at 50% 120%, ${section.color}2e, transparent 70%)`,
            }}
          >
            <p
              className="font-mono text-5xl font-bold"
              style={{ color: section.color }}
            >
              {String(i + 1).padStart(2, "0")}
            </p>
            <p className="mt-2 text-sm font-semibold text-white/85">
              {section.title}
            </p>
          </div>
        ))}
      </div>

      {/* Section dots. */}
      <div className="absolute right-3 top-1/2 -translate-y-1/2 space-y-2">
        {SECTIONS.map((section, i) => (
          <div
            key={section.title}
            className="size-1.5 rounded-full transition-colors"
            style={{
              background: i === index ? "#fff" : "rgba(255,255,255,0.25)",
            }}
          />
        ))}
      </div>
    </div>
  );
}