← каталог
Depth Wave Gallery
3D-галерея-«док на боку»: центральный кадр крупнейший, соседи пакуются плотнее и веером уходят вниз в глубину; drag с инерцией и снапом.
cssdragclickmagneticparallaxadvanced0reduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| images? | string[] | IMAGES | URL картинок (демо-снимки каталога — замени на свои). |
| magnify? | number | 1.45 | Максимальное увеличение центрального элемента (формат «1.9»). |
| depth? | number | 160 | Насколько боковые уходят в глубину, px (формат «260»). |
| arc? | number | 40 | Прогиб дуги вниз, px (формат «90»). |
| tilt? | number | 0 | Наклон боковых, град (формат «28»). |
| spacing? | number | 180 | Базовый шаг между элементами, px (формат «150»). |
| dim? | number | 0.55 | Затемнение боковых карточек тёмным оверлеем, 0–0.9 (формат «0.55»). Не прозрачность — картинки остаются непрозрачными и не просвечивают друг сквозь друга. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/depth-wave-gallery.jsonЧто нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}
/**
* Depth Wave Gallery — vanilla JS, ноль зависимостей (тот же алгоритм, что в React-версии).
*
* Использование:
* <div id="gallery" style="position:relative;height:420px"></div>
* <script type="module">
* import { DepthWaveGallery } from './depth-wave-gallery.vanilla.js';
* const g = new DepthWaveGallery(document.querySelector('#gallery'), {
* images: ['a.jpg', 'b.jpg', ...], // свои картинки
* magnify: 1.45, depth: 160, arc: 40, tilt: 0, spacing: 180,
* });
* // g.destroy() при размонтировании
* </script>
*/
export class DepthWaveGallery {
constructor(container, options = {}) {
this.el = container;
this.cfg = {
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",
],
magnify: 1.45,
depth: 160,
arc: 40,
tilt: 0,
spacing: 180,
dim: 0.55, // затемнение боковых оверлеем (не прозрачность — картинки не просвечивают)
...options,
};
this.reduced =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
this.offset = 0;
this.vel = 0;
this.dragging = false;
this.running = false;
this.settled = false;
this.raf = 0;
this.lastX = 0;
this.buildDom();
this.bind();
this.layout();
}
buildDom() {
const s = this.el.style;
s.overflow = "hidden";
s.perspective = "1100px";
s.cursor = "grab";
s.touchAction = "pan-y";
if (getComputedStyle(this.el).position === "static") s.position = "relative";
this.el.tabIndex = 0;
this.el.setAttribute("role", "region");
this.el.setAttribute("aria-label", "3D-галерея, стрелки — листать");
this.items = this.cfg.images.map((src, i) => {
const item = document.createElement("div");
item.style.cssText = `position:absolute;left:50%;top:44%;width:clamp(120px,20vw,200px);
aspect-ratio:3/4;border-radius:14px;overflow:hidden;border:1px solid oklch(0.34 0.02 270);
box-shadow:0 24px 60px -18px rgb(0 0 0 / 0.6);will-change:transform,opacity;user-select:none;`;
const img = document.createElement("img");
img.src = src;
img.alt = "";
img.draggable = false;
img.loading = i < 5 ? "eager" : "lazy";
img.style.cssText = "width:100%;height:100%;object-fit:cover;pointer-events:none;";
item.appendChild(img);
const shade = document.createElement("span");
shade.className = "dwg-dim";
shade.style.cssText = "position:absolute;inset:0;background:#0b0b12;opacity:0;pointer-events:none;";
item.appendChild(shade);
item.addEventListener("click", () => this.goTo(i));
this.el.appendChild(item);
return item;
});
}
/* Раскладка кадра: гаусс-колокол масштаба + упаковка + дуга вниз + глубина + наклон */
layout() {
const { magnify: M, depth: D, arc: A, tilt: T, spacing: S, dim: DIM } = this.cfg;
const n = this.items.length;
const total = n * S;
for (let i = 0; i < n; i++) {
const raw = ((((i * S - this.offset) % total) + total * 1.5) % total) - total / 2;
const d = raw / (S * 1.6);
const bell = Math.exp(-d * d);
const x = raw * (0.7 + 0.3 * bell);
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 it = this.items[i];
it.style.transform = `translate(-50%,-50%) translate3d(${x}px,${y}px,${z}px) rotateY(${rot}deg) scale(${scale})`;
it.style.zIndex = String(Math.round(bell * 100));
it.querySelector(".dwg-dim").style.opacity = String(DIM * (1 - bell));
}
}
nearestSnap() {
return Math.round(this.offset / this.cfg.spacing) * this.cfg.spacing;
}
tick = () => {
if (!this.dragging) {
this.offset += this.vel;
this.vel *= 0.94;
const target = this.nearestSnap();
if (Math.abs(this.vel) < 0.4) {
this.offset += (target - this.offset) * 0.12;
if (Math.abs(target - this.offset) < 0.3 && Math.abs(this.vel) < 0.05) {
this.offset = target;
this.settled = true;
}
}
}
this.layout();
if (this.settled && !this.dragging) {
this.running = false; // всё замерло — rAF спит до следующего input
return;
}
this.raf = requestAnimationFrame(this.tick);
};
wake() {
this.settled = false;
if (!this.running) {
this.running = true;
this.raf = requestAnimationFrame(this.tick);
}
}
goTo(i) {
const S = this.cfg.spacing;
const total = this.items.length * S;
const raw = ((((i * S - this.offset) % total) + total * 1.5) % total) - total / 2;
if (Math.abs(raw) < S * 0.4) return;
this.offset += raw;
this.wake();
}
bind() {
this.onDown = (e) => {
this.dragging = true;
this.lastX = e.clientX;
this.vel = 0;
this.el.setPointerCapture?.(e.pointerId);
this.wake();
};
this.onMove = (e) => {
if (!this.dragging) return;
const dx = e.clientX - this.lastX;
this.lastX = e.clientX;
this.offset -= dx;
this.vel = this.reduced ? 0 : -dx * 0.9;
};
this.onUp = () => {
this.dragging = false;
this.wake();
};
this.onWheel = (e) => {
e.preventDefault();
this.offset += (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY) * 0.6;
this.vel = 0;
this.wake();
};
this.onKey = (e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
this.offset = this.nearestSnap() + (e.key === "ArrowRight" ? this.cfg.spacing : -this.cfg.spacing);
this.wake();
};
this.el.addEventListener("pointerdown", this.onDown);
window.addEventListener("pointermove", this.onMove);
window.addEventListener("pointerup", this.onUp);
this.el.addEventListener("wheel", this.onWheel, { passive: false });
this.el.addEventListener("keydown", this.onKey);
}
/** Обновить настройки на лету (напр. из своей панели). */
update(options) {
Object.assign(this.cfg, options);
this.wake();
}
destroy() {
cancelAnimationFrame(this.raf);
this.el.removeEventListener("pointerdown", this.onDown);
window.removeEventListener("pointermove", this.onMove);
window.removeEventListener("pointerup", this.onUp);
this.el.removeEventListener("wheel", this.onWheel);
this.el.removeEventListener("keydown", this.onKey);
this.items.forEach((it) => it.remove());
}
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референсы — Framer Depth Wave Gallery, Lightswind 3D sliderv1.0.0