06
Scroll-jacking · Section Snap
스크롤재킹
01
휠 한 번
02
섹션 하나
03
통째로 스냅
04
끝에선 돌려주기
데모 위에서 휠을 굴려보세요
↑ 데모 위에서 휠을 굴리면 섹션 단위로 넘어갑니다
브라우저의 기본 스크롤을 가로채(preventDefault) 휠 한 번을 '다음 섹션으로'라는 명령으로 바꿉니다. 애플 제품 페이지식 풀페이지 전환의 핵심이지만, 사용자가 스크롤 속도를 통제할 수 없어 약간 부정적인 뉘앙스로 '스크롤재킹'이라 불립니다 — 쓸 땐 끝에 도달하면 스크롤을 돌려주는 매너가 필수입니다.
- wheel 이벤트를 passive: false로 등록해야 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>
);
}