Skip to content
Layout

Masonry

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

Walkthrough

1. 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>

2. 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>

3. 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>

4. 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>