- 1条目 00001标签
- 2条目 00002
- 3条目 00003
- 4条目 00004标签
- 5条目 00005
- 6条目 00006
- 7条目 00007标签
- 8条目 00008
- 9条目 00009
- 10条目 00010标签
'use client'
import { VirtualList, Tag, Inline, Text } from '@hina-ui/react'
const items = Array.from({ length: 10000 }, (_, id) => ({
id,
label: `条目 ${String(id + 1).padStart(5, '0')}`,
}))
export default function Demo() {
return (
<VirtualList
items={items}
getKey={item => item.id}
height={320}
estimateSize={64}
label="条目列表"
className="border-line bg-surface rounded-lg border"
>
{({ item, index }) => (
<Inline wrap={false} className="border-line h-16 border-b px-4">
<Text
as="span"
size="xs"
tone="muted"
className="bg-inset flex size-9 shrink-0 items-center justify-center rounded-md tabular-nums"
>
{index + 1}
</Text>
<Text as="span" size="sm" truncate className="min-w-0 flex-1">
{item.label}
</Text>
{index % 3 === 0 && <Tag size="sm">标签</Tag>}
</Inline>
)}
</VirtualList>
)
}
用法
import { VirtualList, type VirtualListExpose } from '@hina-ui/react'
items 提供数据,getKey 返回每项唯一、稳定的字符串或数字标识,children 是函数,接收 { item, index },保留 item 的完整类型。更新或重排数据时不要用数组位置作为 key。getKey 与 children 都是函数,渲染 VirtualList 的组件需要声明 'use client'。
组件内置 ScrollArea,默认高度为 320px。height 可传像素数或 CSS 长度;设为 100% 时父容器需要有确定高度。外观和条目内容由调用方定义,组件本身不增加边框、选中态或点击行为。首个示例包含一万项,并在尾部组合了 Tag。
示例
固定尺寸
设置 dynamic={false} 后,estimateSize 是条目的实际高度,不再测量 DOM。它也可以是 (item, index) => number,用于已知的不同尺寸。尺寸包含条目自身的 padding 和 border,不包含 gap;内容需要放得进声明的尺寸。
- 条目 11 / 1000
- 条目 22 / 1000
- 条目 33 / 1000
- 条目 44 / 1000
- 条目 55 / 1000
- 条目 66 / 1000
- 条目 77 / 1000
- 条目 88 / 1000
- 条目 99 / 1000
- 条目 1010 / 1000
'use client'
import { VirtualList, Inline, Text } from '@hina-ui/react'
const items = Array.from({ length: 1000 }, (_, id) => ({ id, label: `条目 ${id + 1}` }))
export default function Demo() {
return (
<VirtualList
items={items}
getKey={item => item.id}
height={240}
estimateSize={48}
dynamic={false}
label="固定高度列表"
className="border-line rounded-lg border"
>
{({ item, index }) => (
<Inline
justify="between"
gap="sm"
wrap={false}
className="border-line h-full border-b px-4"
>
<Text as="span" size="sm">
{item.label}
</Text>
<Text as="span" size="sm" tone="muted" className="tabular-nums">
{index + 1} / {items.length}
</Text>
</Inline>
)}
</VirtualList>
)
}
动态尺寸
默认 dynamic 开启,条目随内容自然撑开。estimateSize 是尚未测量条目的预估高度,尽量取接近真实内容的值。内容展开、图片加载或容器变窄后,会重新计算实际高度和后续条目的位置。滚动条总长度也会随测量修正。
间隔使用 gap,首尾留白使用 paddingStart 和 paddingEnd,单位均为像素。避免用条目外部 margin 表示这些间隔,因为 margin 不计入条目测量。示例用 Collapsible 展开附加内容,列表跟随开合动画更新高度。开合状态按条目 key 保存在外部,滚出可见范围再返回时仍然保留。
条目 1
内容自然换行,每项高度由实际内容决定。
条目 2
内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。
条目 3
内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。
条目 4
内容自然换行,每项高度由实际内容决定。
条目 5
内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。
条目 6
内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。
条目 7
内容自然换行,每项高度由实际内容决定。
条目 8
内容自然换行,每项高度由实际内容决定。内容自然换行,每项高度由实际内容决定。
'use client'
import { useState } from 'react'
import {
VirtualList,
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
Text,
} from '@hina-ui/react'
const items = Array.from({ length: 500 }, (_, id) => ({
id,
title: `条目 ${id + 1}`,
description: '内容自然换行,每项高度由实际内容决定。'.repeat((id % 3) + 1),
}))
export default function Demo() {
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
return (
<VirtualList
items={items}
getKey={item => item.id}
estimateSize={140}
height={360}
label="动态高度列表"
className="border-line rounded-lg border"
>
{({ item }) => (
<Collapsible
open={!!expanded[item.id]}
onOpenChange={open => setExpanded(current => ({ ...current, [item.id]: open }))}
className="border-line border-b p-4"
>
<Text size="sm" weight="medium">
{item.title}
</Text>
<Text size="sm" tone="muted" className="mt-1">
{item.description}
</Text>
<CollapsibleTrigger className="mt-2">
{expanded[item.id] ? '收起' : '展开'}
</CollapsibleTrigger>
<CollapsibleContent>
<Text size="sm" tone="muted" className="pt-2">
{'展开后增加的内容会自动参与高度计算。'.repeat(5)}
</Text>
</CollapsibleContent>
</Collapsible>
)}
</VirtualList>
)
}
滚动与可见范围
通过 ref 调用 scrollToIndex(index, { align, behavior }) 或 scrollToOffset(offset, { behavior })。索引从 0 开始,align 支持 start、center、end 和 auto;默认 auto 只在目标超出视口时滚动。越界索引会限制到首尾项。
behavior="smooth" 开启平滑滚动,系统要求减弱动态效果时使用即时滚动。动态尺寸的远距离跳转会随着目标附近的实际测量继续校正;已知尺寸时使用固定模式可获得精确位置。
onRangeChange 收到实际可见的首尾索引,不包含预渲染和保留焦点的额外条目。可据此按需追加数据;请求状态和是否还有数据由调用方控制。示例使用 NumberInput 指定目标。
条目 1
条目 2
条目 3
条目 4
条目 5
条目 6
条目 7
条目 8
条目 9
条目 10
条目 11
'use client'
import { useRef, useState } from 'react'
import {
VirtualList,
NumberInput,
Button,
Inline,
Stack,
Text,
type VirtualListExpose,
type VirtualListRange,
} from '@hina-ui/react'
const items = Array.from({ length: 10000 }, (_, id) => ({ id, label: `条目 ${id + 1}` }))
export default function Demo() {
const list = useRef<VirtualListExpose>(null)
const [target, setTarget] = useState<number | null>(5000)
const [range, setRange] = useState<VirtualListRange>({ startIndex: 0, endIndex: 0 })
return (
<Stack className="w-full">
<Inline gap="sm">
<NumberInput
value={target}
onValueChange={value => setTarget(value ?? null)}
min={1}
max={items.length}
aria-label="条目序号"
className="w-36"
/>
<Button onClick={() => list.current?.scrollToIndex((target ?? 1) - 1, { align: 'center' })}>
跳转
</Button>
<Button variant="outline" onClick={() => list.current?.scrollToOffset(0)}>
回到顶部
</Button>
<Text as="span" size="sm" tone="muted" className="tabular-nums">
可见 {range.startIndex + 1}–{range.endIndex + 1}
</Text>
</Inline>
<VirtualList
ref={list}
items={items}
getKey={item => item.id}
dynamic={false}
estimateSize={48}
height={288}
label="可跳转列表"
className="border-line rounded-lg border"
onRangeChange={setRange}
>
{({ item }) => (
<Inline gap="none" className="border-line h-full border-b px-4">
<Text size="sm">{item.label}</Text>
</Inline>
)}
</VirtualList>
</Stack>
)
}
横向与 RTL
orientation="horizontal" 时,estimateSize 表示宽度,height 控制容器高度。动态模式下给 children 返回的内容定义自然宽度;固定模式直接使用声明宽度。dir 可显式设置,也会继承方向。RTL 下条目从右向左排列,滚动方法仍使用正数逻辑偏移。
ltr
01
条目 1
02
条目 2
03
条目 3
04
条目 4
05
条目 5
06
条目 6
07
条目 7
rtl
01
条目 1
02
条目 2
03
条目 3
04
条目 4
05
条目 5
06
条目 6
07
条目 7
'use client'
import { VirtualList, Stack, Text } from '@hina-ui/react'
const items = Array.from({ length: 200 }, (_, id) => ({ id, label: `条目 ${id + 1}` }))
export default function Demo() {
return (
<Stack className="w-full">
{(['ltr', 'rtl'] as const).map(dir => (
<Stack key={dir} gap="sm">
<Text size="xs" tone="muted" className="uppercase">
{dir}
</Text>
<VirtualList
items={items}
getKey={item => item.id}
orientation="horizontal"
dir={dir}
height={144}
estimateSize={160}
dynamic={false}
gap={12}
label={`${dir} 横向列表`}
>
{({ item, index }) => (
<Stack
justify="between"
gap="sm"
className="border-line bg-surface h-full rounded-lg border p-4"
>
<Text size="2xl" tone="muted" className="tabular-nums">
{String(index + 1).padStart(2, '0')}
</Text>
<Text size="sm">{item.label}</Text>
</Stack>
)}
</VirtualList>
</Stack>
))}
</Stack>
)
}
加载与空态
loading 使用 LoadingOverlay 在列表中央显示加载提示,保留已有条目和滚动位置,不改变滚动视口的尺寸。没有条目时也居中显示,加载时不显示空态。loadingContent 替换加载层的内容,empty 替换空态,空态也可以用 emptyText 修改默认文案。
示例组合 Switch 和 Empty。组件不请求数据,也不清空已有数据。
条目 1
条目 2
条目 3
条目 4
条目 5
条目 6
条目 7
条目 8
条目 9
条目 10
'use client'
import { useMemo, useState } from 'react'
import { VirtualList, Button, Switch, Empty, Inline, Stack, Text } from '@hina-ui/react'
export default function Demo() {
const [loading, setLoading] = useState(false)
const [empty, setEmpty] = useState(false)
const items = useMemo(
() => (empty ? [] : Array.from({ length: 100 }, (_, id) => ({ id, label: `条目 ${id + 1}` }))),
[empty],
)
return (
<Stack className="w-full">
<Inline>
<Switch checked={loading} onCheckedChange={setLoading}>
加载中
</Switch>
<Button variant="outline" onClick={() => setEmpty(!empty)}>
{empty ? '恢复条目' : '清空条目'}
</Button>
</Inline>
<VirtualList
items={items}
getKey={item => item.id}
loading={loading}
height={240}
estimateSize={48}
dynamic={false}
label="列表状态"
className="border-line rounded-lg border"
empty={<Empty title="暂无条目" description="列表中没有可显示的内容。" size="sm" />}
>
{({ item }) => (
<Inline gap="none" className="border-line h-full border-b px-4">
<Text size="sm">{item.label}</Text>
</Inline>
)}
</VirtualList>
</Stack>
)
}
SSR 与测量
服务端根据 estimateSize、initialRect 和 initialOffset 渲染首批条目,并预留整个列表的滚动长度。默认初始视口为 320px 宽和 height 指定的数值高度;height 为 CSS 字符串时,预估高度为 320px。需要更准确的首屏范围时显式传入 initialRect,服务端和客户端保持一致。
viewport 在 ScrollArea 完成初始化后才可用。在此之前调用滚动方法会暂存最后一次请求,初始化完成后执行。initialOffset 仅用于初始位置;后续移动使用滚动方法。
动态测量会缓存稳定 key 对应的尺寸。如果批量修改了尚未渲染条目的内容,可调用 measure() 清空尺寸缓存并重新测量当前条目。
无障碍
- 滚动区域可聚焦,通过
label命名,保留浏览器原生键盘滚动。 - 内容使用
list/listitem语义,aria-posinset和aria-setsize标注条目在完整列表中的位置;不增加选择或菜单语义。 - 滚动时包含输入焦点的条目会保持挂载,直到焦点移出;该项被数据删除时仍会卸载。
- 虚拟化条目离开范围后会卸载。需要保留的输入值或展开状态应按 key 存在组件外部。
- 页面搜索和辅助技术只能访问当前挂载的内容。如果必须一次访问全部条目,使用普通 List 或分页呈现。
API
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
items | readonly T[] | 必填 | 完整数据数组 |
getKey | (item: T, index: number) => string | number | 必填 | 唯一、稳定的标识 |
estimateSize | number | ((item: T, index: number) => number) | 48 | 正数像素尺寸;动态模式下为预估值 |
dynamic | boolean | true | 自动测量条目尺寸 |
height | number | string | 320 | 像素数或 CSS 高度 |
orientation | 'vertical' | 'horizontal' | 'vertical' | 虚拟滚动方向 |
dir | 'ltr' | 'rtl' | 继承 | 内容方向 |
overscan | number | 5 | 在可见范围两侧各预渲染的条目数 |
gap | number | 0 | 条目间距,像素 |
paddingStart | number | 0 | 滚动轴起始留白,像素 |
paddingEnd | number | 0 | 滚动轴末尾留白,像素 |
initialRect | { width: number; height: number } | 见上文 | SSR 初始视口尺寸 |
initialOffset | number | 0 | 初始逻辑滚动偏移,像素 |
loading | boolean | false | 加载提示及 aria-busy |
emptyText | string | locale | 默认空态文字 |
label | string | locale | 滚动区域的无障碍名称 |
shadow | boolean | true | ScrollArea 边缘阴影 |
className | string | — | 根元素类 |
itemClass | string | ((item: T, index: number) => string | undefined) | — | 条目容器类 |
其他原生属性透传到根元素。用 itemClass 调整条目外观,不要覆盖其定位属性;动态模式的纵向 padding 会正常计入测量。
内容属性
| 属性 | 参数 | 说明 |
|---|---|---|
children | { item: T, index: number } | 条目内容 |
empty | — | 无条目且未加载时的内容 |
loadingContent | — | 加载中的内容 |
回调
| 回调 | 参数 | 说明 |
|---|---|---|
onRangeChange | { startIndex: number; endIndex: number } | 初始及可见范围变化时触发;无可见项时均为 -1 |
Ref
| 名称 | 类型 | 说明 |
|---|---|---|
viewport | HTMLElement | undefined | 实际滚动元素 |
scrollToIndex | (index, options?: VirtualListScrollOptions) => void | 滚动到指定索引 |
scrollToOffset | (offset, options?: { behavior?: 'auto' | 'smooth' }) => void | 滚动到逻辑偏移 |
measure | () => void | 重置缓存并重新测量 |
同时导出 VirtualListProps<T>、VirtualListSlotProps<T>、VirtualListKey、VirtualListRange、VirtualListScrollOptions 和 VirtualListExpose 类型。
组件集成
Select, MultiSelect, Combobox, MultiCombobox, Listbox, CommandPalette, Tree, TreeSelect, DataList, DataTable 已提供可选的 virtualize,共用虚拟滚动底层,同时保留各自的选择、搜索和键盘行为。无需在这些组件外再嵌套 VirtualList。