Layout
Masonry
Columns of items with uneven heights, packed without gaps, for boards, galleries and quotes.
- Built on
CSS multi-columndisplay: grid-lanes- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 1.4 kB
- Dependencies
- React
Ship dark mode
Fix export timeout
Audit log
SAML for the Team plan
Faster search
Try its options
Loading its options.
Code
import { Masonry } from "@/components/equip/masonry";
const notes = [
{ text: "Ship dark mode", h: 40 },
{ text: "Fix export timeout", h: 24 },
{ text: "Audit log", h: 24 },
{ text: "SAML for the Team plan", h: 40 },
{ text: "Faster search", h: 24 },
];
export default function Demo() {
return (
<Masonry min="6rem" columns={2} gap={2} style={{ width: 208 }}>
{notes.map((note) => (
<div
key={note.text}
style={{
minHeight: note.h,
padding: "6px 8px",
borderRadius: "var(--eq-radius-s)",
background: "var(--eq-tint-strong)",
fontSize: "var(--eq-text-xs)",
lineHeight: 1.35,
}}
>
{note.text}
</div>
))}
</Masonry>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div class="eq-masonry" style="--_min:6rem;--_columns:2;width:208px" data-gap="2">
<div class="eq-masonry-item">
<div style="min-height:40px;padding:6px 8px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);font-size:var(--eq-text-xs);line-height:1.35">Ship dark mode</div>
</div>
<div class="eq-masonry-item">
<div style="min-height:24px;padding:6px 8px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);font-size:var(--eq-text-xs);line-height:1.35">Fix export timeout</div>
</div>
<div class="eq-masonry-item">
<div style="min-height:24px;padding:6px 8px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);font-size:var(--eq-text-xs);line-height:1.35">Audit log</div>
</div>
<div class="eq-masonry-item">
<div style="min-height:40px;padding:6px 8px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);font-size:var(--eq-text-xs);line-height:1.35">SAML for the Team plan</div>
</div>
<div class="eq-masonry-item">
<div style="min-height:24px;padding:6px 8px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);font-size:var(--eq-text-xs);line-height:1.35">Faster search</div>
</div>
</div>Examples
A product inspiration board
In the column fallback the first items fill the first column top to bottom; with native masonry each item takes the shortest column, so the order runs roughly across.
Linen overshirt
Sand
Canvas tote
Natural
Rain shell
Olive
Wool beanie
Rust
Cord trousers
Chocolate
Merino crew
Navy
Field watch
Steel
Code
import { Masonry } from "@/components/equip/masonry";
const pins = [
{ name: "Linen overshirt", tone: "Sand", ratio: "3 / 4" },
{ name: "Canvas tote", tone: "Natural", ratio: "1" },
{ name: "Rain shell", tone: "Olive", ratio: "2 / 3" },
{ name: "Wool beanie", tone: "Rust", ratio: "4 / 3" },
{ name: "Cord trousers", tone: "Chocolate", ratio: "3 / 5" },
{ name: "Merino crew", tone: "Navy", ratio: "1" },
{ name: "Field watch", tone: "Steel", ratio: "4 / 5" },
];
export default function Example() {
return (
<Masonry as="ul" min="9rem" columns={4} gap={3} style={{ width: "100%" }}>
{pins.map((pin) => (
<article key={pin.name}>
<div style={{ aspectRatio: pin.ratio, borderRadius: "var(--eq-radius-m)", background: "var(--eq-tint-strong)" }} />
<h3 style={{ margin: "6px 0 0", fontSize: "var(--eq-text-s)" }}>{pin.name}</h3>
<p style={{ margin: 0, fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>{pin.tone}</p>
</article>
))}
</Masonry>
);
}HTMLworks in any framework, with the part's CSS
<ul class="eq-masonry" style="--_min:9rem;--_columns:4;width:100%" data-gap="3">
<li class="eq-masonry-item">
<article>
<div style="aspect-ratio:3 / 4;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
<h3 style="margin:6px 0 0;font-size:var(--eq-text-s)">Linen overshirt</h3>
<p style="margin:0;font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Sand</p>
</article>
</li>
<li class="eq-masonry-item">
<article>
<div style="aspect-ratio:1;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
<h3 style="margin:6px 0 0;font-size:var(--eq-text-s)">Canvas tote</h3>
<p style="margin:0;font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Natural</p>
</article>
</li>
<li class="eq-masonry-item">
<article>
<div style="aspect-ratio:2 / 3;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
<h3 style="margin:6px 0 0;font-size:var(--eq-text-s)">Rain shell</h3>
<p style="margin:0;font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Olive</p>
</article>
</li>
<li class="eq-masonry-item">
<article>
<div style="aspect-ratio:4 / 3;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
<h3 style="margin:6px 0 0;font-size:var(--eq-text-s)">Wool beanie</h3>
<p style="margin:0;font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Rust</p>
</article>
</li>
<li class="eq-masonry-item">
<article>
<div style="aspect-ratio:3 / 5;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
<h3 style="margin:6px 0 0;font-size:var(--eq-text-s)">Cord trousers</h3>
<p style="margin:0;font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Chocolate</p>
</article>
</li>
<li class="eq-masonry-item">
<article>
<div style="aspect-ratio:1;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
<h3 style="margin:6px 0 0;font-size:var(--eq-text-s)">Merino crew</h3>
<p style="margin:0;font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Navy</p>
</article>
</li>
<li class="eq-masonry-item">
<article>
<div style="aspect-ratio:4 / 5;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
<h3 style="margin:6px 0 0;font-size:var(--eq-text-s)">Field watch</h3>
<p style="margin:0;font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Steel</p>
</article>
</li>
</ul>Quotes of different lengths
Each item is kept whole and never split across two columns.
We replaced three dashboards with one. The team actually opens it now.
Setup took an afternoon.
The audit log alone paid for the plan. When a customer asked who changed their billing address, we had the answer in a minute, with the old value and the new one.
Support answered on a Sunday, which I did not expect.
Exports that used to time out finish in seconds.
Code
import { Masonry } from "@/components/equip/masonry";
const quotes = [
{ who: "Ana, Larder", text: "We replaced three dashboards with one. The team actually opens it now." },
{ who: "Kofi, Fabrikam", text: "Setup took an afternoon." },
{
who: "Lena, Fernhill",
text: "The audit log alone paid for the plan. When a customer asked who changed their billing address, we had the answer in a minute, with the old value and the new one.",
},
{ who: "Ravi, Tailspin", text: "Support answered on a Sunday, which I did not expect." },
{ who: "Jun, Litware", text: "Exports that used to time out finish in seconds." },
];
export default function Example() {
return (
<Masonry min="12rem" columns={3} gap={4} style={{ width: "100%" }}>
{quotes.map((quote) => (
<figure
key={quote.who}
style={{
margin: 0,
padding: 16,
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-l)",
background: "var(--eq-surface)",
}}
>
<blockquote style={{ margin: 0, fontSize: "var(--eq-text-m)", lineHeight: 1.5 }}>{quote.text}</blockquote>
<figcaption style={{ marginTop: 8, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>{quote.who}</figcaption>
</figure>
))}
</Masonry>
);
}HTMLworks in any framework, with the part's CSS
<div class="eq-masonry" style="--_min:12rem;--_columns:3;width:100%" data-gap="4">
<div class="eq-masonry-item">
<figure style="margin:0;padding:16px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-l);background:var(--eq-surface)">
<blockquote style="margin:0;font-size:var(--eq-text-m);line-height:1.5">We replaced three dashboards with one. The team actually opens it now.</blockquote>
<figcaption style="margin-top:8px;font-size:var(--eq-text-s);color:var(--eq-ink-2)">Ana, Larder</figcaption>
</figure>
</div>
<div class="eq-masonry-item">
<figure style="margin:0;padding:16px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-l);background:var(--eq-surface)">
<blockquote style="margin:0;font-size:var(--eq-text-m);line-height:1.5">Setup took an afternoon.</blockquote>
<figcaption style="margin-top:8px;font-size:var(--eq-text-s);color:var(--eq-ink-2)">Kofi, Fabrikam</figcaption>
</figure>
</div>
<div class="eq-masonry-item">
<figure style="margin:0;padding:16px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-l);background:var(--eq-surface)">
<blockquote style="margin:0;font-size:var(--eq-text-m);line-height:1.5">The audit log alone paid for the plan. When a customer asked who changed their billing address, we had the answer in a minute, with the old value and the new one.</blockquote>
<figcaption style="margin-top:8px;font-size:var(--eq-text-s);color:var(--eq-ink-2)">Lena, Fernhill</figcaption>
</figure>
</div>
<div class="eq-masonry-item">
<figure style="margin:0;padding:16px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-l);background:var(--eq-surface)">
<blockquote style="margin:0;font-size:var(--eq-text-m);line-height:1.5">Support answered on a Sunday, which I did not expect.</blockquote>
<figcaption style="margin-top:8px;font-size:var(--eq-text-s);color:var(--eq-ink-2)">Ravi, Tailspin</figcaption>
</figure>
</div>
<div class="eq-masonry-item">
<figure style="margin:0;padding:16px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-l);background:var(--eq-surface)">
<blockquote style="margin:0;font-size:var(--eq-text-m);line-height:1.5">Exports that used to time out finish in seconds.</blockquote>
<figcaption style="margin-top:8px;font-size:var(--eq-text-s);color:var(--eq-ink-2)">Jun, Litware</figcaption>
</figure>
</div>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
as | "div" | "section" | "ul" | "ol" | "div" | The root element. With ul or ol each item is wrapped in an li. |
min | string | "12rem" | The narrowest a column may get. |
columns | number | 4 | The most columns there will be. |
gap | 1 | 2 | 3 | 4 | 5 | 6 | 3 | Space between items, from --eq-space-1 to --eq-space-6. |
...rest | ComponentProps<"div"> | none | Every native attribute of the root element. |
Anatomy
| root | .eq-masonry | |
|---|---|---|
| item | .eq-masonry-item | Wraps each child, so the layout never fights the child's own margins. |
Notes
- Renders on the server with no client JavaScript.
- Reading order: in the column fallback items run down the first column, then the next, so the second item sits under the first, not beside it.
- With native masonry each item goes into the shortest column, so the order runs roughly across, row by row.
- Either way tab and screen reader order is the DOM order, so put the most important items first and do not rely on position to carry meaning.
- Each item is kept whole and never split across two columns.
- Native masonry uses display: grid-lanes, which is not yet shipped by default in any engine; it is applied only where @supports (display: grid-lanes) passes. Everywhere else the part uses CSS multi-column, which every current engine has.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/masonry.tsx47 lines
import { Children, type ComponentProps, type CSSProperties } from "react";
import "./masonry.css";
export type MasonryProps = ComponentProps<"div"> & {
/** The element at the root. With ul or ol each item is wrapped in an li. */
as?: "div" | "section" | "ul" | "ol";
/** The narrowest a column may get. Any CSS length. */
min?: string;
/** The most columns there will be. */
columns?: number;
/** Space between items, read from --eq-space-1 to --eq-space-6. */
gap?: 1 | 2 | 3 | 4 | 5 | 6;
};
/**
* Columns of items with uneven heights. Uses native masonry layout where the
* engine has it and CSS multi-column everywhere else.
*/
export function Masonry({
as = "div",
min = "12rem",
columns = 4,
gap = 3,
className,
style,
children,
...rest
}: MasonryProps) {
// The root changes element but never its attributes, so the div typing holds for all four.
const Root = as as "div";
const Item = as === "ul" || as === "ol" ? "li" : "div";
return (
<Root
{...rest}
className={["eq-masonry", className].filter(Boolean).join(" ")}
style={{ "--_min": min, "--_columns": columns, ...style } as CSSProperties}
data-gap={gap}
>
{Children.toArray(children).map((child, index) => (
<Item key={index} className="eq-masonry-item">
{child}
</Item>
))}
</Root>
);
}components/equip/masonry.css63 lines
@layer equip {
/* Fallback, and what every current engine without native masonry uses: CSS multi-column.
Items run down the first column, then the next, which is why the notes say to keep
DOM order meaningful on its own. */
.eq-masonry {
--_gap: var(--eq-space-3);
box-sizing: border-box;
display: block;
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
/* A length only: multi-column drops to one column on its own when the box is narrower. */
column-width: var(--_min, 12rem);
column-count: var(--_columns, 4);
column-gap: var(--_gap);
}
.eq-masonry[data-gap="1"] { --_gap: var(--eq-space-1); }
.eq-masonry[data-gap="2"] { --_gap: var(--eq-space-2); }
.eq-masonry[data-gap="3"] { --_gap: var(--eq-space-3); }
.eq-masonry[data-gap="4"] { --_gap: var(--eq-space-4); }
.eq-masonry[data-gap="5"] { --_gap: var(--eq-space-5); }
.eq-masonry[data-gap="6"] { --_gap: var(--eq-space-6); }
.eq-masonry-item {
box-sizing: border-box;
display: block;
min-width: 0;
margin: 0 0 var(--_gap);
break-inside: avoid;
list-style: none;
}
.eq-masonry-item:last-child {
margin-bottom: 0;
}
/* Native masonry: each item goes into the column that is currently shortest,
so items read roughly across, row by row. */
@supports (display: grid-lanes) {
.eq-masonry {
display: grid-lanes;
grid-template-columns: repeat(
auto-fill,
minmax(
max(
min(var(--_min, 12rem), 100%),
(100% - (var(--_columns, 4) - 1) * var(--_gap)) / var(--_columns, 4)
),
1fr
)
);
gap: var(--_gap);
columns: auto;
}
.eq-masonry-item {
margin: 0;
}
}
}