Carousel
CarouselEngine enhances a native scroll-snap container: it tracks visible and interactive slides,
groups them into pages, manages tabindex/aria for accessibility, mirrors for RTL, and can add
pointer drag with inertia. The browser still does the scrolling — the engine just measures it and
exposes reactive controls.
useCarousel
useCarousel
Drag the cards, use the buttons, or scroll horizontally.
Usage
Mark the scrollable element with a ref and tag each slide with data-slide. useCarousel returns
the engine; useCarouselControls turns it into reactive UI state:
import { useRef } from 'react';
import { useCarousel, useCarouselControls } from '@60fps/ui-react/carousel-engine';
function Gallery() {
const ref = useRef<HTMLDivElement>(null);
const engine = useCarousel(ref);
const controls = useCarouselControls(engine);
return (
<>
<button disabled={!controls.canScrollPrev} onClick={controls.goToPrevPage}>
Prev
</button>
<button disabled={!controls.canScrollNext} onClick={controls.goToNextPage}>
Next
</button>
<div ref={ref} className="relative flex snap-x snap-mandatory gap-4 overflow-x-auto">
{items.map((item) => (
<article key={item.id} data-slide className="shrink-0 snap-start">
…
</article>
))}
</div>
</>
);
}
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { useCarousel, useCarouselControls } from '@60fps/ui-vue/carousel-engine';
const ref = useTemplateRef('carousel');
const engine = useCarousel(ref);
const controls = useCarouselControls(engine);
</script>
<template>
<button :disabled="!controls.canScrollPrev" @click="controls.goToPrevPage">Prev</button>
<div ref="carousel" class="relative flex snap-x snap-mandatory gap-4 overflow-x-auto">
<article v-for="item in items" :key="item.id" data-slide class="shrink-0 snap-start">…</article>
</div>
</template>
Controls anywhere in the tree
Navigation often lives deep inside the carousel markup — arrows overlaid on the slides, dots in a
footer, a “next” CTA inside a slide. Instead of drilling the controls down as props, both adapters
expose them through context, each with its framework’s idiom: in React, wrap the carousel with
CarouselProvider (it calls useCarousel + useCarouselControls for you) and read
useCarouselContext() from any descendant — or the narrower useCarouselControlsContext() /
useCarouselEngineContext(). In Vue, useCarouselControls already provides its controls — no
wrapper component needed, descendants inject(carouselSymbol).
import { useRef } from 'react';
import { CarouselProvider, useCarouselContext } from '@60fps/ui-react/carousel-engine';
function NextButton() {
// Works anywhere under <CarouselProvider> — no props needed.
const { controls } = useCarouselContext();
return (
<button disabled={!controls.canScrollNext} onClick={controls.goToNextPage}>
Next
</button>
);
}
function Gallery() {
const ref = useRef<HTMLDivElement>(null);
return (
<CarouselProvider elementRef={ref} mouseDrag>
<div ref={ref} className="flex snap-x snap-mandatory gap-4 overflow-x-auto">
{items.map((item) => (
<article key={item.id} data-slide>
…
</article>
))}
</div>
<NextButton />
</CarouselProvider>
);
}
<!-- NextButton.vue — works anywhere under the component that called useCarouselControls -->
<script setup lang="ts">
import { inject } from 'vue';
import { carouselSymbol } from '@60fps/ui-vue/carousel-engine';
const controls = inject(carouselSymbol)!;
</script>
<template>
<button :disabled="!controls.canScrollNext" @click="controls.goToNextPage">Next</button>
</template>
| Export | Type | Default | Description |
|---|---|---|---|
CarouselProvider | React component | — | Creates the engine for elementRef (same options as useCarousel) and provides engine + controls. |
useCarouselContext() | React hook | — | { engine, controls } from the nearest provider. Throws outside one. |
useCarouselControlsContext() / useCarouselEngineContext() | React hooks | — | Narrower variants when a component needs only one of the two. |
carouselSymbol | Vue InjectionKey | — | Injection key for the controls that useCarouselControls provides automatically. |
Configurations
The options compose freely — a few common setups:
Paged (default)
Slides scroll a page at a time; goToNextPage advances by the number visible together.
useCarousel(ref);
One slide at a time
Set scrollMode: 'slide' so a single slide is the active unit for navigation and accessibility.
useCarousel(ref, { scrollMode: 'slide' });
Start centered
There’s no center option — the carousel only learns its slide count once it measures. You already
know it though, so compute the middle index yourself and pass it as startIndex:
useCarousel(ref, { startIndex: Math.floor((items.length - 1) / 2) });
Pointer drag with inertia
mouseDrag adds click-and-drag scrolling with an inertial release on top of native touch scrolling.
useCarousel(ref, { mouseDrag: true });
Responsive
The carousel adapts to its content. With few enough slides to fit, it doesn’t overflow — there’s
nothing to page through — so canScrollPrev and canScrollNext both report false, the arrows
disable themselves, and it stays a single page. Narrow the window until the six slides overflow and
the controls light back up.
API
useCarousel(ref, options?)
| Option | Type | Default | Description |
|---|---|---|---|
startIndex | number | 0 | Initial slide index. |
mouseDrag | boolean | false | Enable pointer drag with inertial release. |
scrollMode | 'page' | 'slide' | 'page' | `slide` treats one slide as the active unit for a11y/paging. |
updateVisibilityOnScroll | boolean | — | Update aria/tabindex on every scroll, not only at rest. |
getChildren | (el) => slides | — | Override how slides are located. Defaults to `[data-slide]`. |
useCarouselControls(engine)
Reactive snapshot, refreshed on ready / change / rest / resize.
| Member | Type | Default | Description |
|---|---|---|---|
index / setIndex | number / (n) => void | — | Active slide index, controlled. |
pageIndex / setPageIndex | number / (n) => void | — | Active page, where a page is the set of slides visible together. |
pages / pageCount | number[][] / number | — | Slide indexes grouped per page. |
visibleIndexes | number[] | — | Slides at least half in view. |
canScrollPrev / canScrollNext | boolean | — | Whether paging is possible in each direction. |
goToPrevPage / goToNextPage | () => void | — | Page navigation. |
overflows | boolean | — | Whether content actually overflows (carousel is active). |