不同尺寸的作品封面,点击可预览。
'use client'
import { Card, Image, ImageGroup, Masonry, Skeleton, Stack, Text } from '@hina-ui/react'
import { masonryGallery } from '../../masonry'
const items = masonryGallery('zh-CN')
export default function Demo() {
return (
<Stack className="w-full max-w-2xl" gap="sm">
<Text size="sm" tone="muted">
不同尺寸的作品封面,点击可预览。
</Text>
<ImageGroup>
<Masonry
items={items}
getKey={item => item.id}
minColumnWidth={160}
gap="lg"
label="作品封面"
pending={
<Stack aria-hidden="true" className="block columns-[160px] gap-[var(--hn-masonry-gap)]">
{items.map(item => (
<Card
key={item.id}
padded={false}
className="mb-[var(--hn-masonry-row-gap)] break-inside-avoid"
>
<Skeleton
className="w-full"
style={{ aspectRatio: item.cover.width / item.cover.height }}
/>
<Stack className="p-3" gap="sm">
<Skeleton className="h-4 w-2/3 rounded" />
<Skeleton className="h-3 w-1/2 rounded" />
</Stack>
</Card>
))}
</Stack>
}
>
{({ item }) => (
<Card padded={false}>
<Image
src={item.cover.src}
alt={item.title}
ratio={item.cover.width / item.cover.height}
previewSize={item.cover}
preview
className="w-full rounded-t-xl"
/>
<Stack className="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>
)}
</Masonry>
</ImageGroup>
</Stack>
)
}
用法
import { Masonry } from '@hina-ui/react'
传入数据、稳定的 getKey,再用函数形式的 children 渲染每个条目。默认按当前最短列排列,列数根据容器宽度计算。getKey 与 children 都是函数,渲染 Masonry 的组件需要声明 'use client'。
<Masonry items={photos} getKey={photo => photo.id} minColumnWidth={200} label="照片">
{({ item }) => <Image src={item.src} alt={item.title} ratio={item.width / item.height} />}
</Masonry>
Masonry 负责布局,不增加卡片外观、点击行为或内部滚动条。用 Card、Image 或自己的内容构成条目;放入 ScrollArea 可使用容器滚动。需要内容对齐、逐行比较时,使用 Grid、DataList 或 DataTable 更合适。
示例
首次布局占位
SSR 场景建议提供 pending,用骨架屏或其他占位内容遮住初始化排版。SSR 与客户端初始渲染显示同一份占位,真实条目仍挂载在相同宽度下完成测量,但不可见、不可交互,也不会进入 Tab 顺序;首次布局完成后直接展示已经排好的内容。
CSR 也使用同一套行为。pending 只是占位内容,何时显示由组件管理,不需要额外的状态属性。 首批请求期间传 loading,直到数据返回:没有可展示内容时显示 pending,已有布局时显示末尾的 loadingContent,不会把已有列表重新盖住。清空后再次请求视为新的首批加载。
骨架用 CSS 多列排成不同高度的瀑布流,SSR 首屏无需测量。点击按钮可重演首批请求。
'use client'
import { useEffect, useRef, useState } from 'react'
import { Button, Card, Masonry, Skeleton, Stack, Text } from '@hina-ui/react'
import { masonryNotes } from '../../masonry'
const notes = masonryNotes('zh-CN')
export default function Demo() {
const [count, setCount] = useState(6)
const [loading, setLoading] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const items = Array.from({ length: count }, (_, id) => ({ ...notes[id % notes.length]!, id }))
function reload() {
clearTimeout(timer.current)
setCount(0)
setLoading(true)
timer.current = setTimeout(() => {
setCount(6)
setLoading(false)
}, 800)
}
useEffect(() => () => clearTimeout(timer.current), [])
return (
<Stack className="w-full max-w-2xl">
<Button
className="self-start"
variant="outline"
size="sm"
disabled={loading}
onClick={reload}
>
重新加载,查看占位
</Button>
<Masonry
items={items}
getKey={item => item.id}
minColumnWidth={180}
loading={loading}
label="设计笔记的首次加载"
pending={
<Stack aria-hidden="true" className="block columns-[180px] gap-[var(--hn-masonry-gap)]">
{[2, 4, 3, 2, 5, 3].map((lines, index) => (
<Card key={index} className="mb-[var(--hn-masonry-row-gap)] break-inside-avoid">
<Stack gap="sm">
<Skeleton className="h-5 w-2/3 rounded" />
{Array.from({ length: lines }, (_, offset) => offset + 1).map(line => (
<Skeleton
key={line}
className={`h-4 rounded ${line === lines ? 'w-4/5' : 'w-full'}`}
/>
))}
</Stack>
</Card>
))}
</Stack>
}
>
{({ item }) => (
<Card>
<Stack gap="sm">
<Text size="sm" weight="medium">
{item.title}
</Text>
<Text size="sm" tone="muted">
{item.body}
</Text>
</Stack>
</Card>
)}
</Masonry>
<Text size="sm" tone="muted">
骨架用 CSS 多列排成不同高度的瀑布流,SSR 首屏无需测量。点击按钮可重演首批请求。
</Text>
</Stack>
)
}
示例的骨架采用 CSS 多列,卡片高度错落排列,间距跟随 Masonry 的 token。它不需要 JavaScript 测量,SSR 首屏即可呈现瀑布流占位;占位自身不会再经历从 Grid 到瀑布流的切换。
pending 等待的是首次布局,不会等待图片下载、字体加载或条目内容的后续异步请求。图片有尺寸时使用 ratio 预留空间;占位与最终列表的总高度仍可能不同,需要控制页面位移时,应为占位设计接近的高度或设置外层最小高度。
未提供 pending 时沿用可见 Grid 的 SSR 回退。提供后,需要客户端脚本完成初始化才会展示条目;无需针对 SSR / CSR 写两套内容。
自适应列数与方向
minColumnWidth 是每列期望的最小宽度,单位 px。空间不足时退到一列并缩到容器宽度。显式传入 columns 后固定列数,忽略最小列宽;小屏场景优先使用自动列数。
支持继承方向或显式设置 dir,列位置使用逻辑方向。
01
内容决定高度
封面、标题和摘要不必裁成相同的高度。
02
给图片预留比例
后端返回宽高时,直接传给 Image 的 ratio。图片下载之前就能预留空间,避免内容反复跳动。
03
按内容选布局
图集适合瀑布流,逐项比较更适合 Grid 或 DataTable。
04
稳定的条目标识
使用业务 id 作为 key。
05
跟随容器变化
侧栏开关、分栏调整和窗口缩放都会改变可用宽度,列数跟着容器走。
06
让内容自然展开
详情使用 Collapsible,布局跟随真实高度更新。
'use client'
import { useState } from 'react'
import {
Card,
FormField,
Masonry,
Select,
Stack,
Switch,
Text,
Flex,
type SelectValue,
} from '@hina-ui/react'
import { masonryNotes } from '../../masonry'
const items = masonryNotes('zh-CN')
const options = [
{ label: '自动列数', value: 'auto' },
{ label: '固定两列', value: '2' },
{ label: '固定三列', value: '3' },
]
export default function Demo() {
const [narrow, setNarrow] = useState(false)
const [rtl, setRtl] = useState(false)
const [columns, setColumns] = useState<SelectValue>('auto')
return (
<Stack className="w-full max-w-2xl">
<Flex wrap gap="md" align="center">
<Select
value={columns}
onValueChange={setColumns}
options={options}
aria-label="列数"
className="w-44"
/>
<FormField label="窄容器" orientation="horizontal">
<Switch checked={narrow} onCheckedChange={setNarrow} />
</FormField>
<FormField label="RTL" orientation="horizontal">
<Switch checked={rtl} onCheckedChange={setRtl} />
</FormField>
</Flex>
<Masonry
items={items}
getKey={item => item.id}
columns={columns === 'auto' ? undefined : Number(columns)}
minColumnWidth={180}
dir={rtl ? 'rtl' : 'ltr'}
className={narrow ? 'mx-auto max-w-xs' : ''}
label="设计笔记"
>
{({ 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>
)}
</Masonry>
</Stack>
)
}
内容展开
图片、文本换行或 Collapsible 引起的高度变化会自动参与布局。条目保留稳定的 DOM 节点,尺寸变化不会重建内部组件。
内容决定高度
封面、标题和摘要不必裁成相同的高度。
给图片预留比例
后端返回宽高时,直接传给 Image 的 ratio。图片下载之前就能预留空间,避免内容反复跳动。
按内容选布局
图集适合瀑布流,逐项比较更适合 Grid 或 DataTable。
稳定的条目标识
使用业务 id 作为 key。
跟随容器变化
侧栏开关、分栏调整和窗口缩放都会改变可用宽度,列数跟着容器走。
让内容自然展开
详情使用 Collapsible,布局跟随真实高度更新。
'use client'
import {
Card,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
Masonry,
Stack,
Text,
} from '@hina-ui/react'
import { masonryNotes } from '../../masonry'
const items = masonryNotes('zh-CN')
export default function Demo() {
return (
<Masonry
items={items}
getKey={item => item.id}
minColumnWidth={180}
label="可展开的设计笔记"
className="max-w-2xl"
>
{({ item }) => (
<Card>
<Stack gap="sm">
<Text weight="medium" size="sm">
{item.title}
</Text>
<Text size="sm" tone="muted">
{item.body}
</Text>
<Collapsible>
<CollapsibleTrigger className="text-sm">详细说明</CollapsibleTrigger>
<CollapsibleContent>
<Text size="sm" tone="muted" className="pt-3">
{item.detail}
</Text>
</CollapsibleContent>
</Collapsible>
</Stack>
</Card>
)}
</Masonry>
)
}
排列顺序
默认将下一项放到最短列,适合尽量紧凑地展示内容。sequential 按第 1、2、3 列轮流放置,保留每一轮的横向顺序,但各列总高度可能更不均匀。
两种方式都保留数据的 DOM 顺序和 Tab 顺序,不会生成多个列容器重新组织节点。瀑布流的视觉位置仍有高低差;有严格阅读先后关系的内容应优先用普通列表。
1
2
3
4
5
6
7
8
9
关闭时优先填入最短的一列;开启后依次放入第 1、2、3 列。
'use client'
import { useState } from 'react'
import { Card, FormField, Masonry, Stack, Switch, Text } from '@hina-ui/react'
const heights = [120, 200, 88, 148, 100, 168, 88, 112, 144].map((height, id) => ({ id, height }))
export default function Demo() {
const [sequential, setSequential] = useState(false)
return (
<Stack className="w-full max-w-lg">
<FormField label="按列轮流排列" orientation="horizontal">
<Switch checked={sequential} onCheckedChange={setSequential} />
</FormField>
<Masonry
items={heights}
getKey={item => item.id}
columns={3}
sequential={sequential}
label="排列顺序对比"
>
{({ item, index }) => (
<Card
className="bg-accent-soft flex items-center justify-center"
style={{ height: item.height + 'px' }}
>
<Text tone="accent" weight="medium">
{index + 1}
</Text>
</Card>
)}
</Masonry>
<Text size="sm" tone="muted">
关闭时优先填入最短的一列;开启后依次放入第 1、2、3 列。
</Text>
</Stack>
)
}
追加加载与空状态
loading 保留现有内容,在列表末尾显示加载提示;没有数据且未加载时显示 empty。请求、分页和何时加载更多由调用方决定。追加时使用稳定 key,已有条目不会重建或主动滚动。
6 条笔记
内容决定高度
封面、标题和摘要不必裁成相同的高度。
给图片预留比例
后端返回宽高时,直接传给 Image 的 ratio。图片下载之前就能预留空间,避免内容反复跳动。
按内容选布局
图集适合瀑布流,逐项比较更适合 Grid 或 DataTable。
稳定的条目标识
使用业务 id 作为 key。
跟随容器变化
侧栏开关、分栏调整和窗口缩放都会改变可用宽度,列数跟着容器走。
让内容自然展开
详情使用 Collapsible,布局跟随真实高度更新。
'use client'
import { useEffect, useRef, useState } from 'react'
import { Button, Card, Masonry, Stack, Text, Flex } from '@hina-ui/react'
import { masonryNotes } from '../../masonry'
const notes = masonryNotes('zh-CN')
export default function Demo() {
const [count, setCount] = useState(6)
const [loading, setLoading] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const items = Array.from({ length: count }, (_, id) => ({ ...notes[id % notes.length]!, id }))
function load() {
if (loading) return
setLoading(true)
timer.current = setTimeout(() => {
setCount(value => value + 6)
setLoading(false)
}, 650)
}
useEffect(() => () => clearTimeout(timer.current), [])
return (
<Stack className="w-full max-w-2xl">
<Flex wrap align="center" gap="sm">
<Button size="sm" loading={loading} disabled={count >= 24} onClick={load}>
追加 6 条
</Button>
<Button
size="sm"
variant="outline"
disabled={loading || count === 0}
onClick={() => setCount(0)}
>
清空
</Button>
<Text size="sm" tone="muted">
{count} 条笔记
</Text>
</Flex>
<Masonry
items={items}
getKey={item => item.id}
minColumnWidth={180}
loading={loading}
label="笔记列表"
empty={
<Stack align="center" gap="sm">
<Text weight="medium">还没有笔记</Text>
<Text size="sm" tone="muted">
点击“追加 6 条”重新加载。
</Text>
</Stack>
}
>
{({ item }) => (
<Card>
<Stack gap="sm">
<Text size="sm" weight="medium">
{item.title}
</Text>
<Text size="sm" tone="muted">
{item.body}
</Text>
</Stack>
</Card>
)}
</Masonry>
</Stack>
)
}
首屏与性能
- 提供
pending时,SSR 和首次布局阶段显示占位,真实条目保持可测量;完成定位后切换为可见内容。容器暂时隐藏或宽度为零时继续等待。 - 未提供
pending时,SSR 输出完整的响应式 Grid,挂载后转为瀑布流,下方条目的位置可能变化。没有 JavaScript 时保留这份 Grid;使用pending则保留占位。不支持 ResizeObserver 的客户端会结束占位并回退到 Grid。 - 使用 ResizeObserver 缓存每项高度。同一帧的变化合并处理,只写入变化的几何属性;闲置和滚动时不做逐帧轮询。
- 渲染全部数据,不提供虚拟化。特别长的集合先分页或分批加载;需要虚拟化的普通列表使用 VirtualList。
- 不为重排加入位移或缩放动画。布局变化不会改变条目的焦点顺序;重新排序仍存在的焦点节点时保留焦点,不额外滚动。
itemClass用于条目外观;间隔使用gap,不要给条目添加外边距或覆盖其定位和宽度。外框的 padding、border 和背景放在根节点className上。
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' | 继承 | 排列方向 |
className | string | — | 根节点样式;原生属性及 style 也落到根节点 |
itemClass | string | ((item: T, index: number) => string | undefined) | — | 条目包装节点样式 |
内容属性
| 属性 | 参数 | 说明 |
|---|---|---|
children | { item: T, index: number } | 条目内容 |
empty | — | 空状态 |
pending | — | 首次布局及首批请求的占位;外层提供加载状态语义 |
loadingContent | — | 替换末尾加载提示,外层保留 role="status" |
回调
| 回调 | 参数 | 说明 |
|---|---|---|
onLayout | { columns: number, height: number } | 首次测量完成,或列数、列表高度变化;height 不含加载提示 |
Ref
| 名称 | 类型 | 说明 |
|---|---|---|
element | HTMLElement | undefined | 根节点 |
measure | () => void | 请求下一帧重新测量;普通内容、尺寸变化无需调用 |
间距 token 的变化通过独立的尺寸探针自动同步,无需观察整棵应用 DOM 的样式变化。