Gravity Gallery
Снимки падают в кадр и скачут по полу, пока не улягутся; щелчок подбрасывает всё заново. Единственная запись каталога с настоящей физикой: скорость, тяжесть, отскок с потерей энергии, вращение от удара. Десяток строк на rAF, цикл засыпает, когда все тела легли; позиции пишутся только translate/rotate — раскладку физика не трогает.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| gravity? | number | 0.55 | Сила тяжести, px/кадр². |
| bounce? | number | 0.45 | Упругость пола: 0 — падают замертво, 0.7 — скачут долго. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/gravity-gallery.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
const css = `.gvg {
position: relative;
display: flex;
align-items: flex-end;
justify-content: center;
gap: clamp(8px, 2cqw, 18px);
width: min(640px, 92cqw);
/* высота с запасом над кучей: подброс живёт ВНУТРИ рамки, а не срезается кромкой */
height: clamp(300px, 56cqh, 420px);
cursor: pointer;
/* упавшее не должно выпрыгивать из рамки при подбросе */
overflow: clip;
/* пол: тонкая линия, чтобы удару было обо что читаться */
border-bottom: 1px solid var(--ds-border, oklch(0.3 0.02 275));
}
.gvg-card {
max-width: none;
width: clamp(72px, 17%, 116px);
aspect-ratio: 3 / 4;
border-radius: 10px;
object-fit: cover;
border: 3px solid var(--ds-surface, oklch(0.95 0.01 265));
box-shadow: 0 8px 20px oklch(0 0 0 / 0.28);
/* каждый снимок чуть выше/ниже соседа (сдвиг приходит инлайном) — куча, а не строй */
margin-bottom: var(--gvg-lift, 0px);
user-select: none;
}
`;
export interface GravityGalleryProps {
/** Снимки; падают в том порядке, в каком заданы. */
items?: string[];
/** Сила тяжести, px/кадр². */
gravity?: number;
/** Упругость пола: 0 — падают замертво, 0.7 — скачут долго. */
bounce?: number;
}
const ITEMS = [
"https://motiva.pages.dev/img/1015-600x750.jpg",
"https://motiva.pages.dev/img/1019-600x750.jpg",
"https://motiva.pages.dev/img/1021-600x750.jpg",
"https://motiva.pages.dev/img/1024-600x750.jpg",
"https://motiva.pages.dev/img/1025-600x750.jpg",
];
interface Body {
y: number;
v: number;
rot: number;
vr: number;
delay: number;
rest: number;
}
/**
* Снимки ПАДАЮТ в кадр и скачут по полу, пока не улягутся; щелчок подбрасывает
* всё заново. Единственная запись каталога с настоящей (пусть и крошечной)
* физикой: скорость, тяжесть, отскок с потерей энергии, вращение от удара.
*
* Física — десяток строк на rAF: `v += g; y += v;` и на полу `v = -v × bounce`.
* Цикл ЗАСЫПАЕТ, когда все тела легли (низкая скорость на полу) — в покое ноль
* кадров; падение запускает `IntersectionObserver` при входе в кадр. Позиции
* пишутся только `translate/rotate` — раскладку физика не трогает.
*
* Под `prefers-reduced-motion` и `data-shot` снимки сразу лежат — финальная
* композиция и есть база; подбрасывание при reduce отключено.
*/
export function GravityGallery({ items = ITEMS, gravity = 0.55, bounce = 0.45 }: GravityGalleryProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ gravity, bounce });
cfg.current = { gravity, bounce };
useEffect(() => {
const el = host.current;
if (!el) return;
const cards = [...el.querySelectorAll<HTMLElement>(".gvg-card")];
if (!cards.length) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) return; // база: лежат с рождения
const bodies: Body[] = cards.map((_, i) => ({
y: 0,
v: 0,
rot: 0,
vr: 0,
delay: i * 9 + Math.random() * 6,
rest: (i % 2 ? -1 : 1) * (2 + Math.random() * 4),
}));
let raf = 0;
const drop = (toss: boolean) => {
const h = el.clientHeight;
// потолок подброса: скорость ограничена запасом высоты НАД карточкой, чтобы
// полёт не срезался кромкой рамки — апекс v²/2g обязан помещаться в headroom
const cardH = cards[0]!.offsetHeight;
const headroom = Math.max(40, h - cardH - 30);
const vMax = Math.sqrt(2 * cfg.current.gravity * headroom * 0.88);
for (const b of bodies) {
if (toss) {
b.v = -vMax * (0.62 + Math.random() * 0.38);
b.vr = (Math.random() - 0.5) * 7;
b.delay = 0;
} else {
b.y = -(h + 60 + Math.random() * 120);
b.v = 0;
b.rot = (Math.random() - 0.5) * 24;
b.vr = 0;
}
}
wake();
};
const step = () => {
const { gravity: g, bounce: k } = cfg.current;
let busy = false;
bodies.forEach((b, i) => {
if (b.delay > 0) {
b.delay--;
busy = true;
return;
}
b.v += g;
b.y += b.v;
b.rot += b.vr;
if (b.y > 0) {
b.y = 0;
if (Math.abs(b.v) > 1.2) {
// удар: часть энергии в отскок, часть — в поворот
b.vr += b.v * 0.12 * (i % 2 ? -1 : 1);
b.v = -b.v * k;
busy = true;
} else {
b.v = 0;
// лёжа доворачиваемся к покойному наклону
b.rot += (b.rest - b.rot) * 0.2;
b.vr = 0;
if (Math.abs(b.rest - b.rot) > 0.3) busy = true;
}
} else busy = true;
b.vr *= 0.97;
cards[i]!.style.translate = `0 ${b.y.toFixed(1)}px`;
cards[i]!.style.rotate = `${b.rot.toFixed(2)}deg`;
});
if (busy) raf = requestAnimationFrame(step);
else raf = 0;
};
const wake = () => {
if (!raf) raf = requestAnimationFrame(step);
};
let dropped = false;
const io = new IntersectionObserver(([e]) => {
if (e!.isIntersecting && !dropped) {
dropped = true;
drop(false);
}
});
io.observe(el);
const onClick = () => dropped && drop(true);
el.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(raf);
io.disconnect();
el.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={host} className="gvg" role="presentation">
<style href="motiva-gravity-gallery" precedence="medium">{css}</style>
{items.map((src, i) => (
<img
key={src}
className="gvg-card"
src={src}
alt=""
loading="lazy"
draggable={false}
style={{ "--gvg-lift": `${(i * 7) % 22}px` } as CSSProperties}
/>
))}
</div>
);
}