Masonry 瀑布流

保留内容自然高度的瀑布流,适合封面、图片和卡片集合。

不同尺寸的作品封面,点击可预览。

'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>
  )
}
tsx

用法

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

传入数据、稳定的 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>
tsx

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>
  )
}
tsx

示例的骨架采用 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>
  )
}
tsx

内容展开

图片、文本换行或 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>
  )
}
tsx

排列顺序

默认将下一项放到最短列,适合尽量紧凑地展示内容。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>
  )
}
tsx

追加加载与空状态

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>
  )
}
tsx

首屏与性能

  • 提供 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 的样式变化。