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

Gravity Gallery

Снимки падают в кадр и скачут по полу, пока не улягутся; щелчок подбрасывает всё заново. Единственная запись каталога с настоящей физикой: скорость, тяжесть, отскок с потерей энергии, вращение от удара. Десяток строк на rAF, цикл засыпает, когда все тела легли; позиции пишутся только translate/rotate — раскладку физика не трогает.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
gravity?number0.55Сила тяжести, px/кадр².
bounce?number0.45Упругость пола: 0 — падают замертво, 0.7 — скачут долго.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/gravity-gallery.json
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — падающие с отскоком снимки (madewithgsap free-превью, GSAP Physics2D-демо); код не заимствованv1.0.0