Carousels

Carousel

Nuxt UI's carousel with every slide pushed into a 3D coverflow fan.

MCarousel is Nuxt UI's UCarousel with the slide motion replaced. Scrolling, snapping, controls and looping stay exactly as they were — you get the same items array, the same { item, index } slot and the same props. What changes is the presentation: each slide is wrapped in a motion element that pushes it sideways, rotates it on the Y axis and pulls it back in Z, so the neighbours read as a fan instead of a strip.

Distance drives everything. The offset of a slide from the selected one sets its rotation, its scale, its depth and its stacking order, so the geometry stays consistent for any combination of props — change the rotation and the fan opens or closes, change the depth and it flattens.

Beyond three slides away a slide is faded out and, with loop, an offset is measured the short way round the ring so the last slide turns into the first rather than flying back across the whole track.

Usage

The default slot is scoped, so declare it with v-slot="{ item, index }" on <MCarousel>. Everything the Nuxt UI carousel accepts — items, loop, arrows, dots, ui, orientation — is forwarded through $attrs, so set coverflow to false to get the plain back out.

Iceland

North

Iceland

Kyoto

East

Kyoto

Patagonia

South

Patagonia

Dolomites

Alps

Dolomites

Namibia

Desert

Namibia

Slide 1 of 5 · the three on each side fan away in 3D

<template>
  <div class="flex w-full max-w-md flex-col gap-4">
    <MCarousel
      v-slot="{ item }"
      :items="items"
      :ui="{ item: 'w-full' }"
      class="rounded-2xl"
      loop
      arrows
      dots
      @select="active = $event"
    >
      <div class="relative h-56 w-full overflow-hidden rounded-2xl">
        <img
          :src="item.image"
          :alt="item.title"
          class="h-full w-full object-cover"
        >
        <div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 pt-12 text-white">
          <p class="text-xs uppercase tracking-widest text-white/70">
            {{ item.tag }}
          </p>
          <p class="text-lg font-semibold">
            {{ item.title }}
          </p>
        </div>
      </div>
    </MCarousel>

    <p class="text-center text-xs text-muted">
      Slide {{ active + 1 }} of {{ items.length }} · the three on each side fan away in 3D
    </p>
  </div>
</template>

<script setup lang="ts">
const active = ref(0)

const items = [
  { title: 'Iceland', tag: 'North', image: 'https://picsum.photos/seed/carousel-1/800/600' },
  { title: 'Kyoto', tag: 'East', image: 'https://picsum.photos/seed/carousel-2/800/600' },
  { title: 'Patagonia', tag: 'South', image: 'https://picsum.photos/seed/carousel-3/800/600' },
  { title: 'Dolomites', tag: 'Alps', image: 'https://picsum.photos/seed/carousel-4/800/600' },
  { title: 'Namibia', tag: 'Desert', image: 'https://picsum.photos/seed/carousel-5/800/600' },
]
</script>

Examples

Tuning the Fan

rotation sets the angle one slide away, depth sets how far each step is pushed back, and scaleStep sets how much each step shrinks. Raise all three for a wider, more theatrical fan.

Subtle · rotation 25 · depth 80 · scaleStep 0.1

01
02
03
04
05

Dramatic · rotation 60 · depth 260 · scaleStep 0.18

01
02
03
04
05
<template>
  <div class="flex w-full max-w-md flex-col gap-10 pb-2">
    <section>
      <p class="mb-6 text-xs font-medium uppercase tracking-wide text-muted">
        Subtle · rotation 25 · depth 80 · scaleStep 0.1
      </p>
      <MCarousel
        v-slot="{ item }"
        :items="slides"
        :ui="{ item: 'w-full' }"
        :rotation="25"
        :depth="80"
        :scale-step="0.1"
        loop
        arrows
      >
        <div
          class="flex h-40 w-full items-center justify-center rounded-2xl"
          :class="item.class"
        >
          <span class="text-3xl font-semibold text-white">{{ item.label }}</span>
        </div>
      </MCarousel>
    </section>

    <section>
      <p class="mb-6 text-xs font-medium uppercase tracking-wide text-muted">
        Dramatic · rotation 60 · depth 260 · scaleStep 0.18
      </p>
      <MCarousel
        v-slot="{ item }"
        :items="slides"
        :ui="{ item: 'w-full' }"
        :rotation="60"
        :depth="260"
        :scale-step="0.18"
        loop
        arrows
      >
        <div
          class="flex h-40 w-full items-center justify-center rounded-2xl"
          :class="item.class"
        >
          <span class="text-3xl font-semibold text-white">{{ item.label }}</span>
        </div>
      </MCarousel>
    </section>
  </div>
</template>

<script setup lang="ts">
const slides = [
  { label: '01', class: 'bg-gradient-to-br from-indigo-500 to-violet-600' },
  { label: '02', class: 'bg-gradient-to-br from-sky-500 to-cyan-500' },
  { label: '03', class: 'bg-gradient-to-br from-emerald-500 to-teal-600' },
  { label: '04', class: 'bg-gradient-to-br from-amber-500 to-orange-600' },
  { label: '05', class: 'bg-gradient-to-br from-rose-500 to-pink-600' },
]
</script>

Inverted

Set inverted to mirror the rotation, so the fan curves the other way. Travel and snapping are unchanged — only which way the slides turn.

01
02
03
04
05
<template>
  <div class="w-full max-w-md pb-8">
    <MCarousel
      v-slot="{ item }"
      :items="slides"
      :ui="{ item: 'w-full' }"
      :inverted="true"
      :rotation="55"
      loop
      arrows
      dots
    >
      <div
        class="flex h-48 w-full items-center justify-center rounded-2xl"
        :class="item.class"
      >
        <span class="text-4xl font-semibold text-white">{{ item.label }}</span>
      </div>
    </MCarousel>
  </div>
</template>

<script setup lang="ts">
const slides = [
  { label: '01', class: 'bg-gradient-to-br from-sky-500 to-indigo-600' },
  { label: '02', class: 'bg-gradient-to-br from-violet-500 to-fuchsia-600' },
  { label: '03', class: 'bg-gradient-to-br from-rose-500 to-red-600' },
  { label: '04', class: 'bg-gradient-to-br from-amber-500 to-orange-600' },
  { label: '05', class: 'bg-gradient-to-br from-emerald-500 to-teal-600' },
]
</script>

Plain

With coverflow set to false the slot renders straight into the carousel, so the same markup becomes a plain UCarousel with no 3D transform.

01
02
03
04
05
<template>
  <div class="w-full max-w-md pb-8">
    <MCarousel
      v-slot="{ item }"
      :items="slides"
      :ui="{ item: 'w-full' }"
      :coverflow="false"
      loop
      arrows
      dots
    >
      <div
        class="flex h-48 w-full items-center justify-center rounded-2xl"
        :class="item.class"
      >
        <span class="text-4xl font-semibold text-white">{{ item.label }}</span>
      </div>
    </MCarousel>
  </div>
</template>

<script setup lang="ts">
const slides = [
  { label: '01', class: 'bg-gradient-to-br from-slate-600 to-slate-800' },
  { label: '02', class: 'bg-gradient-to-br from-slate-600 to-slate-800' },
  { label: '03', class: 'bg-gradient-to-br from-slate-600 to-slate-800' },
  { label: '04', class: 'bg-gradient-to-br from-slate-600 to-slate-800' },
  { label: '05', class: 'bg-gradient-to-br from-slate-600 to-slate-800' },
]
</script>

Customize

Customize

Coverflow
Rotation45
Depth180
Scale Step0.15
Perspective1200
Inverted

Props

Prop Type Default Description
coverflowbooleantrueWhether slides get the 3D fan treatment. Set it to false and the slot renders straight into the carousel, unchanged — the same component becomes a plain UCarousel.
rotationnumber45Degrees of Y rotation at one slide of distance, signed by which side the slide is on. Slides two away therefore sit at roughly twice the angle.
depthnumber180Pixels each slide is pushed back in Z per step of distance, which is what makes the fan read as depth rather than as flat rotation.
scaleStepnumber0.15How much each step of distance shrinks a slide. Floored at 0.4, so a dense fan never collapses into invisibility.
perspectivenumber1200Perspective in pixels applied to each slide. Larger is flatter and further away, smaller is wider angle and more dramatic.
invertedbooleanfalseMirrors the rotation direction, so the fan curves the other way. Travel and snapping are unchanged — only which way the slides turn.
itemsunknown[]-The slide data, forwarded to UCarousel through $attrs. Also read internally to compute offsets, which is why it must be an array.
loopboolean-Forwarded to UCarousel, and also changes how offsets are measured: with loop on, an offset is taken the short way round the ring so the tail turns into the head instead of travelling the length of the track.
classstring-Merged onto UCarousel's root through $attrs.

Events

Event Payload Description
select(index: number) => voidFired with the index of the newly selected slide, on Embla's select and again on reInit — so a resize that re-snaps reports the current position too.

Slots

Slot Props Description
default-Scoped with { item, index } from UCarousel, the same shape you would get from the Nuxt UI carousel directly. When coverflow is on it is wrapped in a motion element that carries the 3D transform.

Accessibility

The component adds nothing of its own: no roles, no ARIA attributes, no keyboard handling. Everything comes from UCarousel, which renders its own region, slides and controls — MCarousel only replaces the visual wrapper on each slide, leaving Embla's keyboard and focus behaviour in place.

Under prefers-reduced-motion the 3D is dropped entirely. Each slide animates to a flat state with a zero-duration transition: the selected one at full opacity, every other at zero, so only the current slide is visible. Snapping and the select event still work, so the carousel stays navigable — just without the fan.

Performance

  • Offsets are computed per slide per render from one selectedIndex ref — no layout reads, no measurement loop.
  • The only animated properties are rotateY, scale, z and opacity, all compositor-side, and every slide carries will-change: transform and transform-style: preserve-3d.
  • Depth beyond three slides is clamped for both the opacity cut-off and the stacking order, so the tail of a long track costs the same as its head.
  • The Embla API is captured once with a post-flush watcher and its listeners are removed on unmount; re-attaching to the same instance is a no-op.
  • inheritAttrs is off, so attributes are forwarded once to UCarousel rather than being applied to the root and to the inner carousel.
scaleStep and depth are measured per step of distance, so changing them on a five-item carousel and then on a twenty-item one produces different geometry. If you want the same look across different list lengths, divide them by the number of slides you intend to show.
Copyright © 2026