UIPackage

Carousel

Vue data-display
Edit on GitHub

Horizontal 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

$ npx 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