Carousel
Provides a scroll carousel that uses the native CSS scroll-snap property.
The carousel is CSS-first — scrolling, snapping and layout are handled by CSS. The optional JavaScript layer adds navigation, pagination, counters, progress, autoplay and drag helpers.
Experimental version
An experimental Carousel is also available. It follows emerging native carousel APIs more closely, but browser support is still incomplete and not all required APIs have polyfills. Use this stable version when broad browser compatibility is required.
Installation
Follow the instructions for your integration:
Styles
Variants
Scripts
The Carousel script keeps counters, progress, pagination and scroll-edge state in sync. Navigation can move by one item or scroll directly to an item by index.
Examples
Basic
<x-carousel class="x-carousel flex gap-4 items-center w-full" id="basicCarousel">
<button class="x-button circle muted" data-x-carousel-part="prev" command="--scrollPrev" commandfor="basicCarousel" aria-controls="basicCarouselContent" aria-label="Prev" disabled>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</button>
<div class="x-carousel-content gap-2 w-full" id="basicCarouselContent" tabindex="-1" data-x-carousel-part="content">
<div class="x-image w-full rounded-md aspect-square">
<img src="https://placehold.co/512" alt="" loading="lazy">
</div>
<div class="x-image w-full rounded-md aspect-square">
<img src="https://placehold.co/512" alt="" loading="lazy">
</div>
</div>
<button class="x-button circle muted" data-x-carousel-part="next" command="--scrollNext" commandfor="basicCarousel" aria-controls="basicCarouselContent" aria-label="Next">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</button>
</x-carousel><div data-controller="x-carousel" class="x-carousel flex gap-4 items-center w-full" id="basicCarousel">
<button class="x-button circle muted" data-x-carousel-target="prev" data-action="x-carousel#scrollPrev" aria-controls="basicCarouselContent" aria-label="Prev" disabled>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</button>
<div class="x-carousel-content gap-2 w-full" id="basicCarouselContent" tabindex="-1" data-x-carousel-target="content" data-action="scroll->x-carousel#scroll">
<div class="x-image w-full rounded-md aspect-square">
<img src="https://placehold.co/512" alt="" loading="lazy">
</div>
<div class="x-image w-full rounded-md aspect-square">
<img src="https://placehold.co/512" alt="" loading="lazy">
</div>
</div>
<button class="x-button circle muted" data-x-carousel-target="next" data-action="x-carousel#scrollNext" aria-controls="basicCarouselContent" aria-label="Next">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</button>
</div><script setup lang="ts">
import { ref } from 'vue'
import { Carousel } from '@/components/carousel'
const contentElement = ref<HTMLElement>()
const prevElement = ref<HTMLButtonElement>()
const nextElement = ref<HTMLButtonElement>()
</script>
<template>
<Carousel
v-slot="{ scrollPrev, scrollNext, onScroll }"
:refs="{ contentElement, prevElement, nextElement }"
class="flex gap-4 items-center w-full"
>
<button ref="prevElement" class="x-button circle muted" aria-label="Prev" disabled @click="scrollPrev">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</button>
<div ref="contentElement" class="x-carousel-content gap-2 w-full" tabindex="-1" @scroll="onScroll">
<div class="x-image w-full rounded-md aspect-square">
<img src="https://placehold.co/512" alt="" loading="lazy">
</div>
<div class="x-image w-full rounded-md aspect-square">
<img src="https://placehold.co/512" alt="" loading="lazy">
</div>
</div>
<button ref="nextElement" class="x-button circle muted" aria-label="Next" @click="scrollNext">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</button>
</Carousel>
</template>import { useRef } from 'react'
import { Carousel } from '@/components/carousel'
export function Example() {
const contentElement = useRef<HTMLDivElement>(null)
const prevElement = useRef<HTMLButtonElement>(null)
const nextElement = useRef<HTMLButtonElement>(null)
return (
<Carousel
className="flex gap-4 items-center w-full"
refs={{ contentElement, prevElement, nextElement }}
>
{({ scrollPrev, scrollNext, onScroll }) => (
<>
<button ref={prevElement} className="x-button circle muted" aria-label="Prev" disabled onClick={scrollPrev}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</button>
<div ref={contentElement} className="x-carousel-content gap-2 w-full" tabIndex={-1} onScroll={onScroll}>
<div className="x-image w-full rounded-md aspect-square">
<img src="https://placehold.co/512" alt="" loading="lazy" />
</div>
<div className="x-image w-full rounded-md aspect-square">
<img src="https://placehold.co/512" alt="" loading="lazy" />
</div>
</div>
<button ref={nextElement} className="x-button circle muted" aria-label="Next" onClick={scrollNext}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</button>
</>
)}
</Carousel>
)
}Full
<x-carousel class="x-carousel flex flex-col gap-4" id="fullCarousel" role="region" aria-roledescription="carousel" aria-label="Carousel gallery">
<div class="flex justify-center" aria-live="polite">
<span data-x-carousel-part="counterMin"></span>/<span data-x-carousel-part="counterMax"></span>
</div>
<div class="flex gap-4 items-center">
<button class="x-button circle muted" data-x-carousel-part="prev" command="--scrollPrev" commandfor="fullCarousel" aria-controls="fullCarouselContent" aria-label="Prev" disabled>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</button>
<div class="x-carousel-content gap-4 w-full" id="fullCarouselContent" tabindex="-1" data-x-carousel-part="content">
<div class="x-image rounded-md">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240">
</div>
<div class="x-image rounded-md">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240">
</div>
<div class="x-image rounded-md">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240">
</div>
<div class="x-image rounded-md">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240">
</div>
<div class="x-image rounded-md">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240">
</div>
<div class="x-image rounded-md">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240">
</div>
</div>
<button class="x-button circle muted" data-x-carousel-part="next" command="--scrollNext" commandfor="fullCarousel" aria-controls="fullCarouselContent" aria-label="Next">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</button>
</div>
<nav class="justify-center flex gap-1.5 h-4 py-4" data-x-carousel-part="pagination" aria-label="Carousel navigation"></nav>
<progress class="x-progress sm" value="0" max="100" data-x-carousel-part="progress" aria-label="Carousel progress"></progress>
</x-carousel><div data-controller="x-carousel" class="x-carousel flex flex-col gap-4" id="fullCarousel" role="region" aria-roledescription="carousel" aria-label="Carousel gallery">
<div class="flex justify-center" aria-live="polite">
<span data-x-carousel-target="counterMin"></span>/<span data-x-carousel-target="counterMax"></span>
</div>
<div class="flex gap-4 items-center">
<button class="x-button circle muted" data-x-carousel-target="prev" data-action="x-carousel#scrollPrev" aria-controls="fullCarouselContent" aria-label="Prev" disabled>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</button>
<div class="x-carousel-content gap-4 w-full" id="fullCarouselContent" tabindex="-1" data-x-carousel-target="content" data-action="scroll->x-carousel#scroll">
<div class="x-image rounded-md">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240">
</div>
<div class="x-image rounded-md">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240">
</div>
<div class="x-image rounded-md">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240">
</div>
<div class="x-image rounded-md">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240">
</div>
<div class="x-image rounded-md">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240">
</div>
<div class="x-image rounded-md">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240">
</div>
</div>
<button class="x-button circle muted" data-x-carousel-target="next" data-action="x-carousel#scrollNext" aria-controls="fullCarouselContent" aria-label="Next">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</button>
</div>
<nav class="justify-center flex gap-1.5 h-4 py-4" data-x-carousel-target="pagination" aria-label="Carousel navigation"></nav>
<progress class="x-progress sm" value="0" max="100" data-x-carousel-target="progress" aria-label="Carousel progress"></progress>
</div><script setup lang="ts">
import { ref } from 'vue'
import { Carousel } from '@/components/carousel'
const contentElement = ref<HTMLElement>()
const prevElement = ref<HTMLButtonElement>()
const nextElement = ref<HTMLButtonElement>()
const paginationElement = ref<HTMLElement>()
const slides = [1, 2, 3, 4, 5, 6]
</script>
<template>
<Carousel
v-slot="{ scrollPrev, scrollNext, onScroll }"
:refs="{ contentElement, prevElement, nextElement, paginationElement }"
class="flex flex-col gap-4"
role="region"
aria-roledescription="carousel"
aria-label="Carousel gallery"
>
<div class="flex gap-4 items-center">
<button ref="prevElement" class="x-button circle muted" aria-label="Prev" disabled @click="scrollPrev">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</button>
<div
ref="contentElement"
class="x-carousel-content gap-4 w-full"
tabindex="-1"
@scroll="onScroll($event, { pagination: { element: paginationElement } })"
>
<div v-for="slide in slides" :key="slide" class="x-image rounded-md w-full">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240">
</div>
</div>
<button ref="nextElement" class="x-button circle muted" aria-label="Next" @click="scrollNext">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</button>
</div>
<div ref="paginationElement" class="justify-center flex gap-1.5 h-4 py-4" aria-hidden="true">
<div
v-for="(slide, index) in slides"
:key="slide"
class="dot size-2 bg-body-secondary transition data-active:bg-accent"
:data-active="index === 0 ? true : undefined"
></div>
</div>
</Carousel>
</template>import { useRef } from 'react'
import { Carousel } from '@/components/carousel'
const slides = [1, 2, 3, 4, 5, 6]
export function Example() {
const contentElement = useRef<HTMLDivElement>(null)
const prevElement = useRef<HTMLButtonElement>(null)
const nextElement = useRef<HTMLButtonElement>(null)
const paginationElement = useRef<HTMLDivElement>(null)
return (
<Carousel
className="flex flex-col gap-4"
refs={{ contentElement, prevElement, nextElement, paginationElement }}
role="region"
aria-roledescription="carousel"
aria-label="Carousel gallery"
>
{({ scrollPrev, scrollNext, onScroll }) => (
<>
<div className="flex gap-4 items-center">
<button ref={prevElement} className="x-button circle muted" aria-label="Prev" disabled onClick={scrollPrev}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</button>
<div
ref={contentElement}
className="x-carousel-content gap-4 w-full"
tabIndex={-1}
onScroll={(event) => onScroll(event, {
pagination: { element: paginationElement.current ?? undefined }
})}
>
{slides.map((slide) => (
<div key={slide} className="x-image rounded-md w-full">
<img src="https://placehold.co/160x240" alt="" loading="lazy" width="160" height="240" />
</div>
))}
</div>
<button ref={nextElement} className="x-button circle muted" aria-label="Next" onClick={scrollNext}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</button>
</div>
<div ref={paginationElement} className="justify-center flex gap-1.5 h-4 py-4" aria-hidden="true">
{slides.map((slide, index) => (
<div
key={slide}
className="dot size-2 bg-body-secondary transition data-active:bg-accent"
data-active={index === 0 ? true : undefined}
></div>
))}
</div>
</>
)}
</Carousel>
)
}JavaScript API
Low-level helpers used by winduum-elements, winduum-stimulus, winduum-vue and winduum-react — you can also use them to build your own integration.
scrollTo
- Type:
(element: HTMLElement | Element, index?: number) => void - Kind:
sync
Scrolls to a carousel item by its index.
scrollNext
- Type:
(element: HTMLElement | Element) => void - Kind:
sync
Scrolls to the next item based on the active index set by observeCarousel.
scrollPrev
- Type:
(element: HTMLElement | Element) => void - Kind:
sync
Scrolls to the previous item based on the active index set by observeCarousel.
getItemCount
- Type:
(element: HTMLElement | Element, scrollWidth?: number, mathFloor?: boolean) => number - Kind:
sync
Returns the number of possible carousel positions.
observeCarousel
- Type:
(element: HTMLElement | Element, options?: ObserveCarouselOptions) => IntersectionObserver - Kind:
sync
Observes carousel items, toggles a visibility attribute and stores _observer and _activeIndex on the content element.
ObserveCarouselOptions
visibleAttribute
- Type:
string - Default:
data-visible
Attribute added to carousel items while they intersect the carousel viewport.
observerOptions
- Type:
IntersectionObserverInit - Default:
{ threshold: 0.75 }
Additional configuration for the underlying IntersectionObserver.
dragCarousel
- Type:
(element: HTMLElement | Element, options?: DragCarouselOptions) => void - Kind:
sync
Adds mouse dragging on devices with a fine pointer.
DragCarouselOptions
activeAttribute
- Type:
string - Default:
data-grabbing
paginationCarousel
- Type:
(element: HTMLElement | Element, options?: PaginationCarouselOptions) => void - Kind:
sync
Creates pagination items and wires them to the carousel.
PaginationCarouselOptions
element
- Type:
HTMLElement | Element - Default:
undefined
itemContent
- Type:
string - Default:
<div aria-hidden="true"></div>
activeAttribute
- Type:
string - Default:
data-active
autoplayCarousel
- Type:
(element: HTMLElement | Element, options?: AutoplayCarouselOptions) => void - Kind:
sync
Automatically advances the carousel and restarts from the beginning after the final item.
AutoplayCarouselOptions
delay
- Type:
number - Default:
4000
Delay in milliseconds.
pauseElements
- Type:
(HTMLElement | Element)[] - Default:
[]
Elements that pause autoplay while hovered.
scrollCarousel
- Type:
(element: HTMLElement | Element, options?: ScrollCarouselOptions) => void - Kind:
sync
Updates pagination, progress and counter state after scrolling.
ScrollCarouselOptions
pagination
- Type:
PaginationCarouselOptions - Default:
{ activeAttribute: 'data-active' }
progressElement
- Type:
HTMLProgressElement | Element - Default:
undefined
counterMinElement
- Type:
HTMLElement | Element - Default:
undefined
counterMaxElement
- Type:
HTMLElement | Element - Default:
undefined