Carousel
Swipe between cards with a spring snap, on a built-in coverflow you can tune or replace.
The carousel handles the gesture, the physics and the look:
- the track follows the finger 1:1 and rubber-bands past the ends;
- a release lands on the card the speed points to, never more than one away;
- a quick flick always moves one card, however short the drag;
- the snap is a spring that keeps the speed of the release;
- ←/→ move between cards (mirrored right to left), Home and End go to the ends;
- the cards beside the middle one stand turned away and a little back.
import { CardCarousel } from "@danolekh/cardstock/carousel";
<CardCarousel.Root index={index} onIndexChange={setIndex}>
<CardCarousel.Viewport>
<CardCarousel.Track>
{cards.map((card) => (
<CardCarousel.Slide key={card.id} label={card.name}>
{({ active }) => <PaymentCard {...card} active={active} />}
</CardCarousel.Slide>
))}
</CardCarousel.Track>
</CardCarousel.Viewport>
<CardCarousel.Previous />
<CardCarousel.Next />
<CardCarousel.Indicators />
</CardCarousel.Root>;Slides need no index: the track numbers its children in order. The viewport is labelled, focusable and keyboard-driven out of the box, and the slides out of focus are inert.
The built-in look
By default the carousel lays the slides out itself, as a coverflow: stacked in one grid cell, each moved by its distance from the middle, the neighbours narrower, slanted, smaller and faded. Tune it on the root:
<CardCarousel.Root gap={24} turn={0.6} depth={0.08} fade={0.3}>The turn is drawn in 2D (a narrower, slanted card) rather than with rotateY under perspective. A layer turned in 3D is drawn from a texture at a scale the browser has to guess, and some renderers smear fine artwork on it; a 2D transform stays sharp. For reduced motion, nothing turns or recedes, the fade stays, and the snap is a short ease.
The styles are the parts' own inline styles, so your style wins property by property: pass style={{ opacity: 1 }} on the slides to drop the fade, say.
Your own look
effect="none" writes no styles, only the variables. Every frame each slide gets its distance from the middle, and your CSS decides what that looks like:
| Variable | On | Meaning |
|---|---|---|
--slide-offset | slide | Slides from the middle, signed: −1 is one before |
--slide-offset-clamped | slide | The same, clamped to −1 … 1 |
--slide-distance | slide | How far from the middle, 0 … 1, without the sign |
--carousel-position | root | The track's fractional index, 0 … n−1 |
--carousel-gap | root | gap, in px |
--carousel-turn, --carousel-depth, --carousel-fade | root | The tuning (0 for turn and depth with reduced motion) |
--carousel-direction | root | 1, or −1 right to left |
[data-slot="carousel-track"] {
display: grid;
}
[data-slot="carousel-slide"] {
grid-area: 1 / 1;
transform: translateX(calc(var(--slide-offset) * (100% + 16px)))
scale(calc(1 - var(--slide-distance) * 0.15));
opacity: calc(1 - var(--slide-distance) * 0.5);
}Tie anything else to --carousel-position, such as a backdrop colour that blends as you swipe. In JavaScript, useCarouselPosition() gives the same number to subscribe to, without re-rendering.
Reading them deeper down
The four moving variables (--carousel-position and the three on each slide) are registered as numbers that don't inherit. An inherited variable that changes every frame restyles every element under it, and with a few cards on the track that's hundreds of elements a frame, enough to drop frames on a slower machine; this way a swipe restyles only the slides. Style the slides themselves with them, as above. To use one deeper inside a slide, copy it into a variable of your own on the slide, which does inherit:
[data-slot="carousel-slide"] {
--distance: var(--slide-distance);
}
.card-shine {
opacity: calc(1 - var(--distance));
}Everything under the slide then restyles every frame again, so copy only what you use, onto the smallest part that needs it.
Slide content
A slide's children can be a function of its state, or read it with useCarouselSlide() anywhere inside:
<CardCarousel.Slide>{({ active, offset }) => <PaymentCard active={active} />}</CardCarousel.Slide>;
function Badge() {
const { index, count, active } = useCarouselSlide();
return active ? (
<span>
{index + 1} of {count}
</span>
) : null;
}offset is in whole slides (index - current); the moving value is in CSS. The slide also carries data-active, data-side="previous | current | next" and data-index.
Counting and server rendering
The track counts its children, so on the client every part knows how many slides there are. While server-rendering, the parts outside the track (Previous, Next, the indicators) haven't heard the count yet: Next renders enabled until hydration. Pass count on the root when the first HTML should already be exact.
If a slide isn't a direct child of the track (two slides in one wrapper component), give it index.
Clicks and hidden slides
- A click on a card that isn't current makes it current (turn it off per slide with
selectOnClick={false}). Slides out of focus areinert, so the track finds the one under the pointer. - The click that ends a drag is swallowed, so a swipe never presses a button on a card.
- Being
inert, a slide out of focus is hidden from assistive tech and out of the tab order, with nothing on it to press or focus. There's nothing to add to its buttons. - Keys typed into a field on a slide stay in the field.
Indicators
CardCarousel.Indicators turns the indicators into tabs, one per slide: ←/→ and Home/End move between them, and each slide becomes its tab's panel. With no children it renders one per slide, labelled "Card 1", "Card 2"; give it Indicators with text to show names:
<CardCarousel.Indicators className="flex gap-2">
{cards.map((card) => (
<CardCarousel.Indicator key={card.id} className="data-active:font-medium">
{card.name}
</CardCarousel.Indicator>
))}
</CardCarousel.Indicators>An Indicator outside Indicators is a plain button with aria-current on the current slide; give it index.
Labels
Everything the carousel reads out is in labels, English by default:
<CardCarousel.Root
labels={{
carousel: "Karten",
slide: (index, count, label) => `${label ? `${label}, ` : ""}${index + 1} von ${count}`,
previous: "Vorherige Karte",
next: "Nächste Karte",
indicators: "Karte wählen",
indicator: (index) => `Karte ${index + 1}`,
}}
>Right to left
The carousel follows the page's direction, or dir on the root: the track runs the other way, the drag and the arrow keys mirror, and so does the coverflow's turn.
Physics
snapTarget, rubberBand, stepSpring, isSettled and releaseVelocity are exported as pure functions, with DEFAULT_SPRING, for building your own track.
API reference
Root
Prop
Type
Renders a <div> with data-dragging, data-effect, data-dir and the root variables.
Viewport
The region: role="region", aria-roledescription="carousel", aria-label from labels.carousel and tabIndex={0}, all overridable. Renders a <div>.
Track
The surface you drag; it counts and numbers its children. flickVelocity and elastic still work here, but belong on the root. Renders a <div> with data-dragging.
Slide
Prop
Type
Renders a <div> with data-active, data-side, data-index and the slide variables; inert while it isn't current.
Previous and Next
Buttons to the neighbouring slide, labelled from labels, disabled (with data-disabled) at the ends. Render a <button>.
Indicators and Indicator
Indicators renders a <div role="tablist">, with one Indicator per slide when it has no children. Indicator takes index (by default its order inside Indicators) and renders a <button>: a tab inside Indicators, with data-active.
Hooks
| Hook | Returns |
|---|---|
useCardCarousel() | index, count, setIndex, dir, labels: re-renders on a change of index or count |
useCarouselSlide() | The enclosing slide's index, count, active, offset |
useCarouselPosition() | { get, subscribe } for the fractional position, every frame |
useCarouselDragging() | Whether the track is being dragged |
Migrating from 0.2
- The carousel now lays slides out itself. If you wrote the transforms in CSS, add
effect="none"to keep yours, or delete them. - Slides no longer need
index, and the viewport no longer needsaria-labelortabIndex. - Slides out of focus are
inert: remove anytabIndex={-1}you put on their buttons. flickVelocityandelasticmove to the root; the track still takes them for now.- Wrap indicators in
CardCarousel.Indicatorsto make them tabs. - A slide's React children can be a function of its state, which replaces a component calling
useCardCarousel()to compare indices.