Masonry layout
A masonry for pictures, quotes, notes and figures of any height. Balanced columns, ordered rows, filter chips or load more.
Every height, one tidy wall
A masonry layout stacks items of different heights into one tidy wall. This one takes pictures, quotes, notes and figures in the same grid, and the ordered version keeps the reading order left to right for content where the sequence matters.
Columns is the classic masonry in balanced columns with mixed cards that rise in as they scroll into view. Ordered rows runs the reading order left to right, row by row, while each card keeps its own height. With filter adds chips with counts that filter the cards by topic, the rest closes the gaps and rises in again. Load more shows a first batch with progress and a button that adds the next, and the focus lands on the first new card.
Use columns for galleries and mood boards, ordered rows for news or anything sorted by date. Filters help from about twenty items. Load more keeps long walls fast and gives people a sense of progress.
Variants
Details
What Masonry layout brings along, read from the registry and the source.
- Category
- Layouts
- Area
- Layout
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/masonry-layout.tsx
- Brings along
- utilskiticonsectionreveal
- npm packages
- None
- Languages
- English and German, 6 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The MasonryLayoutProps interface from components/ui/masonry-layout.tsx, with the comments from the code.
variantoptionalMasonryLayoutVariantcolumnsorderedfilterload-more
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeitemsrequiredMasonryItem[]columnsoptional2 | 3 | 4Columns on wide screens, 2 to 4
batchoptionalnumberload-more: cards per batch
moreHrefoptionalstringload-more: a page with everything, used without JavaScript
Built-in texts
Masonry layout writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 6 texts
| Key | English | German |
|---|---|---|
| all | English All | German Alle |
| shown | English {n} shown | German {n} angezeigt |
| more | English Show {n} more | German {n} weitere zeigen |
| of | English {a} of {b} | German {a} von {b} |
| filter | English Filter by topic | German Nach Thema filtern |
| open | English Open | German Öffnen |
Accessibility
Read from the source of the component.
- Sets aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
- Uses the role group.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 308 lines
'use client';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';
/*
* A masonry for cards of different heights: pictures, quotes, notes and
* figures, as a pinboard, a journal or a wall of work.
*
* columns balanced columns, the classic masonry, read top to bottom
* ordered the reading order stays left to right, row by row, while
* every card keeps its own height (measured rows)
* filter chips above that filter the cards, the rest closes the gaps
* load-more a first batch and a button that adds the next, the focus
* lands on the first new card
*
* Without JavaScript columns and filter show everything, ordered falls back
* to an even grid and load-more shows the first batch with a link.
*/
export type MasonryLayoutVariant = 'columns' | 'ordered' | 'filter' | 'load-more';
export interface MasonryItem {
/** image: picture with caption. quote: a quote with cite. note: title and text. stat: a figure. */
kind?: 'image' | 'quote' | 'note' | 'stat';
title?: string;
text?: string;
/** stat: the figure */
value?: string;
/** image: caption under the picture, quote: who said it */
caption?: string;
/** Filter group, also shown as a small label */
tag?: string;
href?: string;
/** image: the picture */
media?: React.ReactNode;
/** image: aspect ratio of the picture, e.g. 4/5 or 16/10 */
aspect?: string;
}
const TEXTS = {
en: { all: 'All', shown: '{n} shown', more: 'Show {n} more', of: '{a} of {b}', filter: 'Filter by topic', open: 'Open' },
de: { all: 'Alle', shown: '{n} angezeigt', more: '{n} weitere zeigen', of: '{a} von {b}', filter: 'Nach Thema filtern', open: 'Öffnen' },
};
type Texts = (typeof TEXTS)['en'];
export interface MasonryLayoutProps {
variant?: MasonryLayoutVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
items: MasonryItem[];
/** Columns on wide screens, 2 to 4 */
columns?: 2 | 3 | 4;
/** load-more: cards per batch */
batch?: number;
/** load-more: a page with everything, used without JavaScript */
moreHref?: string;
labels?: Partial<Texts>;
className?: string;
}
const focus = 'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring';
const fill = (s: string, v: Record<string, string | number>) => Object.entries(v).reduce((a, [k, x]) => a.replace(`{${k}}`, String(x)), s);
const COLS: Record<2 | 3 | 4, string> = { 2: 'sm:columns-2', 3: 'sm:columns-2 lg:columns-3', 4: 'sm:columns-2 lg:columns-3 xl:columns-4' };
const GRID: Record<2 | 3 | 4, string> = { 2: 'sm:grid-cols-2', 3: 'sm:grid-cols-2 lg:grid-cols-3', 4: 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4' };
/* ------------------------------------------------------------------ */
/* One card */
/* ------------------------------------------------------------------ */
function Card({ it }: { it: MasonryItem }) {
const kind = it.kind ?? (it.media ? 'image' : it.value ? 'stat' : 'note');
const Wrap = it.href ? 'a' : 'div';
const base = cn('group relative block overflow-hidden rounded-xl border border-border shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow] duration-300 ease-[var(--ease-kit)]', it.href && 'hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:hover:translate-y-0', it.href && focus);
const tag = it.tag && <span className="text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{it.tag}</span>;
if (kind === 'image')
return (
<Wrap {...(it.href ? { href: it.href } : {})} className={cn(base, 'bg-card')}>
<span data-clip-ok className="relative block overflow-hidden bg-muted" style={{ aspectRatio: it.aspect ?? '4 / 3' }}>
<span className="absolute inset-0 block transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-[1.04] motion-reduce:transition-none [&>*]:size-full">{it.media}</span>
</span>
{(it.title || it.caption || it.tag) && (
<span className="block border-t border-border px-4 py-3.5">
{tag}
{it.title && <span className="mt-0.5 block text-[15px] font-semibold leading-snug text-foreground">{it.title}</span>}
{it.caption && <span className="mt-0.5 block text-[13px] text-muted-foreground">{it.caption}</span>}
</span>
)}
</Wrap>
);
if (kind === 'quote')
return (
<Wrap {...(it.href ? { href: it.href } : {})} className={cn(base, 'bg-surface p-6')}>
<Icon name="quote" size={20} className="text-[color-mix(in_oklab,var(--primary)_70%,var(--border-strong))]" />
<span className="mt-4 block text-pretty text-[17px] font-medium leading-snug tracking-[-0.015em] text-foreground">{it.text}</span>
{it.caption && <span className="mt-4 block text-[13px] text-muted-foreground">{it.caption}</span>}
</Wrap>
);
if (kind === 'stat')
return (
<Wrap {...(it.href ? { href: it.href } : {})} className={cn(base, 'bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] p-6')}>
{tag}
<span className="mt-2 block text-[clamp(2.25rem,1.8rem+1.6vw,3rem)] font-semibold leading-none tracking-[-0.04em] text-foreground tabular-nums">{it.value}</span>
{it.title && <span className="mt-3 block text-[14.5px] font-medium text-foreground">{it.title}</span>}
{it.text && <span className="mt-1 block text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{it.text}</span>}
</Wrap>
);
return (
<Wrap {...(it.href ? { href: it.href } : {})} className={cn(base, 'bg-card p-6')}>
{tag}
{it.title && <span className="mt-1.5 block text-balance text-[17px] font-semibold leading-snug tracking-[-0.015em] text-foreground">{it.title}</span>}
{it.text && <span className="mt-2 block text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{it.text}</span>}
{it.href && (
<span className="mt-4 inline-flex items-center gap-1 text-[13px] font-medium text-foreground">
<Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover:translate-x-0.5" />
</span>
)}
</Wrap>
);
}
function Head({ p, children }: { p: MasonryLayoutProps; children?: React.ReactNode }) {
if (!p.title && !p.eyebrow && !children) return null;
return (
<div className="mb-10 grid gap-6 sm:mb-12 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end lg:gap-12">
<div className="max-w-[40rem]">
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
{p.title && <h2 className="text-balance text-h2 font-semibold text-foreground">{p.title}</h2>}
{p.description && <p className="mt-5 text-pretty text-lead text-muted-foreground">{p.description}</p>}
</div>
{children}
</div>
);
}
/* ------------------------------------------------------------------ */
function Columns({ p, items }: { p: MasonryLayoutProps; items: MasonryItem[] }) {
return (
<ul className={cn('columns-1 gap-4 [&>li]:mb-4', COLS[p.columns ?? 3])}>
{items.map((it, i) => (
<Reveal as="li" key={(it.title ?? it.text ?? '') + i} delay={(i % 6) * 60} className="break-inside-avoid">
<Card it={it} />
</Reveal>
))}
</ul>
);
}
/** Row by row, with each card spanning as many 8 px rows as it is tall */
function Ordered({ p }: { p: MasonryLayoutProps }) {
const list = useRef<HTMLUListElement>(null);
const [spans, setSpans] = useState<number[] | null>(null);
useLayoutEffect(() => {
const el = list.current;
if (!el) return;
const measure = () => {
const kids = Array.from(el.children) as HTMLElement[];
setSpans(kids.map((k) => Math.ceil(((k.firstElementChild as HTMLElement | null)?.getBoundingClientRect().height ?? 0) / 8) + 2));
};
measure();
const ro = new ResizeObserver(measure);
Array.from(el.children).forEach((k) => k.firstElementChild && ro.observe(k.firstElementChild));
return () => ro.disconnect();
}, [p.items]);
return (
<ul ref={list} className={cn('grid grid-cols-[minmax(0,1fr)] gap-x-4', GRID[p.columns ?? 3], spans ? 'auto-rows-[8px]' : 'gap-y-4')}>
{p.items.map((it, i) => (
<Reveal as="li" key={(it.title ?? it.text ?? '') + i} delay={(i % 6) * 60} className="self-start" style={spans ? { gridRowEnd: `span ${spans[i] ?? 1}` } : undefined}>
<div>
<Card it={it} />
</div>
</Reveal>
))}
</ul>
);
}
function Filter({ p, t }: { p: MasonryLayoutProps; t: Texts }) {
const tags = Array.from(new Set(p.items.map((i) => i.tag).filter(Boolean))) as string[];
const [on, setOn] = useState<string | null>(null);
const shown = p.items.filter((i) => !on || i.tag === on);
return (
<>
<Head p={p}>
<div role="group" aria-label={t.filter} className="kit-no-scrollbar -mx-6 flex gap-2 overflow-x-auto px-6 max-lg:kit-fade-end lg:mx-0 lg:flex-wrap lg:justify-end lg:overflow-visible lg:px-0">
{[null, ...tags].map((tag) => {
const active = on === tag;
const count = tag ? p.items.filter((i) => i.tag === tag).length : p.items.length;
return (
<button
key={tag ?? 'all'}
type="button"
aria-pressed={active}
onClick={() => setOn(tag)}
className={cn('inline-flex min-h-11 shrink-0 cursor-pointer items-center gap-2 rounded-full border px-4 text-[13.5px] font-medium transition-[background-color,color,border-color] duration-200 sm:min-h-10', active ? 'border-foreground bg-foreground text-background' : 'border-border bg-background text-muted-foreground hover:border-border-strong hover:text-foreground', focus)}
>
{tag ?? t.all}
<span className={cn('text-[12px] tabular-nums', active ? 'text-background/70' : 'text-subtle-foreground')}>{count}</span>
</button>
);
})}
</div>
</Head>
<p className="sr-only" aria-live="polite">
{fill(t.shown, { n: shown.length })}
</p>
<ul key={on ?? 'all'} className={cn('columns-1 gap-4 [&>li]:mb-4', COLS[p.columns ?? 3])}>
{shown.map((it, i) => (
<li key={(it.title ?? it.text ?? '') + i} className="break-inside-avoid motion-safe:animate-[kit-rise_0.5s_var(--ease-kit)_both]" style={{ animationDelay: `${Math.min(i, 8) * 40}ms` }}>
<Card it={it} />
</li>
))}
</ul>
</>
);
}
function LoadMore({ p, t }: { p: MasonryLayoutProps; t: Texts }) {
const batch = p.batch ?? 6;
const [count, setCount] = useState(batch);
const [hydrated, setHydrated] = useState(false);
const first = useRef<HTMLLIElement>(null);
const [focusAt, setFocusAt] = useState<number | null>(null);
const locale = useLocale();
useEffect(() => setHydrated(true), []);
useEffect(() => {
if (focusAt === null) return;
const target = first.current?.querySelector<HTMLElement>('a, [tabindex]') ?? first.current;
target?.focus();
}, [focusAt]);
const shown = p.items.slice(0, count);
const rest = p.items.length - count;
return (
<>
<Head p={p} />
<ul className={cn('columns-1 gap-4 [&>li]:mb-4', COLS[p.columns ?? 3])}>
{shown.map((it, i) => (
<li
key={(it.title ?? it.text ?? '') + i}
ref={i === focusAt ? first : undefined}
tabIndex={i === focusAt && !it.href ? -1 : undefined}
className={cn('break-inside-avoid rounded-xl', focus, i >= batch && 'motion-safe:animate-[kit-rise_0.6s_var(--ease-kit)_both]')}
style={i >= batch ? { animationDelay: `${Math.min(i % batch, 8) * 50}ms` } : undefined}
>
<Card it={it} />
</li>
))}
</ul>
<div className="mt-8 flex flex-col items-center gap-4">
<div className="flex w-full max-w-[16rem] items-center gap-3 text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-track" aria-hidden>
<span className="block h-full origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)]" style={{ transform: `scaleX(${Math.min(1, count / Math.max(1, p.items.length))})` }} />
</span>
{fill(t.of, { a: new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(Math.min(count, p.items.length)), b: p.items.length })}
</div>
{rest > 0 &&
(hydrated || !p.moreHref ? (
<button
type="button"
onClick={() => {
setFocusAt(count);
setCount((c) => c + batch);
}}
className={cn('inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg border border-border-strong bg-background px-5 text-[14px] font-medium text-foreground shadow-[var(--shadow-sm)] transition-colors hover:bg-accent active:scale-[0.98]', focus)}
>
<Icon name="plus" size={16} />
{fill(t.more, { n: Math.min(batch, rest) })}
</button>
) : (
<a href={p.moreHref} className={cn('inline-flex min-h-11 items-center gap-2 rounded-lg border border-border-strong bg-background px-5 text-[14px] font-medium text-foreground', focus)}>
{fill(t.more, { n: rest })}
</a>
))}
</div>
</>
);
}
/**
* A masonry of cards with pictures, quotes, notes and figures: balanced
* columns, an ordered grid with measured rows, filter chips or load more.
* <MasonryLayout variant="columns" items={[{ kind: 'image', media, aspect: '4/5', title }, { kind: 'quote', text, caption }]} />
*/
export function MasonryLayout(props: MasonryLayoutProps) {
const { variant = 'columns', className, labels } = props;
const t = useTexts(TEXTS, labels);
return (
<Section className={className}>
{variant === 'filter' ? (
<Filter p={props} t={t} />
) : variant === 'load-more' ? (
<LoadMore p={props} t={t} />
) : (
<>
<Head p={props} />
{variant === 'ordered' ? <Ordered p={props} /> : <Columns p={props} items={props.items} />}
</>
)}
</Section>
);
}Frequent questions
How do I install Masonry layout?
With aiw add @ai-website/masonry-layout. The command puts components/ui/masonry-layout.tsx into your project, the code is yours from then on. It brings along icon, section and reveal. It needs no further npm packages.
Which variants does Masonry layout have?
4 variants, Columns, Ordered rows, With filter and Load more. You pick one with the variant prop, for example variant="ordered".
Can I translate the texts of Masonry layout?
Yes. Masonry layout carries 6 texts of its own in English and German, such as “All”, “Filter by topic” and “Open”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Masonry layout?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Masonry layout with aiw_components and install it with aiw_add @ai-website/masonry-layout. Name the variant in your prompt, for example variant="columns", and the assistant writes your content straight into the props.