Masonry Grid
Masonry как у Pinterest: плитка уходит в самую короткую колонку, а высота считается из соотношения сторон — ни одного чтения раскладки и нулевой CLS.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | MasonryTile[] | ITEMS | |
| min? | number | 200 | Минимальная ширина колонки, px. |
| gap? | number | 12 | Зазор между плитками, px. |
MasonryTile — что внутри
- src: string
- ratio: number— Соотношение сторон исходника: ширина / высота.
- title?: string
- tag?: string
Установка
npx shadcn@latest add https://motiva.pages.dev/r/masonry-grid.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";
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>
);
}