Carousel
Vue data-displayHorizontal or vertical scroller with previous/next controls. Hand-rolled `useCarousel` composable backed by native CSS scroll-snap — no external carousel library, just the browser. Drop in images, cards, or any custom slide content.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/carousel.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/carousel.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/carousel.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/carousel.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/carousel
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
modelValue | number | 0 | optional |
orientation | 'horizontal''vertical' | 'horizontal' | optional |
loop | boolean | false | optional |
class | HTMLAttributes['class'] | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
CarouselOptions interface CarouselOptions {
loop?: boolean
orientation?: 'horizontal' | 'vertical'
} UseCarouselReturn interface UseCarouselReturn {
activeIndex: Ref<number>
scrollSnaps: Ref<number[]>
canScrollPrev: Ref<boolean>
canScrollNext: Ref<boolean>
scrollTo: (index: number, smooth?: boolean) => void
scrollToPrev: (smooth?: boolean) => void
scrollToNext: (smooth?: boolean) => void
rootRef: Ref<HTMLElement | null>
orientation: Ref<'horizontal' | 'vertical'>
} Dependencies
Files (11)
-
app/components/ui/carousel/Carousel.vue 2.7 kB
<!-- Carousel Component A flexible carousel/slider for cycling through content. @example - Basic carousel with images <Carousel> <CarouselContent> <CarouselItem v-for="img in images" :key="img.id"> <img :src="img.src" :alt="img.alt" class="w-full h-full object-cover" /> </CarouselItem> </CarouselContent> </Carousel> @example - Carousel with navigation controls <Carousel v-model:active-index="activeIndex" :loop="true"> <CarouselContent> <CarouselItem v-for="slide in slides" :key="slide.id"> <SlideContent :content="slide" /> </CarouselItem> </CarouselContent> <template #footer> <CarouselPrevious /> <CarouselIndicators /> <CarouselNext /> </template> </Carousel> @example - Vertical carousel <Carousel orientation="vertical" :loop="false"> <CarouselContent> <CarouselItem v-for="item in items" :key="item.id"> {{ item }} </CarouselItem> </CarouselContent> </Carousel> --> <script setup lang="ts"> import { provide, computed, watch, onMounted, onUnmounted, type HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { useCarousel } from './useCarousel' import { carouselVariants } from './carousel.variants' export interface CarouselProps { modelValue?: number orientation?: 'horizontal' | 'vertical' loop?: boolean class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<CarouselProps>(), { modelValue: 0, orientation: 'horizontal', loop: false, }) const emit = defineEmits<{ 'update:modelValue': [value: number] }>() const carousel = useCarousel({ loop: props.loop, orientation: props.orientation, }) // Sync modelValue → scroll when controlled externally watch( () => props.modelValue, (newVal) => { if (newVal !== carousel.activeIndex.value) { carousel.scrollTo(newVal, false) } }, ) // Emit scroll position changes back to parent function onScroll() { const newIndex = carousel.activeIndex.value if (newIndex !== props.modelValue) { emit('update:modelValue', newIndex) } } onMounted(() => { if (carousel.rootRef.value) { carousel.rootRef.value.addEventListener('scroll', onScroll, { passive: true }) } }) onUnmounted(() => { if (carousel.rootRef.value) { carousel.rootRef.value.removeEventListener('scroll', onScroll) } }) // Provide to children provide('carousel', { ...carousel, orientation: computed(() => props.orientation), loop: computed(() => props.loop), }) </script> <template> <div data-uipkge data-slot="carousel" :class="cn(carouselVariants({ orientation: props.orientation }), props.class)" role="region" aria-label="Carousel" > <slot /> </div> </template> -
app/components/ui/carousel/CarouselContent.vue 1.4 kB
<!-- CarouselContent The scrollable container for carousel items. Uses CSS scroll-snap for smooth sliding behavior. @example <CarouselContent> <CarouselItem v-for="slide in slides" :key="slide.id"> <img :src="slide.src" :alt="slide.alt" /> </CarouselItem> </CarouselContent> --> <script setup lang="ts"> import { inject, computed, type HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() const carousel = inject<{ rootRef: { value: HTMLElement | null } orientation: { value: 'horizontal' | 'vertical' } } | null>('carousel', null) const isHorizontal = computed(() => carousel?.orientation?.value !== 'vertical') const scrollClass = computed(() => { return isHorizontal.value ? 'flex overflow-x-auto scroll-smooth snap-x snap-mandatory' : 'flex flex-col overflow-y-auto snap-y snap-mandatory' }) </script> <template> <div :ref=" (el) => { if (carousel) carousel.rootRef.value = el as HTMLElement } " data-uipkge data-slot="carousel-content" :class=" cn( scrollClass, 'relative h-full w-full', '[-ms-overflow-style:none] [scrollbar-width:none]', '[&::-webkit-scrollbar]:hidden', props.class, ) " :aria-orientation="carousel?.orientation?.value" role="group" > <slot /> </div> </template> -
app/components/ui/carousel/CarouselFooter.vue 0.5 kB
<!-- CarouselFooter Wrapper for carousel footer content (pagination, indicators). @example <CarouselFooter> <CarouselIndicators /> </CarouselFooter> --> <script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <div data-uipkge data-slot="carousel-footer" :class="cn('flex items-center justify-between px-1 pt-2', props.class)"> <slot /> </div> </template> -
app/components/ui/carousel/CarouselHeader.vue 0.5 kB
<!-- CarouselHeader Wrapper for carousel header content (title, controls). @example <CarouselHeader> <h3>Featured Products</h3> </CarouselHeader> --> <script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <div data-uipkge data-slot="carousel-header" :class="cn('flex items-center justify-between px-1 pb-2', props.class)"> <slot /> </div> </template> -
app/components/ui/carousel/CarouselIndicators.vue 1.3 kB
<!-- CarouselIndicators Pagination dots showing current slide position. Clicking a dot navigates to that slide. @example <CarouselIndicators /> --> <script setup lang="ts"> import { inject, type HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } defineProps<Props>() const carousel = inject<{ activeIndex: { value: number } scrollSnaps: { value: number[] } scrollTo: (index: number, smooth?: boolean) => void } | null>('carousel', null) </script> <template> <div v-if="carousel" data-uipkge data-slot="carousel-indicators" class="flex items-center justify-center gap-1.5 py-2" role="tablist" aria-label="Carousel navigation" > <button v-for="(_, index) in carousel.scrollSnaps.value" :key="index" type="button" role="tab" :aria-label="`Go to slide ${index + 1}`" :aria-selected="index === carousel.activeIndex.value" :class=" cn( 'h-2 w-2 rounded-full transition-all duration-200', index === carousel.activeIndex.value ? 'bg-primary w-6' : 'bg-muted-foreground/30 hover:bg-muted-foreground/50', ) " @click="carousel.scrollTo(index, true)" /> </div> </template> -
app/components/ui/carousel/CarouselItem.vue 1 kB
<!-- CarouselItem Individual slide within the carousel. Each item snaps into view when scrolled. @example <CarouselItem> <img src="/img1.jpg" alt="Slide 1" /> </CarouselItem> --> <script setup lang="ts"> import { inject, computed, type HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { carouselItemVariants } from './index' interface Props { class?: HTMLAttributes['class'] active?: boolean } const props = withDefaults(defineProps<Props>(), { active: false, }) const carousel = inject<{ orientation: { value: 'horizontal' | 'vertical' } } | null>('carousel', null) const itemClass = computed(() => { const orientation = carousel?.orientation?.value ?? 'horizontal' return carouselItemVariants({ orientation }) }) </script> <template> <div data-uipkge data-slot="carousel-item" :class="cn(itemClass, 'shrink-0 grow-0 basis-full', 'snap-start', 'relative', props.class)" :aria-hidden="!props.active" role="group" :aria-roledescription="'slide'" > <slot /> </div> </template> -
app/components/ui/carousel/CarouselNext.vue 1.4 kB
<!-- CarouselNext Navigation button to go to the next slide. @example <CarouselNext label="Next slide" /> --> <script setup lang="ts"> import { inject, computed, type HTMLAttributes } from 'vue' import { ChevronRight } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' interface Props { class?: HTMLAttributes['class'] label?: string } const props = withDefaults(defineProps<Props>(), { label: 'Next slide', }) const carousel = inject<{ canScrollNext: { value: boolean } scrollToNext: (smooth?: boolean) => void orientation: { value: 'horizontal' | 'vertical' } } | null>('carousel', null) const isHorizontal = computed(() => carousel?.orientation?.value !== 'vertical') </script> <template> <Button type="button" variant="outline" size="icon" :class=" cn( 'absolute z-10 size-8 shrink-0 rounded-full', 'bg-background/80 border shadow-md backdrop-blur-sm', 'hover:bg-accent hover:text-accent-foreground', 'disabled:pointer-events-none disabled:opacity-50', isHorizontal ? 'top-1/2 -right-3 -translate-y-1/2' : '-bottom-3 left-1/2 -translate-x-1/2 rotate-90', props.class, ) " :disabled="!carousel?.canScrollNext?.value" :aria-label="props.label" @click="carousel?.scrollToNext(true)" > <slot> <ChevronRight class="size-4" aria-hidden="true" /> </slot> </Button> </template> -
app/components/ui/carousel/CarouselPrevious.vue 1.5 kB
<!-- CarouselPrevious Navigation button to go to the previous slide. @example <CarouselPrevious label="Previous slide" /> --> <script setup lang="ts"> import { inject, computed, type HTMLAttributes } from 'vue' import { ChevronLeft } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' interface Props { class?: HTMLAttributes['class'] label?: string } const props = withDefaults(defineProps<Props>(), { label: 'Previous slide', }) const carousel = inject<{ canScrollPrev: { value: boolean } scrollToPrev: (smooth?: boolean) => void orientation: { value: 'horizontal' | 'vertical' } } | null>('carousel', null) const isHorizontal = computed(() => carousel?.orientation?.value !== 'vertical') </script> <template> <Button type="button" variant="outline" size="icon" :class=" cn( 'absolute z-10 size-8 shrink-0 rounded-full', 'bg-background/80 border shadow-md backdrop-blur-sm', 'hover:bg-accent hover:text-accent-foreground', 'disabled:pointer-events-none disabled:opacity-50', isHorizontal ? 'top-1/2 -left-3 -translate-y-1/2' : '-top-3 left-1/2 -translate-x-1/2 rotate-90', props.class, ) " :disabled="!carousel?.canScrollPrev?.value" :aria-label="props.label" @click="carousel?.scrollToPrev(true)" > <slot> <ChevronLeft class="size-4" aria-hidden="true" /> </slot> </Button> </template> -
app/components/ui/carousel/carousel.variants.ts 1.1 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' /** * Variant definitions live in their own file (rather than the package * `index.ts` or inline in the SFC) so `Carousel.vue` / `CarouselItem.vue` * can import them without creating a circular dependency back through * the index. The circular form caused intermittent * `$setup.carouselVariants is not a function` errors during dev SSR. */ export const carouselVariants = cva('relative overflow-hidden', { variants: { orientation: { horizontal: 'w-full', vertical: 'h-full flex-col', }, }, defaultVariants: { orientation: 'horizontal', }, }) export const carouselItemVariants = cva('flex shrink-0 grow-0 basis-full flex-col', { variants: { orientation: { horizontal: 'w-full', vertical: 'h-full', }, }, defaultVariants: { orientation: 'horizontal', }, }) export type CarouselVariants = VariantProps<typeof carouselVariants> export type CarouselItemVariants = VariantProps<typeof carouselItemVariants> -
app/components/ui/carousel/useCarousel.ts 3.8 kB
/** * useCarousel * * Lightweight carousel composable using native CSS scroll-snap. * No external dependencies required. */ import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue' export interface CarouselOptions { loop?: boolean orientation?: 'horizontal' | 'vertical' } export interface UseCarouselReturn { activeIndex: Ref<number> scrollSnaps: Ref<number[]> canScrollPrev: Ref<boolean> canScrollNext: Ref<boolean> scrollTo: (index: number, smooth?: boolean) => void scrollToPrev: (smooth?: boolean) => void scrollToNext: (smooth?: boolean) => void rootRef: Ref<HTMLElement | null> orientation: Ref<'horizontal' | 'vertical'> } export function useCarousel(options: CarouselOptions = {}): UseCarouselReturn { const { loop = false, orientation = 'horizontal' } = options const rootRef = ref<HTMLElement | null>(null) const activeIndex = ref(0) const scrollSnaps = ref<number[]>([]) const canScrollPrev = ref(false) const canScrollNext = ref(false) const isHorizontal = computed(() => orientation === 'horizontal') function getScrollPosition(): number { if (!rootRef.value) return 0 return isHorizontal.value ? rootRef.value.scrollLeft : rootRef.value.scrollTop } function getItemSize(): number { if (!rootRef.value) return 0 return isHorizontal.value ? rootRef.value.offsetWidth : rootRef.value.offsetHeight } function updateScrollState() { if (!rootRef.value) return const el = rootRef.value const itemSize = getItemSize() if (itemSize === 0) return const scrollPos = getScrollPosition() const scrollWidth = isHorizontal.value ? el.scrollWidth : el.scrollHeight const viewportSize = isHorizontal.value ? el.offsetWidth : el.offsetHeight // Calculate active index activeIndex.value = Math.round(scrollPos / itemSize) // Update scroll snaps const newSnaps: number[] = [] const itemCount = Math.ceil(scrollWidth / itemSize) for (let i = 0; i < itemCount; i++) { newSnaps.push(i * itemSize) } scrollSnaps.value = newSnaps // Update navigation state canScrollPrev.value = loop || scrollPos > 0 canScrollNext.value = loop || scrollPos < scrollWidth - viewportSize - 1 } function scrollTo(index: number, smooth = true) { if (!rootRef.value || index < 0) return const el = rootRef.value const itemSize = getItemSize() if (itemSize === 0) return const targetScroll = index * itemSize el.scrollTo({ left: isHorizontal.value ? targetScroll : 0, top: isHorizontal.value ? 0 : targetScroll, behavior: smooth ? 'smooth' : 'auto', }) } function scrollToPrev(smooth = true) { const nextIndex = Math.max(0, activeIndex.value - 1) scrollTo(nextIndex, smooth) } function scrollToNext(smooth = true) { const maxIndex = scrollSnaps.value.length - 1 const nextIndex = loop ? (activeIndex.value + 1) % (maxIndex + 1) : Math.min(maxIndex, activeIndex.value + 1) scrollTo(nextIndex, smooth) } let scrollHandler: (() => void) | null = null let resizeHandler: (() => void) | null = null onMounted(() => { if (rootRef.value) { updateScrollState() scrollHandler = () => updateScrollState() rootRef.value.addEventListener('scroll', scrollHandler, { passive: true }) resizeHandler = () => updateScrollState() window.addEventListener('resize', resizeHandler, { passive: true }) } }) onUnmounted(() => { if (rootRef.value && scrollHandler) { rootRef.value.removeEventListener('scroll', scrollHandler) } if (resizeHandler) { window.removeEventListener('resize', resizeHandler) } }) return { activeIndex, scrollSnaps, canScrollPrev, canScrollNext, scrollTo, scrollToPrev, scrollToNext, rootRef, orientation: ref(orientation), } } -
app/components/ui/carousel/index.ts 0.8 kB
export { default as Carousel } from './Carousel.vue' export { default as CarouselContent } from './CarouselContent.vue' export { default as CarouselItem } from './CarouselItem.vue' export { default as CarouselPrevious } from './CarouselPrevious.vue' export { default as CarouselNext } from './CarouselNext.vue' export { default as CarouselIndicators } from './CarouselIndicators.vue' export { default as CarouselHeader } from './CarouselHeader.vue' export { default as CarouselFooter } from './CarouselFooter.vue' // Re-export variant API from the sibling file (kept separate to avoid the // Carousel.vue <-> index.ts circular import that broke dev SSR). export { carouselVariants, carouselItemVariants, type CarouselVariants, type CarouselItemVariants, } from './carousel.variants'
Raw manifest: https://uipkge.dev/r/vue/carousel.json