Keyboard shortcut: Ctrl + K
Get started

Carousel

Slides scrolled through by touch, trackpad, scrollbar or keyboard. It is one native scroll-snap container: nothing is cloned, and how many slides fit is decided by CSS without a single breakpoint.

Usage

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VCarousel, VCarouselItem } from 'vectis-ui'

const slide = ref(0)
</script>

<template>
  <VCarousel v-model="slide" label="Gallery">
    <VCarouselItem><p class="slide">First slide</p></VCarouselItem>
    <VCarouselItem><p class="slide">Second slide</p></VCarouselItem>
    <VCarouselItem><p class="slide">Third slide</p></VCarouselItem>
  </VCarousel>
</template>

<style scoped>
/* A slide has no height of its own: it takes the one its content brings. */
.slide {
  display: grid;
  place-items: center;
  block-size: 12rem;
  margin: 0;
  border-radius: var(--vectis-radius-surface);
  background: var(--vectis-color-surface-muted);
}
</style>

Examples

Items per view

itemsPerView is how many slides may be visible at once, and itemMinSize how small each one may get before fewer of them fit.

vue
<script setup lang="ts">
import { VCarousel, VCarouselItem } from 'vectis-ui'

/* Flat colours rather than pictures, so one slide is told from the next at a glance. */
const hues = [220, 280, 340, 20, 90, 160]
</script>

<template>
  <VCarousel label="Three at a time" :items-per-view="3" :item-min-size="180">
    <VCarouselItem v-for="(hue, i) in hues" :key="hue">
      <p class="slide" :style="{ background: `oklch(0.45 0.15 ${hue})` }">{{ i + 1 }}</p>
    </VCarouselItem>
  </VCarousel>
</template>

<style scoped>
/* A slide has no height of its own: it takes the one its content brings. */
.slide {
  display: grid;
  place-items: center;
  block-size: 10rem;
  margin: 0;
  border-radius: var(--vectis-radius-surface);
  color: var(--vectis-color-text-on-accent);
  font-size: var(--vectis-text-heading-3-size);
  font-weight: var(--vectis-text-heading-3-weight);
}
</style>

Peek

peek leaves a strip of the next slide showing, the gap before it included.

vue
<script setup lang="ts">
import { VCarousel, VCarouselItem } from 'vectis-ui'

const products = ['Kettle', 'Toaster', 'Blender', 'Grinder', 'Scale', 'Press']
const hues = [220, 280, 340, 20, 90, 160]
</script>

<template>
  <VCarousel label="Product list" :items-per-view="2" peek="3rem">
    <VCarouselItem v-for="(product, i) in products" :key="product">
      <p class="slide" :style="{ background: `oklch(0.45 0.15 ${hues[i]})` }">{{ product }}</p>
    </VCarouselItem>
  </VCarousel>
</template>

<style scoped>
.slide {
  display: grid;
  place-items: center;
  block-size: 10rem;
  margin: 0;
  border-radius: var(--vectis-radius-surface);
  color: var(--vectis-color-text-on-accent);
  font-size: var(--vectis-text-heading-3-size);
  font-weight: var(--vectis-text-heading-3-weight);
}
</style>

Effects

effect decides how one slide gives way to the next: slide animates nothing, fade dissolves each slide in place and needs one slide at a time with no peek, scale pushes the neighbours back.

vue
slide
fade
scale
<script setup lang="ts">
import { VCarousel, VCarouselItem, VTypography, type CarouselEffect } from 'vectis-ui'

const effects: CarouselEffect[] = ['slide', 'fade', 'scale']
const hues = [220, 280, 340, 20, 90, 160]
</script>

<template>
  <div v-for="effect in effects" :key="effect" class="group">
    <VTypography variant="overline" tone="muted">{{ effect }}</VTypography>
    <VCarousel :effect="effect" :label="`The ${effect} effect`">
      <VCarouselItem v-for="(hue, i) in hues" :key="hue">
        <p class="slide" :style="{ background: `oklch(0.45 0.15 ${hue})` }">{{ i + 1 }}</p>
      </VCarouselItem>
    </VCarousel>
  </div>
</template>

<style scoped>
.group {
  display: grid;
  gap: var(--vectis-space-2);
}
.slide {
  display: grid;
  place-items: center;
  block-size: 10rem;
  margin: 0;
  border-radius: var(--vectis-radius-surface);
  color: var(--vectis-color-text-on-accent);
  font-size: var(--vectis-text-heading-3-size);
  font-weight: var(--vectis-text-heading-3-weight);
}
</style>

Orientation

orientation set to vertical turns the whole component onto the block axis. Give a height as well, which the slides take their share of.

vue
horizontal
vertical
<script setup lang="ts">
import { VCarousel, VCarouselItem, VTypography } from 'vectis-ui'

const hues = [220, 280, 340, 20, 90, 160]
</script>

<template>
  <div class="group">
    <VTypography variant="overline" tone="muted">horizontal</VTypography>
    <VCarousel label="Horizontal gallery">
      <VCarouselItem v-for="(hue, i) in hues" :key="hue">
        <p class="slide" :style="{ background: `oklch(0.45 0.15 ${hue})` }">{{ i + 1 }}</p>
      </VCarouselItem>
    </VCarousel>
  </div>

  <div class="group">
    <VTypography variant="overline" tone="muted">vertical</VTypography>
    <!-- Scrolling downwards, the height is the reference a slide takes its share of. -->
    <VCarousel orientation="vertical" height="14rem" label="Vertical gallery">
      <VCarouselItem v-for="(hue, i) in hues" :key="hue">
        <p class="slide fill" :style="{ background: `oklch(0.45 0.15 ${hue})` }">{{ i + 1 }}</p>
      </VCarouselItem>
    </VCarousel>
  </div>
</template>

<style scoped>
.group {
  display: grid;
  gap: var(--vectis-space-2);
}
.slide {
  display: grid;
  place-items: center;
  block-size: 10rem;
  margin: 0;
  border-radius: var(--vectis-radius-surface);
  color: var(--vectis-color-text-on-accent);
  font-size: var(--vectis-text-heading-3-size);
  font-weight: var(--vectis-text-heading-3-weight);
}
/* The vertical slide is already sized by the carousel: the content fills it rather than
   bringing a height of its own. */
.fill {
  block-size: 100%;
}
</style>

Custom icons

prevIcon and nextIcon take an IconSource, and prevLabel and nextLabel the words those buttons announce.

vue
chevrons
carets
<script setup lang="ts">
import { VCarousel, VCarouselItem, VTypography } from 'vectis-ui'
import {
  chevron_left as chevronLeft,
  chevron_right as chevronRight,
  expand_less as expandLess,
  expand_more as expandMore,
} from 'vectis-ui/icons'

const hues = [220, 280, 340, 20, 90, 160]
</script>

<template>
  <div class="group">
    <VTypography variant="overline" tone="muted">chevrons</VTypography>
    <VCarousel label="Chevron controls" :prev-icon="chevronLeft" :next-icon="chevronRight">
      <VCarouselItem v-for="(hue, i) in hues" :key="hue">
        <p class="slide" :style="{ background: `oklch(0.45 0.15 ${hue})` }">{{ i + 1 }}</p>
      </VCarouselItem>
    </VCarousel>
  </div>

  <div class="group">
    <VTypography variant="overline" tone="muted">carets</VTypography>
    <VCarousel
      orientation="vertical"
      height="14rem"
      label="Caret controls"
      :prev-icon="expandLess"
      :next-icon="expandMore"
    >
      <VCarouselItem v-for="(hue, i) in hues" :key="hue">
        <p class="slide fill" :style="{ background: `oklch(0.45 0.15 ${hue})` }">{{ i + 1 }}</p>
      </VCarouselItem>
    </VCarousel>
  </div>
</template>

<style scoped>
.group {
  display: grid;
  gap: var(--vectis-space-2);
}
.slide {
  display: grid;
  place-items: center;
  block-size: 10rem;
  margin: 0;
  border-radius: var(--vectis-radius-surface);
  color: var(--vectis-color-text-on-accent);
  font-size: var(--vectis-text-heading-3-size);
  font-weight: var(--vectis-text-heading-3-weight);
}
.fill {
  block-size: 100%;
}
</style>

Placements

controls and indicators are placed independently: inside lays them over the slides, outside puts them beside, false removes them.

vue
arrows inside, dots outside
arrows inside, dots inside
arrows outside, dots outside
arrows outside, dots inside
<script setup lang="ts">
import {
  VCarousel,
  VCarouselItem,
  VTypography,
  type CarouselControls,
  type CarouselIndicators,
} from 'vectis-ui'

const placements: {
  caption: string
  controls: CarouselControls
  indicators: CarouselIndicators
}[] = [
  { caption: 'arrows inside, dots outside', controls: 'inside', indicators: 'outside' },
  { caption: 'arrows inside, dots inside', controls: 'inside', indicators: 'inside' },
  { caption: 'arrows outside, dots outside', controls: 'outside', indicators: 'outside' },
  { caption: 'arrows outside, dots inside', controls: 'outside', indicators: 'inside' },
]

const hues = [220, 280, 340, 20, 90, 160]
</script>

<template>
  <div v-for="placement in placements" :key="placement.caption" class="group">
    <VTypography variant="overline" tone="muted">{{ placement.caption }}</VTypography>
    <VCarousel
      :controls="placement.controls"
      :indicators="placement.indicators"
      :label="placement.caption"
    >
      <VCarouselItem v-for="(hue, i) in hues" :key="hue">
        <p class="slide" :style="{ background: `oklch(0.45 0.15 ${hue})` }">{{ i + 1 }}</p>
      </VCarouselItem>
    </VCarousel>
  </div>
</template>

<style scoped>
.group {
  display: grid;
  gap: var(--vectis-space-2);
}
.slide {
  display: grid;
  place-items: center;
  block-size: 10rem;
  margin: 0;
  border-radius: var(--vectis-radius-surface);
  color: var(--vectis-color-text-on-accent);
  font-size: var(--vectis-text-heading-3-size);
  font-weight: var(--vectis-text-heading-3-weight);
}
</style>

Jumps

A move of more than one page goes straight there and plays the effect once on arrival. noJump puts the whole travel back, on every route.

vue
straight there, the default
the whole travel
<script setup lang="ts">
import { VCarousel, VCarouselItem, VTypography } from 'vectis-ui'

const hues = [220, 280, 340, 20, 90, 160]
</script>

<template>
  <div class="group">
    <VTypography variant="overline" tone="muted">straight there, the default</VTypography>
    <VCarousel effect="fade" label="Jumping gallery">
      <VCarouselItem v-for="(hue, i) in hues" :key="hue">
        <p class="slide" :style="{ background: `oklch(0.45 0.15 ${hue})` }">{{ i + 1 }}</p>
      </VCarouselItem>
    </VCarousel>
  </div>

  <div class="group">
    <VTypography variant="overline" tone="muted">the whole travel</VTypography>
    <VCarousel effect="fade" no-jump label="Travelling gallery">
      <VCarouselItem v-for="(hue, i) in hues" :key="hue">
        <p class="slide" :style="{ background: `oklch(0.45 0.15 ${hue})` }">{{ i + 1 }}</p>
      </VCarouselItem>
    </VCarousel>
  </div>
</template>

<style scoped>
.group {
  display: grid;
  gap: var(--vectis-space-2);
}
.slide {
  display: grid;
  place-items: center;
  block-size: 10rem;
  margin: 0;
  border-radius: var(--vectis-radius-surface);
  color: var(--vectis-color-text-on-accent);
  font-size: var(--vectis-text-heading-3-size);
  font-weight: var(--vectis-text-heading-3-weight);
}
</style>

Loop

loop leads the last position back to the first, so neither button is ever disabled. It applies to the buttons, the arrow keys and autoplay.

vue
<script setup lang="ts">
import { VCarousel, VCarouselItem } from 'vectis-ui'

const hues = [220, 280, 340, 20, 90, 160]
</script>

<template>
  <VCarousel loop label="Looping gallery">
    <VCarouselItem v-for="(hue, i) in hues" :key="hue">
      <p class="slide" :style="{ background: `oklch(0.45 0.15 ${hue})` }">{{ i + 1 }}</p>
    </VCarouselItem>
  </VCarousel>
</template>

<style scoped>
.slide {
  display: grid;
  place-items: center;
  block-size: 12rem;
  margin: 0;
  border-radius: var(--vectis-radius-surface);
  color: var(--vectis-color-text-on-accent);
  font-size: var(--vectis-text-heading-3-size);
  font-weight: var(--vectis-text-heading-3-weight);
}
</style>

Autoplay

autoplay is an interval in milliseconds, zero disabling it. It holds on hover and on keyboard focus, and never runs for a reader who has asked for less motion. No pause button is rendered: add one, as the example does.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VCarousel, VCarouselItem } from 'vectis-ui'

/* The stop control the component leaves to you: the prop is reactive, so 0 cancels the
   timer on the spot. Hover and keyboard focus already hold the rotation, which leaves a
   touch user with nothing without this button. */
const paused = ref(false)

const hues = [220, 280, 340, 20, 90, 160]
</script>

<template>
  <div class="group">
    <VButton variant="outline" tone="neutral" size="sm" class="stop" @click="paused = !paused">
      {{ paused ? 'Resume' : 'Pause' }}
    </VButton>

    <VCarousel :autoplay="paused ? 0 : 3000" label="Rotating gallery">
      <VCarouselItem v-for="(hue, i) in hues" :key="hue">
        <p class="slide" :style="{ background: `oklch(0.45 0.15 ${hue})` }">{{ i + 1 }}</p>
      </VCarouselItem>
    </VCarousel>
  </div>
</template>

<style scoped>
.group {
  display: grid;
  gap: var(--vectis-space-3);
}
.stop {
  justify-self: start;
}
.slide {
  display: grid;
  place-items: center;
  block-size: 12rem;
  margin: 0;
  border-radius: var(--vectis-radius-surface);
  color: var(--vectis-color-text-on-accent);
  font-size: var(--vectis-text-heading-3-size);
  font-weight: var(--vectis-text-heading-3-weight);
}
</style>

API

Props

VCarousel
PropTypeDefault
itemsPerViewnumber1
How many slides may be visible at once. It is a maximum and not a target: the floor below decides how many actually fit, which is what makes the whole thing responsive without a breakpoint.
itemMinSizenumber | stringnone
How small a slide is allowed to get. Once an equal share would fall below this, fewer slides fit and the carousel scrolls further instead. A number is read as pixels; anything else is used as given, so '20vw' works.
peeknumber | stringnone
How much of the next slide is left showing, as a hint that there is more. It includes the gap before it. It cannot be combined with the fade effect, which assumes a slide exactly fills the view.
gapnumber | stringnone
The space between two slides.
orientationCarouselOrientation'horizontal' | 'vertical''horizontal'
Whether the carousel scrolls across the page or down it.
effectCarouselEffect'slide' | 'fade' | 'scale''slide'
How one slide gives way to the next, driven by the scroll itself. Sliding means no animation at all. Fading requires one slide at a time and no peek, since it works by holding each slide in place while the scroll moves under it; asked for otherwise, it falls back to sliding rather than degrading.
heightnumber | stringnone
The height of the visible area. Give one when the carousel scrolls downwards: a slide sized as a share of the height needs a height to take a share of, and without it every slide collapses onto its own content. Scrolling across the page, the height comes from the slides themselves.
loopbooleanfalse
Whether the carousel comes back round: past the last position it returns to the first, and before the first it goes to the last. Nothing is cloned to achieve it: the real track goes back to the beginning, at once, playing the transition on arrival rather than passing every slide in between. It has no effect where there is only one position to rest on, and the buttons stay disabled there rather than becoming two controls that do nothing.
noJumpbooleanfalse
Whether a move of more than one page keeps the whole scroll instead of going straight there. Off by default: a dot five pages away lands at once and plays the transition once, on arrival. Turn it on when the travel is the point, over a handful of slides where watching the track run past says something about how far the reader has moved. It covers every route, the dots, the Home and End keys and a looping carousel coming back round, and it changes nothing for a reader who has asked for less motion, that preference already making every scroll instant.
autoplaynumber0
How long each slide is shown before the next, in milliseconds; zero means it does not advance by itself. It stops at the last page unless the carousel loops, pauses while the pointer rests on it or the keyboard focus is inside it, and never runs at all for a reader who has asked for less motion. No pause button is rendered: this prop is reactive, so binding it to zero is a one-line stop control on your side, and it is worth adding, since hover and focus leave a touch user with none. Looping makes that binding necessary rather than advisable, the movement no longer ending on its own.
controlsCarouselControlsfalse | 'inside' | 'outside''inside'
Where the previous and next buttons go: over the slides, beside them, or nowhere. Placed beside, their room is reserved as padding, so the component's footprint is unchanged and the slides narrow instead. Either way they are centred on the slides and never on the slides plus the dots.
indicatorsCarouselIndicatorsfalse | 'inside' | 'outside''outside'
Where the position dots go: over the slides, after them, or nowhere. After them means below when the carousel scrolls across the page, and beside it when it scrolls down.
controlsVisibilityCarouselControlsVisibility'always' | 'hover''always'
Whether those buttons are always visible, or appear when the pointer is over the carousel or the keyboard focus is inside it. Where there is no pointer to hover with, they stay visible whatever this says. The dots are never hidden.
prevIconIconSourcenone
The icon of the previous button. It follows the orientation by default.
nextIconIconSourcenone
The icon of the next button. It follows the orientation by default.
prevLabelstringnone
What the previous button does, in words. It falls back to the dictionary.
nextLabelstringnone
What the next button does, in words. It falls back to the dictionary.
labelstringnone
What screen readers announce for the carousel as a whole. Give a distinct one to every carousel on a page: this is a landmark, and two landmarks bearing the same name cannot be told apart by someone navigating between them.
v-modelnumber0
Which slide is current: the first one fully visible when several fit at once, which is also the position the carousel has come to rest on. A value outside the positions the carousel can rest on is brought back into range.
VCarouselItem
PropTypeDefault
indexnumber0
Which slide this is among its siblings. The carousel injects it as it renders them. Never pass it by hand: it is what makes the "3 of 8" a screen reader announces identical on the server and in the browser.

Slots

VCarousel
SlotType
default{}
The slides. How many there are is read from what this slot renders, so a v-for is perfectly fine, but the slot must not depend on something only true in a browser, or the server and the client would count differently.
controlsCarouselControlsSlotProps
Replaces the previous and next buttons entirely, their placement included, so custom content positions itself and the visibility setting no longer applies to it.
indicatorsCarouselIndicatorsSlotProps
Replaces the whole bar of dots. Render one control per position and not per slide: a position past the last one cannot be reached, so a bar built on the number of slides offers dots that scroll nowhere. The slide count is passed as well, for wording such as "3 of 8".
indicatorCarouselIndicatorSlotProps
Replaces what is drawn inside one dot. The button itself, and everything that makes it announce and behave correctly, stays the design system's.
VCarouselItem
SlotType
default{}
The slide's content: an image, a card, free text.

Types

The types the tables above name, written as the library declares them. The ones carrying export can be imported from vectis-ui to type your own code; the others are the shape of what a slot hands out.

export interface BuiltinIcon {
  name: string
  paths: readonly [string] | readonly [string, string]
}
export interface CarouselControlsSlotProps {
  previous: () => void
  next: () => void
  atStart: boolean
  atEnd: boolean
  index: number
  count: number
  pageCount: number
  orientation: CarouselOrientation
}
export interface CarouselIndicatorSlotProps {
  index: number
  active: boolean
}
export interface CarouselIndicatorsSlotProps {
  index: number
  count: number
  pageCount: number
  goTo: (index: number) => void
  orientation: CarouselOrientation
}
export type CarouselOrientation = 'horizontal' | 'vertical'
export type IconRender =
  | { path: string; viewBox?: string }
  | { component: Component; props?: Record<string, unknown> }
  | { src: string }
  | { text: string; class?: string }
  | { class: string }
export type IconSource = string | BuiltinIcon | IconRender

CSS variables

TokenValue
--vectis-control-size-carousel-block24rem
--vectis-control-size-carousel-indicator0.625rem
--vectis-control-size-carousel-indicator-active1.25rem