Carousel 轮播

通过拖拽、键盘和导航控件浏览一组相关内容。

'use client'

import { useState } from 'react'
import { Carousel, Card, Image, Link, Stack, Text } from '@hina-ui/react'
import { dataListDemo } from '../../data-list'

const items = dataListDemo('zh-CN').slice(0, 5)

export default function Demo() {
  const [index, setIndex] = useState(0)

  return (
    <Carousel
      index={index}
      onIndexChange={setIndex}
      items={items}
      getKey={item => item.id}
      label="精选视觉小说"
      indicators
      className="max-w-2xl"
    >
      {({ item }) => (
        <Card
          padded={false}
          className="grid h-full grid-cols-1 overflow-hidden shadow-none @min-[480px]/hn-carousel:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"
        >
          <Image
            src={item.cover.src}
            alt={item.title}
            draggable={false}
            className="aspect-[4/3] @min-[480px]/hn-carousel:aspect-[3/4]"
            imageClass="absolute inset-0"
          />
          <Stack
            gap="sm"
            justify="center"
            className="min-w-0 px-4 py-5 @min-[480px]/hn-carousel:px-8"
          >
            <Text as="h3" size="lg" weight="medium" className="line-clamp-2">
              {item.title}
            </Text>
            <Text size="sm" tone="muted" className="line-clamp-2">
              {item.developer}
            </Text>
            <Text as="time" dateTime={item.released} size="xs" tone="muted">
              {item.released}
            </Text>
            <Link
              href={item.url}
              target="_blank"
              rel="noopener noreferrer"
              className="mt-3 w-fit text-sm"
            >
              查看作品
            </Link>
          </Stack>
        </Card>
      )}
    </Carousel>
  )
}
tsx

用法

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

传入 items、稳定的 getKey,用 children 渲染每一项。卡片和图片的外观由调用方决定。

<Carousel index={index} onIndexChange={setIndex} items={works} getKey={work => work.id} indicators>
  {({ item }) => <Image src={item.cover} alt={item.title} ratio={16 / 9} draggable={false} />}
</Carousel>
tsx

index 表示从零开始的停靠位置,不一定等于数据下标。一屏一项时两者相同;多卡片、分组翻页或部分露出的布局可能产生更少的停靠点。snapCount 和导航方法都对应测量后的停靠列表;visibleItems 才是当前可见的数据下标。

示例

不等宽卡片与动态分组

示例混排竖版、方形和横版封面,按原图比例计算各张卡片的宽度,用 itemClass="basis-auto" 让项目跟随内容尺寸。宽图在窄容器内最多占满一屏,不超出视口。

slidesToScroll="auto" 根据容器和每张卡片的实际宽度分组,每组不固定张数。拖动滑块可连续调整容器宽度,停靠点数量和前后导航会随尺寸变化重新计算;不需要业务方监听宽度或手动调用 refresh()。卡片自身尺寸改变也会触发重算。

renderControls 替换整个默认控制区,接收当前状态和导航方法。children 函数接收原始数据、数据下标、是否可见和是否完成初始化。切换 RTL 后,拖拽方向和键盘方向一起改变。

竖版、方形、横版封面保留各自比例。拖动滑块改变容器宽度,观察分组变化。

'use client'

import { useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import {
  Carousel,
  FormField,
  IconButton,
  Image,
  Inline,
  Link,
  Slider,
  Stack,
  Switch,
  Text,
} from '@hina-ui/react'
import { dataListDemo } from '../../data-list'

const library = dataListDemo('zh-CN')
const items = [0, 3, 10, 5, 11, 6, 4, 8].map(index => library[index]!)

export default function Demo() {
  const [width, setWidth] = useState<number | undefined>(100)
  const [rtl, setRtl] = useState(false)

  return (
    <Stack className="w-full max-w-2xl" data-demo-carousel-grouped="">
      <Inline gap="lg" align="end">
        <FormField label="容器宽度" className="min-w-40 flex-1">
          <Slider
            value={width}
            onValueChange={setWidth}
            min={50}
            max={100}
            format={value => value + '%'}
          />
        </FormField>
        <Switch checked={rtl} onCheckedChange={setRtl} className="mb-1">
          从右向左(RTL)
        </Switch>
      </Inline>
      <Text size="sm" tone="muted">
        竖版、方形、横版封面保留各自比例。拖动滑块改变容器宽度,观察分组变化。
      </Text>
      <Carousel
        items={items}
        getKey={item => item.id}
        dir={rtl ? 'rtl' : 'ltr'}
        slidesToScroll="auto"
        itemClass="basis-auto"
        label="浏览书库"
        className="self-center"
        style={{ width: width + '%' }}
        renderControls={({ prev, next, canPrev, canNext, index, snapCount }) => (
          <Inline justify="between" className="w-full">
            <Text size="sm" tone="muted" dir="ltr">
              {snapCount ? `${index + 1} / ${snapCount}` : '—'}
            </Text>
            <Inline gap="xs">
              <IconButton label="上一组" disabled={!canPrev} onClick={prev}>
                <ChevronLeft className="rtl:rotate-180" />
              </IconButton>
              <IconButton label="下一组" disabled={!canNext} onClick={next}>
                <ChevronRight className="rtl:rotate-180" />
              </IconButton>
            </Inline>
          </Inline>
        )}
      >
        {({ item }) => (
          <Stack
            gap="sm"
            className="h-full"
            style={{ width: `min(${(192 * item.cover.width) / item.cover.height}px, 100cqw)` }}
          >
            <Image
              src={item.cover.src}
              alt={item.title}
              ratio={item.cover.width / item.cover.height}
              draggable={false}
              className="rounded-lg"
            />
            <Link
              href={item.url}
              target="_blank"
              rel="noopener noreferrer"
              tone="neutral"
              className="line-clamp-2 w-fit max-w-full text-sm"
            >
              {item.title}
            </Link>
            <Text size="xs" tone="muted" truncate>
              {item.developer}
            </Text>
          </Stack>
        )}
      </Carousel>
    </Stack>
  )
}
tsx

自定义指示器

默认指示器使用胶囊样式突出当前项,各项的点击区域保持固定,切换不会推动左右按钮。过渡使用 Hina 动效 token,并尊重减少动态效果的系统偏好。

  • renderIndicator 接收 { index, active, snapCount },只替换单项外观,可以放缩略图、数字或自定义图形。按钮、点击切换、键盘焦点和无障碍名称由 Carousel 保留;不要在里面嵌套按钮或链接。
  • renderIndicators 替换整条指示器,保留两侧导航按钮。它接收 CarouselControls 的完整状态和方法,以及用于 aria-controls 的 viewportId,可以做进度条、计数器或自定义跳转控件。
  • 提供其中任一个就会显示该区域,不必额外设置 indicators。renderControls 仍可替换整个导航区,优先于这两者。

下面可以切换默认、缩略图和进度条三种外观,切换指示器不重置轮播。示例一屏一项,因此缩略图可直接按 index 取数据;分组场景的 index 仍是停靠位置,不能当作数据下标。

'use client'

import { useState } from 'react'
import { Carousel, Image, Inline, Progress, SegmentedControl, Stack, Text } from '@hina-ui/react'
import { dataListDemo } from '../../data-list'

const items = dataListDemo('zh-CN').slice(0, 4)
const options = [
  { value: 'capsule', label: '默认' },
  { value: 'thumbnails', label: '缩略图' },
  { value: 'progress', label: '进度条' },
]

export default function Demo() {
  const [mode, setMode] = useState<string | number>('thumbnails')
  const [index, setIndex] = useState(0)

  return (
    <Stack className="w-full max-w-lg" data-demo-carousel-indicators="">
      <SegmentedControl
        value={mode}
        onValueChange={setMode}
        options={options}
        aria-label="指示器样式"
        size="sm"
        className="self-center"
      />
      <Carousel
        index={index}
        onIndexChange={setIndex}
        items={items}
        getKey={item => item.id}
        indicators
        label="自定义封面画廊"
        renderIndicator={
          mode === 'thumbnails'
            ? ({ index: position, active }) => (
                <Image
                  src={items[position]!.cover.src}
                  alt=""
                  ratio={3 / 4}
                  lazy={false}
                  draggable={false}
                  className={`w-8 rounded-sm transition-opacity duration-(--hn-duration-base) motion-reduce:transition-none ${active ? 'opacity-100 ring-2 ring-accent ring-offset-2 ring-offset-surface' : 'opacity-50'}`}
                />
              )
            : undefined
        }
        renderIndicators={
          mode === 'progress'
            ? ({ index: position, snapCount }) => (
                <Inline gap="sm" wrap={false} className="w-36">
                  <Progress
                    value={position + 1}
                    max={Math.max(1, snapCount)}
                    size="sm"
                    aria-label="画廊进度"
                    className="flex-1"
                  />
                  <Text size="xs" tone="muted" dir="ltr" className="shrink-0 tabular-nums">
                    {position + 1} / {snapCount}
                  </Text>
                </Inline>
              )
            : undefined
        }
      >
        {({ item }) => (
          <Image
            src={item.cover.src}
            alt={item.title}
            ratio={16 / 10}
            draggable={false}
            className="rounded-xl"
          />
        )}
      </Carousel>
    </Stack>
  )
}
tsx

纵向内容

纵向视口需要明确的高度。下面每屏在 288px 内显示两项。项目尺寸仍由 CSS 控制,改变方向不会改变内容顺序。

'use client'

import { Carousel, Card, Image, Link, Stack, Text } from '@hina-ui/react'
import { dataListDemo } from '../../data-list'

const items = dataListDemo('zh-CN').slice(0, 6)

export default function Demo() {
  return (
    <Carousel
      items={items}
      getKey={item => item.id}
      orientation="vertical"
      viewportClass="h-72"
      itemClass="basis-1/2"
      label="待阅书单"
      className="max-w-lg"
    >
      {({ item }) => (
        <Card className="flex h-full items-center gap-4 shadow-none">
          <Image
            src={item.cover.src}
            alt=""
            ratio={3 / 4}
            draggable={false}
            className="w-16 shrink-0 rounded-md"
          />
          <Stack gap="xs" className="min-w-0">
            <Link
              href={item.url}
              target="_blank"
              rel="noopener noreferrer"
              tone="neutral"
              className="line-clamp-2 w-fit text-sm font-medium"
            >
              {item.title}
            </Link>
            <Text size="xs" tone="muted" truncate>
              {item.developer}
            </Text>
            <Text as="time" dateTime={item.released} size="xs" tone="muted">
              {item.released}
            </Text>
          </Stack>
        </Card>
      )}
    </Carousel>
  )
}
tsx

按需自动播放

默认不自动播放。启用后,开始/暂停按钮出现在内容前方,也是轮播内的第一个键盘停靠点。

  • 鼠标悬停、离开页面可视区或浏览器标签页隐藏时暂时暂停。
  • 焦点进入、拖拽或手动导航后停止,只有显式点击开始才会恢复。
  • 系统偏好减少动态效果时不自动启动;仍可手动开始,此时切换不播放位移动画。
  • 非循环轮播到末尾停止,再次开始会返回第一项;内容数量足够时,可用 loop 连续循环。

悬停时暂停;键盘进入或手动切换后,点击“开始自动播放”恢复。

'use client'

import { Carousel, Card, Image, Link, Stack, Text } from '@hina-ui/react'
import { dataListDemo } from '../../data-list'

const items = dataListDemo('zh-CN').slice(0, 4)

export default function Demo() {
  return (
    <Stack className="w-full max-w-lg">
      <Carousel
        items={items}
        getKey={item => item.id}
        loop
        autoplay={4000}
        indicators
        label="阅读推荐"
      >
        {({ item }) => (
          <Card className="flex items-center gap-5 shadow-none">
            <Image
              src={item.cover.src}
              alt=""
              ratio={3 / 4}
              draggable={false}
              className="w-24 shrink-0 rounded-md"
            />
            <Stack gap="sm" className="min-w-0">
              <Link
                href={item.url}
                target="_blank"
                rel="noopener noreferrer"
                tone="neutral"
                className="line-clamp-2 w-fit font-medium"
              >
                {item.title}
              </Link>
              <Text size="sm" tone="muted" className="line-clamp-2">
                {item.developer}
              </Text>
            </Stack>
          </Card>
        )}
      </Carousel>
      <Text size="sm" tone="muted">
        悬停时暂停;键盘进入或手动切换后,点击“开始自动播放”恢复。
      </Text>
    </Stack>
  )
}
tsx

初始位置与 SSR

默认一屏一项、每次前进一项时,SSR 直接渲染完整的指示点、当前高亮和前后导航状态。初始 index 通过 CSS 百分比定位,包含间距,并支持 RTL、纵向和循环模式;不需要先显示第一项再等客户端纠正。卡片尺寸由 CSS 决定,图片用 ratio 预留空间。

下面的初始下标为 2,服务端 HTML 就显示第三项、五个指示点和第三个高亮。水合保留原有内容和指示点节点,仅接管交互。普通模式不需要 pending 属性。

'use client'

import { useState } from 'react'
import { Carousel, Image } from '@hina-ui/react'
import { dataListDemo } from '../../data-list'

const items = dataListDemo('zh-CN').slice(0, 5)

export default function Demo() {
  const [index, setIndex] = useState(2)

  return (
    <Carousel
      index={index}
      onIndexChange={setIndex}
      items={items}
      getKey={item => item.id}
      indicators
      label="封面画廊"
      className="max-w-lg"
    >
      {({ item }) => (
        <Image
          src={item.cover.src}
          alt={item.title}
          ratio={4 / 3}
          draggable={false}
          className="rounded-xl"
        />
      )}
    </Carousel>
  )
}
tsx

itemClass 可改变项目尺寸,组件不会解析类名推断宽高;此类自定义布局、分组跨项翻页的停靠位置由客户端测量。只修改卡片外观时,把样式放在 children 内即可保留默认布局的 SSR 定位。确实需要遮住自定义布局测量阶段时仍可提供 pending,并让占位尺寸匹配内容。它不表示接口加载:空数组显示 empty,数据请求由外层管理。

行为与自定义

  • children 可放图片、链接、表单和任意组合内容。高度由内容决定,不强制比例,也不添加自动高度过渡。
  • align="center" 居中停靠。边缘约束会优先消除空白,必要时覆盖对齐;设计上需要首尾留白时设置 containScroll={false}。
  • dragFree 允许拖拽后停在两个停靠点之间,index 仍对应最近选中的停靠点。
  • 图片内容请设置 draggable={false},避免触发浏览器自带的图片拖拽。
  • 视口接受与方向一致的方向键和 Home / End;内部输入框、链接、按钮保留自己的键盘行为。点击导航按钮不会将焦点移入内容。
  • 可见范围确定后,屏外项目会变为 inert。项目名称和位置播报支持本地化;自动播放期间不逐项播报。
  • 根节点提供 data-ready、data-orientation;项目容器提供 data-visible。自定义 className 合并在默认样式之后。
  • 手势运动使用 Hina 动效档位,并尊重减少动态效果的系统偏好;自动播放间隔与运动时间分开。

API

Props

属性
类型
默认值
说明
items
readonly T[]
—
内容数据,保持稳定的 key
getKey
(item: T, index: number) => string | number
—
必填,返回唯一 key
index / onIndexChange
number
0
从零开始的停靠位置
label
string
本地化文案 / localized
轮播区域的无障碍名称
dir
'ltr' | 'rtl'
继承 / inherited
布局与键盘方向
orientation
'horizontal' | 'vertical'
'horizontal'
纵向时须为 viewport 设置高度
align
'start' | 'center' | 'end'
'start'
卡片与视口的对齐方式
containScroll
false | 'trimSnaps' | 'keepSnaps'
'trimSnaps'
去掉边缘空白;trimSnaps 合并重复停靠点
slidesToScroll
number | 'auto'
1
每次前进的项目数;auto 按视口分组
loop
boolean
false
空间足够时循环;不足时退化为有限滚动
draggable
boolean
true
鼠标与触摸拖拽
dragFree
boolean
false
拖拽后自由停留,不强制吸附
autoplay
boolean | number
false
true 为 5000ms;数字为间隔,最小 1000ms
arrows
boolean
true
默认前后导航按钮
indicators
boolean
false
按停靠位置显示圆点
gap
'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
'md'
Hina 间距档位
className
string
—
根节点样式
viewportClass
string
—
视口样式,可设置纵向高度
itemClass
string | ((item: T, index: number) => string | undefined)
—
项目容器样式,可按项设置尺寸

内容属性

属性
参数
说明
children
CarouselItemSlot<T>
{ item, index, isVisible, ready }
renderControls
CarouselControls
替换导航区
renderIndicators
CarouselIndicatorsSlot
替换整条指示器,保留前后导航按钮;CarouselControls 加 viewportId
renderIndicator
CarouselIndicatorSlot
单项外观:{ index, active, snapCount },交互仍由组件负责
pending
—
首次测量占位
empty
—
空数据内容

回调与 Ref

onReady 在非空且可测量的内容首次初始化后调用;onSelect 在选中的停靠点变化时调用。两者都接收 CarouselState:

interface CarouselState {
  index: number
  snapCount: number
  canPrev: boolean
  canNext: boolean
  visibleItems: readonly number[]
  ready: boolean
  playing: boolean
}
ts

ref 上的 state 和 renderControls 接收的参数实时更新。onSelect 可能早于动画结束;需要判断当前屏内内容时读取实时的 visibleItems。

CarouselControls 在状态上增加 prev()、next()、scrollTo(index, instant?)、play()、pause()。组件 ref 暴露同样的方法,以及 state、element、viewport、refresh()。正常的尺寸变化会自动重新测量;特殊外部布局变化后可调用 refresh() 主动刷新。

以上类型均从包根导出,内部引擎实例和专属配置不作为公共 API。