Skip to content
60fps/ui
@60fps/ui-carouselv1.1.5

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

Fort Chauncey

Fort Chauncey

Adsum usitas paulatim suggero.

West Ciara

West Ciara

Omnis adipiscor pectus assumenda advoco vinum vociferor.

Dunwoody

Dunwoody

Color eius incidunt audacia volubilis terga vigor varius.

Grand Island

Grand Island

Illo aqua thorax aestas vos.

North Erick

North Erick

Colo communis suspendo.

Alexandrafurt

Alexandrafurt

Dolor venustas carus cunae temeritas.

Fort Sylvesterborough

Fort Sylvesterborough

Calcar curso toties.

Fort Jared

Fort Jared

Cinis adfectus peior desolo admoneo.

useCarousel

Fort Chauncey

Fort Chauncey

Adsum usitas paulatim suggero.

West Ciara

West Ciara

Omnis adipiscor pectus assumenda advoco vinum vociferor.

Dunwoody

Dunwoody

Color eius incidunt audacia volubilis terga vigor varius.

Grand Island

Grand Island

Illo aqua thorax aestas vos.

North Erick

North Erick

Colo communis suspendo.

Alexandrafurt

Alexandrafurt

Dolor venustas carus cunae temeritas.

Fort Sylvesterborough

Fort Sylvesterborough

Calcar curso toties.

Fort Jared

Fort Jared

Cinis adfectus peior desolo admoneo.

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>
ExportTypeDefaultDescription
CarouselProviderReact 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.
carouselSymbolVue 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);
page 1/0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
page 1/0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

One slide at a time

Set scrollMode: 'slide' so a single slide is the active unit for navigation and accessibility.

useCarousel(ref, { scrollMode: 'slide' });
page 1/0
1
2
3
4
5
6
page 1/0
1
2
3
4
5
6

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) });
page 1/0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
page 1/0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

Pointer drag with inertia

mouseDrag adds click-and-drag scrolling with an inertial release on top of native touch scrolling.

useCarousel(ref, { mouseDrag: true });
page 1/0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
page 1/0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

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.

page 1/0
1
2
3
4
5
6
7
8
9
10
11
12
0.000
page 1/0
1
2
3
4
5
6
7
8
9
10
11
12
0.000

Free motion (RTL)

page 1/0
1
2
3
4
5
6
7
8
9
10
11
12
0.000
page 1/0
1
2
3
4
5
6
7
8
9
10
11
12
0.000

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.

page 1/0
1
2
3
4
5
6
7
8
0.000
page 1/0
1
2
3
4
5
6
7
8
0.000

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.

page 1/0
1
2
3
4
5
6
page 1/0
1
2
3
4
5
6

API

useCarousel(ref, options?)

OptionTypeDefaultDescription
startIndexnumber0Initial slide index.
infinitebooleanfalseUse drag-based looping instead of native scrolling. Stays bounded with insufficient content.
mouseDragbooleanfalseEnable pointer drag with inertial release.
scrollMode'page' | 'slide''page'`slide` treats one slide as the active unit for a11y/paging.
updateVisibilityOnScrollboolean—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.

MemberTypeDefaultDescription
index / setIndexnumber / (n) => void—Active slide index, controlled.
pageIndex / setPageIndexnumber / (n) => void—Active page, where a page is the set of slides visible together.
pages / pageCountnumber[][] / number—Slide indexes grouped per page.
visibleIndexesnumber[]—Slides at least half in view.
canScrollPrev / canScrollNextboolean—Whether paging is possible in each direction.
goToPrevPage / goToNextPage() => void—Page navigation.
overflowsboolean—Whether content actually overflows (carousel is active).