Masonry

Arrange covers, images and cards at their natural heights.

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

Usage

import { Masonry } from '@hina-ui/vue'
ts

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

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

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

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

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

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

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 itemClass for appearance and gap for spacing. Do not add item margins or override item position and width. Apply outer padding, borders and backgrounds to the root class.

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.