Motiva
Мои коллекции
← каталог

Parallax Depth Scene

Параллакс на настоящей перспективе: камера летит сквозь коридор из планов фотографий — ближние проносятся мимо, дальние наплывают; курсор доворачивает камеру.

gsaplenisscrollmouse-moveparallaxadvanced~92 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
title?string"into the deep"Заголовок, висящий в глубине сцены.
tilt?number5Насколько курсор доворачивает камеру, град. (формат «5»).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/parallax-depth-scene.json

зависимости: gsap@^3.15.0 · lenis@^1.3.25

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

const PERSPECTIVE = 900; // px, «фокусное расстояние» камеры
const GAP = 380; // расстояние между планами по Z
const DEPTHS = 6; // сколько планов в коридоре

/**
 * Карточки коридора. Ближние планы разведены к краям кадра, дальние — ближе к центру:
 * перспектива сама стягивает их к точке схода, а центр остаётся открытым — сквозь него летит камера.
 */
const CARDS: { d: number; x: number; y: number; w: number; rot: number; id: number }[] = [
  { d: 0, x: -64, y: -30, w: 21, rot: -8, id: 1015 },
  { d: 0, x: 62, y: 24, w: 20, rot: 7, id: 1016 },
  { d: 1, x: 54, y: -32, w: 18, rot: 6, id: 1018 },
  { d: 1, x: -52, y: 30, w: 19, rot: -6, id: 1019 },
  { d: 2, x: -42, y: -14, w: 16, rot: -4, id: 1021 },
  { d: 2, x: 44, y: 10, w: 16, rot: 4, id: 1024 },
  { d: 3, x: -32, y: 26, w: 14, rot: -3, id: 1029 },
  { d: 3, x: 34, y: -26, w: 14, rot: 3, id: 1035 },
  { d: 4, x: -24, y: -20, w: 12, rot: -2, id: 1039 },
  { d: 4, x: 26, y: 20, w: 12, rot: 2, id: 1043 },
  { d: 5, x: -16, y: 14, w: 10, rot: -1, id: 1050 },
  { d: 5, x: 18, y: -14, w: 10, rot: 1, id: 1057 },
];

const photo = (id: number) => `https://motiva.pages.dev/img/${id}-600x750.jpg`;
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));

export interface ParallaxDepthSceneProps {
  /** Заголовок, висящий в глубине сцены. */
  title?: string;
  /** Насколько курсор доворачивает камеру, град. (формат «5»). */
  tilt?: number;
}

/**
 * Параллакс на настоящей перспективе: камера летит сквозь коридор из планов фотографий.
 * Ближние карточки проносятся мимо и растворяются, дальние наплывают — эффект глубины даёт
 * CSS-perspective, а не разные скорости `yPercent`. Курсор доворачивает камеру.
 * Всё на transform, один общий пересчёт на кадр скролла. `reduced-motion`/`data-shot` — стоп-кадр.
 */
export function ParallaxDepthScene({ title = "into the deep", tilt = 5 }: ParallaxDepthSceneProps) {
  const root = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = root.current;
    if (!el) return;

    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const cards = [...el.querySelectorAll<HTMLElement>(".pds-card")];
    const camera = el.querySelector<HTMLElement>(".pds-camera");
    const scene = el.querySelector(".pds-scene");
    if (!camera || !scene) return;

    /** Позиция плана по Z при заданном прогрессе полёта + его прозрачность у самой камеры. */
    const place = (progress: number) => {
      const travel = (DEPTHS - 1) * GAP;
      for (const card of cards) {
        const d = Number(card.dataset.depth);
        const z = -d * GAP + progress * travel;
        // масштаб компенсирует перспективу: на старте все планы выглядят соразмерно
        const scale = 1 + (d * GAP) / PERSPECTIVE;
        // карточка, подлетевшая вплотную, растворяется — иначе она рвётся о плоскость камеры
        const fade = clamp01((-z - 30) / 170);
        card.style.transform = `translate3d(-50%, -50%, ${z.toFixed(1)}px) scale(${scale.toFixed(3)}) rotate(${card.dataset.rot}deg)`;
        card.style.opacity = String(fade);
      }
    };

    if (frozen) {
      place(0);
      return;
    }

    gsap.registerPlugin(ScrollTrigger);
    const lenis = new Lenis({ duration: 1.1 });
    lenis.on("scroll", ScrollTrigger.update);
    const rafFn = (t: number) => lenis.raf(t * 1000);
    gsap.ticker.add(rafFn);
    gsap.ticker.lagSmoothing(0);

    // Имена именно `rotationX`/`rotationY`: краткие `rotateX`/`rotateY` GSAP понимает
    // в объекте vars (`gsap.to(el, { rotateX })`), но НЕ в `quickTo`, где имя свойства
    // приходит строкой и мимо разбора синонимов. Доворот молча не доезжал до трансформа:
    // GSAP писал в камеру `transform: translate(0px, 0px)`, то есть единичную матрицу,
    // при том что обработчик исправно считал угол (замер: 2.88° на краю кадра).
    const rotX = gsap.quickTo(camera, "rotationX", { duration: 0.6, ease: "power3.out" });
    const rotY = gsap.quickTo(camera, "rotationY", { duration: 0.6, ease: "power3.out" });
    const onMove = (e: PointerEvent) => {
      const r = camera.getBoundingClientRect();
      rotY(((e.clientX - r.left) / r.width - 0.5) * tilt * 2);
      rotX(-((e.clientY - r.top) / r.height - 0.5) * tilt * 2);
    };
    const onLeave = () => {
      rotX(0);
      rotY(0);
    };

    const ctx = gsap.context(() => {
      place(0);
      ScrollTrigger.create({
        trigger: scene,
        start: "top top",
        end: "bottom bottom",
        scrub: true,
        // один пересчёт всех планов на кадр — дешевле, чем тринадцать отдельных твинов
        onUpdate: (self) => place(self.progress),
      });
      gsap.fromTo(
        el.querySelector(".pds-title"),
        { opacity: 0, z: -520 },
        {
          opacity: 1,
          z: 0,
          ease: "none",
          scrollTrigger: { trigger: scene, start: "top top", end: "70% bottom", scrub: true },
        },
      );
    }, el);

    const stage = el.querySelector<HTMLElement>(".pds-stage");
    stage?.addEventListener("pointermove", onMove);
    stage?.addEventListener("pointerleave", onLeave);

    return () => {
      stage?.removeEventListener("pointermove", onMove);
      stage?.removeEventListener("pointerleave", onLeave);
      ctx.revert();
      gsap.ticker.remove(rafFn);
      lenis.destroy();
    };
  }, [tilt]);

  return (
    <div ref={root} style={{ width: "100%", color: "oklch(0.94 0.01 265)" }}>
      <style>{`
        .pds-stage { position: sticky; top: 0; height: 100vh; overflow: hidden;
          perspective: ${PERSPECTIVE}px; perspective-origin: 50% 50%;
          background: radial-gradient(120% 80% at 50% 45%, oklch(0.24 0.04 275) 0%, oklch(0.11 0.02 270) 60%, oklch(0.07 0.01 270) 100%); }
        .pds-camera { position: absolute; inset: 0; transform-style: preserve-3d; will-change: transform; }
        .pds-card { position: absolute; top: 50%; left: 50%; border-radius: 12px; overflow: hidden;
          box-shadow: 0 30px 70px oklch(0 0 0 / 0.55); will-change: transform, opacity;
          border: 1px solid oklch(0.35 0.02 270 / 0.6); }
        .pds-card img { display: block; width: 100%; aspect-ratio: 4 / 5; object-fit: cover; }
        .pds-title { position: absolute; top: 50%; left: 50%; translate: -50% -50%;
          transform-style: preserve-3d; text-align: center; pointer-events: none;
          font-size: clamp(2rem, 1rem + 5vw, 5rem); font-weight: 700; letter-spacing: -0.04em;
          text-shadow: 0 10px 40px oklch(0 0 0 / 0.7); }
        /* туман к краям — усиливает ощущение глубины и прячет край коридора */
        .pds-fog { position: absolute; inset: 0; pointer-events: none;
          background: radial-gradient(60% 55% at 50% 50%, transparent 40%, oklch(0.08 0.015 270 / 0.85) 100%); }
      `}</style>

      <section style={{ minHeight: "35vh", display: "flex", alignItems: "flex-end", padding: "0 6vw 4vh" }}>
        <p style={{ margin: 0, fontFamily: "ui-monospace, monospace", fontSize: "0.8rem", opacity: 0.6 }}>
          крути вниз — камера летит сквозь планы · веди курсором — доворачивает
        </p>
      </section>

      <div className="pds-scene" style={{ minHeight: "320vh" }}>
        <div className="pds-stage">
          <div className="pds-camera">
            {CARDS.map((c) => (
              <div
                key={c.id}
                className="pds-card"
                data-depth={c.d}
                data-rot={c.rot}
                style={{ width: `${c.w}%`, marginLeft: `${c.x}%`, marginTop: `${c.y}%` }}
              >
                <img src={photo(c.id)} alt="" loading="lazy" />
              </div>
            ))}
            <div className="pds-title">{title}</div>
          </div>
          <div className="pds-fog" />
        </div>
      </div>

      <section style={{ height: "30vh", display: "grid", placeItems: "center", opacity: 0.5 }}>
        <span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.78rem" }}>конец сцены</span>
      </section>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); техника — CSS perspective + движение камеры по Zv1.0.0