不同尺寸的作品封面,点击可预览。
<script setup lang="ts">
import { Card, Image, ImageGroup, Masonry, Skeleton, Stack, Text } from '@hina-ui/vue'
import { masonryGallery } from '../../masonry'
const items = masonryGallery('zh-CN')
</script>
<template>
<Stack class="w-full max-w-2xl" gap="sm">
<Text size="sm" tone="muted">不同尺寸的作品封面,点击可预览。</Text>
<ImageGroup>
<Masonry
:items="items"
:get-key="item => item.id"
:min-column-width="160"
gap="lg"
label="作品封面"
>
<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>
用法
import { Masonry } from '@hina-ui/vue'
传入数据、稳定的 get-key 和条目插槽。默认按当前最短列排列,列数根据容器宽度计算。
<Masonry :items="photos" :get-key="photo => photo.id" :min-column-width="200" label="照片">
<template #default="{ item }">
<Image :src="item.src" :alt="item.title" :ratio="item.width / item.height" />
</template>
</Masonry>
Masonry 负责布局,不增加卡片外观、点击行为或内部滚动条。用 Card、Image 或自己的内容构成条目;放入 ScrollArea 可使用容器滚动。需要内容对齐、逐行比较时,使用 Grid、DataList 或 DataTable 更合适。
示例
首次布局占位
SSR 场景建议提供 #pending,用骨架屏或其他占位内容遮住初始化排版。SSR 与客户端初始渲染显示同一份占位,真实条目仍挂载在相同宽度下完成测量,但不可见、不可交互,也不会进入 Tab 顺序;首次布局完成后直接展示已经排好的内容。
CSR 也使用同一套行为。这是组件管理的插槽,没有额外的 pending prop。 首批请求期间传 loading,直到数据返回:没有可展示内容时使用 #pending,已有布局时使用末尾的 #loading,不会把已有列表重新盖住。清空后再次请求视为新的首批加载。
骨架用 CSS 多列排成不同高度的瀑布流,SSR 首屏无需测量。点击按钮可重演首批请求。
<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('zh-CN')
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">
重新加载,查看占位
</Button>
<Masonry
:items="items"
:get-key="item => item.id"
:min-column-width="180"
:loading="loading"
label="设计笔记的首次加载"
>
<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 多列排成不同高度的瀑布流,SSR 首屏无需测量。点击按钮可重演首批请求。
</Text>
</Stack>
</template>
示例的骨架采用 CSS 多列,卡片高度错落排列,间距跟随 Masonry 的 token。它不需要 JavaScript 测量,SSR 首屏即可呈现瀑布流占位;占位自身不会再经历从 Grid 到瀑布流的切换。
#pending 等待的是首次布局,不会等待图片下载、字体加载或任意插槽的后续异步请求。图片有尺寸时使用 ratio 预留空间;占位与最终列表的总高度仍可能不同,需要控制页面位移时,应为占位设计接近的高度或设置外层最小高度。
未提供 #pending 时沿用可见 Grid 的 SSR 回退。提供后,需要客户端脚本完成初始化才会展示条目;无需针对 SSR / CSR 写两套模板。
自适应列数与方向
min-column-width 是每列期望的最小宽度,单位 px。空间不足时退到一列并缩到容器宽度。显式传入 columns 后固定列数,忽略最小列宽;小屏场景优先使用自动列数。
支持继承方向或显式设置 dir,列位置使用逻辑方向。
01
内容决定高度
封面、标题和摘要不必裁成相同的高度。
02
给图片预留比例
后端返回宽高时,直接传给 Image 的 ratio。图片下载之前就能预留空间,避免内容反复跳动。
03
按内容选布局
图集适合瀑布流,逐项比较更适合 Grid 或 DataTable。
04
稳定的条目标识
使用业务 id 作为 key。
05
跟随容器变化
侧栏开关、分栏调整和窗口缩放都会改变可用宽度,列数跟着容器走。
06
让内容自然展开
详情使用 Collapsible,布局跟随真实高度更新。
<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('zh-CN')
const options = [
{ label: '自动列数', value: 'auto' },
{ label: '固定两列', value: '2' },
{ label: '固定三列', 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="列数" class="w-44" />
<FormField label="窄容器" 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="设计笔记"
>
<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>
内容展开
图片、文本换行或 Collapsible 引起的高度变化会自动参与布局。条目保留稳定的 DOM 节点,尺寸变化不会重建内部组件。
内容决定高度
封面、标题和摘要不必裁成相同的高度。
给图片预留比例
后端返回宽高时,直接传给 Image 的 ratio。图片下载之前就能预留空间,避免内容反复跳动。
按内容选布局
图集适合瀑布流,逐项比较更适合 Grid 或 DataTable。
稳定的条目标识
使用业务 id 作为 key。
跟随容器变化
侧栏开关、分栏调整和窗口缩放都会改变可用宽度,列数跟着容器走。
让内容自然展开
详情使用 Collapsible,布局跟随真实高度更新。
<script setup lang="ts">
import {
Card,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
Masonry,
Stack,
Text,
} from '@hina-ui/vue'
import { masonryNotes } from '../../masonry'
const items = masonryNotes('zh-CN')
</script>
<template>
<Masonry
:items="items"
:get-key="item => item.id"
:min-column-width="180"
label="可展开的设计笔记"
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">详细说明</CollapsibleTrigger>
<CollapsibleContent>
<Text size="sm" tone="muted" class="pt-3">{{ item.detail }}</Text>
</CollapsibleContent>
</Collapsible>
</Stack>
</Card>
</template>
</Masonry>
</template>
排列顺序
默认将下一项放到最短列,适合尽量紧凑地展示内容。sequential 按第 1、2、3 列轮流放置,保留每一轮的横向顺序,但各列总高度可能更不均匀。
两种方式都保留数据的 DOM 顺序和 Tab 顺序,不会生成多个列容器重新组织节点。瀑布流的视觉位置仍有高低差;有严格阅读先后关系的内容应优先用普通列表。
1
2
3
4
5
6
7
8
9
关闭时优先填入最短的一列;开启后依次放入第 1、2、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="按列轮流排列" orientation="horizontal">
<Switch v-model="sequential" />
</FormField>
<Masonry
:items="heights"
:get-key="item => item.id"
:columns="3"
:sequential="sequential"
label="排列顺序对比"
>
<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">关闭时优先填入最短的一列;开启后依次放入第 1、2、3 列。</Text>
</Stack>
</template>
追加加载与空状态
loading 保留现有内容,在列表末尾显示加载提示;没有数据且未加载时显示 #empty。请求、分页和何时加载更多由调用方决定。追加时使用稳定 key,已有条目不会重建或主动滚动。
6 条笔记
内容决定高度
封面、标题和摘要不必裁成相同的高度。
给图片预留比例
后端返回宽高时,直接传给 Image 的 ratio。图片下载之前就能预留空间,避免内容反复跳动。
按内容选布局
图集适合瀑布流,逐项比较更适合 Grid 或 DataTable。
稳定的条目标识
使用业务 id 作为 key。
跟随容器变化
侧栏开关、分栏调整和窗口缩放都会改变可用宽度,列数跟着容器走。
让内容自然展开
详情使用 Collapsible,布局跟随真实高度更新。
<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('zh-CN')
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">追加 6 条</Button>
<Button size="sm" variant="outline" :disabled="loading || count === 0" @click="count = 0">
清空
</Button>
<Text size="sm" tone="muted">{{ count }} 条笔记</Text>
</Flex>
<Masonry
:items="items"
:get-key="item => item.id"
:min-column-width="180"
:loading="loading"
label="笔记列表"
>
<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">还没有笔记</Text>
<Text size="sm" tone="muted">点击“追加 6 条”重新加载。</Text>
</Stack>
</template>
</Masonry>
</Stack>
</template>
首屏与性能
- 提供
#pending时,SSR 和首次布局阶段显示占位,真实条目保持可测量;完成定位后切换为可见内容。容器暂时隐藏或宽度为零时继续等待。 - 未提供
#pending时,SSR 输出完整的响应式 Grid,挂载后转为瀑布流,下方条目的位置可能变化。没有 JavaScript 时保留这份 Grid;使用#pending则保留占位。不支持 ResizeObserver 的客户端会结束占位并回退到 Grid。 - 使用 ResizeObserver 缓存每项高度。同一帧的变化合并处理,只写入变化的几何属性;闲置和滚动时不做逐帧轮询。
- 渲染全部数据,不提供虚拟化。特别长的集合先分页或分批加载;需要虚拟化的普通列表使用 VirtualList。
- 不为重排加入位移或缩放动画。布局变化不会改变条目的焦点顺序;重新排序仍存在的焦点节点时保留焦点,不额外滚动。
itemClass用于条目外观;间隔使用gap,不要给条目添加外边距或覆盖其定位和宽度。外框的 padding、border 和背景放在根节点class上。
API
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
items | readonly T[] | 必填 | 条目数据 |
getKey | (item: T, index: number) => string | number | 必填 | 唯一、稳定的业务标识;可增删或排序时不要使用下标 |
columns | number | — | 固定列数;不传则根据容器计算 |
minColumnWidth | number | 240 | 自动列数的最小列宽,单位 px |
gap | 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Hina 间距;md 跟随密度 token |
sequential | boolean | false | 按列轮流排列,代替最短列优先 |
loading | boolean | false | 列表繁忙状态及末尾加载提示 |
emptyText | string | 当前语言的“暂无内容” | 空状态文字 |
label | string | — | 列表的无障碍名称 |
dir | 'ltr' | 'rtl' | 继承 | 排列方向 |
class | string | — | 根节点样式;原生属性及 style 也落到根节点 |
itemClass | string | ((item: T, index: number) => string | undefined) | — | 条目包装节点样式 |
Slots
| 插槽 | 参数 | 说明 |
|---|---|---|
default | { item: T, index: number } | 条目内容 |
empty | — | 空状态 |
pending | — | 首次布局及首批请求的占位;外层提供加载状态语义 |
loading | — | 替换末尾加载提示,外层保留 role="status" |
Events
| 事件 | 参数 | 说明 |
|---|---|---|
layout | { columns: number, height: number } | 首次测量完成,或列数、列表高度变化;height 不含加载提示 |
Expose
| 名称 | 类型 | 说明 |
|---|---|---|
element | HTMLElement | undefined | 根节点 |
measure | () => void | 请求下一帧重新测量;普通内容、尺寸变化无需调用 |
间距 token 的变化通过独立的尺寸探针自动同步,无需观察整棵应用 DOM 的样式变化。