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.
Ana, Larder
Setup took an afternoon.
Kofi, Fabrikam
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.
Lena, Fernhill
Support answered on a Sunday, which I did not expect.
Ravi, Tailspin
Exports that used to time out finish in seconds.
Jun, Litware
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

PropTypeDefaultWhat it does
as"div" | "section" | "ul" | "ol""div"The root element. With ul or ol each item is wrapped in an li.
minstring"12rem"The narrowest a column may get.
columnsnumber4The most columns there will be.
gap1 | 2 | 3 | 4 | 5 | 63Space between items, from --eq-space-1 to --eq-space-6.
...restComponentProps<"div">noneEvery native attribute of the root element.

Anatomy

root.eq-masonry
item.eq-masonry-itemWraps 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;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index