Carousel
A carousel with motion and swipe built using Embla.
Demo
Related Components
This requires the following components to be installed:
Related Theme
This requires the following theme to be installed:
Component
vue
<script lang="ts">
import type { ButtonProps } from '@/ui/components/Button.vue'
import type { EmblaCarouselType, EmblaOptionsType, EmblaPluginType } from 'embla-carousel'
import type { AutoHeightOptionsType } from 'embla-carousel-auto-height'
import type { AutoScrollOptionsType } from 'embla-carousel-auto-scroll'
import type { AutoplayOptionsType } from 'embla-carousel-autoplay'
import type { ClassNamesOptionsType } from 'embla-carousel-class-names'
import type { FadeOptionsType } from 'embla-carousel-fade'
import type { WheelGesturesPluginOptions } from 'embla-carousel-wheel-gestures'
import type { AcceptableValue } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Button from '@/ui/components/Button.vue'
import { arrowLeftIcon, arrowRightIcon } from '@/ui/icons'
import theme from '@/ui/theme/carousel'
import { computedAsync } from '@vueuse/core'
import { reactivePick } from '@vueuse/shared'
import useEmblaCarousel from 'embla-carousel-vue'
import { Primitive, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, onMounted, ref, watch } from 'vue'
const carousel = tv(theme)
type CarouselVariants = VariantProps<typeof carousel>
export interface CarouselProps<T> extends Omit<EmblaOptionsType, 'axis' | 'container' | 'slides' | 'direction'> {
as?: any
prev?: ButtonProps
next?: ButtonProps
arrows?: boolean
dots?: boolean
orientation?: CarouselVariants['orientation']
items?: T[]
autoplay?: boolean | AutoplayOptionsType
autoScroll?: boolean | AutoScrollOptionsType
autoHeight?: boolean | AutoHeightOptionsType
classNames?: boolean | ClassNamesOptionsType
fade?: boolean | FadeOptionsType
wheelGestures?: boolean | WheelGesturesPluginOptions
class?: any
ui?: Partial<typeof carousel.slots>
}
export interface CarouselSlots<T> {
default: (props: { item: T, index: number }) => any
}
</script>
<script setup lang="ts" generic="T extends AcceptableValue">
const props = withDefaults(defineProps<CarouselProps<T>>(), {
orientation: 'horizontal',
arrows: false,
dots: false,
// Embla Options
active: true,
align: 'center',
breakpoints: () => ({}),
containScroll: 'trimSnaps',
dragFree: false,
dragThreshold: 10,
duration: 25,
inViewThreshold: 0,
loop: false,
skipSnaps: false,
slidesToScroll: 1,
startIndex: 0,
watchDrag: true,
watchResize: true,
watchSlides: true,
watchFocus: true,
// Embla Plugins
autoplay: false,
autoScroll: false,
autoHeight: false,
classNames: false,
fade: false,
wheelGestures: false,
})
defineSlots<CarouselSlots<T>>()
const rootProps = useForwardProps(reactivePick(props, 'active', 'align', 'breakpoints', 'containScroll', 'dragFree', 'dragThreshold', 'duration', 'inViewThreshold', 'loop', 'skipSnaps', 'slidesToScroll', 'startIndex', 'watchDrag', 'watchResize', 'watchSlides', 'watchFocus'))
const ui = computed(() => carousel({
orientation: props.orientation,
}))
const options = computed<EmblaOptionsType>(() => ({
...(props.fade ? { align: 'center', containScroll: false } : {}),
...rootProps.value,
axis: props.orientation === 'horizontal' ? 'x' : 'y',
}))
const plugins = computedAsync<EmblaPluginType[]>(async () => {
const plugins = []
if (props.autoplay) {
const AutoplayPlugin = await import('embla-carousel-autoplay').then(r => r.default)
plugins.push(AutoplayPlugin(typeof props.autoplay === 'boolean' ? {} : props.autoplay))
}
if (props.autoScroll) {
const AutoScrollPlugin = await import('embla-carousel-auto-scroll').then(r => r.default)
plugins.push(AutoScrollPlugin(typeof props.autoScroll === 'boolean' ? {} : props.autoScroll))
}
if (props.autoHeight) {
const AutoHeightPlugin = await import('embla-carousel-auto-height').then(r => r.default)
plugins.push(AutoHeightPlugin(typeof props.autoHeight === 'boolean' ? {} : props.autoHeight))
}
if (props.classNames) {
const ClassNamesPlugin = await import('embla-carousel-class-names').then(r => r.default)
plugins.push(ClassNamesPlugin(typeof props.classNames === 'boolean' ? {} : props.classNames))
}
if (props.fade) {
const FadePlugin = await import('embla-carousel-fade').then(r => r.default)
plugins.push(FadePlugin(typeof props.fade === 'boolean' ? {} : props.fade))
}
if (props.wheelGestures) {
const { WheelGesturesPlugin } = await import('embla-carousel-wheel-gestures')
plugins.push(WheelGesturesPlugin(typeof props.wheelGestures === 'boolean' ? {} : props.wheelGestures))
}
return plugins
})
const [emblaRef, emblaApi] = useEmblaCarousel(options.value, plugins.value)
watch([options, plugins], () => {
emblaApi.value?.reInit(options.value, plugins.value)
})
function scrollPrev() {
emblaApi.value?.scrollPrev()
}
function scrollNext() {
emblaApi.value?.scrollNext()
}
function scrollTo(index: number) {
emblaApi.value?.scrollTo(index)
}
function onKeyDown(event: KeyboardEvent) {
const prevKey = props.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft'
const nextKey = props.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight'
if (event.key === prevKey) {
event.preventDefault()
scrollPrev()
return
}
if (event.key === nextKey) {
event.preventDefault()
scrollNext()
}
}
const canScrollNext = ref(false)
const canScrollPrev = ref(false)
const selectedIndex = ref<number>(0)
const scrollSnaps = ref<number[]>([])
function onInit(api: EmblaCarouselType) {
scrollSnaps.value = api?.scrollSnapList() || []
}
function onSelect(api: EmblaCarouselType) {
canScrollNext.value = api?.canScrollNext() || false
canScrollPrev.value = api?.canScrollPrev() || false
selectedIndex.value = api?.selectedScrollSnap() || 0
}
onMounted(() => {
if (!emblaApi.value) {
return
}
emblaApi.value?.on('init', onInit)
emblaApi.value?.on('init', onSelect)
emblaApi.value?.on('reInit', onInit)
emblaApi.value?.on('reInit', onSelect)
emblaApi.value?.on('select', onSelect)
})
defineExpose({
emblaRef,
emblaApi,
})
</script>
<template>
<Primitive
:as="as"
role="region"
aria-roledescription="carousel"
tabindex="0"
:class="ui.root({ class: [props.class, props.ui?.root] })"
@keydown="onKeyDown"
>
<div ref="emblaRef" :class="ui.viewport({ class: props.ui?.viewport })">
<div :class="ui.container({ class: props.ui?.container })">
<div
v-for="(item, index) in items"
:key="index"
role="group"
aria-roledescription="slide"
:class="ui.item({ class: props.ui?.item })"
>
<slot :item="item" :index="index" />
</div>
</div>
</div>
<div v-if="arrows || dots" :class="ui.controls({ class: props.ui?.controls })">
<div v-if="arrows" :class="ui.arrows({ class: props.ui?.arrows })">
<Button
:disabled="!canScrollPrev"
:icon="arrowLeftIcon"
size="md"
color="neutral"
variant="outline"
aria-label="Prev"
v-bind="typeof prev === 'object' ? prev : undefined"
:class="ui.prev({ class: props.ui?.prev })"
@click="scrollPrev"
/>
<Button
:disabled="!canScrollNext"
:icon="arrowRightIcon"
size="md"
color="neutral"
variant="outline"
aria-label="Next"
v-bind="typeof next === 'object' ? next : undefined"
:class="ui.next({ class: props.ui?.next })"
@click="scrollNext"
/>
</div>
<div v-if="dots" :class="ui.dots({ class: props.ui?.dots })">
<template v-for="(_, index) in scrollSnaps" :key="index">
<button
:aria-label="`Go to slide ${index + 1}`"
:class="ui.dot({ class: props.ui?.dot, active: selectedIndex === index })"
@click="scrollTo(index)"
/>
</template>
</div>
</div>
</Primitive>
</template>vue
<script lang="ts">
import type { ButtonProps } from '@/UI/Components/Button.vue'
import type { EmblaCarouselType, EmblaOptionsType, EmblaPluginType } from 'embla-carousel'
import type { AutoHeightOptionsType } from 'embla-carousel-auto-height'
import type { AutoScrollOptionsType } from 'embla-carousel-auto-scroll'
import type { AutoplayOptionsType } from 'embla-carousel-autoplay'
import type { ClassNamesOptionsType } from 'embla-carousel-class-names'
import type { FadeOptionsType } from 'embla-carousel-fade'
import type { WheelGesturesPluginOptions } from 'embla-carousel-wheel-gestures'
import type { AcceptableValue } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Button from '@/UI/Components/Button.vue'
import { arrowLeftIcon, arrowRightIcon } from '@/UI/icons'
import theme from '@/UI/Theme/carousel'
import { computedAsync } from '@vueuse/core'
import { reactivePick } from '@vueuse/shared'
import useEmblaCarousel from 'embla-carousel-vue'
import { Primitive, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, onMounted, ref, watch } from 'vue'
const carousel = tv(theme)
type CarouselVariants = VariantProps<typeof carousel>
export interface CarouselProps<T> extends Omit<EmblaOptionsType, 'axis' | 'container' | 'slides' | 'direction'> {
as?: any
prev?: ButtonProps
next?: ButtonProps
arrows?: boolean
dots?: boolean
orientation?: CarouselVariants['orientation']
items?: T[]
autoplay?: boolean | AutoplayOptionsType
autoScroll?: boolean | AutoScrollOptionsType
autoHeight?: boolean | AutoHeightOptionsType
classNames?: boolean | ClassNamesOptionsType
fade?: boolean | FadeOptionsType
wheelGestures?: boolean | WheelGesturesPluginOptions
class?: any
ui?: Partial<typeof carousel.slots>
}
export interface CarouselSlots<T> {
default: (props: { item: T, index: number }) => any
}
</script>
<script setup lang="ts" generic="T extends AcceptableValue">
const props = withDefaults(defineProps<CarouselProps<T>>(), {
orientation: 'horizontal',
arrows: false,
dots: false,
// Embla Options
active: true,
align: 'center',
breakpoints: () => ({}),
containScroll: 'trimSnaps',
dragFree: false,
dragThreshold: 10,
duration: 25,
inViewThreshold: 0,
loop: false,
skipSnaps: false,
slidesToScroll: 1,
startIndex: 0,
watchDrag: true,
watchResize: true,
watchSlides: true,
watchFocus: true,
// Embla Plugins
autoplay: false,
autoScroll: false,
autoHeight: false,
classNames: false,
fade: false,
wheelGestures: false,
})
defineSlots<CarouselSlots<T>>()
const rootProps = useForwardProps(reactivePick(props, 'active', 'align', 'breakpoints', 'containScroll', 'dragFree', 'dragThreshold', 'duration', 'inViewThreshold', 'loop', 'skipSnaps', 'slidesToScroll', 'startIndex', 'watchDrag', 'watchResize', 'watchSlides', 'watchFocus'))
const ui = computed(() => carousel({
orientation: props.orientation,
}))
const options = computed<EmblaOptionsType>(() => ({
...(props.fade ? { align: 'center', containScroll: false } : {}),
...rootProps.value,
axis: props.orientation === 'horizontal' ? 'x' : 'y',
}))
const plugins = computedAsync<EmblaPluginType[]>(async () => {
const plugins = []
if (props.autoplay) {
const AutoplayPlugin = await import('embla-carousel-autoplay').then(r => r.default)
plugins.push(AutoplayPlugin(typeof props.autoplay === 'boolean' ? {} : props.autoplay))
}
if (props.autoScroll) {
const AutoScrollPlugin = await import('embla-carousel-auto-scroll').then(r => r.default)
plugins.push(AutoScrollPlugin(typeof props.autoScroll === 'boolean' ? {} : props.autoScroll))
}
if (props.autoHeight) {
const AutoHeightPlugin = await import('embla-carousel-auto-height').then(r => r.default)
plugins.push(AutoHeightPlugin(typeof props.autoHeight === 'boolean' ? {} : props.autoHeight))
}
if (props.classNames) {
const ClassNamesPlugin = await import('embla-carousel-class-names').then(r => r.default)
plugins.push(ClassNamesPlugin(typeof props.classNames === 'boolean' ? {} : props.classNames))
}
if (props.fade) {
const FadePlugin = await import('embla-carousel-fade').then(r => r.default)
plugins.push(FadePlugin(typeof props.fade === 'boolean' ? {} : props.fade))
}
if (props.wheelGestures) {
const { WheelGesturesPlugin } = await import('embla-carousel-wheel-gestures')
plugins.push(WheelGesturesPlugin(typeof props.wheelGestures === 'boolean' ? {} : props.wheelGestures))
}
return plugins
})
const [emblaRef, emblaApi] = useEmblaCarousel(options.value, plugins.value)
watch([options, plugins], () => {
emblaApi.value?.reInit(options.value, plugins.value)
})
function scrollPrev() {
emblaApi.value?.scrollPrev()
}
function scrollNext() {
emblaApi.value?.scrollNext()
}
function scrollTo(index: number) {
emblaApi.value?.scrollTo(index)
}
function onKeyDown(event: KeyboardEvent) {
const prevKey = props.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft'
const nextKey = props.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight'
if (event.key === prevKey) {
event.preventDefault()
scrollPrev()
return
}
if (event.key === nextKey) {
event.preventDefault()
scrollNext()
}
}
const canScrollNext = ref(false)
const canScrollPrev = ref(false)
const selectedIndex = ref<number>(0)
const scrollSnaps = ref<number[]>([])
function onInit(api: EmblaCarouselType) {
scrollSnaps.value = api?.scrollSnapList() || []
}
function onSelect(api: EmblaCarouselType) {
canScrollNext.value = api?.canScrollNext() || false
canScrollPrev.value = api?.canScrollPrev() || false
selectedIndex.value = api?.selectedScrollSnap() || 0
}
onMounted(() => {
if (!emblaApi.value) {
return
}
emblaApi.value?.on('init', onInit)
emblaApi.value?.on('init', onSelect)
emblaApi.value?.on('reInit', onInit)
emblaApi.value?.on('reInit', onSelect)
emblaApi.value?.on('select', onSelect)
})
defineExpose({
emblaRef,
emblaApi,
})
</script>
<template>
<Primitive
:as="as"
role="region"
aria-roledescription="carousel"
tabindex="0"
:class="ui.root({ class: [props.class, props.ui?.root] })"
@keydown="onKeyDown"
>
<div ref="emblaRef" :class="ui.viewport({ class: props.ui?.viewport })">
<div :class="ui.container({ class: props.ui?.container })">
<div
v-for="(item, index) in items"
:key="index"
role="group"
aria-roledescription="slide"
:class="ui.item({ class: props.ui?.item })"
>
<slot :item="item" :index="index" />
</div>
</div>
</div>
<div v-if="arrows || dots" :class="ui.controls({ class: props.ui?.controls })">
<div v-if="arrows" :class="ui.arrows({ class: props.ui?.arrows })">
<Button
:disabled="!canScrollPrev"
:icon="arrowLeftIcon"
size="md"
color="neutral"
variant="outline"
aria-label="Prev"
v-bind="typeof prev === 'object' ? prev : undefined"
:class="ui.prev({ class: props.ui?.prev })"
@click="scrollPrev"
/>
<Button
:disabled="!canScrollNext"
:icon="arrowRightIcon"
size="md"
color="neutral"
variant="outline"
aria-label="Next"
v-bind="typeof next === 'object' ? next : undefined"
:class="ui.next({ class: props.ui?.next })"
@click="scrollNext"
/>
</div>
<div v-if="dots" :class="ui.dots({ class: props.ui?.dots })">
<template v-for="(_, index) in scrollSnaps" :key="index">
<button
:aria-label="`Go to slide ${index + 1}`"
:class="ui.dot({ class: props.ui?.dot, active: selectedIndex === index })"
@click="scrollTo(index)"
/>
</template>
</div>
</div>
</Primitive>
</template>Theme
ts
export default {
slots: {
root: '',
viewport: '',
container: '',
item: '',
controls: '',
arrows: '',
prev: '',
next: '',
dots: '',
dot: '',
},
variants: {
orientation: {
vertical: {
container: '',
item: '',
prev: '',
next: '',
},
horizontal: {
container: '',
item: '',
prev: '',
next: '',
},
},
active: {
true: {
dot: '',
},
},
},
}View Nuxt UI theme
ts
export default {
slots: {
root: 'relative focus:outline-none',
viewport: 'overflow-hidden',
container: 'flex items-start',
item: 'min-w-0 shrink-0 basis-full',
controls: '',
arrows: '',
prev: 'absolute rounded-full',
next: 'absolute rounded-full',
dots: 'absolute inset-x-0 -bottom-7 flex flex-wrap items-center justify-center gap-3',
dot: 'cursor-pointer size-3 bg-accented rounded-full transition',
},
variants: {
orientation: {
vertical: {
container: 'flex-col -mt-4',
item: 'pt-4',
prev: '-top-12 left-1/2 -translate-x-1/2 rotate-90 rtl:-rotate-90',
next: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90 rtl:-rotate-90',
},
horizontal: {
container: 'flex-row -ms-4',
item: 'ps-4',
prev: '-start-12 top-1/2 -translate-y-1/2',
next: '-end-12 top-1/2 -translate-y-1/2',
},
},
active: {
true: {
dot: 'bg-inverted',
},
},
},
}Test
To test this component, you can use the following test file:
ts
import type { RenderOptions } from '@testing-library/vue'
import Carousel from '@/ui/components/Carousel.vue'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
const CarouselWrapper = defineComponent({
components: {
Carousel: Carousel as any,
},
template: `<Carousel v-slot="{ item }">
<img :src="item.src" width="300" height="300" class="rounded-lg">
</Carousel>`,
})
describe('carousel', () => {
globalThis.matchMedia = globalThis.matchMedia || (() => ({ addListener: () => {}, removeListener: () => {} }))
globalThis.IntersectionObserver = class IntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
} as any
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
const items = [
{ src: 'https://picsum.photos/600/600?random=1' },
{ src: 'https://picsum.photos/600/600?random=2' },
{ src: 'https://picsum.photos/600/600?random=3' },
{ src: 'https://picsum.photos/600/600?random=4' },
{ src: 'https://picsum.photos/600/600?random=5' },
{ src: 'https://picsum.photos/600/600?random=6' },
]
const props = { items }
it.each<[string, RenderOptions<typeof Carousel>]>([
// Props
['with items', { props }],
['with orientation vertical', { props: { ...props, orientation: 'vertical' as const } }],
['with arrows', { props: { ...props, arrows: true } }],
['with prev', { props: { ...props, arrows: true, prev: { color: 'primary' as const } } }],
['with next', { props: { ...props, arrows: true, next: { color: 'primary' as const } } }],
['with dots', { props: { ...props, dots: true } }],
['with as', { props: { ...props, as: 'nav' } }],
['with class', { props: { ...props, class: 'w-full max-w-xs' } }],
['with ui', { props: { ...props, ui: { viewport: 'h-[320px]' } } }],
])('renders %s correctly', (name, options) => {
render(CarouselWrapper, {
attrs: {
'data-testid': 'carousel',
},
...options,
})
expect(screen.getByTestId('carousel')).matchSnapshot()
})
})ts
import type { RenderOptions } from '@testing-library/vue'
import Carousel from '@/UI/Components/Carousel.vue'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
const CarouselWrapper = defineComponent({
components: {
Carousel: Carousel as any,
},
template: `<Carousel v-slot="{ item }">
<img :src="item.src" width="300" height="300" class="rounded-lg">
</Carousel>`,
})
describe('carousel', () => {
globalThis.matchMedia = globalThis.matchMedia || (() => ({ addListener: () => {}, removeListener: () => {} }))
globalThis.IntersectionObserver = class IntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
} as any
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
const items = [
{ src: 'https://picsum.photos/600/600?random=1' },
{ src: 'https://picsum.photos/600/600?random=2' },
{ src: 'https://picsum.photos/600/600?random=3' },
{ src: 'https://picsum.photos/600/600?random=4' },
{ src: 'https://picsum.photos/600/600?random=5' },
{ src: 'https://picsum.photos/600/600?random=6' },
]
const props = { items }
it.each<[string, RenderOptions<typeof Carousel>]>([
// Props
['with items', { props }],
['with orientation vertical', { props: { ...props, orientation: 'vertical' as const } }],
['with arrows', { props: { ...props, arrows: true } }],
['with prev', { props: { ...props, arrows: true, prev: { color: 'primary' as const } } }],
['with next', { props: { ...props, arrows: true, next: { color: 'primary' as const } } }],
['with dots', { props: { ...props, dots: true } }],
['with as', { props: { ...props, as: 'nav' } }],
['with class', { props: { ...props, class: 'w-full max-w-xs' } }],
['with ui', { props: { ...props, ui: { viewport: 'h-[320px]' } } }],
])('renders %s correctly', (name, options) => {
render(CarouselWrapper, {
attrs: {
'data-testid': 'carousel',
},
...options,
})
expect(screen.getByTestId('carousel')).matchSnapshot()
})
})