Covers in their original proportions. Select an image to preview it.
<script setup lang="ts">
import { Card, Image, ImageGroup, Masonry, Skeleton, Stack, Text } from '@hina-ui/vue'
import { masonryGallery } from '../../masonry'
const items = masonryGallery('en')
</script>
<template>
<Stack class="w-full max-w-2xl" gap="sm">
<Text size="sm" tone="muted">
Covers in their original proportions. Select an image to preview it.
</Text>
<ImageGroup>
<Masonry
:items="items"
:get-key="item => item.id"
:min-column-width="160"
gap="lg"
label="Cover gallery"
>
<template #pending>
<Stack aria-hidden="true" class="block columns-[160px] gap-[var(--hn-masonry-gap)]">
<Card
v-for="item in items"
:key="item.id"
:padded="false"
class="mb-[var(--hn-masonry-row-gap)] break-inside-avoid"
>
<Skeleton
class="w-full"
:style="{ aspectRatio: item.cover.width / item.cover.height }"
/>
<Stack class="p-3" gap="sm">
<Skeleton class="h-4 w-2/3 rounded" />
<Skeleton class="h-3 w-1/2 rounded" />
</Stack>
</Card>
</Stack>
</template>
<template #default="{ item }">
<Card :padded="false">
<Image
:src="item.cover.src"
:alt="item.title"
:ratio="item.cover.width / item.cover.height"
:preview-size="item.cover"
preview
class="w-full rounded-t-xl"
/>
<Stack class="p-3" gap="xs">
<Text size="sm" weight="medium">{{ item.title }}</Text>
<Text as="time" :datetime="item.released" size="xs" tone="muted">
{{ item.released }}
</Text>
</Stack>
</Card>
</template>
</Masonry>
</ImageGroup>
</Stack>
</template>
Usage
import { Masonry } from '@hina-ui/vue'
Provide data, a stable get-key, and an item slot. Items fill the shortest column by default. The number of columns follows the container width.
<Masonry :items="photos" :get-key="photo => photo.id" :min-column-width="200" label="Photos">
<template #default="{ item }">
<Image :src="item.src" :alt="item.title" :ratio="item.width / item.height" />
</template>
</Masonry>
Masonry owns layout, without adding card styling, click actions or an internal scroll container. Compose entries with Card, Image or your own content. Place the component inside ScrollArea for local scrolling. Prefer Grid, DataList or DataTable for aligned rows and direct comparisons.
Examples
Initial layout placeholder
For SSR, provide #pending to cover initial positioning with a skeleton or another placeholder. SSR and the first client render show the same placeholder. Real items remain mounted and measurable at their actual width, but are invisible, inert and excluded from the Tab sequence. They become visible after their first layout is positioned.
CSR uses the same behavior. This is a component-managed slot, not a separate pending prop. Pass loading while fetching the first batch. Before content is available, #pending is shown; once a layout exists, subsequent requests use the trailing #loading slot and keep the cards visible. Clearing and fetching again starts a new initial load.
CSS columns arrange varied-height skeletons without measuring on the first SSR paint. Reload simulates the first request.
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { Button, Card, Masonry, Skeleton, Stack, Text } from '@hina-ui/vue'
import { masonryNotes } from '../../masonry'
const count = ref(6)
const loading = ref(false)
const notes = masonryNotes('en')
const items = computed(() =>
Array.from({ length: count.value }, (_, id) => ({ ...notes[id % notes.length]!, id })),
)
let timer: ReturnType<typeof setTimeout> | undefined
function reload() {
clearTimeout(timer)
count.value = 0
loading.value = true
timer = setTimeout(() => {
count.value = 6
loading.value = false
}, 800)
}
onBeforeUnmount(() => clearTimeout(timer))
</script>
<template>
<Stack class="w-full max-w-2xl">
<Button class="self-start" variant="outline" size="sm" :disabled="loading" @click="reload">
Reload to preview pending
</Button>
<Masonry
:items="items"
:get-key="item => item.id"
:min-column-width="180"
:loading="loading"
label="Initial load of design notes"
>
<template #pending>
<Stack aria-hidden="true" class="block columns-[180px] gap-[var(--hn-masonry-gap)]">
<Card
v-for="(lines, index) in [2, 4, 3, 2, 5, 3]"
:key="index"
class="mb-[var(--hn-masonry-row-gap)] break-inside-avoid"
>
<Stack gap="sm">
<Skeleton class="h-5 w-2/3 rounded" />
<Skeleton
v-for="line in lines"
:key="line"
class="h-4 rounded"
:class="line === lines ? 'w-4/5' : 'w-full'"
/>
</Stack>
</Card>
</Stack>
</template>
<template #default="{ item }">
<Card>
<Stack gap="sm">
<Text size="sm" weight="medium">{{ item.title }}</Text>
<Text size="sm" tone="muted">{{ item.body }}</Text>
</Stack>
</Card>
</template>
</Masonry>
<Text size="sm" tone="muted">
CSS columns arrange varied-height skeletons without measuring on the first SSR paint. Reload
simulates the first request.
</Text>
</Stack>
</template>
The skeleton uses CSS columns with varied card heights and Masonry spacing tokens. It needs no JavaScript measurement, so the SSR first paint already shows a staggered placeholder without another Grid-to-masonry transition.
#pending waits for the first layout, not image downloads, font loading or arbitrary asynchronous slot work. Reserve known image dimensions with ratio. The placeholder and final list can still have different total heights; use a similarly sized placeholder or an outer minimum height when page movement matters.
Without #pending, SSR keeps the visible Grid fallback. With it, client initialization is required to reveal the items. There is no need to author separate SSR and CSR templates.
Columns and direction
min-column-width sets the preferred minimum column width in px. A narrower container uses one column that fits its width. Explicit columns fixes the column count and ignores the minimum width; automatic columns are usually better on small screens.
Direction is inherited or set with dir. Column positions use logical coordinates.
01
Let content set the height
Covers, titles and summaries do not need matching heights.
02
Reserve image space
When the API provides dimensions, pass their ratio to Image. Space is reserved before the image downloads.
03
Choose a layout for the task
Masonry suits a gallery; Grid or DataTable is better for direct comparisons.
04
Keep stable item keys
Use the business id as the key.
05
Follow the container
Sidebars, split panes and window resizing all change the space available to the gallery.
06
Expand content naturally
Details use Collapsible. The layout follows the actual height.
<script setup lang="ts">
import { ref } from 'vue'
import { Card, FormField, Masonry, Select, Stack, Switch, Text, Flex } from '@hina-ui/vue'
import { masonryNotes } from '../../masonry'
const narrow = ref(false)
const rtl = ref(false)
const columns = ref('auto')
const items = masonryNotes('en')
const options = [
{ label: 'Automatic columns', value: 'auto' },
{ label: 'Two columns', value: '2' },
{ label: 'Three columns', value: '3' },
]
</script>
<template>
<Stack class="w-full max-w-2xl">
<Flex wrap gap="md" align="center">
<Select v-model="columns" :options="options" aria-label="Columns" class="w-44" />
<FormField label="Narrow container" orientation="horizontal">
<Switch v-model="narrow" />
</FormField>
<FormField label="RTL" orientation="horizontal"><Switch v-model="rtl" /></FormField>
</Flex>
<Masonry
:items="items"
:get-key="item => item.id"
:columns="columns === 'auto' ? undefined : Number(columns)"
:min-column-width="180"
:dir="rtl ? 'rtl' : 'ltr'"
:class="narrow ? 'mx-auto max-w-xs' : ''"
label="Design notes"
>
<template #default="{ item, index }">
<Card>
<Stack gap="sm">
<Text tone="accent" size="sm" weight="medium">
{{ String(index + 1).padStart(2, '0') }}
</Text>
<Text size="sm" weight="medium">{{ item.title }}</Text>
<Text size="sm" tone="muted">{{ item.body }}</Text>
</Stack>
</Card>
</template>
</Masonry>
</Stack>
</template>
Expanding content
Images, wrapped text and Collapsible height changes update the layout automatically. Stable DOM nodes preserve each item’s internal component state.
Let content set the height
Covers, titles and summaries do not need matching heights.
Reserve image space
When the API provides dimensions, pass their ratio to Image. Space is reserved before the image downloads.
Choose a layout for the task
Masonry suits a gallery; Grid or DataTable is better for direct comparisons.
Keep stable item keys
Use the business id as the key.
Follow the container
Sidebars, split panes and window resizing all change the space available to the gallery.
Expand content naturally
Details use Collapsible. The layout follows the actual height.
<script setup lang="ts">
import {
Card,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
Masonry,
Stack,
Text,
} from '@hina-ui/vue'
import { masonryNotes } from '../../masonry'
const items = masonryNotes('en')
</script>
<template>
<Masonry
:items="items"
:get-key="item => item.id"
:min-column-width="180"
label="Expandable design notes"
class="max-w-2xl"
>
<template #default="{ item }">
<Card>
<Stack gap="sm">
<Text weight="medium" size="sm">{{ item.title }}</Text>
<Text size="sm" tone="muted">{{ item.body }}</Text>
<Collapsible>
<CollapsibleTrigger class="text-sm">Details</CollapsibleTrigger>
<CollapsibleContent>
<Text size="sm" tone="muted" class="pt-3">{{ item.detail }}</Text>
</CollapsibleContent>
</Collapsible>
</Stack>
</Card>
</template>
</Masonry>
</template>
Placement order
By default, the next entry fills the shortest column for a compact layout. sequential cycles through columns 1, 2, 3 and so on, preserving horizontal order within each cycle at the cost of less balanced column heights.
Both modes retain data order in the DOM and Tab sequence. Items are not regrouped into separate column containers. Visual positions still have different heights; prefer a regular list when a strict reading sequence is important.
1
2
3
4
5
6
7
8
9
By default, each card fills the shortest column. Sequential placement cycles through columns 1, 2 and 3.
<script setup lang="ts">
import { ref } from 'vue'
import { Card, FormField, Masonry, Stack, Switch, Text } from '@hina-ui/vue'
const sequential = ref(false)
const heights = [120, 200, 88, 148, 100, 168, 88, 112, 144].map((height, id) => ({ id, height }))
</script>
<template>
<Stack class="w-full max-w-lg">
<FormField label="Place in sequential columns" orientation="horizontal">
<Switch v-model="sequential" />
</FormField>
<Masonry
:items="heights"
:get-key="item => item.id"
:columns="3"
:sequential="sequential"
label="Placement comparison"
>
<template #default="{ item, index }">
<Card
class="bg-accent-soft flex items-center justify-center"
:style="{ height: item.height + 'px' }"
>
<Text tone="accent" weight="medium">{{ index + 1 }}</Text>
</Card>
</template>
</Masonry>
<Text size="sm" tone="muted">
By default, each card fills the shortest column. Sequential placement cycles through columns
1, 2 and 3.
</Text>
</Stack>
</template>
Loading and empty states
loading preserves existing entries and adds a status at the end. #empty appears when there are no entries and loading is false. The caller owns requests, pagination and when to load more. Stable keys preserve existing entries on append without automatic scrolling.
6 notes
Let content set the height
Covers, titles and summaries do not need matching heights.
Reserve image space
When the API provides dimensions, pass their ratio to Image. Space is reserved before the image downloads.
Choose a layout for the task
Masonry suits a gallery; Grid or DataTable is better for direct comparisons.
Keep stable item keys
Use the business id as the key.
Follow the container
Sidebars, split panes and window resizing all change the space available to the gallery.
Expand content naturally
Details use Collapsible. The layout follows the actual height.
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { Button, Card, Masonry, Stack, Text, Flex } from '@hina-ui/vue'
import { masonryNotes } from '../../masonry'
const notes = masonryNotes('en')
const count = ref(6)
const loading = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
const items = computed(() =>
Array.from({ length: count.value }, (_, id) => ({ ...notes[id % notes.length]!, id })),
)
function load() {
if (loading.value) return
loading.value = true
timer = setTimeout(() => {
count.value += 6
loading.value = false
}, 650)
}
onBeforeUnmount(() => clearTimeout(timer))
</script>
<template>
<Stack class="w-full max-w-2xl">
<Flex wrap align="center" gap="sm">
<Button size="sm" :loading="loading" :disabled="count >= 24" @click="load">
Add 6 notes
</Button>
<Button size="sm" variant="outline" :disabled="loading || count === 0" @click="count = 0">
Clear
</Button>
<Text size="sm" tone="muted">{{ count }} notes</Text>
</Flex>
<Masonry
:items="items"
:get-key="item => item.id"
:min-column-width="180"
:loading="loading"
label="Notes"
>
<template #default="{ item }">
<Card>
<Stack gap="sm">
<Text size="sm" weight="medium">{{ item.title }}</Text>
<Text size="sm" tone="muted">{{ item.body }}</Text>
</Stack>
</Card>
</template>
<template #empty>
<Stack align="center" gap="sm">
<Text weight="medium">No notes yet</Text>
<Text size="sm" tone="muted">Use “Add 6 notes” to load another batch.</Text>
</Stack>
</template>
</Masonry>
</Stack>
</template>
Rendering and performance
- With
#pending, SSR and initial layout show a placeholder while real items remain measurable. Items are revealed after positioning. A hidden or zero-width container continues waiting until measurement is possible. - Without
#pending, SSR renders a complete responsive Grid and switches to masonry after mount, which can move later items. Without JavaScript, that Grid remains; with#pending, the placeholder remains instead. Clients without ResizeObserver dismiss the placeholder and fall back to Grid. - ResizeObserver caches item heights. Changes in the same frame are batched and only changed geometry is written. There is no continuous polling while idle or scrolling.
- All entries are rendered; this component is not virtualized. Paginate or load large collections in batches. Use VirtualList for virtualized regular lists.
- Reflow does not add movement or scaling animations. Tab order stays stable; surviving focused elements keep focus during reordering without extra scrolling.
- Use
itemClassfor appearance andgapfor spacing. Do not add item margins or override item position and width. Apply outer padding, borders and backgrounds to the rootclass.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | readonly T[] | Required | Item data |
getKey | (item: T, index: number) => string | number | Required | Unique, stable business key; avoid indices for mutable or reordered collections |
columns | number | — | Fixed column count; otherwise based on container width |
minColumnWidth | number | 240 | Minimum width for automatic columns, in px |
gap | 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Hina spacing; md uses density tokens |
sequential | boolean | false | Cycle through columns instead of choosing the shortest |
loading | boolean | false | Busy list state and loading status at the end |
emptyText | string | Localized “No items” | Empty state text |
label | string | — | Accessible list name |
dir | 'ltr' | 'rtl' | Inherited | Layout direction |
class | string | — | Root styling; native attributes and style also reach the root |
itemClass | string | ((item: T, index: number) => string | undefined) | — | Item wrapper styling |
Slots
| Slot | Props | Description |
|---|---|---|
default | { item: T, index: number } | Item content |
empty | — | Empty state |
pending | — | Initial layout and first-request placeholder with loading status semantics |
loading | — | Replace the trailing status; the wrapper keeps role="status" |
Events
| Event | Payload | Description |
|---|---|---|
layout | { columns: number, height: number } | First measurement or a change in column count or list height; height excludes the loading status |
Expose
| Name | Type | Description |
|---|---|---|
element | HTMLElement | undefined | Root element |
measure | () => void | Remeasure on the next frame; ordinary size or content changes are automatic |
Inherited spacing tokens are tracked with a dedicated size probe, without observing style mutations throughout the application DOM.