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

Depth Wave Gallery

3D-галерея-«док на боку»: центральный кадр крупнейший, соседи пакуются плотнее и веером уходят вниз в глубину; drag с инерцией и снапом.

cssdragclickmagneticparallaxadvanced0reduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
images?string[]IMAGESURL картинок (демо-снимки каталога — замени на свои).
magnify?number1.45Максимальное увеличение центрального элемента (формат «1.9»).
depth?number160Насколько боковые уходят в глубину, px (формат «260»).
arc?number40Прогиб дуги вниз, px (формат «90»).
tilt?number0Наклон боковых, град (формат «28»).
spacing?number180Базовый шаг между элементами, px (формат «150»).
dim?number0.55Затемнение боковых карточек тёмным оверлеем, 0–0.9 (формат «0.55»). Не прозрачность — картинки остаются непрозрачными и не просвечивают друг сквозь друга.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/depth-wave-gallery.json
Что нужно в проекте до установки
  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";

export interface DepthWaveGalleryProps {
  /** URL картинок (демо-снимки каталога — замени на свои). */
  images?: string[];
  /** Максимальное увеличение центрального элемента (формат «1.9»). */
  magnify?: number;
  /** Насколько боковые уходят в глубину, px (формат «260»). */
  depth?: number;
  /** Прогиб дуги вниз, px (формат «90»). */
  arc?: number;
  /** Наклон боковых, град (формат «28»). */
  tilt?: number;
  /** Базовый шаг между элементами, px (формат «150»). */
  spacing?: number;
  /** Затемнение боковых карточек тёмным оверлеем, 0–0.9 (формат «0.55»). Не прозрачность —
   *  картинки остаются непрозрачными и не просвечивают друг сквозь друга. */
  dim?: number;
}

const IMAGES = [
  "https://motiva.pages.dev/img/902-420x560.jpg",
  "https://motiva.pages.dev/img/928-420x560.jpg",
  "https://motiva.pages.dev/img/1076-420x560.jpg",
  "https://motiva.pages.dev/img/190-420x560.jpg",
  "https://motiva.pages.dev/img/230-420x560.jpg",
  "https://motiva.pages.dev/img/1047-420x560.jpg",
  "https://motiva.pages.dev/img/289-420x560.jpg",
  "https://motiva.pages.dev/img/195-420x560.jpg",
  "https://motiva.pages.dev/img/866-420x560.jpg",
];

/**
 * Depth Wave Gallery: горизонтальная лента-«док, положенный на бок» — центральный элемент
 * крупнейший, соседи сжимаются, пакуются плотнее и веером уходят вниз по дуге в глубину
 * (гаусс-колокол + CSS 3D). Drag с инерцией и снапом, колесо, стрелки, клик по соседу.
 * Бесконечная (закольцованная) лента, только transform/opacity. Уважает reduced-motion.
 */
export function DepthWaveGallery({
  images = IMAGES,
  magnify = 1.45,
  depth = 160,
  arc = 40,
  tilt = 0,
  spacing = 180,
  dim = 0.55,
}: DepthWaveGalleryProps) {
  const root = useRef<HTMLDivElement>(null);
  const cfg = useRef({ magnify, depth, arc, tilt, spacing, dim });
  cfg.current = { magnify, depth, arc, tilt, spacing, dim };
  const kick = useRef<() => void>(() => {});

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

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

    const items = [...el.querySelectorAll<HTMLElement>(".dwg-item")];
    const n = items.length;

    // Состояние ленты: offset в px, скорость для инерции; rAF-луп спит, когда всё замерло
    let offset = 0;
    let vel = 0;
    let dragging = false;
    let raf = 0;
    let running = false;
    let settled = false;

    const layout = () => {
      const { magnify: M, depth: D, arc: A, tilt: T, spacing: S, dim: DIM } = cfg.current;
      const total = n * S;
      for (let i = 0; i < n; i++) {
        // закольцованная позиция: элемент всегда в диапазоне [-total/2, total/2) от центра
        const raw = (((i * S - offset) % total) + total * 1.5) % total - total / 2;
        const d = raw / (S * 1.6); // «ширина колокола» ≈ 1.6 шага
        const bell = Math.exp(-d * d);
        const x = raw * (0.7 + 0.3 * bell); // дальние пакуются плотнее (интервал сжат до 0.7)
        const y = A * ((d * d) / (1 + d * d)); // веер вниз по дуге
        const z = -D * (1 - bell); // глубина
        const rot = -T * Math.tanh(d); // наклон боковых к центру
        const scale = 1 + (M - 1) * bell;
        const item = items[i]!;
        item.style.transform = `translate(-50%, -50%) translate3d(${x}px, ${y}px, ${z}px) rotateY(${rot}deg) scale(${scale})`;
        item.style.zIndex = String(Math.round(bell * 100));
        // боковые НЕ прозрачные (не просвечивают друг сквозь друга), а притемнены оверлеем
        const shade = item.querySelector<HTMLElement>(".dwg-dim");
        if (shade) shade.style.opacity = String(DIM * (1 - bell));
      }
    };

    const nearestSnap = () => Math.round(offset / cfg.current.spacing) * cfg.current.spacing;

    const tick = () => {
      if (!dragging) {
        offset += vel;
        vel *= 0.94;
        const target = nearestSnap();
        if (Math.abs(vel) < 0.4) {
          offset += (target - offset) * 0.12; // мягкий снап к ближайшему
          if (Math.abs(target - offset) < 0.3 && Math.abs(vel) < 0.05) {
            offset = target;
            settled = true;
          }
        }
      }
      layout();
      if (settled && !dragging) {
        running = false; // лента замерла — луп спит до следующего input
        return;
      }
      raf = requestAnimationFrame(tick);
    };
    const wake = () => {
      settled = false;
      if (!running) {
        running = true;
        raf = requestAnimationFrame(tick);
      }
    };
    kick.current = wake;

    // drag
    let lastX = 0;
    let travelled = 0; // сколько пальцем/мышью проехали за жест — им отличается клик от протяжки
    const onDown = (e: PointerEvent) => {
      dragging = true;
      lastX = e.clientX;
      travelled = 0;
      vel = 0;
      el.setPointerCapture?.(e.pointerId);
      wake();
    };
    const onMove = (e: PointerEvent) => {
      if (!dragging) return;
      const dx = e.clientX - lastX;
      lastX = e.clientX;
      travelled += Math.abs(dx);
      offset -= dx;
      vel = -dx * (frozen ? 0 : 0.9); // reduced-motion: без инерции
    };
    const onUp = (e: PointerEvent) => {
      const wasDragging = dragging;
      dragging = false;
      if (frozen) vel = 0;
      // Клик по соседу обрабатываем ЗДЕСЬ, а не слушателем на каждой карточке: `onDown`
      // забирает указатель через setPointerCapture, и событие click после этого приходит
      // не в карточку, а в контейнер — замер: «клик пришёл в КОНТЕЙНЕР, target=<корень>»,
      // слушатели карточек не срабатывали НИ РАЗУ, и приём «щёлкнуть по соседу» был мёртв.
      // Карточку берём по координате, а не по e.target: у захваченного указателя target
      // это тоже контейнер.
      if (wasDragging && travelled < 6) {
        const under = document.elementFromPoint(e.clientX, e.clientY);
        const card = under?.closest<HTMLElement>(".dwg-item");
        const i = card ? items.indexOf(card) : -1;
        if (i >= 0) onClickItem(i);
      }
      wake();
    };
    const onWheel = (e: WheelEvent) => {
      e.preventDefault();
      offset += (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY) * 0.6;
      vel = 0;
      wake();
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
      e.preventDefault();
      offset = nearestSnap() + (e.key === "ArrowRight" ? cfg.current.spacing : -cfg.current.spacing);
      wake();
    };
    const onClickItem = (i: number) => {
      // клик по соседу — привезти его в центр (кратчайшим путём по кольцу)
      const S = cfg.current.spacing;
      const total = n * S;
      const raw = (((i * S - offset) % total) + total * 1.5) % total - total / 2;
      if (Math.abs(raw) < S * 0.4) return; // уже в центре
      offset += raw;
      wake();
    };
    el.addEventListener("pointerdown", onDown);
    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerup", onUp);
    el.addEventListener("wheel", onWheel, { passive: false });
    el.addEventListener("keydown", onKey);

    layout(); // первый кадр — сразу корректная раскладка (и для data-shot)

    return () => {
      cancelAnimationFrame(raf);
      el.removeEventListener("pointerdown", onDown);
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      el.removeEventListener("wheel", onWheel);
      el.removeEventListener("keydown", onKey);
    };
  }, [images.length]);

  // params от панели (Tier-2) приходят как новые props → перераскладка без пересоздания
  useEffect(() => {
    kick.current();
  }, [magnify, depth, arc, tilt, spacing, dim]);

  return (
    <div
      ref={root}
      tabIndex={0}
      role="region"
      aria-label="3D-галерея, стрелки — листать"
      style={{
        position: "absolute",
        inset: 0,
        overflow: "hidden",
        perspective: 1100,
        cursor: "grab",
        touchAction: "pan-y",
        outline: "none",
      }}
    >
      {images.map((src, i) => (
        <div
          key={i}
          className="dwg-item"
          style={{
            position: "absolute",
            left: "50%",
            top: "44%",
            width: "clamp(120px, 20vw, 200px)",
            aspectRatio: "3 / 4",
            borderRadius: 14,
            overflow: "hidden",
            border: "1px solid oklch(0.34 0.02 270)",
            boxShadow: "0 24px 60px -18px rgb(0 0 0 / 0.6)",
            willChange: "transform, opacity",
            userSelect: "none",
          }}
        >
          <img
            src={src}
            alt=""
            draggable={false}
            loading={i < 5 ? "eager" : "lazy"}
            style={{ width: "100%", height: "100%", objectFit: "cover", pointerEvents: "none" }}
          />
          <span
            className="dwg-dim"
            aria-hidden
            style={{ position: "absolute", inset: 0, background: "#0b0b12", opacity: 0, pointerEvents: "none" }}
          />
        </div>
      ))}
      <div
        style={{
          position: "absolute",
          left: "50%",
          bottom: 14,
          transform: "translateX(-50%)",
          fontFamily: "ui-monospace, monospace",
          fontSize: "0.72rem",
          color: "oklch(0.7 0.02 265)",
          pointerEvents: "none",
        }}
      >
        тяни · колесо · ←→ · клик по соседу
      </div>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референсы — Framer Depth Wave Gallery, Lightswind 3D sliderv1.0.0