Artoke
07

Parallax Scrolling

패럴랙스 스크롤링

LAYER SPEED — 배경 느리게 · 전경 빠르게

가까운 것은 빨리

1 / 4

LAYER SPEED — 배경 느리게 · 전경 빠르게

먼 것은 천천히

2 / 4

LAYER SPEED — 배경 느리게 · 전경 빠르게

겹치면 깊이가 생긴다

3 / 4

LAYER SPEED — 배경 느리게 · 전경 빠르게

이게 패럴랙스

4 / 4

데모 안을 스크롤해보세요

↑ 데모 안을 스크롤하면 레이어가 서로 다른 속도로 흐릅니다

가까운 것은 빨리, 먼 것은 천천히 — 차창 밖 풍경의 원근감을 스크롤로 재현합니다. 각 레이어를 스크롤 양 × 고유 속도만큼 반대로 이동시키는 게 전부지만, 레이어를 3장만 겹쳐도 평면 페이지에 즉시 깊이가 생깁니다.

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