Skip to content
omaris

Layout

Masonry

A masonry grid — the photo wall. Cards of unequal height, packed so the bottom edge comes out level, in reading order.

import { Masonry } from 'omaris'
Learn

Examples

Photo wall

The photo wall. Every pin is one flat list item positioned with translate, so switching boards adds and removes pins without the rest re-mounting — the survivors slide to their new place. Hover a pin for the actions.

River bend at first light
River bend at first light
Lina Haddad Lina Haddad
Cliffs above the valley
Cliffs above the valley
Omar Nasir Omar Nasir
Alpine lake, late summer
Alpine lake, late summer
Sara Kamal Sara Kamal
The road out of town
The road out of town
Yousif Amin Yousif Amin
Bear country
Bear country
Dilan Rashid Dilan Rashid
Fog on the ridge
Fog on the ridge
Lina Haddad Lina Haddad
Pug, portrait mode
Pug, portrait mode
Amina Yusuf Amina Yusuf
Long exposure, waterfall
Long exposure, waterfall
Omar Nasir Omar Nasir
Snowline
Snowline
Sara Kamal Sara Kamal
Trail through the pines
Trail through the pines
Yousif Amin Yousif Amin
Old stone bridge
Old stone bridge
Dilan Rashid Dilan Rashid
Rooftops
Rooftops
Amina Yusuf Amina Yusuf
Coastline from the pier
Coastline from the pier
Lina Haddad Lina Haddad
Kitchen garden
Kitchen garden
Omar Nasir Omar Nasir
Harbour at dusk
Harbour at dusk
Sara Kamal Sara Kamal
Wind over the dunes
Wind over the dunes
Yousif Amin Yousif Amin
Autumn, one tree
Autumn, one tree
Dilan Rashid Dilan Rashid
Half-lit hallway
Half-lit hallway
Amina Yusuf Amina Yusuf
Highland loch
Highland loch
Lina Haddad Lina Haddad
Sunset on the pass
Sunset on the pass
Omar Nasir Omar Nasir
<script lang="ts" module>	export const meta = { fill: true };​	type Pin = {		id: number;		w: number;		h: number;		title: string;		author: string;		board: string;	};</script>​<script lang="ts">	import { Avatar, Button, Chip, ChipGroup, IconButton, Masonry, Text } from 'omaris';​	const BOARDS = ['All', 'Landscapes', 'Water', 'Weekend', 'Studio'];​	const RATIOS = [		[600, 800],		[600, 900],		[600, 600],		[600, 750],		[540, 960],		[800, 600]	];​	const PINS: Pin[] = [		[1015, 'River bend at first light', 'Lina Haddad', 'Water'],		[1016, 'Cliffs above the valley', 'Omar Nasir', 'Landscapes'],		[1018, 'Alpine lake, late summer', 'Sara Kamal', 'Water'],		[1019, 'The road out of town', 'Yousif Amin', 'Weekend'],		[1020, 'Bear country', 'Dilan Rashid', 'Landscapes'],		[1024, 'Fog on the ridge', 'Lina Haddad', 'Landscapes'],		[1025, 'Pug, portrait mode', 'Amina Yusuf', 'Studio'],		[1035, 'Long exposure, waterfall', 'Omar Nasir', 'Water'],		[1036, 'Snowline', 'Sara Kamal', 'Landscapes'],		[1039, 'Trail through the pines', 'Yousif Amin', 'Weekend'],		[1040, 'Old stone bridge', 'Dilan Rashid', 'Weekend'],		[1043, 'Rooftops', 'Amina Yusuf', 'Studio'],		[1044, 'Coastline from the pier', 'Lina Haddad', 'Water'],		[1045, 'Kitchen garden', 'Omar Nasir', 'Studio'],		[1047, 'Harbour at dusk', 'Sara Kamal', 'Water'],		[1050, 'Wind over the dunes', 'Yousif Amin', 'Landscapes'],		[1051, 'Autumn, one tree', 'Dilan Rashid', 'Weekend'],		[1052, 'Half-lit hallway', 'Amina Yusuf', 'Studio'],		[1053, 'Highland loch', 'Lina Haddad', 'Water'],		[1054, 'Sunset on the pass', 'Omar Nasir', 'Landscapes']	].map(([id, title, author, board], i) => {		const [w, h] = RATIOS[i % RATIOS.length];		return { id: id as number, w, h, title, author, board } as Pin;	});​	let board = $state('All');	const shown = $derived(board === 'All' ? PINS : PINS.filter((p) => p.board === board));</script>​{#snippet pin(p: Pin)}	<article class="group flex flex-col gap-2">		<div class="relative overflow-hidden rounded-shape-xl bg-surface-container">			<img				src="https://picsum.photos/id/{p.id}/{p.w}/{p.h}"				alt={p.title}				width={p.w}				height={p.h}				style="aspect-ratio: {p.w} / {p.h}"				loading="lazy"				class="block w-full object-cover"			/>			<div				class="absolute inset-0 flex flex-col justify-between bg-linear-to-b from-black/45 via-transparent to-black/45 p-3 opacity-0 transition-opacity duration-200 ease-standard group-focus-within:opacity-100 group-hover:opacity-100 motion-reduce:transition-none"			>				<div class="flex justify-end">					<Button size="xs">Save</Button>				</div>				<div class="flex justify-end gap-1">					<IconButton variant="elevated" size="xs" aria-label="Share">						<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">							<path								d="M12 4v11m0-11-4 4m4-4 4 4M6 14v4a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-4"								stroke-linecap="round"								stroke-linejoin="round"							/>						</svg>					</IconButton>					<IconButton variant="elevated" size="xs" aria-label="More">						<svg viewBox="0 0 24 24" fill="currentColor">							<circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle								cx="19"								cy="12"								r="1.6"							/>						</svg>					</IconButton>				</div>			</div>		</div>		<div class="flex flex-col gap-1 px-1">			<Text variant="label-lg" lines={1}>{p.title}</Text>			<div class="flex items-center gap-2">				<Avatar size="xs" name={p.author} colorize />				<Text variant="label-sm" tone="muted" lines={1}>{p.author}</Text>			</div>		</div>	</article>{/snippet}​<div class="flex w-full flex-col gap-4 px-4">	<ChipGroup label="Boards" overflow="scroll">		{#each BOARDS as b (b)}			<Chip selectable selected={board === b} onclick={() => (board = b)}>{b}</Chip>		{/each}	</ChipGroup>​	<Masonry items={shown} item={pin} key={(p) => p.id} minColumnWidth={200} gap={16} /></div>

Load more

onend fires once when the bottom of the wall comes within endOffset of the viewport, and again after the next page lands — infinite scroll in one callback. New pins fade and rise in, staggered.

Off the map 1
Off the map 1
Lina Haddad Lina Haddad
Slow morning 2
Slow morning 2
Omar Nasir Omar Nasir
Nowhere in particular 3
Nowhere in particular 3
Sara Kamal Sara Kamal
Field notes 4
Field notes 4
Yousif Amin Yousif Amin
Golden hour 5
Golden hour 5
Dilan Rashid Dilan Rashid
Off the map 6
Off the map 6
Lina Haddad Lina Haddad
Slow morning 7
Slow morning 7
Omar Nasir Omar Nasir
Nowhere in particular 8
Nowhere in particular 8
Sara Kamal Sara Kamal
Field notes 9
Field notes 9
Yousif Amin Yousif Amin
Golden hour 10
Golden hour 10
Dilan Rashid Dilan Rashid
Off the map 11
Off the map 11
Lina Haddad Lina Haddad
Slow morning 12
Slow morning 12
Omar Nasir Omar Nasir
<script lang="ts" module>	export const meta = { fill: true };​	type Pin = { id: number; key: string; w: number; h: number; title: string; author: string };</script>​<script lang="ts">	import { Avatar, Masonry, Skeleton, Text } from 'omaris';​	const IDS = [		1055, 1057, 1059, 1060, 1062, 1063, 1064, 1065, 1067, 1069, 1070, 1071, 1072, 1074, 1080, 1081,		1082, 1083, 1084, 1015, 1016, 1018, 1019, 1020	];	const RATIOS = [		[600, 800],		[600, 600],		[540, 960],		[600, 750],		[800, 600],		[600, 900]	];	const TITLES = [		'Off the map',		'Slow morning',		'Nowhere in particular',		'Field notes',		'Golden hour'	];	const AUTHORS = ['Lina Haddad', 'Omar Nasir', 'Sara Kamal', 'Yousif Amin', 'Dilan Rashid'];​	const PAGE = 12;	const PAGES = 3;​	function page(n: number): Pin[] {		return Array.from({ length: PAGE }, (_, i) => {			const k = n * PAGE + i;			const [w, h] = RATIOS[k % RATIOS.length];			return {				id: IDS[k % IDS.length],				key: `${n}-${i}`,				w,				h,				title: `${TITLES[k % TITLES.length]} ${k + 1}`,				author: AUTHORS[k % AUTHORS.length]			};		});	}​	let pins = $state<Pin[]>(page(0));	let loaded = $state(1);	let loading = $state(false);	const done = $derived(loaded >= PAGES);​	function more() {		if (loading || done) return;		loading = true;		setTimeout(() => {			pins = [...pins, ...page(loaded)];			loaded += 1;			loading = false;		}, 600);	}</script>​{#snippet pin(p: Pin)}	<article class="flex flex-col gap-2">		<img			src="https://picsum.photos/id/{p.id}/{p.w}/{p.h}"			alt={p.title}			width={p.w}			height={p.h}			style="aspect-ratio: {p.w} / {p.h}"			loading="lazy"			class="block w-full rounded-shape-xl bg-surface-container object-cover"		/>		<div class="flex flex-col gap-1 px-1">			<Text variant="label-lg" lines={1}>{p.title}</Text>			<div class="flex items-center gap-2">				<Avatar size="xs" name={p.author} colorize />				<Text variant="label-sm" tone="muted" lines={1}>{p.author}</Text>			</div>		</div>	</article>{/snippet}​<div class="flex w-full flex-col gap-4" aria-busy={loading}>	<Masonry items={pins} item={pin} key={(p) => p.key} minColumnWidth={180} gap={16} onend={more} />​	{#if loading}		<div class="flex gap-4" role="status" aria-label="Loading more pins">			<Skeleton class="aspect-3/4 min-w-0 flex-1 rounded-shape-xl" />			<Skeleton class="aspect-square min-w-0 flex-1 rounded-shape-xl" />			<Skeleton class="aspect-4/5 min-w-0 flex-1 rounded-shape-xl" />			<Skeleton class="aspect-3/4 min-w-0 flex-1 rounded-shape-xl" />		</div>	{:else if done}		<Text variant="label-md" tone="muted" class="py-4 text-center">			That's all — {pins.length} pins.		</Text>	{/if}</div>

Columns and balance

balance="height" packs each card into the shortest column, so the bottom edge comes out level. balance="order" deals them out in turn instead — a strict left-to-right rhythm, at the cost of a ragged bottom. columns fixes the count rather than deriving it. Change either and watch the cards slide: they move, they are not re-drawn.

1 col 1
2 col 1
3 col 1
4 col 1
5 col 1
6 col 1
7 col 1
8 col 1
9 col 1
10 col 1
11 col 1
12 col 1
<script lang="ts">	import { Masonry, SegmentedButton, Text, type MasonryBalance } from 'omaris';​	const TINTS = [		'bg-primary-container text-on-primary-container',		'bg-secondary-container text-on-secondary-container',		'bg-tertiary-container text-on-tertiary-container',		'bg-surface-container-high text-foreground'	];	const HEIGHTS = [120, 64, 96, 152, 72, 108, 88, 136, 60, 116, 80, 128];	const NOTES = HEIGHTS.map((h, i) => ({ id: i + 1, h, tint: TINTS[i % TINTS.length] }));​	let columns = $state('auto');	let balance = $state<MasonryBalance>('height');</script>​<div class="flex w-full flex-col gap-3">	<div class="flex flex-wrap gap-3">		<SegmentedButton			label="Columns"			size="sm"			mandatory			value={columns}			onchange={(next) => (columns = next as string)}			items={[				{ value: 'auto', label: 'Auto' },				{ value: '2', label: '2' },				{ value: '3', label: '3' },				{ value: '4', label: '4' }			]}		/>		<SegmentedButton			label="Balance"			size="sm"			mandatory			value={balance}			onchange={(next) => (balance = next as MasonryBalance)}			items={[				{ value: 'height', label: 'Shortest column' },				{ value: 'order', label: 'In turn' }			]}		/>	</div>​	<Masonry		items={NOTES}		columns={columns === 'auto' ? undefined : Number(columns)}		minColumnWidth={140}		gap={10}		{balance}	>		{#snippet item(note, ctx)}			<div				class="flex items-center justify-center rounded-shape-md {note.tint}"				style="height: {note.h}px"			>				<Text variant="label-lg" class="text-inherit">{note.id}</Text>				<Text variant="label-sm" class="ms-1 text-inherit opacity-60">col {ctx.column + 1}</Text>			</div>		{/snippet}	</Masonry></div>

Overridden

The count follows the container, not the viewport: the same wall is one column in a 260px rail and several beside it. classes.item reaches the wrapper around each pin, and class on the root — padding included — is respected by the packing.

the rest of the row
Photo 1
Photo 2
Photo 3
Photo 4
Photo 5
Photo 6
Photo 7
Photo 8
Photo 9
Photo 10
<script lang="ts" module>	export const meta = { fill: true };​	type Pin = { id: number; w: number; h: number; alt: string };</script>​<script lang="ts">	import { Masonry, Text } from 'omaris';​	const RATIOS = [		[600, 800],		[600, 600],		[600, 750],		[540, 960],		[800, 600],		[600, 900]	];	const PINS: Pin[] = [1057, 1059, 1060, 1062, 1063, 1064, 1065, 1067, 1069, 1070].map((id, i) => {		const [w, h] = RATIOS[i % RATIOS.length];		return { id, w, h, alt: `Photo ${i + 1}` };	});</script>​{#snippet pin(p: Pin)}	<img		src="https://picsum.photos/id/{p.id}/{p.w}/{p.h}"		alt={p.alt}		width={p.w}		height={p.h}		style="aspect-ratio: {p.w} / {p.h}"		loading="lazy"		class="block w-full bg-surface-container object-cover"	/>{/snippet}​<div class="flex w-full flex-wrap gap-4">	<aside class="flex w-65 max-w-full shrink-0 flex-col gap-2">		<Text variant="label-sm" tone="muted">260px rail</Text>		<Masonry			items={PINS}			item={pin}			key={(p) => p.id}			minColumnWidth={110}			gap={6}			classes={{ item: 'overflow-hidden rounded-shape-sm' }}			class="rounded-shape-lg bg-surface-container-low p-2"		/>	</aside>​	<div class="flex min-w-56 flex-1 flex-col gap-2">		<Text variant="label-sm" tone="muted">the rest of the row</Text>		<Masonry			items={PINS}			item={pin}			key={(p) => p.id}			minColumnWidth={110}			gap={6}			classes={{ item: 'overflow-hidden rounded-shape-sm' }}			class="rounded-shape-lg bg-surface-container-low p-2"		/>	</div></div>

When to use it

Use it for

  • A wall of pictures or cards of unequal height: a photo board, a moodboard, a wall of notes. Cards pack in reading order and the bottom edge comes out level.
  • Infinite scroll. onend fires as the bottom nears the viewport and re-arms when the next page lands.
  • A board with filters. A key per pin means switching boards slides the survivors into place instead of re-mounting them.
  • The same wall in a rail and in the main column. The column count follows the container and minColumnWidth, not the viewport.

Not for

  • Cards of the same height → a plain grid, grid-cols-[repeat(auto-fill,minmax(16rem,1fr))]. Nothing to level.
  • A row paged through, one in front → Carousel.
  • Rows of data → List or Table.
  • Columns of cards you drag between → Kanban.
  • Opening a picture → Image Viewer around the wall, with each pin an ImageViewerItem.

Do

  • Give every <img> width, height and an aspect-ratio, so the first pack is right before the picture loads and nothing jumps after.
  • Pass key from your data, not the index, so a filter or a new page keeps each pin's identity.
  • Leave balance="height" for pictures. Use order when the sequence must read strictly left to right: numbered steps, a timeline of notes.
  • Show a Skeleton row under the wall while onend loads, and set aria-busy on the container.

Don't

  • Use CSS columns. It lays out down-then-across and slices a card at the column break.
  • Fix columns for a wall that fills the page. Let minColumnWidth derive it; a fixed count is for a small known set, like a three-column pricing wall.
  • Set the gap or the count with classes per breakpoint. gap and minColumnWidth are props, and padding in class is already respected.
  • Animate a board of thousands. Set animate={false} when a re-pack moves more than a screenful.

API

Masonry

A masonry grid — the photo wall. Cards of unequal height, packed so the bottom edge comes out level, in reading order.

CSS columns is the cheap version of this and it has two problems: it lays items out top-to-bottom-then-across, so reading order runs down the first column, and a card can be sliced in half across a column break. This measures instead. Every card's height is watched by one ResizeObserver, and each one goes to whichever column is currently shortest — the greedy packing that actually levels the bottom — while the DOM stays one flat list in source order, so tabbing through it goes left to right and top to bottom like the eye does.

Nothing is ever re-created. Every card is absolutely positioned and moved with translate, so a card that lands in a different column after a resize slides there — it is not torn down and rebuilt. Focus, scroll, a playing video and a decoded image all survive the reflow, and the reflow itself is a transition rather than a jump.

The column count is derived from the container, not the viewport, so a grid in a sidebar collapses to one column while the same component in the main region shows four, with no breakpoints to keep in sync.

Before the first measurement — on the server, and in the instant before hydration — the cards sit in a plain CSS grid with the same column count, so a prerendered page is never empty.

import { Masonry } from 'omaris'
<Masonry items={photos} minColumnWidth={200} gap={16} onend={loadMore}>  {#snippet item(photo)}    <img src={photo.src} alt={photo.alt} width={photo.w} height={photo.h} class="w-full rounded-shape-xl" />  {/snippet}</Masonry>

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

item
Wrapper around one entry — what gets measured and moved. Until the entry has been measured it is drawn transparent at a guessed position, so a grid is never blank while it settles.
sentinel
The hairline at the bottom that onend watches.

Props

items required
T[]
item required
Snippet<[T, MasonryItemContext]>

How to draw one entry. It is given the full width of its column.

columns
number

Fix the column count. Left off, it is derived from the container's width and minColumnWidth.

minColumnWidth

Defaults to 240

number

Narrowest a column may get before one is dropped.

maxColumns

Defaults to 8

number

Ceiling for the derived count.

gap

Defaults to 16

number | string

Gap between columns and between cards.

balance

Defaults to 'height'

MasonryBalance

height packs each card into the shortest column — a level bottom edge. order deals them out in turn, which keeps a strict left-to-right rhythm at the cost of a ragged bottom.

animate

Defaults to true

boolean

Slide cards on a re-pack and fade new ones in. Reduced motion turns it off.

key
(item: T, index: number) => string | number

Stable key per entry. Defaults to the index.

onend
() => void

Called once when the bottom of the wall scrolls within endOffset of the viewport — the hook for infinite scroll. It re-arms when items change, so appending a page makes it fire again for the next one.

endOffset

Defaults to 400

number

How far above the bottom onend fires, in px.

class
string
classes
MasonryClasses

Per-part Tailwind overrides. class still covers the root.