zeroUI

Masonry

A masonry layout for React: cards of different heights packed into as many columns as fit, with CSS columns, so the server sends the final layout.

Install

terminal
# once per project: where @zeroui items come frompnpm dlx shadcn@latest registry add @zeroui=https://zeroui.catforest.dev/r/{name}.jsonpnpm dlx shadcn@latest add @zeroui/masonry

Examples

Posts of different heights

Lost
Seen 5,150 times
JUJuniper

Question

Grey, sealed, one corner dented. Last seen at the rail yards.

Two streets
Seen 3,301 times
B-B-Side

Commission

Pays well. Leave before midnight.

Gate shuts
Seen 199,861 times
DIDispatch

Notice

Anything not through by then waits until morning.

Ranked
Seen 27,014 times
NONoodleCartographer

Post

Thirty-one stalls, eleven nights, one map.

Swap
Seen 880 times
RORook

Post

Bring two, take two. No spares, no trades.

Pick a post.

post-card-feed.tsx
import { type ComponentType, useState } from "react";import { Avatar, AvatarFallback } from "@/components/zero/avatar";import { CellIcon } from "@/components/zero/icons/cell";import { CrateIcon } from "@/components/zero/icons/crate";import { FlareIcon } from "@/components/zero/icons/flare";import { KeyIcon } from "@/components/zero/icons/key";import { KitIcon } from "@/components/zero/icons/kit";import { Masonry } from "@/components/zero/masonry";import {  PostCard,  PostCardAuthor,  PostCardCover,  PostCardExcerpt,  PostCardTitle,} from "@/components/zero/post-card";/** A poster for a cover: a ground, a big leaning headline, an item icon. */function Poster({  tone,  ink,  icon: Icon,  line,  tall,}: {  tone: string;  ink: string;  icon: ComponentType<{ className?: string }>;  line: string;  tall?: boolean;}) {  return (    <div      className={`grid grid-rows-[auto_1fr] justify-items-center gap-2 overflow-hidden bg-(image:--zero-hatch) px-4 pt-10 pb-7 ${tall ? "aspect-[4/5]" : "aspect-[16/10]"}`}      style={{ backgroundColor: tone, color: ink }}    >      <span className="justify-self-stretch origin-left rotate-(--zero-tilt) font-black font-zero text-zero-xl uppercase italic leading-none">        {line}      </span>      <Icon className="size-16 self-center opacity-90" />    </div>  );}const posts = [  {    id: 1,    kind: "Question",    title: "Has anyone seen this crate?",    excerpt: "Grey, sealed, one corner dented. Last seen at the rail yards.",    author: "Juniper",    views: 5150,    poster: {      tone: "var(--color-zero-rarity-legendary)",      ink: "var(--color-zero-ink)",      icon: CrateIcon,      line: "Lost",      tall: true,    },  },  {    id: 2,    kind: "Commission",    title: "Need a courier: one box, two streets, no questions",    excerpt: "Pays well. Leave before midnight.",    author: "B-Side",    views: 3301,    poster: {      tone: "var(--color-zero-rarity-rare)",      ink: "var(--color-zero-ink)",      icon: KeyIcon,      line: "Two streets",    },  },  {    id: 3,    kind: "Notice",    title: "The north gate shuts at two tonight",    excerpt: "Anything not through by then waits until morning.",    author: "Dispatch",    views: 199861,    poster: {      tone: "var(--color-zero-accent)",      ink: "var(--color-zero-ink)",      icon: FlareIcon,      line: "Gate shuts",      tall: true,    },  },  {    id: 4,    kind: "Post",    title: "Every power cell vendor after midnight, ranked",    excerpt: "Thirty-one stalls, eleven nights, one map.",    author: "NoodleCartographer",    views: 27014,    poster: {      tone: "var(--color-zero-raised)",      ink: "var(--color-zero-rarity-uncommon)",      icon: CellIcon,      line: "Ranked",    },  },  {    id: 5,    kind: "Post",    title: "Kit swap at the old market, Thursday",    excerpt: "Bring two, take two. No spares, no trades.",    author: "Rook",    views: 880,    poster: {      tone: "var(--color-zero-rarity-epic)",      ink: "var(--color-zero-ink)",      icon: KitIcon,      line: "Swap",    },  },];export default function PostCardFeed() {  const [open, setOpen] = useState<string | null>(null);  return (    <div className="grid w-full gap-4">      <Masonry columnWidth="15rem">        {posts.map((post) => (          <PostCard key={post.id}>            <PostCardCover views={post.views}>              <Poster {...post.poster} />            </PostCardCover>            <PostCardAuthor              avatar={                <Avatar>                  <AvatarFallback>                    {post.author.slice(0, 2).toUpperCase()}                  </AvatarFallback>                </Avatar>              }            >              {post.author}            </PostCardAuthor>            <PostCardTitle kind={post.kind}>              <button type="button" onClick={() => setOpen(post.title)}>                {post.title}              </button>            </PostCardTitle>            <PostCardExcerpt>{post.excerpt}</PostCardExcerpt>          </PostCard>        ))}      </Masonry>      <p className="font-medium font-zero-text text-zero-sm text-zero-text-2">        {open ? `Opened: ${open}` : "Pick a post."}      </p>    </div>  );}

Behaviour

As many columns of at least columnWidth as fit, each item kept whole and spaced evenly. It is CSS columns: nothing is measured in the browser, so the layout the server sends is the final one and nothing jumps once scripts run.

Items read down the first column, then the next, in the order they are written, which is also the order Tab and screen readers follow. If the order across a row matters more than packing, use a grid instead.

Props

Masonry

PropTypeDefaultDescription
columnWidthstring"17rem"The narrowest a column may be.