Carousel

Browse related content by dragging, using the keyboard or choosing a position.

<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('en').slice(0, 5)
  const index = ref(0)
</script>
<template>
  <Carousel
    v-model:index="index"
    :items="items"
    :get-key="item => item.id"
    label="Featured visual novels"
    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"
          >
            View work
          </Link>
        </Stack>
      </Card>
    </template>
  </Carousel>
</template>
vue

Usage

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

Provide items, a stable getKey, and the content of each slide. There is no imposed card or image design.

<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 counts snap positions, not necessarily items. With one full-width slide per view they match; grouped or partially visible cards may produce fewer snaps. snapCount and navigation controls always refer to the measured snap list. visibleItems contains item indices.

Examples

Variable-width cards and dynamic grouping

Portrait, square and landscape covers keep their original proportions. Each card derives its width from its image ratio, while item-class="basis-auto" lets the slide follow that content width. Wide images are capped at the viewport width in narrow containers.

slides-to-scroll="auto" groups slides using the measured container and individual card widths, rather than a fixed number of cards per group. Drag the slider to resize the container continuously: snap counts and navigation update automatically, without calling refresh() or observing widths in application code. Changes to individual card dimensions also trigger measurement.

The controls slot replaces the whole default control row. It receives current state and navigation methods; the slide slot receives the original item, index, visibility and readiness. Switching direction also updates drag and keyboard behavior.

Portrait, square and landscape covers keep their proportions. Resize the container to regroup them.

<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('en')
  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="Container width" 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">Right-to-left</Switch>
    </Inline>
    <Text size="sm" tone="muted">
      Portrait, square and landscape covers keep their proportions. Resize the container to regroup
      them.
    </Text>
    <Carousel
      :items="items"
      :get-key="item => item.id"
      :dir="rtl ? 'rtl' : 'ltr'"
      slides-to-scroll="auto"
      item-class="basis-auto"
      label="Browse the library"
      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="Previous group" :disabled="!canPrev" @click="prev">
              <ChevronLeft class="rtl:rotate-180" />
            </IconButton>
            <IconButton label="Next group" :disabled="!canNext" @click="next">
              <ChevronRight class="rtl:rotate-180" />
            </IconButton>
          </Inline>
        </Inline>
      </template>
    </Carousel>
  </Stack>
</template>
vue

Custom indicators

The default picker emphasizes the current position with a capsule. Hit targets keep a fixed size, so changing selection does not shift neighboring controls. Transitions use Hina motion tokens and respect reduced motion.

  • #indicator="{ index, active, snapCount }" replaces each item’s appearance with a thumbnail, number or graphic. Carousel retains the button, selection action, keyboard focus and accessible name. Do not nest links or buttons inside it.
  • #indicators replaces the entire picker while preserving the previous and next buttons. It receives all CarouselControls state and methods plus viewportId for aria-controls, supporting progress bars, counters and custom navigation.
  • Either slot enables the indicator region without the indicators prop. #controls still replaces the whole navigation row and takes precedence over both slots.

Switch between the default, thumbnails and progress without resetting the carousel. This example has one item per snap, so each thumbnail can use index to look up its item. For grouped layouts, index still refers to a snap, not an item.

<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('en').slice(0, 4)
  const mode = ref('thumbnails')
  const index = ref(0)
  const options = [
    { value: 'capsule', label: 'Default' },
    { value: 'thumbnails', label: 'Thumbnails' },
    { value: 'progress', label: 'Progress' },
  ]
</script>
<template>
  <Stack class="w-full max-w-lg" data-demo-carousel-indicators>
    <SegmentedControl
      v-model="mode"
      :options="options"
      aria-label="Indicator style"
      size="sm"
      class="self-center"
    />
    <Carousel
      v-model:index="index"
      :items="items"
      :get-key="item => item.id"
      indicators
      label="Custom cover gallery"
    >
      <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="Gallery progress"
            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

Vertical content

A vertical viewport needs a definite height. Here two items share a 288px viewport. Card sizing remains CSS-driven; changing orientation does not change content order.

<script setup lang="ts">
  import { Carousel, Card, Image, Link, Stack, Text } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const items = dataListDemo('en').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="Reading queue"
    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

Optional automatic rotation

Automatic rotation is off by default. When enabled, a visible start/pause control appears before slide content in the tab order.

  • Hover, leaving the page viewport, or a hidden browser tab temporarily pauses rotation.
  • Focus entering the carousel, dragging, and manual navigation stop rotation. It resumes only after an explicit start.
  • Reduced motion prevents automatic startup. Users can still start rotation explicitly; slide changes are then immediate.
  • A finite carousel stops at the end. Starting it again returns to the beginning; loop enables continuous rotation when enough slides are available.

Hover to pause temporarily. After using the keyboard or changing slides, choose Start rotation to resume.

<script setup lang="ts">
  import { Carousel, Card, Image, Link, Stack, Text } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const items = dataListDemo('en').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="Reading recommendations"
    >
      <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">
      Hover to pause temporarily. After using the keyboard or changing slides, choose Start rotation
      to resume.
    </Text>
  </Stack>
</template>
vue

Initial position and SSR

With the default full-width slides and one slide per step, SSR renders all picker dots, the selected dot and navigation state. CSS percentages establish the initial position including gutters, with RTL, vertical and looping support. The requested slide is visible without first displaying the beginning of the track. CSS determines slide sizes; use Image’s ratio to reserve image space.

This example starts at index 2. Server HTML already displays the third slide, five dots and the third dot selected. Hydration preserves those nodes and takes over interaction. The default layout does not need a pending slot.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Carousel, Image } from '@hina-ui/vue'
  import { dataListDemo } from '../../data-list'
  const items = dataListDemo('en').slice(0, 5)
  const index = ref(2)
</script>
<template>
  <Carousel
    v-model:index="index"
    :items="items"
    :get-key="item => item.id"
    indicators
    label="Cover gallery"
    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 can change slide dimensions; the component does not parse class names to infer geometry. Custom slide layouts and multi-item steps use client measurements for snap positioning. Put purely visual card styles inside the default slot to retain the default layout’s SSR positioning. When a custom layout needs a measurement placeholder, #pending remains available; match its dimensions to the content. It does not represent network loading: an empty array renders #empty, while fetching is managed by the parent.

Behavior and customization

  • The default slot supports images, links, forms and arbitrary composed content. Card height is content-driven; no forced aspect ratio or automatic height transition is added.
  • align="center" centers snaps. Edge containment can override this alignment to avoid blank space; use :contain-scroll="false" when intentional space at either end is part of the design.
  • drag-free allows the track to rest between snaps. The model still identifies the nearest selected snap.
  • Pointer dragging uses the slide track. On image content, set :draggable="false" to avoid the browser’s native image drag.
  • The viewport accepts direction-aware arrow keys and Home / End. Nested inputs, links and buttons retain their own keyboard behavior. Navigation buttons keep focus when changing slides.
  • Offscreen items become inert after measuring visibility. Slide labels and the live position announcement are localized. Automatic rotation does not announce every change.
  • data-ready and data-orientation are on the root; slide wrappers expose data-visible. Class overrides are merged after defaults.
  • Gesture motion follows Hina’s motion scale and reduced-motion preference. Autoplay intervals are independent of animation timing.

API

Props

Prop
Type
Default
Description
items
readonly T[]
—
Slide data with stable keys
getKey
(item: T, index: number) => string | number
—
Required unique key resolver
v-model:index
number
0
Zero-based snap position
label
string
本地化文案 / localized
Accessible name of the carousel region
dir
'ltr' | 'rtl'
继承 / inherited
Layout and keyboard direction
orientation
'horizontal' | 'vertical'
‘horizontal’
Vertical layouts require a viewport height
align
'start' | 'center' | 'end'
‘start’
Slide alignment within the viewport
containScroll
false | 'trimSnaps' | 'keepSnaps'
‘trimSnaps’
Remove empty edge space; trimSnaps merges duplicate snaps
slidesToScroll
number | 'auto'
1
Items per step; auto groups by viewport size
loop
boolean
false
Loop when content permits; otherwise use finite scrolling
draggable
boolean
true
Mouse and touch dragging
dragFree
boolean
false
Free scrolling after a drag instead of snapping
autoplay
boolean | number
false
true uses 5000ms; a number sets the interval, minimum 1000ms
arrows
boolean
true
Default previous and next controls
indicators
boolean
false
Show a picker dot for each snap
gap
'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
‘md’
Hina spacing scale
class
string
—
Root classes
viewportClass
string
—
Viewport classes, including vertical height
itemClass
string | ((item: T, index: number) => string | undefined)
—
Slide classes, including per-item sizes

Slots

SlotParametersPurpose
defaultCarouselItemSlot<T>{ item, index, isVisible, ready }
controlsCarouselControlsReplace the navigation row
indicatorsCarouselIndicatorsSlotReplace the picker, retaining arrow controls; CarouselControls plus viewportId
indicatorCarouselIndicatorSlotItem appearance: { index, active, snapCount }; interaction stays with the component
pending—Initial measurement placeholder
empty—Empty data

Events and exposed controls

ready fires when the engine first initializes with nonempty measurable content; select fires when the selected snap changes. Both carry CarouselState:

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

The exposed state and the controls slot update reactively. A select event can precede motion settling; use live visibleItems for the currently visible content.

CarouselControls adds prev(), next(), scrollTo(index, instant?), play() and pause(). The component ref exposes the same methods, state, element, viewport and refresh(). Normal size changes are observed automatically; use refresh() to request a new measurement after an unusual external layout change.

The public types are exported from the package root. Engine instances and engine-specific options are not part of the public API.