Carousel

Slides that shrink and wait behind the neighbours away from the center, dots that stretch from one to the next and a sliding counter. A drop-in replacement for embla-carousel-vue, with the same API.

<script setup lang="ts">
import { computed } from 'vue'
import { Card, CardContent } from '@/components/ui/card'
import { CarouselCounter, CarouselDots } from '@animate-ui/vue/primitives/carousel'
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from './carousel'

const props = withDefaults(
  defineProps<{
    scale?: number | string
    visible?: number | string
    loop?: boolean | string
    dots?: boolean | string
    counter?: boolean | string
  }>(),
  { scale: 0.8, visible: 3, loop: true, dots: true, counter: true },
)

const on = (value: boolean | string) => String(value) === 'true'
const shown = computed(() => Number(props.visible))
// The row's width in slides: the selected one, then the smaller ones out.
const row = computed(() => {
  const scale = Number(props.scale)
  let width = 1
  for (let n = 1; n <= (shown.value - 1) / 2; n++) width += 2 * scale ** n
  if (shown.value % 2 === 0) width += scale ** (shown.value / 2)
  return width
})
// A loop needs a slide more than it shows on each side.
const count = computed(() => Math.max(5, 2 * Math.floor(shown.value / 2) + 3))
</script>

<template>
  <div class="flex w-full flex-col items-center gap-4">
    <Carousel
      :key="`${on(loop)}-${count}`"
      :opts="{ loop: on(loop), align: 'center', containScroll: false, scale: Number(scale), visible: shown }"
      class="w-full"
      :style="{ maxWidth: `min(${16 * row}rem, 100% - 7rem)` }"
    >
      <CarouselContent class="-ml-1">
        <CarouselItem v-for="(_, index) in count" :key="index" class="pl-1">
          <div>
            <Card class="border-[color-mix(in_oklab,var(--foreground)_10%,var(--card))]">
              <CardContent class="flex aspect-[13/15] items-center justify-center p-6">
                <span class="text-4xl font-semibold select-none">{{ index + 1 }}</span>
              </CardContent>
            </Card>
          </div>
        </CarouselItem>
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
      <div v-if="on(dots) || on(counter)" class="mt-4 flex flex-col items-center gap-2">
        <CarouselDots v-if="on(dots)" />
        <CarouselCounter v-if="on(counter)" class="text-sm text-muted-foreground" />
      </div>
    </Carousel>
  </div>
</template>

Installation

Install the packages

npm install @animate-ui/vue motion-v
pnpm add @animate-ui/vue motion-v
yarn add @animate-ui/vue motion-v
bun add @animate-ui/vue motion-v

Add the shadcn-vue component

npx shadcn-vue@latest add carousel
pnpm dlx shadcn-vue@latest add carousel
yarn dlx shadcn-vue@latest add carousel
bun x shadcn-vue@latest add carousel

Animate it

The CLI finds your shadcn-vue components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate carousel
pnpm dlx @animate-ui/cli@latest animate carousel
yarn dlx @animate-ui/cli@latest animate carousel
bun x @animate-ui/cli@latest animate carousel

shadcn-vue's Carousel is built on Embla Carousel. Animate UI's carousel exports exactly the same emblaCarouselVue and types and adds the animation, set from opts. animate-ui animate carousel swaps the embla-carousel-vue import for it. It is built on embla-carousel-vue, so keep that dependency (shadcn-vue add carousel installs it).

Usage

<script setup lang="ts">
import {
  CarouselCounter,
  CarouselDots,
} from '@animate-ui/vue/primitives/carousel'
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/ui/carousel'
</script>

<template>
  <Carousel :opts="{ loop: true, align: 'center', scale: 0.8, visible: 3 }">
    <CarouselContent class="-ml-1">
      <CarouselItem v-for="index in 5" :key="index" class="pl-1">
        <img
          :src="`/photo-${index}.jpg`"
          class="aspect-square rounded-xl object-cover"
        />
      </CarouselItem>
    </CarouselContent>
    <CarouselPrevious />
    <CarouselNext />
    <CarouselDots />
    <CarouselCounter />
  </Carousel>
</template>

Everything else is shadcn-vue's and Embla's: parts, sizes, spacing, orientation, options, API, events and plugins. Without scale and visible, it lays out and moves exactly like shadcn-vue's.

  • scale is the neighbours' scale, from 0 to 1: the slides shrink away from the center (1 or none leaves them as they are). Use it with align: 'center', in a loop for slides on both sides (without one, add containScroll: false so the first and last slides come to the center too). It scales each slide's content, its first child, so give each CarouselItem one.
  • visible sizes the slides so that many show, the selected one in the middle, edge to edge with scale, keeping your spacing (pl-* on the items). With an even number, the slides at the edges show half. A loop needs a slide more than it shows on each side: at least 5 slides for 3 visible, 7 for 4 or 5. The slides are sized once the carousel mounts: on a server-rendered page, give CarouselItem a close basis to start from.
  • The edges clip what overflows them, so draw a slide's outline inside it (a border or an inset ring).
  • The arrows sit along the slides, whatever you put under them, standing up too.
  • The slides out of sight, behind the others or past the edges, are inert and aria-hidden until they show.
  • CarouselDots and CarouselCounter come from the package: place them anywhere inside Carousel. Style them with classes (the dots and pill take currentColor). CarouselCounter's default slot gets current and count for your own text.

Animation

  • With scale, the slides follow the scroll, frame by frame, while dragged or snapping: the selected one at its full size, each slide out scale times smaller (0.8, then 0.64) and drawn in to keep the gap between slides, out to the ones visible shows; the ones past them wait behind, a little smaller still, and slide out from behind as they come in. In a loop, each slide takes the nearest way around, so both sides stay filled. It works the same standing up (orientation="vertical", with a height on CarouselContent). Each slide sits above the ones farther from the center.
  • CarouselDots: the current dot is a pill that follows the scroll, its front stretching to the next dot over the first half of the way and its back catching up over the second. In a loop, from the last slide to the first, it stretches back across all the dots the same way.
  • CarouselCounter: the number slides to the next one (Sliding Number).
  • Under reduced motion, the slides keep their size and the counter's number changes at once.

API Reference

emblaCarouselVue takes Embla's options and plugins; shadcn-vue's Carousel passes its opts to it. Animate UI adds:

opts

PropTypeDefault
scale?
number-
visible?
number-

CarouselDots

The dots, one per slide.

CarouselCounter

Its default slot gets current and count; without one, it reads Slide {current} of {count}.

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/27/2026