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. By default scrolling stays native. Setting infinite: true selects
transform-based dragging and controlled horizontal motion instead.
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 });
Infinite
useCarousel(ref, { infinite: true, mouseDrag: true });
Slides loop in both directions with a continuous position and velocity. The engine moves each
original slide to its next occurrence only while it is outside the viewport, preserving React/Vue
state, event handlers and focus. It supports variable widths, fractional gaps, RTL, and CSS
scroll-snap-align. Remove scroll snap for free motion with inertia.
Free motion (RTL)
CarouselEngine delegates to the original ScrollCarouselEngine by default, or to DragCarouselEngine
when infinite is requested. Both engines implement the exported CarouselDriver interface, which
defines their public properties, navigation, lifecycle and typed events. This is a type-only contract:
layout, pagination, events, focus and timing remain specific to each implementation. The native
scroll logic is unchanged; its additional infinite getter always returns false.
DragCarouselEngine owns gestures, movement and carousel state in a single class.
In drag mode, DragEngine handles horizontal gestures and Motion handles animation, inertia and
snapping. Horizontal touch and trackpad scrolling are therefore controlled, not native. Vertical
page scrolling stays native for gestures that start vertically, and pinch zoom remains available.
This mode requires Pointer Events for movement, with a non-passive touchmove listener to prevent
page scrolling during a recognized horizontal drag. mouseDrag enables mouse and pen gestures
independently of touch. A second touch, browser cancellation or capture loss ends the drag without
release inertia. In native scroll mode, only the optional mouse/pen drag uses DragEngine;
touch scrolling remains native.
There is no native scroll reserve, snap-marker DOM, cloning or scrollend recentering. Only the
offscreen occurrence of a slide wraps; the movement’s position and velocity remain continuous.
The track must be a horizontal row with a consistent gap. Each slide must fit fully outside the
viewport before it can be recycled: the total cycle width minus the widest slide must be at least
the viewport width. With less content, the drag implementation stays bounded. engine.infinite reports
whether looping is active. No application slides are cloned. The engine owns each slide’s CSS translate;
apply custom translations or transitions to a child element.
Indexes and pages always refer to the original slides. scroll is a continuous pixel position
(possibly negative or spanning several turns), and progress wraps from 1 back to 0 once per cycle.
Use the engine’s navigation methods in drag mode; scrollLeft is not its position. Call
engine.layout() after adding, removing, or resizing slides without resizing the viewport.
Loop event
Both modes expose ready, change, rest, resize, scroll, progress, dragging, animating
and scrolling with the same argument types. Their timing follows the selected movement engine.
Infinite mode also emits loop when the position crosses a cycle boundary, where progress wraps:
engine.on('loop', ({ direction, count }) => {
console.log(direction, count);
});
direction is 1 towards the next slides and -1 towards the previous slides, including in RTL.
count is usually 1; a single position update crossing several cycles emits one event with their
total count. This works with dragging, inertia, trackpad and programmatic navigation. It tracks actual
position rather than the requested index, and is independent of individual offscreen slide recycling.
Initialization, layout/resize and cleanup do not emit loop. Neither native scrolling nor the bounded
drag fallback emits it. The payload type is exported as CarouselLoopEvent.
RTL
Nothing to configure: the engine reads the computed direction of the scroll container and mirrors
its measurements, so snapPoints, index, pages and progress all stay in the same logical space
as in LTR — progress still runs 0 → 1 from the first slide to the last, and goToNextPage still
advances. Only the underlying scrollLeft runs negative. Toggle the direction below: the arrows swap
sides with the content, and the readout shows progress leaving the 0–1 range when you drag past an
edge.
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. |
infinite | boolean | false | Use drag-based looping instead of native scrolling. Stays bounded with insufficient content. |
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). |