Carousel 轮播

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

<script setup lang="ts">
  import { ref } from 'vue'
  import { Carousel, Card, Image, Link, Stack, Text } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const items = dataListDemo('zh-CN').slice(0, 5)
  const index = ref(0)
</script>
<template>
  <Carousel
    v-model:index="index"
    :items="items"
    :get-key="item => item.id"
    label="精选视觉小说"
    indicators
    class="max-w-2xl"
  >
    <template #default="{ item }">
      <Card
        :padded="false"
        class="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"
          class="aspect-[4/3] @min-[480px]/hn-carousel:aspect-[3/4]"
          image-class="absolute inset-0"
        />
        <Stack gap="sm" justify="center" class="min-w-0 px-4 py-5 @min-[480px]/hn-carousel:px-8">
          <Text as="h3" size="lg" weight="medium" class="line-clamp-2">{{ item.title }}</Text>
          <Text size="sm" tone="muted" class="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"
            class="mt-3 w-fit text-sm"
          >
            查看作品
          </Link>
        </Stack>
      </Card>
    </template>
  </Carousel>
</template>
vue

用法

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

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

<Carousel v-model:index="index" :items="works" :get-key="work => work.id" indicators>
  <template #default="{ item }">
    <Image :src="item.cover" :alt="item.title" :ratio="16 / 9" :draggable="false" />
  </template>
</Carousel>
vue

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

示例

不等宽卡片与动态分组

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

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

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

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

<script setup lang="ts">
  import { ref } from 'vue'
  import { ChevronLeft, ChevronRight } from '@lucide/vue'
  import {
    Carousel,
    FormField,
    IconButton,
    Image,
    Inline,
    Link,
    Slider,
    Stack,
    Switch,
    Text,
  } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const library = dataListDemo('zh-CN')
  const items = [0, 3, 10, 5, 11, 6, 4, 8].map(index => library[index]!)
  const width = ref(100)
  const rtl = ref(false)
</script>
<template>
  <Stack class="w-full max-w-2xl" data-demo-carousel-grouped>
    <Inline gap="lg" align="end">
      <FormField label="容器宽度" class="min-w-40 flex-1">
        <Slider v-model="width" :min="50" :max="100" :format="value => value + '%'" />
      </FormField>
      <Switch v-model="rtl" class="mb-1">从右向左(RTL)</Switch>
    </Inline>
    <Text size="sm" tone="muted">
      竖版、方形、横版封面保留各自比例。拖动滑块改变容器宽度,观察分组变化。
    </Text>
    <Carousel
      :items="items"
      :get-key="item => item.id"
      :dir="rtl ? 'rtl' : 'ltr'"
      slides-to-scroll="auto"
      item-class="basis-auto"
      label="浏览书库"
      class="self-center"
      :style="{ width: width + '%' }"
    >
      <template #default="{ item }">
        <Stack
          gap="sm"
          class="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"
            class="rounded-lg"
          />
          <Link
            :href="item.url"
            target="_blank"
            rel="noopener noreferrer"
            tone="neutral"
            class="line-clamp-2 w-fit max-w-full text-sm"
          >
            {{ item.title }}
          </Link>
          <Text size="xs" tone="muted" truncate>{{ item.developer }}</Text>
        </Stack>
      </template>
      <template #controls="{ prev, next, canPrev, canNext, index, snapCount }">
        <Inline justify="between" class="w-full">
          <Text size="sm" tone="muted" dir="ltr">
            {{ snapCount ? `${index + 1} / ${snapCount}` : '—' }}
          </Text>
          <Inline gap="xs">
            <IconButton label="上一组" :disabled="!canPrev" @click="prev">
              <ChevronLeft class="rtl:rotate-180" />
            </IconButton>
            <IconButton label="下一组" :disabled="!canNext" @click="next">
              <ChevronRight class="rtl:rotate-180" />
            </IconButton>
          </Inline>
        </Inline>
      </template>
    </Carousel>
  </Stack>
</template>
vue

自定义指示器

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

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

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

<script setup lang="ts">
  import { ref } from 'vue'
  import { Carousel, Image, Inline, Progress, SegmentedControl, Stack, Text } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const items = dataListDemo('zh-CN').slice(0, 4)
  const mode = ref('thumbnails')
  const index = ref(0)
  const options = [
    { value: 'capsule', label: '默认' },
    { value: 'thumbnails', label: '缩略图' },
    { value: 'progress', label: '进度条' },
  ]
</script>
<template>
  <Stack class="w-full max-w-lg" data-demo-carousel-indicators>
    <SegmentedControl
      v-model="mode"
      :options="options"
      aria-label="指示器样式"
      size="sm"
      class="self-center"
    />
    <Carousel
      v-model:index="index"
      :items="items"
      :get-key="item => item.id"
      indicators
      label="自定义封面画廊"
    >
      <template #default="{ item }">
        <Image
          :src="item.cover.src"
          :alt="item.title"
          :ratio="16 / 10"
          :draggable="false"
          class="rounded-xl"
        />
      </template>
      <template v-if="mode === 'thumbnails'" #indicator="{ index: position, active }">
        <Image
          :src="items[position]!.cover.src"
          alt=""
          :ratio="3 / 4"
          :lazy="false"
          :draggable="false"
          :class="`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'}`"
        />
      </template>
      <template v-if="mode === 'progress'" #indicators="{ index: position, snapCount }">
        <Inline gap="sm" :wrap="false" class="w-36">
          <Progress
            :value="position + 1"
            :max="Math.max(1, snapCount)"
            size="sm"
            aria-label="画廊进度"
            class="flex-1"
          />
          <Text size="xs" tone="muted" dir="ltr" class="shrink-0 tabular-nums">
            {{ position + 1 }} / {{ snapCount }}
          </Text>
        </Inline>
      </template>
    </Carousel>
  </Stack>
</template>
vue

纵向内容

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

<script setup lang="ts">
  import { Carousel, Card, Image, Link, Stack, Text } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const items = dataListDemo('zh-CN').slice(0, 6)
</script>
<template>
  <Carousel
    :items="items"
    :get-key="item => item.id"
    orientation="vertical"
    viewport-class="h-72"
    item-class="basis-1/2"
    label="待阅书单"
    class="max-w-lg"
  >
    <template #default="{ item }">
      <Card class="flex h-full items-center gap-4 shadow-none">
        <Image
          :src="item.cover.src"
          alt=""
          :ratio="3 / 4"
          :draggable="false"
          class="w-16 shrink-0 rounded-md"
        />
        <Stack gap="xs" class="min-w-0">
          <Link
            :href="item.url"
            target="_blank"
            rel="noopener noreferrer"
            tone="neutral"
            class="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>
    </template>
  </Carousel>
</template>
vue

按需自动播放

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

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

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

<script setup lang="ts">
  import { Carousel, Card, Image, Link, Stack, Text } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const items = dataListDemo('zh-CN').slice(0, 4)
</script>
<template>
  <Stack class="w-full max-w-lg">
    <Carousel
      :items="items"
      :get-key="item => item.id"
      loop
      :autoplay="4000"
      indicators
      label="阅读推荐"
    >
      <template #default="{ item }">
        <Card class="flex items-center gap-5 shadow-none">
          <Image
            :src="item.cover.src"
            alt=""
            :ratio="3 / 4"
            :draggable="false"
            class="w-24 shrink-0 rounded-md"
          />
          <Stack gap="sm" class="min-w-0">
            <Link
              :href="item.url"
              target="_blank"
              rel="noopener noreferrer"
              tone="neutral"
              class="line-clamp-2 w-fit font-medium"
            >
              {{ item.title }}
            </Link>
            <Text size="sm" tone="muted" class="line-clamp-2">{{ item.developer }}</Text>
          </Stack>
        </Card>
      </template>
    </Carousel>
    <Text size="sm" tone="muted">悬停时暂停;键盘进入或手动切换后,点击“开始自动播放”恢复。</Text>
  </Stack>
</template>
vue

初始位置与 SSR

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

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

<script setup lang="ts">
  import { ref } from 'vue'
  import { Carousel, Image } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const items = dataListDemo('zh-CN').slice(0, 5)
  const index = ref(2)
</script>
<template>
  <Carousel
    v-model:index="index"
    :items="items"
    :get-key="item => item.id"
    indicators
    label="封面画廊"
    class="max-w-lg"
  >
    <template #default="{ item }">
      <Image
        :src="item.cover.src"
        :alt="item.title"
        :ratio="4 / 3"
        :draggable="false"
        class="rounded-xl"
      />
    </template>
  </Carousel>
</template>
vue

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

行为与自定义

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

API

Props

属性
类型
默认值
说明
items
readonly T[]
—
内容数据,保持稳定的 key
getKey
(item: T, index: number) => string | number
—
必填,返回唯一 key
v-model:index
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 间距档位
class
string
—
根节点样式
viewportClass
string
—
视口样式,可设置纵向高度
itemClass
string | ((item: T, index: number) => string | undefined)
—
项目容器样式,可按项设置尺寸

Slots

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

Events 与 Expose

ready 在非空且可测量的内容首次初始化后触发;select 在选中的停靠点变化时触发。两者都携带 CarouselState:

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

暴露的 state 和 controls 插槽参数实时更新。select 可能早于动画结束;需要判断当前屏内内容时读取实时的 visibleItems。

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

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