<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>
Usage
import { Carousel } from '@hina-ui/vue'
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>
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>
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.#indicatorsreplaces the entire picker while preserving the previous and next buttons. It receives allCarouselControlsstate and methods plusviewportIdforaria-controls, supporting progress bars, counters and custom navigation.- Either slot enables the indicator region without the
indicatorsprop.#controlsstill 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>
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>
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;
loopenables 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>
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>
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-freeallows 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-readyanddata-orientationare on the root; slide wrappers exposedata-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
| Slot | Parameters | Purpose |
|---|---|---|
default | CarouselItemSlot<T> | { item, index, isVisible, ready } |
controls | CarouselControls | Replace the navigation row |
indicators | CarouselIndicatorsSlot | Replace the picker, retaining arrow controls; CarouselControls plus viewportId |
indicator | CarouselIndicatorSlot | Item 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
}
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.