Listbox 列表框

常驻的可选列表。

想看
在看
看过
抛弃
'use client'

import { useState } from 'react'
import { Listbox, type ListboxValue } from '@hina-ui/react'

const statuses = [
  { value: 'wish', label: '想看' },
  { value: 'doing', label: '在看' },
  { value: 'done', label: '看过' },
  { value: 'dropped', label: '抛弃' },
]

export default function Demo() {
  const [status, setStatus] = useState<ListboxValue>('doing')

  return (
    <Listbox
      value={status}
      onValueChange={setStatus}
      options={statuses}
      aria-label="收藏状态"
      className="w-56"
    />
  )
}
tsx

用法

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

列表框把可选项常驻在页面上,与 Select 共用同一套选项数据。value / onValueChange 绑定选中的值,设置 multiple 后绑定数组。列表超过 maxHeight 时在框内滚动。未声明的属性都会传给内部的列表元素。

Galgame
轻小说
漫画
动画

当前值:无

'use client'

import { useState } from 'react'
import { Listbox, Stack, Text, type ListboxValue } from '@hina-ui/react'

const types = [
  { value: 'gal', label: 'Galgame' },
  { value: 'ln', label: '轻小说' },
  { value: 'manga', label: '漫画' },
  { value: 'anime', label: '动画' },
]

export default function Demo() {
  const [type, setType] = useState<ListboxValue>(null)

  return (
    <Stack className="w-56">
      <Listbox value={type} onValueChange={setType} options={types} aria-label="作品类型" />
      <Text tone="muted">当前值:{type ?? '无'}</Text>
    </Stack>
  )
}
tsx

示例

多选

设置 multiple 后可以同时选中多项,再次点选取消。

校园
科幻
恋爱
悬疑

已选:school

'use client'

import { useState } from 'react'
import { Listbox, Stack, Text } from '@hina-ui/react'

const options = [
  { value: 'school', label: '校园' },
  { value: 'sf', label: '科幻' },
  { value: 'romance', label: '恋爱' },
  { value: 'mystery', label: '悬疑' },
]

export default function Demo() {
  const [tags, setTags] = useState<Array<string | number>>(['school'])

  return (
    <Stack className="w-56">
      <Listbox
        value={tags}
        onValueChange={value => setTags(value as Array<string | number>)}
        options={options}
        multiple
        aria-label="标签"
      />
      <Text tone="muted">已选:{tags.length ? tags.join('、') : '无'}</Text>
    </Stack>
  )
}
tsx

分组

分组项带 label 与 options,可以与普通选项混排。

最近更新
按评分
评分从高到低
评分从低到高
按发售
最新发售
最早发售
'use client'

import { useState } from 'react'
import { Listbox, type ListboxValue } from '@hina-ui/react'

const sorts = [
  { value: 'latest', label: '最近更新' },
  {
    label: '按评分',
    options: [
      { value: 'rating-desc', label: '评分从高到低' },
      { value: 'rating-asc', label: '评分从低到高' },
    ],
  },
  {
    label: '按发售',
    options: [
      { value: 'release-desc', label: '最新发售' },
      { value: 'release-asc', label: '最早发售' },
    ],
  },
]

export default function Demo() {
  const [sort, setSort] = useState<ListboxValue>('latest')

  return (
    <Listbox
      value={sort}
      onValueChange={setSort}
      options={sorts}
      aria-label="排序"
      className="w-56"
    />
  )
}
tsx

定制内容

renderOption 属性接收 { option, selected },定制每一项的内容。

组件从 options 推断完整选项类型,渲染函数中的 option 保留额外字段及其类型;value / onValueChange 仍绑定选项的 value,多选时为数组。类型定义见 Select。

Galgame视觉小说与冒险游戏
轻小说文库本与网络连载
动画TV、剧场版与 OVA
'use client'

import { useState } from 'react'
import { BookOpen, Clapperboard, Gamepad2, type LucideIcon } from 'lucide-react'
import { Inline, Listbox, Stack, Text, type ListboxValue } from '@hina-ui/react'

const types = [
  { value: 'gal', label: 'Galgame', description: '视觉小说与冒险游戏' },
  { value: 'ln', label: '轻小说', description: '文库本与网络连载' },
  { value: 'anime', label: '动画', description: 'TV、剧场版与 OVA' },
]
const icons: Record<string, LucideIcon> = { gal: Gamepad2, ln: BookOpen, anime: Clapperboard }

export default function Demo() {
  const [type, setType] = useState<ListboxValue>('gal')

  return (
    <Listbox
      value={type}
      onValueChange={setType}
      options={types}
      aria-label="作品类型"
      className="w-64"
      renderOption={({ option }) => {
        const Icon = icons[option.value]!
        return (
          <Inline as="span" gap="sm" wrap={false}>
            <Icon />
            <Stack as="span" gap="none" className="min-w-0">
              <Text as="span">{option.label}</Text>
              <Text as="span" size="xs" tone="muted">
                {option.description}
              </Text>
            </Stack>
          </Inline>
        )
      }}
    />
  )
}
tsx

尾部内容

renderTrailing 接收 { option, selected },接管整个行尾区域。未提供 renderTrailing 时,保留原有的勾选指示器和占位;提供后若返回空内容,该行不再保留尾部与间距,也不会恢复默认指示器。可用 renderTrailing={() => null} 清除所有行的尾部,或按条件返回 null 只清除部分行。

自定义尾部的宽度由内容决定。示例通过 selected 在 Tag 与勾选图标之间切换,并对部分选项返回空内容。renderOption 与 renderTrailing 获取相同的选中状态,支持普通选项、分组选项、单选与多选。

选项 A
分组
选项 B2048
选项 C
'use client'

import { useState } from 'react'
import { Check } from 'lucide-react'
import {
  Card,
  Listbox,
  Tag,
  Text,
  type ListboxValue,
  type SelectItems,
  type SelectOption,
} from '@hina-ui/react'

const options: SelectItems<SelectOption<{ count: number | null }>> = [
  { count: 128, value: 'a', label: '选项 A' },
  {
    label: '分组',
    options: [
      { count: 2048, value: 'b', label: '选项 B' },
      { count: null, value: 'c', label: '选项 C' },
    ],
  },
]

export default function Demo() {
  const [value, setValue] = useState<ListboxValue>('a')

  return (
    <Card padded={false} className="w-64">
      <Listbox
        value={value}
        onValueChange={setValue}
        options={options}
        variant="bare"
        padded={false}
        aria-label="自定义尾部"
        renderOption={({ option, selected }) => (
          <Text as="span" weight={selected ? 'medium' : 'normal'} truncate>
            {option.label}
          </Text>
        )}
        renderTrailing={({ option, selected }) =>
          option.count != null && (
            <Text
              as="span"
              size="xs"
              tone="muted"
              className="flex min-w-4 shrink-0 items-center justify-end"
            >
              {selected ? <Check aria-hidden="true" /> : <Tag tone="accent">{option.count}</Tag>}
            </Text>
          )
        }
      />
    </Card>
  )
}
tsx

自定义尾部不改变选项的选择行为。

滚动

maxHeight 限制列表高度,默认 20rem,超出后在框内滚动。

1996 年
1997 年
1998 年
1999 年
2000 年
2001 年
2002 年
2003 年
2004 年
2005 年
2006 年
2007 年
2008 年
2009 年
2010 年
2011 年
2012 年
2013 年
2014 年
2015 年
2016 年
2017 年
2018 年
2019 年
2020 年
2021 年
2022 年
2023 年
2024 年
2025 年
'use client'

import { useState } from 'react'
import { Listbox, type ListboxValue } from '@hina-ui/react'

const years = Array.from({ length: 30 }, (_, i) => ({ value: 1996 + i, label: `${1996 + i} 年` }))

export default function Demo() {
  const [year, setYear] = useState<ListboxValue>(2020)

  return (
    <Listbox
      value={year}
      onValueChange={setYear}
      options={years}
      maxHeight="12rem"
      aria-label="年份"
      className="w-40"
    />
  )
}
tsx

形态

primary 带背景、边框与阴影;secondary 只有浅色背景。

bare 去除根容器的背景、边框、阴影和圆角。选项样式与滚动行为仍然保留。

选项 A
选项 B
选项 A
选项 B
选项 A
选项 B
import { Listbox, Stack } from '@hina-ui/react'

const options = [
  { value: 'a', label: '选项 A' },
  { value: 'b', label: '选项 B' },
]

export default function Demo() {
  return (
    <Stack className="w-56">
      <Listbox options={options} value="a" aria-label="Primary" />
      <Listbox variant="secondary" options={options} value="a" aria-label="Secondary" />
      <Listbox variant="bare" options={options} value="a" aria-label="Bare" />
    </Stack>
  )
}
tsx

内边距

padded 默认为 true,设为 false 可去掉内部列表外围的 4px 留白,与 variant 独立。选项和分组标题自身的内边距不变。className 仍用于根容器。

选项 A
分组
选项 B
'use client'

import { useState } from 'react'
import { Card, Listbox, Stack, Switch, type ListboxValue } from '@hina-ui/react'

const options = [
  { value: 'a', label: '选项 A' },
  { label: '分组', options: [{ value: 'b', label: '选项 B' }] },
]

export default function Demo() {
  const [padded, setPadded] = useState(false)
  const [selected, setSelected] = useState<ListboxValue>('a')

  return (
    <Stack className="w-64">
      <Switch checked={padded} onCheckedChange={setPadded} controlPlacement="end" block>
        列表外围留白
      </Switch>
      <Card padded={false}>
        <Listbox
          value={selected}
          onValueChange={setSelected}
          options={options}
          variant="bare"
          padded={padded}
          aria-label="列表外围留白"
        />
      </Card>
    </Stack>
  )
}
tsx

状态

disabled 禁用整个列表,选项上的 disabled 只禁用该项。

Galgame
轻小说
漫画
Galgame
轻小说
漫画
import { Listbox, Stack } from '@hina-ui/react'

const options = [
  { value: 'gal', label: 'Galgame' },
  { value: 'ln', label: '轻小说' },
  { value: 'manga', label: '漫画', disabled: true },
]

export default function Demo() {
  return (
    <Stack className="w-56">
      <Listbox options={options} value="gal" aria-label="含禁用项" />
      <Listbox disabled options={options} value="gal" aria-label="已禁用" />
    </Stack>
  )
}
tsx

在表单中

放进 FormField 后,标签通过 aria-labelledby 关联到列表,错误信息由字段渲染;校验规则与提交交给 Form。

免费版
标准版
专业版
'use client'

import { useState } from 'react'
import * as v from 'valibot'
import { Button, Form, FormField, Listbox, Text, type ListboxValue } from '@hina-ui/react'

const plans = [
  { label: '免费版', value: 'free' },
  { label: '标准版', value: 'standard' },
  { label: '专业版', value: 'pro' },
]

const schema = v.object({
  plan: v.string('请选择套餐'),
})

export default function Demo() {
  const [values, setValues] = useState({ plan: null as ListboxValue })
  const [saved, setSaved] = useState('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 600))
    setSaved(JSON.stringify(data))
  }

  return (
    <Form values={values} rules={schema} className="w-80" onSubmit={save}>
      {({ submitting }) => (
        <>
          <FormField name="plan" label="套餐" required>
            <Listbox
              value={values.plan}
              onValueChange={plan => setValues({ ...values, plan })}
              options={plans}
            />
          </FormField>
          <Button type="submit" loading={submitting} className="self-start">
            订阅
          </Button>
          {saved && (
            <Text tone="muted" size="sm">
              已订阅:{saved}
            </Text>
          )}
        </>
      )}
    </Form>
  )
}
tsx

虚拟滚动

virtualize 按需渲染可见范围附近的条目,与 VirtualList 共用测量与滚动底层。默认关闭;可传 { estimateSize, overscan } 调整预估行高和两侧预渲染数量,行高会按实际内容测量。键盘导航覆盖完整数据,禁用项会跳过。条目离开渲染范围后会卸载;renderOption 与 renderTrailing 渲染的内容中需要持久保留的状态应按唯一 value 存在外部。

已选: 7890

'use client'

import { useState } from 'react'
import { Listbox, Stack, Text, type ListboxValue } from '@hina-ui/react'

const options = Array.from({ length: 10000 }, (_, index) => ({
  value: index,
  label: `条目 ${String(index + 1).padStart(5, '0')}`,
  disabled: index % 97 === 0,
}))

export default function Demo() {
  const [selected, setSelected] = useState<ListboxValue>(7890)

  return (
    <Stack gap="sm" className="w-56 max-w-full">
      <Listbox
        value={selected}
        onValueChange={setSelected}
        options={options}
        virtualize={{ estimateSize: 36, overscan: 6 }}
        aria-label="一万项"
      />
      <Text size="sm" tone="muted">
        已选: {selected}
      </Text>
    </Stack>
  )
}
tsx

行为

  • 点选切换选中。键盘 Tab 进入列表后,方向键移动高亮,Enter 或者空格选中,禁用项会被跳过。
  • 列表超出高度时在框内滚动,键盘高亮跟随滚动。

无障碍

  • 列表为 role="listbox",多选时带 aria-multiselectable;选项为 role="option" 并带 aria-selected;分组为 role="group" 并关联其标签。
  • 应当通过 aria-label 或者 aria-labelledby 给列表命名。

API

Props

T extends SelectOption 从 options 推断,默认是 SelectOption。

属性
类型
默认值
说明
value
string | number | null | Array<string | number>
—
选中的值,多选时为数组
options
SelectItems<T>
—
选项,类型见 Select
virtualize
VirtualizeOptions
false
虚拟滚动;预估行高按内容,overscan 6
multiple
boolean
false
是否多选
maxHeight
string
'20rem'
列表的最大高度
padded
boolean
true
是否保留内部列表外围留白
variant
'primary' | 'secondary' | 'bare'
'primary'
形态
disabled
boolean
false
是否禁用
className
string
—
追加至根元素的类名

内容属性

属性
参数
说明
renderOption
{ option: T; selected: boolean }
每一项的内容
renderTrailing
{ option: T; selected: boolean }
整个行尾区域,空内容不占位

回调

回调
参数
说明
onValueChange
value: string | number | Array<string | number>
选中值变化
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }
ts