← каталог
Masonry Columns
Пинтерест-стена на чистом CSS: одно свойство columns, ни строчки JS и ни одного медиазапроса. Честно описано ограничение — порядок чтения идёт по колонкам.
csshoverrevealbasic~2 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | MasonryColumnsItem[] | ITEMS | |
| min? | number | 210 | Минимальная ширина колонки, px (формат «210»). |
| gap? | number | 12 | Зазор, px (формат «12»). |
MasonryColumnsItem — что внутри
- src: string
- title?: string
- tag?: string
Установка
npx shadcn@latest add https://motiva.pages.dev/r/masonry-columns.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.
Экспорт
Код
export interface MasonryColumnsItem {
src: string;
title?: string;
tag?: string;
}
export interface MasonryColumnsProps {
items?: MasonryColumnsItem[];
/** Минимальная ширина колонки, px (формат «210»). */
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 ITEMS: MasonryColumnsItem[] = [
{ src: p(1015, 600, 800), title: "Fjord", tag: "пейзаж" },
{ src: p(1018, 600, 400), title: "Ridge", tag: "горы" },
{ src: p(1024, 600, 900), title: "Bear", tag: "дикая природа" },
{ src: p(1035, 600, 400), title: "Coast", tag: "море" },
{ src: p(1039, 600, 750), title: "Falls", tag: "вода" },
{ src: p(1043, 600, 400), title: "Valley", tag: "лес" },
{ src: p(1044, 600, 850), title: "Pines", tag: "лес" },
{ src: p(1050, 600, 400), title: "Skyline", tag: "город" },
{ src: p(1056, 600, 800), title: "Dunes", tag: "пустыня" },
{ src: p(1069, 600, 450), title: "Rooftop", tag: "город" },
{ src: p(1074, 600, 900), title: "Shore", tag: "берег" },
{ src: p(1080, 600, 500), title: "Berries", tag: "макро" },
];
/**
* Пинтерест-стена на чистом CSS: ни строчки JS, ни одного замера.
*
* Вся раскладка — свойство `columns`. Указываешь ЖЕЛАЕМУЮ ширину колонки, а не их число:
* браузер сам решает, сколько поместится, и растягивает остаток. Медиазапросы не нужны
* в принципе — это самый дешёвый masonry, который вообще бывает.
*
* ЧЕСТНОЕ ОГРАНИЧЕНИЕ, из-за которого этот вариант годится не всегда: порядок чтения
* идёт ПО КОЛОНКАМ — сверху вниз в первой, потом сверху вниз во второй. Для стены фотографий
* это незаметно, но для ленты новостей или списка товаров порядок будет неправильным
* и для глаза, и для скринридера. Нужен порядок слева направо — бери `masonry-grid`,
* там раскладка считается вручную именно ради этого.
*
* Второе ограничение: разрывы. `break-inside: avoid` не даёт резать плитку между колонками,
* но высоту колонок браузер выравнивает как умеет — идеально ровного низа не будет.
*/
export function MasonryColumns({ items = ITEMS, min = 210, gap = 12 }: MasonryColumnsProps) {
return (
<div
className="msc"
style={{ ["--msc-min" as string]: `${min}px`, ["--msc-gap" as string]: `${gap}px` }}
>
<style>{`
/* вся раскладка — эта одна строка */
.msc { columns: var(--msc-min, 210px); column-gap: var(--msc-gap, 12px); width: 100%; }
.msc-item { break-inside: avoid; margin: 0 0 var(--msc-gap, 12px); position: relative;
border-radius: var(--ds-radius, 14px); overflow: hidden;
background: var(--ds-surface, oklch(0.18 0.02 275)); }
.msc-item img { display: block; width: 100%; height: auto;
transition: transform var(--ds-slow, 0.6s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
/* приближается картинка внутри неподвижной рамки — колонки не пересчитываются */
.msc-item:hover img { transform: scale(1.06); }
.msc-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)); }
.msc-item:hover .msc-meta, .msc-item:focus-within .msc-meta { opacity: 1; translate: 0 0; }
.msc-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); }
.msc-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) {
.msc-item img, .msc-meta { transition: none; }
.msc-meta { opacity: 1; translate: 0 0; }
}
`}</style>
{items.map((it) => (
<figure key={it.src} className="msc-item">
<img src={it.src} alt={it.title ?? ""} loading="lazy" decoding="async" />
{it.title && (
<figcaption className="msc-meta">
<span className="msc-t">{it.title}</span>
{it.tag && <span className="msc-g">{it.tag}</span>}
</figcaption>
)}
</figure>
))}
</div>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0