Carousel Slide Advance
Slides advance with the neighbours peeking, dots tracking position.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useId, useState } from "react";
import { motion, useAnimationControls, useReducedMotion } from "motion/react";
/**
* Vibary · Carousel Slide Advance
*
* A gallery that moves one slide at a time. The neighbours stay visible
* at the edges so the strip reads as a row you are moving along rather
* than a stack of screens, and the accent dot travels to the position you
* landed on. Arrows, dots and a horizontal drag all drive the same move.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the frame reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `defaultIndex`, `slides`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CarouselSlide = {
id: string;
title: string;
price: string;
/** Real photograph for the slide; omit for the gradient stand-in. */
imageSrc?: string;
/** Gradient shown while there is no photo. */
art?: string;
};
export type CarouselSlideAdvanceProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Slide shown on first render. */
defaultIndex?: number;
/** Slides to walk through; each may carry a photo via `imageSrc`. */
slides?: readonly CarouselSlide[];
/** Notified with the index the carousel settles on. */
onChange?: (index: number) => void;
};
type VariantConfig = {
spring: { type: "spring"; stiffness: number; damping: number };
/** Opacity of the slides peeking at either edge. */
neighbourOpacity: number;
/** How much a flick's speed counts toward advancing, in seconds. */
velocityWeight: number;
};
// Quality rule: the whole strip is in motion, so overshoot here moves
// every slide at once and reads as slack. Springs stay at or above a 0.8
// damping ratio (ζ = damping / 2√stiffness) and land once. Variants
// change how briskly the strip crosses, never how much it wobbles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.00 — arrives flat. For a dense product grid.
subtle: {
spring: { type: "spring", stiffness: 630, damping: 50 },
neighbourOpacity: 0.58,
velocityWeight: 0.12,
},
// ζ ≈ 0.90 — one soft settle, the weight that makes the strip feel
// physical. The all-purpose setting.
default: {
spring: { type: "spring", stiffness: 360, damping: 34 },
neighbourOpacity: 0.42,
velocityWeight: 0.18,
},
// ζ ≈ 0.81 — quicker to leave, still one settle, and a flick carries
// further. For a hero gallery that is the point of the page.
playful: {
spring: { type: "spring", stiffness: 290, damping: 28 },
neighbourOpacity: 0.26,
velocityWeight: 0.24,
},
};
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const FRAME = 320;
const SLIDE = 234;
const GAP = 12;
const STEP = SLIDE + GAP;
/** px of drag (plus a weighted flick) that commits to the next slide. */
const COMMIT = 52;
// Stand-ins for photography, not UI surfaces: they keep their own colors
// in both themes, exactly as real images would. Pass `imageSrc` on a
// slide and the photo takes the gradient's place.
export const DEFAULT_SLIDES: readonly CarouselSlide[] = [
{
id: "lamp",
title: "Arc floor lamp",
price: "$248",
art: "linear-gradient(150deg, #3E4C86 0%, #6D7FD1 55%, #C8A78B 100%)",
},
{
id: "chair",
title: "Shell lounge chair",
price: "$690",
art: "linear-gradient(150deg, #1F4E4A 0%, #4C8C7B 60%, #D3C08A 100%)",
},
{
id: "desk",
title: "Oak writing desk",
price: "$1,120",
art: "linear-gradient(150deg, #5A3B4C 0%, #A2687A 55%, #E2B79A 100%)",
},
{
id: "rug",
title: "Wool floor rug",
price: "$430",
art: "linear-gradient(150deg, #2C3A57 0%, #55719B 50%, #9FC0CE 100%)",
},
];
export default function CarouselSlideAdvance({
variant = "default",
defaultIndex = 0,
slides = DEFAULT_SLIDES,
onChange,
}: CarouselSlideAdvanceProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [index, setIndex] = useState(defaultIndex);
const controls = useAnimationControls();
// Scoped per instance: a hard-coded layout id would make two carousels
// on the same page trade the accent dot across the document.
const dotId = useId();
const last = slides.length - 1;
// Reduced motion: the strip changes position without travelling. The
// slide, the counter and the dot all still say where you are.
const travel = reduceMotion ? { duration: 0 } : cfg.spring;
// One place decides where the strip rests, so a drag that does not
// commit snaps back through exactly the same spring as an arrow press.
useEffect(() => {
controls.start({
x: -index * STEP,
transition: reduceMotion ? { duration: 0 } : cfg.spring,
});
}, [index, controls, cfg, reduceMotion]);
const go = (next: number) => {
const clamped = Math.max(0, Math.min(last, next));
if (clamped === index) {
// Nothing changed — settle the strip back where it belongs.
controls.start({ x: -index * STEP, transition: travel });
return;
}
setIndex(clamped);
onChange?.(clamped);
};
return (
<div
style={{
width: FRAME,
borderRadius: 20,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 14px 36px rgba(0,0,0,0.16)",
padding: "14px 0 12px",
// The strip is clipped by this frame rather than the viewport, so
// the pattern drops into a card, a sheet or a page column
// unchanged.
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
padding: "0 16px 10px",
}}
>
<span style={{ fontSize: 13, fontWeight: 650 }}>New this week</span>
<span
style={{ fontSize: 11.5, opacity: 0.5, fontVariantNumeric: "tabular-nums" }}
>
{index + 1} of {slides.length}
</span>
</div>
{/* The viewport is narrower than the track by design: the slide
before and the slide after stay on screen at the edges, which is
what tells you this is a row and not a stack. */}
<div style={{ overflow: "hidden", padding: "0 0 2px" }}>
<motion.div
drag={reduceMotion ? false : "x"}
dragConstraints={{ left: -last * STEP, right: 0 }}
dragElastic={0.14}
dragMomentum={false}
onDragEnd={(_event, info) => {
// A short fast flick should advance as readily as a long slow
// drag, so distance and speed are weighed together rather
// than tested separately.
const intent = info.offset.x + info.velocity.x * cfg.velocityWeight;
if (intent <= -COMMIT) go(index + 1);
else if (intent >= COMMIT) go(index - 1);
else go(index);
}}
animate={controls}
initial={{ x: -defaultIndex * STEP }}
style={{
display: "flex",
gap: GAP,
paddingLeft: (FRAME - SLIDE) / 2,
paddingRight: (FRAME - SLIDE) / 2,
cursor: reduceMotion ? "default" : "grab",
touchAction: "pan-y",
}}
>
{slides.map((slide, slideIndex) => {
const isCurrent = slideIndex === index;
return (
<motion.div
key={slide.id}
aria-hidden={!isCurrent}
// Neighbours recede with opacity alone. Scaling them down
// is the usual trick, but it scales their captions too,
// and text that resizes as it drifts past is the tell of
// a cheap carousel.
animate={{ opacity: isCurrent ? 1 : cfg.neighbourOpacity }}
transition={{ duration: reduceMotion ? 0 : 0.26, ease: "easeOut" }}
style={{
flex: `0 0 ${SLIDE}px`,
borderRadius: 16,
overflow: "hidden",
background: tone(8),
border: `1px solid ${tone(12)}`,
}}
>
<div
style={{
position: "relative",
height: 132,
background: slide.art ?? tone(8),
}}
>
{slide.imageSrc ? (
<img
src={slide.imageSrc}
alt=""
draggable={false}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
// Without a photo the imagery is synthesized: an inline
// SVG motif over a gradient keeps the file portable.
<svg
viewBox="0 0 240 132"
width="100%"
height="100%"
aria-hidden
style={{ display: "block", opacity: 0.5 }}
>
<circle cx="188" cy="30" r="30" fill="rgba(255,255,255,0.22)" />
<path
d="M0 118 L58 74 L104 106 L158 60 L240 112 L240 132 L0 132 Z"
fill="rgba(12,16,28,0.34)"
/>
</svg>
)}
</div>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 8,
padding: "10px 12px 12px",
}}
>
<span style={{ fontSize: 13, fontWeight: 600 }}>{slide.title}</span>
<span style={{ fontSize: 12.5, opacity: 0.6 }}>{slide.price}</span>
</div>
</motion.div>
);
})}
</motion.div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px 0",
}}
>
<Arrow
direction="prev"
disabled={index === 0}
onClick={() => go(index - 1)}
/>
<div
style={{ display: "flex", alignItems: "center", gap: 9 }}
role="tablist"
aria-label="Slides"
>
{slides.map((slide, slideIndex) => {
const isCurrent = slideIndex === index;
return (
<button
key={slide.id}
type="button"
role="tab"
aria-selected={isCurrent}
aria-label={slide.title}
onClick={() => go(slideIndex)}
style={{
position: "relative",
display: "grid",
placeItems: "center",
width: 6,
height: 14,
padding: 0,
border: 0,
background: "none",
cursor: "pointer",
}}
>
<span
aria-hidden
style={{
width: 6,
height: 6,
borderRadius: 999,
background: tone(24),
}}
/>
{isCurrent && (
// The accent marker is removed from the old dot and
// mounted on the new one; the shared layout id makes
// Motion travel it across the row instead of blinking
// it out and back.
<motion.span
layoutId={dotId}
transition={travel}
aria-hidden
style={{
position: "absolute",
width: 16,
height: 6,
borderRadius: 999,
background: ACCENT,
}}
/>
)}
</button>
);
})}
</div>
<Arrow
direction="next"
disabled={index === last}
onClick={() => go(index + 1)}
/>
</div>
</div>
);
}
function Arrow({
direction,
disabled,
onClick,
}: {
direction: "prev" | "next";
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={direction === "prev" ? "Previous slide" : "Next slide"}
style={{
display: "grid",
placeItems: "center",
width: 30,
height: 30,
borderRadius: 999,
border: `1px solid ${tone(14)}`,
background: tone(8),
color: "inherit",
opacity: disabled ? 0.32 : 1,
cursor: disabled ? "default" : "pointer",
}}
>
<svg
width="15"
height="15"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
style={{ transform: direction === "prev" ? "rotate(180deg)" : "none" }}
>
<path d="M7.5 4.5 13 10l-5.5 5.5" />
</svg>
</button>
);
}About this pattern
A gallery you move along rather than page through. Two choices do the work: the viewport is narrower than the track so the neighbour on each side stays visible, and the accent dot is a single marker with a shared layout id, so it travels to the position you landed on instead of blinking out and back. Arrows, dots and a horizontal drag all funnel into one settle function, which is why a flick that does not commit snaps back through exactly the same spring as a button press. Neighbours recede with opacity alone — scaling them down would scale their captions, and text that resizes as it drifts past is the tell of a cheap gallery.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Product page
Photos advance one at a time with dots underneath marking the position.
Related patterns
- Gallery Lightbox OpenThe pressed thumbnail flies out of the grid into the large view while the backdrop dims behind it.
- Swipe Back PeelAn edge drag peels the top page away under your finger and snaps to whichever side the gesture was heading for.
- Detail Close ReturnClosing a detail view returns the tile to the exact row it came from.
