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