A gap-free multi-column layout for tiles of different heights, built on CSS columns so it never shifts on load, with responsive column counts and optional reveal.
import { MasonryGrid, MasonryItem } from "@/components/ballmac/masonry-grid";
const tiles = [
{ h: "h-40", tone: "from-chart-1/40 to-chart-1/10", title: "Morning light" },
{ h: "h-28", tone: "from-chart-2/40 to-chart-2/10", title: "Workshop" },
{ h: "h-52", tone: "from-chart-3/40 to-chart-3/10", title: "Harbor" },
{ h: "h-32", tone: "from-chart-4/40 to-chart-4/10", title: "Studio wall" },
{ h: "h-44", tone: "from-chart-5/40 to-chart-5/10", title: "Field notes" },
{ h: "h-24", tone: "from-chart-1/30 to-chart-2/10", title: "Sketch" },
{ h: "h-36", tone: "from-chart-2/30 to-chart-3/10", title: "Terrace" },
{ h: "h-48", tone: "from-chart-3/30 to-chart-4/10", title: "Market" },
{ h: "h-28", tone: "from-chart-4/30 to-chart-5/10", title: "Ceramics" },
];
export default function MasonryGridDemo() {
return (
<MasonryGrid columns={{ base: 2, sm: 3 }} gap="sm" reveal className="w-full max-w-2xl">
{tiles.map((t) => (
<MasonryItem key={t.title}>
<figure className="overflow-hidden rounded-xl border bg-card shadow-xs">
<div className={`${t.h} bg-gradient-to-br ${t.tone}`} />
<figcaption className="px-3 py-2 text-xs font-medium">{t.title}</figcaption>
</figure>
</MasonryItem>
))}
</MasonryGrid>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/masonry-gridInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/motion-presetsCopy the source into your project.
components/ballmac/masonry-grid.tsx// Ballmac UI: Masonry Grid. https://ui.ballmac.com/components/masonry-grid "use client"; import * as React from "react"; import { motion, useReducedMotion } from "motion/react"; import { ease } from "@/lib/ballmac/motion"; import { cn } from "@/lib/utils"; type ColumnCount = 1 | 2 | 3 | 4 | 5 | 6; type MasonryColumns = ColumnCount | { base?: ColumnCount; sm?: ColumnCount; md?: ColumnCount; lg?: ColumnCount; xl?: ColumnCount }; const BASE: Record<ColumnCount, string> = { 1: "columns-1", 2: "columns-2", 3: "columns-3", 4: "columns-4", 5: "columns-5", 6: "columns-6" }; const SM: Record<ColumnCount, string> = { 1: "sm:columns-1", 2: "sm:columns-2", 3: "sm:columns-3", 4: "sm:columns-4", 5: "sm:columns-5", 6: "sm:columns-6" }; const MD: Record<ColumnCount, string> = { 1: "md:columns-1", 2: "md:columns-2", 3: "md:columns-3", 4: "md:columns-4", 5: "md:columns-5", 6: "md:columns-6" }; const LG: Record<ColumnCount, string> = { 1: "lg:columns-1", 2: "lg:columns-2", 3: "lg:columns-3", 4: "lg:columns-4", 5: "lg:columns-5", 6: "lg:columns-6" }; const XL: Record<ColumnCount, string> = { 1: "xl:columns-1", 2: "xl:columns-2", 3: "xl:columns-3", 4: "xl:columns-4", 5: "xl:columns-5", 6: "xl:columns-6" }; const GAPS = { sm: { gap: "gap-3", item: "mb-3" }, md: { gap: "gap-4", item: "mb-4" }, lg: { gap: "gap-6", item: "mb-6" }, } as const; type MasonryGridProps = React.ComponentProps<"div"> & { /** Number of columns, or a count per breakpoint: `{ base: 1, sm: 2, lg: 4 }`. */ columns?: MasonryColumns; /** Space between items. */ gap?: keyof typeof GAPS; /** Fade items up as they scroll into view. Off under reduced motion. */ reveal?: boolean; }; const GapContext = React.createContext<keyof typeof GAPS>("md"); const RevealContext = React.createContext(false); function columnClasses(columns: MasonryColumns) { if (typeof columns === "number") return BASE[columns]; return [ columns.base && BASE[columns.base], columns.sm && SM[columns.sm], columns.md && MD[columns.md], columns.lg && LG[columns.lg], columns.xl && XL[columns.xl], ]; } /** * A multi-column layout where items of different heights pack without gaps. Built on CSS columns, so it renders the * same on the server and in the browser, never shifts on load, and keeps DOM order equal to reading order * (down the first column, then the next). */ function MasonryGrid({ columns = { base: 1, sm: 2, lg: 3 }, gap = "md", reveal = false, className, ...props }: MasonryGridProps) { return ( <GapContext.Provider value={gap}> <RevealContext.Provider value={reveal}> <div data-slot="masonry-grid" className={cn(columnClasses(columns), GAPS[gap].gap, className)} {...props} /> </RevealContext.Provider> </GapContext.Provider> ); } type MasonryItemProps = Omit<React.ComponentProps<"div">, "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart">; /** One tile. It never splits across columns. */ function MasonryItem({ className, children, ...props }: MasonryItemProps) { const gap = React.useContext(GapContext); const reveal = React.useContext(RevealContext); const reduce = useReducedMotion(); const classes = cn("break-inside-avoid", GAPS[gap].item, className); if (!reveal || reduce) { return ( <div data-slot="masonry-item" className={classes} {...props}> {children} </div> ); } return ( <motion.div data-slot="masonry-item" className={classes} initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "0px 0px -8% 0px" }} transition={{ duration: 0.5, ease: ease.out }} {...props} > {children} </motion.div> ); } export { MasonryGrid, MasonryItem, type MasonryGridProps, type MasonryItemProps, type MasonryColumns };Update the import paths to match your project setup.
Usage
import { MasonryGrid, MasonryItem } from "@/components/ballmac/masonry-grid"The full example is in the Code tab above.
Examples
Notes
Ship small
Release the smallest useful change, then learn from it.
Write it down
A short note today saves a long meeting next month. Keep the feedback loop short so you notice problems early.
Name things well
Good names make code, files and plans easier to follow.
Review early
Share drafts while they are still easy to change. Keep the feedback loop short so you notice problems early.
Measure one thing
Pick a single number that tells you if it worked.
import { MasonryGrid, MasonryItem } from "@/components/ballmac/masonry-grid";
const notes = [
["Ship small", "Release the smallest useful change, then learn from it."],
["Write it down", "A short note today saves a long meeting next month."],
["Name things well", "Good names make code, files and plans easier to follow."],
["Review early", "Share drafts while they are still easy to change."],
["Measure one thing", "Pick a single number that tells you if it worked."],
];
export default function MasonryGridStates() {
return (
<MasonryGrid columns={2} gap="lg" className="w-full max-w-md">
{notes.map(([title, text], i) => (
<MasonryItem key={title}>
<blockquote className="rounded-xl border bg-card p-4">
<p className="text-sm font-semibold">{title}</p>
<p className="mt-1 text-sm text-muted-foreground">{text}{i % 2 ? " Keep the feedback loop short so you notice problems early." : ""}</p>
</blockquote>
</MasonryItem>
))}
</MasonryGrid>
);
}API reference
| Prop | Type | Default |
|---|---|---|
columnsNumber of columns, or a count per breakpoint: `{ base: 1, sm: 2, lg: 4 }`. | MasonryColumns | { base: 1, sm: 2, lg: 3 } |
gapSpace between items. | keyof typeof GAPS | "md" |
revealFade items up as they scroll into view. Off under reduced motion. | boolean | false |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab order | Follows DOM order, which matches the visual column flow |
| Reduced motion | Reveal animation is skipped |
Use with AI
<MasonryGrid columns={{base:2, lg:4}} gap reveal><MasonryItem/>…</MasonryGrid>. DOM order is reading order: down the first column, then the next. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Masonry Grid (@ballmac/masonry-grid) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Galleries, portfolios and boards
- Testimonials or notes of uneven length
Not for
- Rows that must line up across columns; use CSS grid
- Sortable boards; use kanban-board
Registry JSON: https://ui.ballmac.com/r/masonry-grid.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
Pairs well with
Card
A composable content surface with compact spacing, an action slot, and optional interactive feedback.
Bento Grid
A responsive bento layout of feature cards that span columns and rows. Each BentoCard has a background visual slot, icon, title and description, and a link that slides up on hover or focus.
App Shell
The frame of an application page: skip link, sticky header, sidebar that becomes a sheet on small screens, main area, optional aside and footer.
Aspect Ratio
A CSS-native ratio frame that reserves space for media and safely handles invalid ratio values.