07
Parallax Scrolling
패럴랙스 스크롤링
LAYER SPEED — 배경 느리게 · 전경 빠르게
가까운 것은 빨리
1 / 4
LAYER SPEED — 배경 느리게 · 전경 빠르게
먼 것은 천천히
2 / 4
LAYER SPEED — 배경 느리게 · 전경 빠르게
겹치면 깊이가 생긴다
3 / 4
LAYER SPEED — 배경 느리게 · 전경 빠르게
이게 패럴랙스
4 / 4
데모 안을 스크롤해보세요
↑ 데모 안을 스크롤하면 레이어가 서로 다른 속도로 흐릅니다
가까운 것은 빨리, 먼 것은 천천히 — 차창 밖 풍경의 원근감을 스크롤로 재현합니다. 각 레이어를 스크롤 양 × 고유 속도만큼 반대로 이동시키는 게 전부지만, 레이어를 3장만 겹쳐도 평면 페이지에 즉시 깊이가 생깁니다.
- 레이어별 translateY = -scrollTop × 속도 — 속도가 1보다 작으면 천천히 따라와 멀리 있는 것처럼 보입니다.
- transform만 건드리므로 리플로우가 없고, will-change: transform으로 합성 레이어에 올려 부드럽습니다.
- 별·블롭 좌표는 시드 고정 난수로 생성해 서버·클라이언트 렌더가 일치합니다(하이드레이션 안전).
section.tsx — 사용법
// 1) 아래 parallax-demo.tsx 파일을 프로젝트에 넣습니다.
// 2) 컨테이너 안을 스크롤하면 배경·중간·전경 레이어가 다른 속도로 흐릅니다.
import { ParallaxDemo } from "@/components/parallax-demo";
export function Section() {
return (
<section className="relative h-[80vh] overflow-hidden rounded-xl bg-[#08090d]">
<ParallaxDemo />
</section>
);
}parallax-demo.tsx — 전체 컴포넌트 (복사해서 그대로 사용)
"use client";
import { type RefObject, useEffect, useMemo, useRef } from "react";
/**
* 패럴랙스 — three pinned depth layers (stars / blobs / rings) slide against
* the scrolling text at different speeds: translateY = -scrollTop × speed,
* where speed < 1 lags behind and reads as "far away". Only transform moves,
* so there is no reflow.
*
* Layer contents are generated with a seeded PRNG so the server render and
* the client hydration agree (Math.random would mismatch).
*/
export interface ParallaxConfig {
/** Background (stars) speed, 0–1. */
far: number;
/** Middle (blobs) speed, 0–1. */
mid: number;
/** Foreground (rings) speed, 0–1. */
near: number;
}
export const PARALLAX_DEFAULTS: ParallaxConfig = {
far: 0.15,
mid: 0.4,
near: 0.75,
};
const SECTIONS = [
"가까운 것은 빨리",
"먼 것은 천천히",
"겹치면 깊이가 생긴다",
"이게 패럴랙스",
];
// Deterministic pseudo-random (LCG) — same sequence on server and client.
function seeded(seed: number) {
let s = seed;
return () => {
s = (s * 1664525 + 1013904223) % 4294967296;
return s / 4294967296;
};
}
export function ParallaxDemo({
configRef,
auto = false,
}: {
configRef?: RefObject<Partial<ParallaxConfig>>;
auto?: boolean;
}) {
const scrollerRef = useRef<HTMLDivElement>(null);
const farRef = useRef<HTMLDivElement>(null);
const midRef = useRef<HTMLDivElement>(null);
const nearRef = useRef<HTMLDivElement>(null);
const stars = useMemo(() => {
const rnd = seeded(7);
return Array.from({ length: 42 }, () => ({
x: rnd() * 100,
y: rnd() * 100,
r: 1 + rnd() * 1.6,
o: 0.3 + rnd() * 0.6,
}));
}, []);
const blobs = useMemo(() => {
const rnd = seeded(21);
return Array.from({ length: 7 }, (_, i) => ({
x: rnd() * 85,
y: rnd() * 92,
size: 40 + rnd() * 70,
color: ["#a78bfa", "#38bdf8", "#f472b6"][i % 3],
}));
}, []);
const rings = useMemo(() => {
const rnd = seeded(42);
return Array.from({ length: 5 }, () => ({
x: 5 + rnd() * 80,
y: rnd() * 90,
size: 24 + rnd() * 40,
}));
}, []);
useEffect(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
let raf = 0;
const t0 = performance.now();
const frame = (now: number) => {
const cfg = { ...PARALLAX_DEFAULTS, ...configRef?.current };
const max = scroller.scrollHeight - scroller.clientHeight;
if (auto && !reduced && max > 0) {
const p = ((now - t0) / 10000) % 2;
scroller.scrollTop = (p < 1 ? p : 2 - p) * max;
}
const y = scroller.scrollTop;
if (farRef.current)
farRef.current.style.transform = `translateY(${-y * cfg.far}px)`;
if (midRef.current)
midRef.current.style.transform = `translateY(${-y * cfg.mid}px)`;
if (nearRef.current)
nearRef.current.style.transform = `translateY(${-y * cfg.near}px)`;
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [configRef, auto]);
return (
<div className="absolute inset-0 overflow-hidden">
{/* Pinned depth layers — each 400% tall (the scroll content's height)
so no layer runs out while the text scrolls past. */}
<div aria-hidden className="pointer-events-none absolute inset-0">
<div
ref={farRef}
className="absolute inset-x-0 top-0 will-change-transform"
style={{ height: "400%" }}
>
{stars.map((star, i) => (
<span
key={i}
className="absolute rounded-full bg-white"
style={{
left: `${star.x}%`,
top: `${star.y}%`,
width: star.r,
height: star.r,
opacity: star.o,
}}
/>
))}
</div>
<div
ref={midRef}
className="absolute inset-x-0 top-0 will-change-transform"
style={{ height: "400%" }}
>
{blobs.map((blob, i) => (
<span
key={i}
className="absolute rounded-full"
style={{
left: `${blob.x}%`,
top: `${blob.y}%`,
width: blob.size,
height: blob.size,
background: `radial-gradient(circle, ${blob.color}40, transparent 70%)`,
filter: "blur(2px)",
}}
/>
))}
</div>
<div
ref={nearRef}
className="absolute inset-x-0 top-0 will-change-transform"
style={{ height: "400%" }}
>
{rings.map((ring, i) => (
<span
key={i}
className="absolute rounded-full border border-white/25"
style={{
left: `${ring.x}%`,
top: `${ring.y}%`,
width: ring.size,
height: ring.size,
}}
/>
))}
</div>
</div>
{/* The scrolling text — speed 1, the reference plane. */}
<div
ref={scrollerRef}
className="relative h-full overflow-y-auto"
style={{ scrollbarWidth: "thin" }}
>
{SECTIONS.map((line, i) => (
<div
key={line}
className="flex h-full flex-col items-center justify-center"
>
<p className="font-mono text-[11px] text-white/40">
LAYER SPEED — 배경 느리게 · 전경 빠르게
</p>
<p className="mt-2 text-lg font-semibold tracking-tight text-white">
{line}
</p>
<p className="mt-1 font-mono text-[11px]" style={{ color: "#a78bfa" }}>
{i + 1} / {SECTIONS.length}
</p>
</div>
))}
</div>
</div>
);
}