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

Masonry Grid

Masonry как у Pinterest: плитка уходит в самую короткую колонку, а высота считается из соотношения сторон — ни одного чтения раскладки и нулевой CLS.

csshoverloadrevealintermediate~2 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его

Пропсы

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

ПропТипПо умолчаниюЧто это
items?MasonryTile[]ITEMS
min?number200Минимальная ширина колонки, px (формат «200»).
gap?number12Зазор между плитками, px (формат «12»).
MasonryTile — что внутри
  • src: string
  • ratio: number— Соотношение сторон исходника: ширина / высота (формат «0.75»).
  • title?: string
  • tag?: string

Установка

npx shadcn@latest add https://motiva.pages.dev/r/masonry-grid.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 { useCallback, useLayoutEffect, useRef, useState } from "react";

export interface MasonryTile {
  src: string;
  /** Соотношение сторон исходника: ширина / высота (формат «0.75»). */
  ratio: number;
  title?: string;
  tag?: string;
}

export interface MasonryGridProps {
  items?: MasonryTile[];
  /** Минимальная ширина колонки, px (формат «200»). */
  min?: number;
  /** Зазор между плитками, px (формат «12»). */
  gap?: number;
}

const p = (id: number, w: number, h: number) => `https://motiva.pages.dev/img/${id}-${w}x${h}.jpg`;
const tile = (id: number, w: number, h: number, title: string, tag: string): MasonryTile => ({
  src: p(id, w, h),
  ratio: w / h,
  title,
  tag,
});

const ITEMS: MasonryTile[] = [
  tile(1015, 600, 800, "Fjord", "пейзаж"),
  tile(1018, 600, 400, "Ridge", "горы"),
  tile(1024, 600, 900, "Bear", "дикая природа"),
  tile(1035, 600, 400, "Coast", "море"),
  tile(1039, 600, 750, "Falls", "вода"),
  tile(1043, 600, 400, "Valley", "лес"),
  tile(1044, 600, 850, "Pines", "лес"),
  tile(1050, 600, 400, "Skyline", "город"),
  tile(1056, 600, 800, "Dunes", "пустыня"),
  tile(1069, 600, 450, "Rooftop", "город"),
  tile(1074, 600, 900, "Shore", "берег"),
  tile(1080, 600, 500, "Berries", "макро"),
];

interface Placed {
  x: number;
  y: number;
  h: number;
}
interface Layout {
  colW: number;
  height: number;
  pos: Placed[];
}

/**
 * Masonry как у Pinterest: плитки укладываются слева направо, каждая — в САМУЮ КОРОТКУЮ
 * колонку на данный момент. Отсюда и ровный низ, и правильный порядок чтения — в отличие
 * от `columns`, где порядок идёт по колонкам сверху вниз.
 *
 * Главное отличие от типичной библиотеки masonry — здесь НЕТ ни одного чтения раскладки.
 * Обычная реализация измеряет каждую плитку (`offsetHeight`), а значит на каждой заставляет
 * браузер пересчитать раскладку заново: сто плиток — сто принудительных reflow подряд.
 * Здесь высота считается арифметикой из соотношения сторон, которое известно заранее:
 *
 *     высота = ширина_колонки / (ширина_исходника / высота_исходника)
 *
 * Один проход по массиву, ноль обращений к DOM. Побочная выгода крупнее самой скорости:
 * место под картинку известно ДО загрузки, поэтому при подгрузке ничего не прыгает —
 * CLS нулевой без единой строчки лишнего кода.
 *
 * Цена решения честная: соотношение сторон обязано быть в данных. Для галереи это норма
 * (сервер и так его знает), а для плиток с текстом произвольной длины — уже нет, там
 * без замера не обойтись.
 *
 * Первая раскладка считается в layout-эффекте, то есть ДО первой отрисовки: иначе один
 * кадр показал бы стопку плиток в углу. Переходы включаются только после неё — чтобы
 * плитки не «разлетались» из левого верхнего угла на старте.
 */
export function MasonryGrid({ items = ITEMS, min = 200, gap = 12 }: MasonryGridProps) {
  const wrap = useRef<HTMLDivElement>(null);
  const [box, setBox] = useState<Layout | null>(null);

  const relayout = useCallback(
    (w: number) => {
      if (!w) return;
      const cols = Math.max(1, Math.floor((w + gap) / (min + gap)));
      const colW = (w - gap * (cols - 1)) / cols;
      const tops = new Array<number>(cols).fill(0);
      const pos = items.map((it) => {
        // самая короткая колонка на данный момент — весь алгоритм в этих трёх строках
        let c = 0;
        for (let i = 1; i < cols; i++) if (tops[i]! < tops[c]!) c = i;
        const h = colW / it.ratio;
        const placed = { x: c * (colW + gap), y: tops[c]!, h };
        tops[c] = tops[c]! + h + gap;
        return placed;
      });
      setBox({ colW, pos, height: Math.max(...tops) - gap });
    },
    [items, min, gap],
  );

  useLayoutEffect(() => {
    const el = wrap.current;
    if (!el) return;
    // синхронно, до первой отрисовки
    relayout(el.clientWidth);
    let last = el.clientWidth;
    const ro = new ResizeObserver(([e]) => {
      const w = Math.round(e!.contentRect.width);
      // высота контейнера меняется нами же — на неё пересчитывать нельзя, иначе петля
      if (w === last) return;
      last = w;
      relayout(w);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, [relayout]);

  // переходы включаем со следующего кадра после первой раскладки
  const ready = useRef(false);
  useLayoutEffect(() => {
    if (!box || ready.current) return;
    const id = requestAnimationFrame(() => {
      ready.current = true;
      wrap.current?.setAttribute("data-ready", "1");
    });
    return () => cancelAnimationFrame(id);
  }, [box]);

  return (
    <div ref={wrap} className="msg" data-ready="0" style={{ height: box ? box.height : undefined }}>
      <style>{`
        .msg { position: relative; width: 100%; }
        .msg-tile { position: absolute; top: 0; left: 0; margin: 0; overflow: hidden;
          border-radius: var(--ds-radius, 14px);
          background: var(--ds-surface, oklch(0.18 0.02 275)); }
        /* только на переезд плиток; ширину и высоту не анимируем — это раскладка */
        .msg[data-ready="1"] .msg-tile {
          transition: transform var(--ds-slow, 0.55s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
        .msg-tile img { display: block; width: 100%; height: 100%; object-fit: cover;
          transition: transform var(--ds-slow, 0.6s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
        .msg-tile:hover img { transform: scale(1.06); }

        .msg-meta { position: absolute; inset: auto 0 0 0; padding: 30px 12px 10px;
          color: oklch(0.98 0 0);
          background: linear-gradient(to top, oklch(0 0 0 / 0.76), oklch(0 0 0 / 0));
          opacity: 0; translate: 0 20%;
          transition: opacity var(--ds-base, 0.32s),
                      translate var(--ds-base, 0.32s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
        .msg-tile:hover .msg-meta, .msg-tile:focus-within .msg-meta { opacity: 1; translate: 0 0; }
        .msg-t { display: block; font-weight: 700; font-size: var(--ds-text-sm, 0.9rem);
          letter-spacing: var(--ds-tracking-tight, -0.03em);
          font-family: var(--ds-font, system-ui, sans-serif); }
        .msg-g { font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-size: var(--ds-text-xs, 0.72rem); opacity: 0.8; }

        @media (prefers-reduced-motion: reduce) {
          .msg[data-ready="1"] .msg-tile, .msg-tile img, .msg-meta { transition: none; }
          .msg-meta { opacity: 1; translate: 0 0; }
        }
      `}</style>

      {items.map((it, i) => {
        const pl = box?.pos[i];
        return (
          <figure
            key={it.src}
            className="msg-tile"
            style={{
              width: box?.colW,
              height: pl?.h,
              transform: `translate3d(${pl?.x ?? 0}px, ${pl?.y ?? 0}px, 0)`,
            }}
          >
            <img src={it.src} alt={it.title ?? ""} loading="lazy" decoding="async" />
            {it.title && (
              <figcaption className="msg-meta">
                <span className="msg-t">{it.title}</span>
                {it.tag && <span className="msg-g">{it.tag}</span>}
              </figcaption>
            )}
          </figure>
        );
      })}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0