Skip to content
Sponsored by

MagicCarousel

MagicCarousel is a flexible, unstyled carousel component built on native CSS scroll snapping, complete with inertia based mouse dragging. Useful for card sliders, image galleries, product showcases and the like.

<template>
  <magic-carousel-provider id="magic-carousel-default-demo" class="w-full">
    <magic-carousel-track class="demo-carousel w-full">
      <magic-carousel-slide
        v-for="index in 6"
        :key="index"
        class="aspect-[3/4]"
      >
        <carousel-demo-card :index="index" />
      </magic-carousel-slide>
    </magic-carousel-track>
  </magic-carousel-provider>
</template>

<script lang="ts" setup>
import CarouselDemoCard from './components/CarouselDemoCard.vue'
</script>

<style scoped>
.demo-carousel {
  --magic-carousel-slide-size: 70%;
  --magic-carousel-gap: 0.75rem;
}

@media (min-width: 768px) {
  .demo-carousel {
    --magic-carousel-slide-size: 45%;
  }
}
</style>

Overview

Anatomy

vue
<template>
  <magic-carousel-provider id="your-carousel-id">
    <magic-carousel-track>
      <magic-carousel-slide v-for="slide in slides" :key="slide.id">
        <!-- your content -->
      </magic-carousel-slide>
    </magic-carousel-track>

    <magic-carousel-trigger action="previous" />
    <magic-carousel-trigger action="next" />
  </magic-carousel-provider>
</template>

<script setup>
const { activeIndex } = useMagicCarousel('your-carousel-id')
</script>

Installation

CLI

Add @maas/vue-equipment to your dependencies.

sh
pnpm install @maas/vue-equipment
sh
npm install @maas/vue-equipment
sh
yarn add @maas/vue-equipment
sh
bun install @maas/vue-equipment

Vue

If you are using Vue, import and add MagicCarouselPlugin to your app.

js
import { createApp } from 'vue'
import { MagicCarouselPlugin } from '@maas/vue-equipment/plugins/MagicCarousel'

const app = createApp({})

app.use(MagicCarouselPlugin)

Nuxt

The carousel is available as a Nuxt module. In your Nuxt config file add @maas/vue-equipment/nuxt to your modules and add MagicCarousel to the plugins in your configuration.

js
export default defineNuxtConfig({
  modules: ['@maas/vue-equipment/nuxt'],
  vueEquipment: {
    plugins: ['MagicCarousel'],
  },
})

Composable

In order to interact with the carousel from anywhere within your app, we provide a useMagicCarousel composable. Import it directly when needed.

js
import { useMagicCarousel } from '@maas/vue-equipment/plugins/MagicCarousel'

const { next } = useMagicCarousel('your-carousel-id')

function handleClick() {
  next()
}

TIP

If you have installed the carousel as a Nuxt module, the composable will be auto-imported and is automatically available in your Nuxt app.

Peer Dependencies

If you haven’t installed the required peer dependencies automatically, you’ll need to install the following packages manually.

Installation

sh
pnpm install @nuxt/kit @maas/vue-primitive @vueuse/core defu
sh
npm install @nuxt/kit @maas/vue-primitive @vueuse/core defu
sh
yarn add @nuxt/kit @maas/vue-primitive @vueuse/core defu
sh
bun install @nuxt/kit @maas/vue-primitive @vueuse/core defu

API Reference

MagicCarouselProvider

The provider wraps the carousel and provides the necessary context to its children.

Props

PropTypeRequired
id
MaybeRef<string>true
options
MagicCarouselOptionsfalse
asChild
booleanfalse

Options

OptionTypeDefault
loop
booleanfalse
threshold.lock
number10
animation.snap.duration
number300
animation.snap.easing
(t: number) => numbereaseOutQuad
animation.momentum.friction
number0.72
animation.momentum.damping
number0.12
disabled
booleanfalse

MagicCarouselTrack

The track is the scroll container. All slides need to be nested inside it.

Props

PropTypeRequired
asChild
booleanfalse

Slot Props

PropType
activeIndex
number
progress
number
dragging
boolean

CSS Variables

VariableDefault
--magic-carousel-slides-per-view1
--magic-carousel-slide-sizecalc((100% - (var(--magic-carousel-slides-per-view) - 1) * var(--magic-carousel-gap)) / var(--magic-carousel-slides-per-view))
--magic-carousel-gap0px
--magic-carousel-snap-typex mandatory
--magic-carousel-drag-overshoot4rem
--magic-carousel-cursorgrab
--magic-carousel-cursor-dragginggrabbing

MagicCarouselSlide

Props

PropTypeRequired
id
stringfalse
asChild
booleanfalse

Slot Props

PropType
active
boolean
index
number

CSS Variables

VariableDefault
--magic-carousel-snap-alignstart
--magic-carousel-snap-stopnormal

MagicCarouselTrigger

Props

PropTypeDefault
action
'previous' | 'next' | number'next'
disabled
booleanfalse
asChild
booleanfalse

Slot Props

PropType
active
boolean
disabled
boolean

CSS Variables

VariableDefault
--magic-carousel-trigger-cursorpointer
--magic-carousel-trigger-cursor-disablednot-allowed

Events

The carousel emits events through the MagicEmitter. Listen to them from anywhere within your app.

js
import { useMagicEmitter } from '@maas/vue-equipment/plugins/MagicEmitter'

const emitter = useMagicEmitter()

emitter.on('afterSnap', (payload) => {
  console.log(payload.id, payload.snapPoint)
})
EventPayload
beforeDrag
{ id: string, x: number, y: number }
drag
{ id: string, x: number, y: number }
afterDrag
{ id: string, x: number, y: number }
beforeSnap
{ id: string, snapPoint: number }
snapTo
{ id: string, snapPoint: number, duration?: number }
afterSnap
{ id: string, snapPoint: number }
scrollEnd
string

Errors

CodeSourceMessage
missing_instance_idMagicCarouselTrackMagicCarouselTrack must be nested inside MagicCarouselProvider
missing_instance_idMagicCarouselSlideMagicCarouselSlide must be nested inside MagicCarouselProvider
missing_instance_idMagicCarouselTriggerMagicCarouselTrigger must be nested inside MagicCarouselProvider
overshoot_unitMagicCarouselTrack--magic-carousel-drag-overshoot needs to be specified in px or rem

Caveats

Snapping is configured entirely through CSS. Set --magic-carousel-snap-type on the track and --magic-carousel-snap-align on the slides — the drag physics read these values and simulate the same snapping while dragging with a mouse. Set --magic-carousel-snap-type to none to disable snapping altogether.

The same goes for the rubberband effect. --magic-carousel-drag-overshoot sets how far the carousel can be dragged past either end; resistance grows the closer it gets. The value needs to be specified in px or rem. Set it to 0px to prevent overdragging entirely. The variable is ignored when loop is set.

Since the carousel is a native scroll container, responsive layouts don’t need any configuration. Override the CSS variables inside your own media queries instead.

css
.your-carousel {
  --magic-carousel-slides-per-view: 2;
}

@media (min-width: 768px) {
  .your-carousel {
    --magic-carousel-slides-per-view: 4;
  }
}

In order for the drag physics to stay in control of the scroll position, scroll the carousel programmatically through the composable or the element’s scrollTo and scrollBy methods. Never use scrollIntoView to scroll a slide, as it bypasses the carousel and may interrupt an ongoing animation mid-frame.

Loop mode adds 50% inline padding to the track, which makes percentage based slide sizes resolve to 0. The provider is an inline-size container, so use container query units instead — cqi resolves against the provider’s width regardless of the track’s padding. --magic-carousel-slides-per-view switches to a 100cqi basis automatically when loop is set.

css
.your-carousel {
  --magic-carousel-slide-size: 40cqi;
}

Examples

Triggers

MagicCarouselTrigger disables itself once the carousel arrives at the respective end. The disabled slot prop passes that state on to your own button.

<template>
  <magic-carousel-provider
    id="magic-carousel-trigger-demo"
    class="flex w-full flex-col items-center gap-6"
  >
    <magic-carousel-track class="demo-carousel w-full">
      <magic-carousel-slide
        v-for="index in 6"
        :key="index"
        class="aspect-[3/4]"
      >
        <carousel-demo-card :index="index" />
      </magic-carousel-slide>
    </magic-carousel-track>

    <div class="flex items-center gap-2">
      <magic-carousel-trigger v-slot="{ disabled }" action="previous" as-child>
        <m-button
          mode="outline"
          icon
          square
          :disabled="disabled"
          aria-label="Previous slide"
        >
          <i-maas-chevron-w-500 />
        </m-button>
      </magic-carousel-trigger>

      <magic-carousel-trigger v-slot="{ disabled }" action="next" as-child>
        <m-button
          mode="outline"
          icon
          square
          :disabled="disabled"
          aria-label="Next slide"
        >
          <i-maas-chevron-e-500 />
        </m-button>
      </magic-carousel-trigger>
    </div>
  </magic-carousel-provider>
</template>

<script lang="ts" setup>
import { MButton } from '@maas/mirror/vue'
import CarouselDemoCard from './components/CarouselDemoCard.vue'
</script>

<style scoped>
.demo-carousel {
  --magic-carousel-slide-size: 70%;
  --magic-carousel-gap: 0.75rem;
}

@media (min-width: 768px) {
  .demo-carousel {
    --magic-carousel-slide-size: 45%;
  }
}
</style>

Dots

Pass an index as action to snap to a specific slide. The trigger sets data-active while its slide is active.

<template>
  <magic-carousel-provider
    id="magic-carousel-dots-demo"
    class="flex w-full flex-col items-center gap-6"
  >
    <magic-carousel-track class="demo-carousel w-full">
      <magic-carousel-slide
        v-for="index in 6"
        :key="index"
        class="aspect-[3/4]"
      >
        <carousel-demo-card :index="index" />
      </magic-carousel-slide>
    </magic-carousel-track>

    <div class="flex items-center gap-3">
      <magic-carousel-trigger
        v-for="(_, index) in slideCount"
        :key="index"
        :action="index"
        :aria-label="`Slide ${index + 1}`"
        class="demo-dot"
      />
    </div>
  </magic-carousel-provider>
</template>

<script lang="ts" setup>
import { useMagicCarousel } from '@maas/vue-equipment/plugins/MagicCarousel'
import CarouselDemoCard from './components/CarouselDemoCard.vue'

const { slideCount } = useMagicCarousel('magic-carousel-dots-demo')
</script>

<style scoped>
.demo-carousel {
  --magic-carousel-slide-size: 70%;
  --magic-carousel-gap: 0.75rem;
}

@media (min-width: 768px) {
  .demo-carousel {
    --magic-carousel-slide-size: 45%;
  }
}

.demo-dot {
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background-color: currentColor;
  opacity: 0.25;
  transition: opacity 150ms ease;
}

.demo-dot[data-active='true'] {
  opacity: 1;
}
</style>

Loop

<template>
  <magic-carousel-provider
    id="magic-carousel-loop-demo"
    class="w-full"
    :options="{ loop: true }"
  >
    <magic-carousel-track class="demo-carousel w-full">
      <magic-carousel-slide
        v-for="index in 6"
        :key="index"
        class="aspect-[3/4]"
      >
        <carousel-demo-card :index="index" />
      </magic-carousel-slide>
    </magic-carousel-track>
  </magic-carousel-provider>
</template>

<script lang="ts" setup>
import CarouselDemoCard from './components/CarouselDemoCard.vue'
</script>

<style scoped>
.demo-carousel {
  --magic-carousel-slide-size: 14rem;
  --magic-carousel-gap: 0.75rem;
}
</style>

Snap Alignment

Set --magic-carousel-snap-align to center to snap slides to the middle of the track. Add matching padding-inline so the first and last slide can reach the center.

<template>
  <magic-carousel-provider id="magic-carousel-snap-align-demo" class="w-full">
    <magic-carousel-track class="demo-carousel w-full">
      <magic-carousel-slide
        v-for="index in 6"
        :key="index"
        class="demo-slide aspect-[3/4]"
      >
        <carousel-demo-card :index="index" />
      </magic-carousel-slide>
    </magic-carousel-track>
  </magic-carousel-provider>
</template>

<script lang="ts" setup>
import CarouselDemoCard from './components/CarouselDemoCard.vue'
</script>

<style scoped>
.demo-carousel {
  --magic-carousel-slide-size: 16rem;
  --magic-carousel-gap: 0.75rem;
  --magic-carousel-snap-align: center;
  padding-inline: calc(50% - 8rem);
}

.demo-slide {
  opacity: 0.35;
  transition: opacity 300ms ease;
}

.demo-slide[data-active='true'] {
  opacity: 1;
}
</style>

Variable Widths

Set --magic-carousel-slide-size to auto and size each slide individually. The carousel measures every slide separately, so mixed widths snap correctly.

<template>
  <magic-carousel-provider
    id="magic-carousel-variable-width-demo"
    class="w-full"
  >
    <magic-carousel-track class="demo-carousel w-full">
      <magic-carousel-slide
        v-for="(width, index) in widths"
        :key="index"
        :class="['h-80', width]"
      >
        <carousel-demo-card :index="index + 1" />
      </magic-carousel-slide>
    </magic-carousel-track>
  </magic-carousel-provider>
</template>

<script lang="ts" setup>
import CarouselDemoCard from './components/CarouselDemoCard.vue'

const widths = ['w-40', 'w-64', 'w-48', 'w-72', 'w-44', 'w-60']
</script>

<style scoped>
.demo-carousel {
  --magic-carousel-slide-size: auto;
  --magic-carousel-gap: 0.75rem;
}
</style>

Progress

The composable exposes the scroll progress as a reactive value between 0 and 1.

<template>
  <magic-carousel-provider
    id="magic-carousel-progress-demo"
    class="flex w-full flex-col items-center gap-6"
  >
    <magic-carousel-track class="demo-carousel w-full">
      <magic-carousel-slide
        v-for="index in 6"
        :key="index"
        class="aspect-[3/4]"
      >
        <carousel-demo-card :index="index" />
      </magic-carousel-slide>
    </magic-carousel-track>

    <div class="bg-surface-base h-1.5 w-48 overflow-hidden rounded-full">
      <div
        class="bg-primary-solid h-full w-full origin-left rounded-full"
        :style="{ transform: `scaleX(${progress})` }"
      />
    </div>
  </magic-carousel-provider>
</template>

<script lang="ts" setup>
import { useMagicCarousel } from '@maas/vue-equipment/plugins/MagicCarousel'
import CarouselDemoCard from './components/CarouselDemoCard.vue'

const { progress } = useMagicCarousel('magic-carousel-progress-demo')
</script>

<style scoped>
.demo-carousel {
  --magic-carousel-slide-size: 70%;
  --magic-carousel-gap: 0.75rem;
}

@media (min-width: 768px) {
  .demo-carousel {
    --magic-carousel-slide-size: 45%;
  }
}
</style>

Cover Flow

Since the carousel is a native scroll container, scroll driven animations work out of the box. The demo binds a view timeline to each slide, so the rotation follows the scroll position without any JavaScript. Browsers without support for animation-timeline render the slides flat.

<template>
  <magic-carousel-provider id="magic-carousel-cover-flow-demo" class="w-full">
    <magic-carousel-track class="demo-carousel w-full">
      <magic-carousel-slide v-for="index in 8" :key="index" class="demo-slide">
        <div class="demo-cover">
          <carousel-demo-card :index="index" />
        </div>
      </magic-carousel-slide>
    </magic-carousel-track>
  </magic-carousel-provider>
</template>

<script lang="ts" setup>
import CarouselDemoCard from './components/CarouselDemoCard.vue'
</script>

<style scoped>
.demo-carousel {
  --magic-carousel-slide-size: 8.5rem;
  --magic-carousel-gap: 0.25rem;
  --magic-carousel-snap-align: center;
  padding-inline: calc(50% - 4.25rem);
  padding-block: 2rem;
}

.demo-slide {
  position: relative;
  view-timeline: --slide inline calc(50% - 4.5rem);
}

.demo-cover {
  width: 11rem;
  height: 11rem;
  margin-inline: -1.25rem;
}

@supports (animation-timeline: view()) {
  .demo-slide {
    animation: demo-slide-depth linear both;
    animation-timeline: --slide;
    animation-range: cover;
  }

  .demo-cover {
    animation: demo-cover-flow linear both;
    animation-timeline: --slide;
    animation-range: cover;
  }
}

@keyframes demo-slide-depth {
  0% {
    z-index: 0;
  }

  50% {
    z-index: 10;
  }

  100% {
    z-index: 0;
  }
}

@keyframes demo-cover-flow {
  0% {
    transform: perspective(25rem) rotateY(-45deg) scale(0.9);
  }

  50% {
    transform: perspective(25rem) rotateY(0deg) scale(1.15);
  }

  100% {
    transform: perspective(25rem) rotateY(45deg) scale(0.9);
  }
}
</style>

Thumbnails

Two carousels stay in sync through the composable. Clicking a thumbnail snaps the main carousel, and the main carousel’s activeIndex snaps the thumbnail strip along. Both remain independently draggable.

<template>
  <div class="flex w-full flex-col gap-3">
    <magic-carousel-provider
      id="magic-carousel-thumbnails-demo-main"
      class="w-full"
    >
      <magic-carousel-track class="demo-main w-full">
        <magic-carousel-slide
          v-for="index in 10"
          :key="index"
          class="aspect-[3/2]"
        >
          <carousel-demo-card :index="index" />
        </magic-carousel-slide>
      </magic-carousel-track>
    </magic-carousel-provider>

    <magic-carousel-provider
      id="magic-carousel-thumbnails-demo-thumbs"
      class="w-full"
    >
      <magic-carousel-track class="demo-thumbs w-full">
        <magic-carousel-slide
          v-for="index in 10"
          :key="index"
          class="aspect-square"
        >
          <button
            type="button"
            class="demo-thumb h-full w-full rounded-md"
            :data-selected="activeIndex === index - 1 || null"
            :aria-label="`Slide ${index}`"
            @click="snapTo(index - 1)"
          >
            <carousel-demo-card :index="index" size="sm" />
          </button>
        </magic-carousel-slide>
      </magic-carousel-track>
    </magic-carousel-provider>
  </div>
</template>

<script lang="ts" setup>
import { watch } from 'vue'
import { useMagicCarousel } from '@maas/vue-equipment/plugins/MagicCarousel'
import CarouselDemoCard from './components/CarouselDemoCard.vue'

const { activeIndex, snapTo } = useMagicCarousel(
  'magic-carousel-thumbnails-demo-main'
)

const { snapTo: snapThumbsTo } = useMagicCarousel(
  'magic-carousel-thumbnails-demo-thumbs'
)

watch(activeIndex, (value) => snapThumbsTo(value))
</script>

<style scoped>
.demo-main {
  --magic-carousel-slides-per-view: 1;
  --magic-carousel-gap: 0.75rem;
}

.demo-thumbs {
  --magic-carousel-slide-size: 4rem;
  --magic-carousel-gap: 0.5rem;
  --magic-carousel-snap-type: x proximity;
}

.demo-thumb {
  opacity: 0.55;
  transition: opacity 150ms ease;
}

.demo-thumb[data-selected='true'] {
  opacity: 1;
}

.demo-thumb[data-selected='true'] > * {
  box-shadow: inset 0 0 0 1.5px currentColor;
}
</style>