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
<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.
pnpm install @maas/vue-equipmentnpm install @maas/vue-equipmentyarn add @maas/vue-equipmentbun install @maas/vue-equipmentVue
If you are using Vue, import and add MagicCarouselPlugin to your app.
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.
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.
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
pnpm install @nuxt/kit @maas/vue-primitive @vueuse/core defunpm install @nuxt/kit @maas/vue-primitive @vueuse/core defuyarn add @nuxt/kit @maas/vue-primitive @vueuse/core defubun install @nuxt/kit @maas/vue-primitive @vueuse/core defuAPI Reference
MagicCarouselProvider
The provider wraps the carousel and provides the necessary context to its children.
Props
| Prop | Type | Required |
|---|---|---|
MaybeRef<string> | true | |
MagicCarouselOptions | false | |
boolean | false |
Options
| Option | Type | Default |
|---|---|---|
boolean | false | |
number | 10 | |
number | 300 | |
(t: number) => number | easeOutQuad | |
number | 0.72 | |
number | 0.12 | |
boolean | false |
MagicCarouselTrack
The track is the scroll container. All slides need to be nested inside it.
Props
| Prop | Type | Required |
|---|---|---|
boolean | false |
Slot Props
| Prop | Type |
|---|---|
number | |
number | |
boolean |
CSS Variables
| Variable | Default |
|---|---|
--magic-carousel-slides-per-view | 1 |
--magic-carousel-slide-size | calc((100% - (var(--magic-carousel-slides-per-view) - 1) * var(--magic-carousel-gap)) / var(--magic-carousel-slides-per-view)) |
--magic-carousel-gap | 0px |
--magic-carousel-snap-type | x mandatory |
--magic-carousel-drag-overshoot | 4rem |
--magic-carousel-cursor | grab |
--magic-carousel-cursor-dragging | grabbing |
MagicCarouselSlide
Props
| Prop | Type | Required |
|---|---|---|
string | false | |
boolean | false |
Slot Props
| Prop | Type |
|---|---|
boolean | |
number |
CSS Variables
| Variable | Default |
|---|---|
--magic-carousel-snap-align | start |
--magic-carousel-snap-stop | normal |
MagicCarouselTrigger
Props
| Prop | Type | Default |
|---|---|---|
'previous' | 'next' | number | 'next' | |
boolean | false | |
boolean | false |
Slot Props
| Prop | Type |
|---|---|
boolean | |
boolean |
CSS Variables
| Variable | Default |
|---|---|
--magic-carousel-trigger-cursor | pointer |
--magic-carousel-trigger-cursor-disabled | not-allowed |
Events
The carousel emits events through the MagicEmitter. Listen to them from anywhere within your app.
import { useMagicEmitter } from '@maas/vue-equipment/plugins/MagicEmitter'
const emitter = useMagicEmitter()
emitter.on('afterSnap', (payload) => {
console.log(payload.id, payload.snapPoint)
})| Event | Payload |
|---|---|
{ id: string, x: number, y: number } | |
{ id: string, x: number, y: number } | |
{ id: string, x: number, y: number } | |
{ id: string, snapPoint: number } | |
{ id: string, snapPoint: number, duration?: number } | |
{ id: string, snapPoint: number } | |
string |
Errors
| Code | Source | Message |
|---|---|---|
missing_instance_id | MagicCarouselTrack | MagicCarouselTrack must be nested inside MagicCarouselProvider |
missing_instance_id | MagicCarouselSlide | MagicCarouselSlide must be nested inside MagicCarouselProvider |
missing_instance_id | MagicCarouselTrigger | MagicCarouselTrigger must be nested inside MagicCarouselProvider |
overshoot_unit | MagicCarouselTrack | --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.
.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.
.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>