Carousel
A scroll-snap carousel with arrows, dots, autoplay, several slides in view, and an optional cross-fade.
<script setup lang="ts">
import SaCarousel from '@shared/components/common/SaCarousel.vue'
const PHOTOS = [
{ src: '/images/covers/mountain-lake.jpg', title: 'Mountain lake', alt: 'Still water below a snow-topped ridge' },
{ src: '/images/covers/city.jpg', title: 'City at dusk', alt: 'Tower blocks lit against an evening sky' },
{ src: '/images/covers/office.jpg', title: 'The studio', alt: 'A bright desk with plants along the window' },
{ src: '/images/covers/sample-workspace.jpg', title: 'Workspace', alt: 'A laptop and notebook on a pale desk' },
]
</script>
<template>
<SaCarousel :count="PHOTOS.length" label="Photographs" class="max-w-xl">
<template #default="{ index }">
<figure class="w-full overflow-hidden rounded-xl">
<img :src="PHOTOS[index].src" :alt="PHOTOS[index].alt" class="h-56 w-full object-cover">
<figcaption class="px-1 pt-2 text-sm text-muted-foreground">
{{ PHOTOS[index].title }}
</figcaption>
</figure>
</template>
</SaCarousel>
</template>Usage
count is the number of slides. The default slot is rendered once per slide and receives { index }, zero-based, so the slide's content comes from your own array.
It moves by scroll-snap rather than by translating a track. The scroll container does the work, which is why a drag on a touch screen behaves exactly as the reader expects and the keyboard arrows come for free.
Autoplay pauses while the pointer is over the carousel and while focus is inside it, so it cannot move under someone who is reading or tabbing through.
Examples
Several slides at once
slideClass is the width of one slide, as Tailwind classes. It is a class string rather than a perView number because breakpoints are the point, and a number cannot express them without reinventing the breakpoint system.
:loop="false" stops the wrap and disables the arrow at each end.
<script setup lang="ts">
import SaCarousel from '@shared/components/common/SaCarousel.vue'
const PHOTOS = [
{ src: '/images/covers/mountain-lake.jpg', title: 'Mountain lake', alt: 'Still water below a snow-topped ridge' },
{ src: '/images/covers/city.jpg', title: 'City at dusk', alt: 'Tower blocks lit against an evening sky' },
{ src: '/images/covers/office.jpg', title: 'The studio', alt: 'A bright desk with plants along the window' },
{ src: '/images/covers/sample-workspace.jpg', title: 'Workspace', alt: 'A laptop and notebook on a pale desk' },
{ src: '/images/covers/iridescent-hero.jpg', title: 'Iridescent', alt: 'Hands holding glasses, light splitting into color through the lenses' },
]
</script>
<template>
<SaCarousel
:count="PHOTOS.length"
label="Photographs"
slide-class="basis-full sm:basis-1/2 lg:basis-1/3"
:loop="false"
class="max-w-2xl"
>
<template #default="{ index }">
<figure class="mx-1 overflow-hidden rounded-xl border bg-card">
<img :src="PHOTOS[index].src" :alt="PHOTOS[index].alt" class="h-32 w-full object-cover">
<figcaption class="truncate p-3 text-sm font-medium">
{{ PHOTOS[index].title }}
</figcaption>
</figure>
</template>
</SaCarousel>
</template>Cross-fade and autoplay
fade is a different mechanism, not a different transition. Fading needs the slides stacked in one grid cell, which a scroll container cannot do, so the viewport stops scrolling and the current slide is set directly. The container takes the height of the tallest slide, so nothing below it moves as the slides change.
It suits a hero, where movement across the page pulls the eye off whatever is written on top of it.
autoplay is an interval in milliseconds. 0, the default, turns it off.
<script setup lang="ts">
import SaCarousel from '@shared/components/common/SaCarousel.vue'
const PHOTOS = [
{ src: '/images/covers/mountain-lake.jpg', title: 'Mountain lake', alt: 'Still water below a snow-topped ridge' },
{ src: '/images/covers/iridescent-hero.jpg', title: 'Iridescent', alt: 'Hands holding glasses, light splitting into color through the lenses' },
{ src: '/images/covers/city.jpg', title: 'City at dusk', alt: 'Tower blocks lit against an evening sky' },
]
</script>
<template>
<SaCarousel
:count="PHOTOS.length"
label="Photographs, fading"
fade
:autoplay="4000"
:show-arrows="false"
class="max-w-xl"
>
<template #default="{ index }">
<figure class="w-full overflow-hidden rounded-xl">
<img :src="PHOTOS[index].src" :alt="PHOTOS[index].alt" class="h-56 w-full object-cover">
<figcaption class="px-1 pt-2 text-sm text-muted-foreground">
{{ PHOTOS[index].title }}
</figcaption>
</figure>
</template>
</SaCarousel>
</template>Driving it from outside
current, goTo, next and prev are exposed on the component instance, so a page can move the carousel from its own controls with the built-in ones turned off.
<script setup lang="ts">
import SaCarousel from '@shared/components/common/SaCarousel.vue'
import { Button } from '@shared/components/ui/button'
import { useTemplateRef } from 'vue'
const PHOTOS = [
{ src: '/images/covers/office.jpg', step: 'Connect your API', alt: 'A bright desk with plants along the window' },
{ src: '/images/covers/sample-workspace.jpg', step: 'Generate a module', alt: 'A laptop and notebook on a pale desk' },
{ src: '/images/covers/city.jpg', step: 'Set permissions', alt: 'Tower blocks lit against an evening sky' },
]
const carousel = useTemplateRef<InstanceType<typeof SaCarousel>>('carousel')
</script>
<template>
<div class="max-w-xl space-y-3">
<SaCarousel
ref="carousel"
:count="PHOTOS.length"
label="Onboarding steps"
:show-arrows="false"
:show-dots="false"
>
<template #default="{ index }">
<figure class="relative w-full overflow-hidden rounded-xl">
<img :src="PHOTOS[index].src" :alt="PHOTOS[index].alt" class="h-40 w-full object-cover">
<figcaption class="absolute inset-x-0 bottom-0 bg-background/85 p-2 text-sm font-medium backdrop-blur-sm">
{{ index + 1 }}. {{ PHOTOS[index].step }}
</figcaption>
</figure>
</template>
</SaCarousel>
<div class="flex gap-2">
<Button size="sm" variant="outline" @click="carousel?.prev()">
Back
</Button>
<Button size="sm" variant="outline" @click="carousel?.next()">
Next
</Button>
<Button size="sm" variant="outline" @click="carousel?.goTo(0)">
Start over
</Button>
</div>
</div>
</template>API
Source: src/shared/components/common/SaCarousel.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
count | number | required | The number of slides. |
showArrows | boolean | true | The previous and next buttons. Hidden when there is one slide. |
showDots | boolean | true | The dot row. Hidden when there is one slide. |
autoplay | number | 0 | Interval in milliseconds. 0 disables it. |
loop | boolean | true | Wrap from the last slide back to the first. |
label | string | — | The accessible name. Defaults to "Carousel". |
slideClass | string | 'w-full' | The width of one slide, as Tailwind classes. |
fade | boolean | false | Cross-fade stacked slides instead of scrolling. |
Emits
None.
Slots
| Slot | Props | Description |
|---|---|---|
default | index: number | Rendered once per slide, zero-based. |
Exposed
| Member | Type | Description |
|---|---|---|
current | Ref<number> | The active slide, zero-based. |
goTo(index) | (index: number) => void | Move to a slide. |
next() | () => void | One forward, wrapping when loop. |
prev() | () => void | One back, wrapping when loop. |
Accessibility
- The root is a
regionwitharia-roledescription="carousel", named bylabel. - Each slide is a
groupwitharia-roledescription="slide"and a name of the form "2 of 4". - The scroll container takes focus, and
ArrowLeftandArrowRightmove a slide. They map to what is visually previous and next, so they swap in a right-to-left layout. - The arrows are named "Previous slide" and "Next slide", and each dot is named "Go to slide 3". The active dot carries
aria-currentas well as being wider. - In fade mode the slides that are not showing are
aria-hiddenand take no pointer events, so a link inside a hidden slide cannot be reached. - Autoplay pauses on pointer-over and on focus-within.