Value Prop Carousel
Intro slides advance on their own, with the artwork crossfading a beat behind the copy.
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, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Value Prop Carousel
*
* Intro slides that advance on their own. The words change first and the
* artwork crossfades a beat behind them, so the reader is never asked to
* take in two changes at the same instant.
*
* Self-contained: depends only on `react` and `motion`. Artwork is built
* from CSS gradients and inline SVG — no assets. Neutrals are mixed from
* the inherited text color, so it reads on light and dark pages alike.
* Works with zero props; tune via `variant`, `slides`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ValueSlide = {
title: string;
body: string;
/** Which built-in composition to draw. */
art: "stack" | "people" | "chart";
};
export type ValuePropCarouselProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Your own slides. The embedded sample is used when omitted. */
slides?: ValueSlide[];
/** How long each slide is held, in ms. */
dwellMs?: number;
/** Label of the action revealed on the last slide. */
actionLabel?: string;
/** Artwork, bar and button color. */
accent?: string;
/** Fires with the index the carousel moved to. */
onSlideChange?: (index: number) => void;
/** Fires when the final action is pressed. */
onStart?: () => void;
};
type VariantConfig = {
/** px the copy travels as it changes. */
rise: number;
/** Seconds the copy takes to arrive. */
copySeconds: number;
/** Seconds the artwork waits before following the copy. */
artDelay: number;
/** Seconds the artwork takes to crossfade. */
artSeconds: number;
};
// Quality rule: nothing here springs and nothing scales. Slides are copy
// over artwork, and both have to be legible the instant they land — so
// every change is a short ease. Variants change pace and travel only.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A near-straight crossfade. For an intro shown on every launch.
subtle: {
rise: 5,
copySeconds: 0.24,
artDelay: 0.06,
artSeconds: 0.3,
},
// The copy leads clearly and the art follows. All-purpose.
default: {
rise: 9,
copySeconds: 0.3,
artDelay: 0.12,
artSeconds: 0.42,
},
// A long, unhurried dissolve for a first-run story.
playful: {
rise: 13,
copySeconds: 0.36,
artDelay: 0.18,
artSeconds: 0.54,
},
};
const SAMPLE_SLIDES: ValueSlide[] = [
{
title: "Everything in one place",
body: "Projects, documents and the people working on them, in a single workspace.",
art: "stack",
},
{
title: "Built for a team",
body: "Hand work over, follow along, and see who is on what today.",
art: "people",
},
{
title: "Know what comes next",
body: "A weekly view that surfaces what is due before it is late.",
art: "chart",
},
];
const ART_HEIGHT = 116;
/** Fixed so the card cannot resize between slides of different lengths. */
const COPY_HEIGHT = 74;
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function ValuePropCarousel({
variant = "default",
slides = SAMPLE_SLIDES,
dwellMs = 3000,
actionLabel = "Get started",
accent = "#5B5BD6",
onSlideChange,
onStart,
}: ValuePropCarouselProps) {
const [index, setIndex] = useState(0);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const isLast = index === slides.length - 1;
const slide = slides[index];
// Advances on its own and stops on the last slide: an intro that loops
// forever is an intro nobody can finish reading.
useEffect(() => {
if (isLast) return;
const timer = setTimeout(() => {
const next = index + 1;
setIndex(next);
onSlideChange?.(next);
}, dwellMs);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [index, isLast, dwellMs]);
const jump = (next: number) => {
setIndex(next);
onSlideChange?.(next);
};
const rise = reduceMotion ? 0 : cfg.rise;
return (
<div
style={{
width: 320,
boxSizing: "border-box",
padding: 14,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(5),
}}
>
<div
style={{
position: "relative",
height: ART_HEIGHT,
borderRadius: 13,
overflow: "hidden",
background: tone(6),
}}
>
{/* The artwork trails the words. Two changes at the same instant
read as a cut; a beat apart reads as one thought following
another. */}
<AnimatePresence initial={false}>
<motion.div
key={index}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: { duration: cfg.artSeconds * 0.7, ease: "easeInOut" },
}}
transition={{
duration: reduceMotion ? 0.2 : cfg.artSeconds,
delay: reduceMotion ? 0 : cfg.artDelay,
ease: "easeInOut",
}}
style={{ position: "absolute", inset: 0 }}
>
<SlideArt art={slide.art} accent={accent} />
</motion.div>
</AnimatePresence>
</div>
<div style={{ position: "relative", height: COPY_HEIGHT, marginTop: 14 }}>
<AnimatePresence initial={false}>
<motion.div
key={index}
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: -rise * 0.5,
transition: { duration: 0.18, ease: "easeIn" },
}}
transition={{
duration: reduceMotion ? 0.2 : cfg.copySeconds,
ease: "easeOut",
}}
style={{ position: "absolute", inset: 0 }}
>
<div style={{ fontSize: 16, fontWeight: 680, letterSpacing: -0.2 }}>
{slide.title}
</div>
<p
style={{
margin: "6px 0 0",
fontSize: 12.5,
lineHeight: 1.55,
opacity: 0.62,
}}
>
{slide.body}
</p>
</motion.div>
</AnimatePresence>
</div>
<div style={{ display: "flex", gap: 5, marginTop: 12 }}>
{slides.map((item, barIndex) => (
<button
key={item.title}
type="button"
aria-label={item.title}
aria-current={barIndex === index}
onClick={() => jump(barIndex)}
style={{
flex: 1,
padding: "7px 0",
background: "transparent",
border: "none",
color: "inherit",
cursor: "pointer",
lineHeight: 0,
}}
>
<span
style={{
position: "relative",
display: "block",
height: 3,
borderRadius: 3,
background: tone(14),
overflow: "hidden",
}}
>
<motion.span
// Keyed by slide so the fill restarts from empty each
// time this bar becomes the live one. It measures the
// dwell, so it runs linearly — and it keeps running under
// reduced motion, because it is information, not decor.
key={`${barIndex}-${index}`}
initial={{ scaleX: barIndex < index ? 1 : 0 }}
animate={{ scaleX: barIndex <= index ? 1 : 0 }}
transition={
barIndex === index && !isLast
? { duration: dwellMs / 1000, ease: "linear" }
: { duration: barIndex === index ? 0.24 : 0, ease: "easeOut" }
}
style={{
position: "absolute",
inset: 0,
borderRadius: 3,
background: accent,
transformOrigin: "0% 50%",
}}
/>
</span>
</button>
))}
</div>
<div style={{ height: 40, marginTop: 8 }}>
<AnimatePresence>
{isLast && (
<motion.button
key="cta"
type="button"
onClick={onStart}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
transition={{
duration: reduceMotion ? 0.2 : 0.3,
delay: reduceMotion ? 0 : 0.14,
ease: "easeOut",
}}
style={{
width: "100%",
padding: "11px 16px",
fontSize: 13,
fontWeight: 650,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 11,
cursor: "pointer",
}}
>
{actionLabel}
</motion.button>
)}
</AnimatePresence>
</div>
</div>
);
}
/** Built from gradients and inline SVG so the file stays a single
* copyable unit with no artwork to fetch. */
function SlideArt({ art, accent }: { art: ValueSlide["art"]; accent: string }) {
const wash = {
position: "absolute" as const,
inset: 0,
background: `linear-gradient(140deg, color-mix(in srgb, ${accent} 26%, transparent), transparent 72%)`,
};
if (art === "people") {
return (
<div style={{ position: "absolute", inset: 0 }}>
<div style={wash} />
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{[0, 1, 2, 3].map((seat) => (
<span
key={seat}
style={{
width: 34,
height: 34,
marginLeft: seat === 0 ? 0 : -9,
borderRadius: 999,
border: "2px solid Canvas",
background:
seat === 1
? accent
: `color-mix(in srgb, currentColor ${16 + seat * 5}%, transparent)`,
}}
/>
))}
</div>
<div
style={{
position: "absolute",
left: 26,
right: 26,
bottom: 20,
height: 6,
borderRadius: 3,
background: "color-mix(in srgb, currentColor 14%, transparent)",
}}
/>
</div>
);
}
if (art === "chart") {
const bars = [30, 46, 26, 58, 40];
return (
<div style={{ position: "absolute", inset: 0 }}>
<div style={wash} />
<div
style={{
position: "absolute",
left: 24,
right: 24,
bottom: 24,
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
height: 62,
}}
>
{bars.map((barHeight, barIndex) => (
<span
key={barIndex}
style={{
width: 26,
height: barHeight,
borderRadius: 7,
background:
barIndex === 3
? accent
: "color-mix(in srgb, currentColor 18%, transparent)",
}}
/>
))}
</div>
<div
style={{
position: "absolute",
left: 24,
right: 24,
bottom: 18,
height: 1,
background: "color-mix(in srgb, currentColor 14%, transparent)",
}}
/>
</div>
);
}
// "stack": two sheets tucked behind one front document that carries a
// header, body lines and the people on it — the workspace in one card.
// The front sheet sits on `Canvas` so it reads as a surface, not as
// another translucent layer of the pile.
return (
<div style={{ position: "absolute", inset: 0 }}>
<div style={wash} />
{[0, 1].map((sheet) => (
<div
key={sheet}
style={{
position: "absolute",
left: "50%",
top: 14 + sheet * 9,
transform: "translateX(-50%)",
width: 128 + sheet * 20,
height: 44,
borderRadius: 9,
border: "1px solid color-mix(in srgb, currentColor 12%, transparent)",
background: "color-mix(in srgb, currentColor 7%, transparent)",
}}
/>
))}
<div
style={{
position: "absolute",
left: "50%",
top: 32,
transform: "translateX(-50%)",
width: 168,
height: 68,
borderRadius: 10,
border: "1px solid color-mix(in srgb, currentColor 16%, transparent)",
background: "Canvas",
boxSizing: "border-box",
padding: 12,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span
style={{
width: 14,
height: 14,
borderRadius: 4,
background: `color-mix(in srgb, ${accent} 78%, transparent)`,
}}
/>
<span
style={{
width: 58,
height: 6,
borderRadius: 3,
background: "color-mix(in srgb, currentColor 26%, transparent)",
}}
/>
</div>
<div
style={{
marginTop: 9,
width: 112,
height: 5,
borderRadius: 3,
background: "color-mix(in srgb, currentColor 14%, transparent)",
}}
/>
<div
style={{
marginTop: 6,
width: 84,
height: 5,
borderRadius: 3,
background: "color-mix(in srgb, currentColor 10%, transparent)",
}}
/>
<div style={{ position: "absolute", right: 10, bottom: 10, display: "flex" }}>
<span
style={{
width: 16,
height: 16,
borderRadius: 999,
border: "2px solid Canvas",
background: "color-mix(in srgb, currentColor 26%, transparent)",
}}
/>
<span
style={{
width: 16,
height: 16,
marginLeft: -6,
borderRadius: 999,
border: "2px solid Canvas",
background: accent,
}}
/>
</div>
</div>
</div>
);
}About this pattern
The three screens a product shows before asking for anything. The words change first and the artwork follows a beat later, because two changes landing at the same instant read as a cut while a beat apart reads as one thought following another. The frame holds a fixed height for both the art and the copy, so slides of different lengths cannot resize the card mid-dissolve. Each indicator bar fills linearly across the dwell — it is a clock, not decoration, so it keeps running under reduced motion — and the sequence stops on the last slide rather than looping, because an intro that never ends is one nobody finishes reading.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
Illustrated intro pages that advance themselves with a timed indicator.
Related patterns
- Welcome Hero EntranceThe first screen assembles in beats: the mark settles, then the headline, the supporting line, and finally the action.
- Template Gallery PickThe chosen thumbnail grows in place into a preview of the workspace it would create.
- Import Data ConnectA line draws itself between two service marks, records run along it, and the destination takes a check when the import lands.