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

Masonry Grid

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

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

Пропсы

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

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

Установка

npx shadcn@latest add https://motiva.pages.dev/r/masonry-grid.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";

const css = `.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; }
}
`;
import { useCallback, useLayoutEffect, useRef, useState } from "react";

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

export interface MasonryGridProps {
  items?: MasonryTile[];
  /** Минимальная ширина колонки, px. */
  min?: number;
  /** Зазор между плитками, px. */
  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 href="motiva-masonry-grid" precedence="medium">{css}</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