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' 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.
<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.
<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.
<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.
<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.
onendfires as the bottom nears the viewport and re-arms when the next page lands. - A board with filters. A
keyper 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,heightand anaspect-ratio, so the first pack is right before the picture loads and nothing jumps after. - Pass
keyfrom your data, not the index, so a filter or a new page keeps each pin's identity. - Leave
balance="height"for pictures. Useorderwhen the sequence must read strictly left to right: numbered steps, a timeline of notes. - Show a Skeleton row under the wall while
onendloads, and setaria-busyon the container.
Don't
- Use CSS
columns. It lays out down-then-across and slices a card at the column break. - Fix
columnsfor a wall that fills the page. LetminColumnWidthderive 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.
gapandminColumnWidthare props, and padding inclassis 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
onendwatches.
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.