← каталог
Masonry Grid
Masonry как у Pinterest: плитка уходит в самую короткую колонку, а высота считается из соотношения сторон — ни одного чтения раскладки и нулевой CLS.
csshoverloadrevealintermediate~2 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | MasonryTile[] | ITEMS | |
| min? | number | 200 | Минимальная ширина колонки, px (формат «200»). |
| gap? | number | 12 | Зазор между плитками, 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Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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