Carousel Preload Slide
The upcoming frame resolves from placeholder to artwork a beat before the track moves to it.
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 { useCallback, useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Carousel Preload Slide
*
* The slide after the current one is fetched while you are still looking
* at this one, so its placeholder has already resolved into artwork by
* the time the track moves. The carousel never slides onto an empty
* frame.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Slide artwork is synthesized from CSS gradients and inline SVG — no
* image host — and the placeholder is mixed from the inherited text
* color, so it reads on a light page and on a dark one.
* Works with zero props; tune via `variant`, `autoAdvanceMs`, `slides`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PreloadSlide = {
title: string;
meta: string;
/** Real photograph; omit for the gradient-and-motif stand-in. */
imageSrc?: string;
/** Gradient behind the motif while there is no photo. */
art?: string;
motif?: Motif;
};
export type CarouselPreloadSlideProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Slides in rotation; each may carry a photo via `imageSrc`. */
slides?: readonly PreloadSlide[];
/** Active slide. Left undefined, the carousel advances itself. */
index?: number;
/** Only consulted while `index` is undefined: dwell time per slide. */
autoAdvanceMs?: number;
/** How far ahead of the move the next slide's artwork is fetched. */
preloadLeadMs?: number;
/** How long the first slide takes to arrive, so the swap is visible at all. */
firstLoadMs?: number;
/** Slide width in px. */
width?: number;
/** Accessible name for the carousel. */
label?: string;
};
type VariantConfig = {
/** Scale the arriving artwork settles from. */
artFrom: number;
artSeconds: number;
artSpring: { type: "spring"; stiffness: number; damping: number };
/** Spring that carries the track sideways. */
trackSpring: { type: "spring"; stiffness: number; damping: number };
dotSeconds: number;
};
// Quality rule: artwork scales, captions never do — every slide's text
// travels with the track at a constant size. Both springs sit above
// critical damping, because a carousel that overshoots looks like it
// missed the slide it was aiming for.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A straight fade-up with almost no settle. For a compact strip inside
// a denser page.
subtle: {
artFrom: 1.01,
artSeconds: 0.3,
artSpring: { type: "spring", stiffness: 560, damping: 44 },
trackSpring: { type: "spring", stiffness: 380, damping: 36 },
dotSeconds: 0.22,
},
// A visible settle as the artwork lands. The all-purpose setting.
default: {
artFrom: 1.03,
artSeconds: 0.42,
artSpring: { type: "spring", stiffness: 420, damping: 37 },
trackSpring: { type: "spring", stiffness: 300, damping: 32 },
dotSeconds: 0.28,
},
// A wider settle and a slower track — for a full-width hero carousel
// where each slide is meant to be looked at.
playful: {
artFrom: 1.06,
artSeconds: 0.52,
artSpring: { type: "spring", stiffness: 340, damping: 33 },
trackSpring: { type: "spring", stiffness: 240, damping: 29 },
dotSeconds: 0.32,
},
};
/** Theme-adaptive neutral for the placeholder and the frame. The slide
* artwork below stays literal: it stands in for a photograph, which is
* imagery rather than a surface. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export type Motif = "arcs" | "dots" | "bars";
export const DEFAULT_SLIDES: readonly PreloadSlide[] = [
{
title: "Desk Series 02",
meta: "Ships in 2 days · $340",
art: "linear-gradient(142deg, #6E74E8 0%, #3F3F91 58%, #2A2A5E 100%)",
motif: "arcs",
},
{
title: "Field Notebook",
meta: "New this week · $28",
art: "linear-gradient(142deg, #E8A06E 0%, #B85F3C 62%, #7C3A28 100%)",
motif: "dots",
},
{
title: "Travel Duffel",
meta: "Back in stock · $180",
art: "linear-gradient(142deg, #4FBFA8 0%, #2A7F73 60%, #1B4F49 100%)",
motif: "bars",
},
];
/** Abstract product artwork. Inline SVG over a gradient keeps every slide
* in the file — a carousel pattern that needed four hosted images would
* stop being one copyable unit. */
function SlideArt({ motif = "arcs" }: { motif?: Motif }) {
return (
<svg
width="100%"
height="100%"
viewBox="0 0 120 60"
preserveAspectRatio="none"
fill="none"
style={{ display: "block" }}
>
{motif === "arcs" ? (
<g stroke="rgba(255,255,255,0.24)" strokeWidth="1.4" fill="none">
<circle cx="96" cy="52" r="16" />
<circle cx="96" cy="52" r="27" />
<circle cx="96" cy="52" r="38" />
</g>
) : null}
{motif === "dots" ? (
<g fill="rgba(255,255,255,0.26)">
{[0, 1, 2, 3, 4, 5].map((column) =>
[0, 1, 2].map((row) => (
<circle
key={`${column}-${row}`}
cx={70 + column * 9}
cy={16 + row * 14}
r="2.4"
/>
))
)}
</g>
) : null}
{motif === "bars" ? (
<g fill="rgba(255,255,255,0.22)">
<rect x="64" y="34" width="42" height="6" rx="3" />
<rect x="64" y="24" width="30" height="6" rx="3" />
<rect x="64" y="14" width="18" height="6" rx="3" />
</g>
) : null}
</svg>
);
}
/** What a slide looks like before its artwork has arrived. */
function SlidePlaceholder() {
return (
<div
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
background: tone(9),
}}
>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
<rect
x="2.6"
y="4.4"
width="18.8"
height="15.2"
rx="2.6"
stroke={tone(26)}
strokeWidth="1.5"
/>
<circle cx="8.4" cy="9.6" r="1.7" fill={tone(26)} />
<path
d="m4.6 17.4 4.6-4.4 3.2 3 3-2.6 4 4"
stroke={tone(26)}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
export default function CarouselPreloadSlide({
variant = "default",
slides = DEFAULT_SLIDES,
index,
autoAdvanceMs = 2400,
preloadLeadMs = 900,
firstLoadMs = 420,
width = 280,
label = "Featured products",
}: CarouselPreloadSlideProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [active, setActive] = useState(0);
const [loaded, setLoaded] = useState<number[]>([]);
const controlled = index !== undefined;
const current = (controlled ? index : active) % slides.length;
const mark = useCallback((slide: number) => {
setLoaded((already) =>
already.includes(slide) ? already : [...already, slide]
);
}, []);
// The first slide is fetched like any other — it simply has no lead
// time, which is exactly why the very first swap is the one users see.
useEffect(() => {
const timer = setTimeout(() => mark(0), firstLoadMs);
return () => clearTimeout(timer);
}, [mark, firstLoadMs]);
useEffect(() => {
const next = (current + 1) % slides.length;
if (controlled) {
// A controlled carousel owns its own paging; all this stands in for
// is the fetch — the frame it was told to show, then the one after.
const shown = setTimeout(() => mark(current), firstLoadMs);
const ahead = setTimeout(() => mark(next), firstLoadMs + preloadLeadMs);
return () => {
clearTimeout(shown);
clearTimeout(ahead);
};
}
// The whole point of the pattern: request the next slide's artwork
// well before the track is asked to move to it.
const preload = setTimeout(
() => mark(next),
Math.max(0, autoAdvanceMs - preloadLeadMs)
);
const advance = setTimeout(() => setActive(next), autoAdvanceMs);
return () => {
clearTimeout(preload);
clearTimeout(advance);
};
}, [
controlled,
current,
autoAdvanceMs,
preloadLeadMs,
firstLoadMs,
mark,
slides.length,
]);
const artHeight = Math.round(width * 0.46);
return (
<div
role="group"
aria-roledescription="carousel"
aria-label={label}
style={{ width }}
>
<div
style={{
width,
borderRadius: 14,
overflow: "hidden",
border: `1px solid ${tone(12)}`,
background: tone(5),
}}
>
<motion.div
animate={{ x: -current * width }}
transition={reduceMotion ? { duration: 0 } : cfg.trackSpring}
style={{ display: "flex", willChange: "transform" }}
>
{slides.map((slide, slideIndex) => {
const ready = loaded.includes(slideIndex);
return (
<div
key={slide.title}
aria-hidden={slideIndex !== current}
style={{ width, flexShrink: 0 }}
>
<div
style={{
position: "relative",
height: artHeight,
overflow: "hidden",
}}
>
<SlidePlaceholder />
{/* Artwork carries no text, so it can settle from a hair
over full size without anything going soft on the way. */}
<motion.div
initial={false}
animate={{
opacity: ready ? 1 : 0,
scale: ready || reduceMotion ? 1 : cfg.artFrom,
}}
transition={{
opacity: {
duration: reduceMotion ? 0.18 : cfg.artSeconds,
ease: "easeOut",
},
scale: reduceMotion ? { duration: 0 } : cfg.artSpring,
}}
style={{
position: "absolute",
inset: 0,
background: slide.art,
}}
>
{slide.imageSrc ? (
<img
src={slide.imageSrc}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
<SlideArt motif={slide.motif} />
)}
</motion.div>
</div>
<div style={{ padding: "11px 13px 13px" }}>
<div style={{ fontSize: 13.5, fontWeight: 620 }}>
{slide.title}
</div>
<div style={{ fontSize: 11.5, opacity: 0.55, marginTop: 3 }}>
{slide.meta}
</div>
</div>
</div>
);
})}
</motion.div>
</div>
<div
aria-hidden
style={{
display: "flex",
justifyContent: "center",
gap: 6,
marginTop: 11,
}}
>
{slides.map((slide, slideIndex) => (
<motion.span
key={slide.title}
initial={false}
animate={{
width: slideIndex === current ? 18 : 6,
opacity: slideIndex === current ? 0.85 : 0.3,
}}
transition={{
duration: reduceMotion ? 0 : cfg.dotSeconds,
ease: "easeOut",
}}
style={{
height: 6,
borderRadius: 3,
background: "currentColor",
}}
/>
))}
</div>
</div>
);
}About this pattern
A carousel is the one place where you always know what will be needed next, and a carousel that still slides onto an empty frame is simply not using that. Here the request for the following frame goes out roughly a second before the move, so its placeholder has already resolved into artwork by the time the track arrives — the swap happens in the corner of your eye rather than in front of you. Artwork settles from a hair over full size as it fades in, which it can afford to do because it carries no text; the captions travel with the track at a constant size. Both springs sit above critical damping, since a carousel that overshoots looks like it missed the frame it was aiming for.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Media player
Row artwork just off screen is fetched before the row is paged across.
Related patterns
- Image Blur UpA tiny blurred stand-in holds the frame and sharpens into the full picture, with nothing below it moving.
- Page Transition FadeThe outgoing view sinks a few pixels as it fades while the incoming one rises to meet it.
- Shimmer SweepA soft highlight travels across placeholder blocks, each one a beat behind the last.
