Relay Carousel
Testimonial Carousel for React
A looping strip of photo cards where the first card leads: bigger, with its quote showing. Passing the lead on glides the strip, shrinks the old lead out of the way and grows the next card into its place, then sets the new quote in line by line.
<RelayCarousel />The demo follows as you drag.
steady books.
“I used to spend Sunday nights matching invoices to kiln loads. Now an order comes in and the glaze stock counts itself down.”
import { RelayCarousel, RelayCarouselItem } from "@/components/ui/relay-carousel";
const makers = [
{
name: "Joaquim Carvalho",
role: "Ceramicist, Barro Studio",
label: "Ceramics · Porto",
quote:
"I used to spend Sunday nights matching invoices to kiln loads. Now an order comes in and the glaze stock counts itself down.",
photo: "1753164725926-dc4de5911e61",
alt: "A bearded potter in a clay-streaked apron raising a tall vase on the wheel",
},
{
name: "Tomasz Wiśniewski",
role: "Owner, Kruszon Bakery",
label: "Bakery · Kraków",
quote:
"Our cafés reorder from a link I sent them once. I know what to bake tomorrow before the ovens have cooled down.",
photo: "1772758632504-361aa9d14a19",
alt: "A baker sliding flatbreads into a brick oven, rounds of dough waiting in front",
},
{
name: "Gareth Lloyd",
role: "Woodcarver, Grain & Gauge",
label: "Woodwork · Leeds",
quote:
"Every commission keeps its timber, hours and deposit on one page. Quoting a carved panel takes ten minutes, not an evening.",
photo: "1783669081274-3db07e6ec196",
alt: "A woodcarver chiseling a relief panel at a cluttered bench under a desk lamp",
},
{
name: "Kenji Arai",
role: "Roaster, Hiraku Coffee",
label: "Coffee · Osaka",
quote:
"Green beans, roast days and wholesale orders finally talk to each other. I buy sacks when the numbers say so, not when I panic.",
photo: "1607681034512-1c9c5fbda608",
alt: "A roaster in ear defenders checking freshly roasted beans under a magnifier lamp",
},
{
name: "Lena Hoffmann",
role: "Florist, Stiel",
label: "Flowers · Leipzig",
quote:
"Wedding season used to live on sticky notes. This year every bouquet had its stems, its order and its invoice before the bride did.",
photo: "1531058240690-006c446962d8",
alt: "A florist at the counter of her flower shop, seen through the plants",
},
{
name: "Idris Hale",
role: "Leatherworker, Hale & Hide",
label: "Leather · Bristol",
quote:
"Custom orders, hides and the balance owed sit on the same screen. I've stopped losing deposits in the gap between them.",
photo: "1534126511673-b6899657816a",
alt: "A craftsman stitching a leather strap on an industrial sewing machine",
},
{
name: "Rafael Moreno",
role: "Mechanic, Cadena Cycles",
label: "Repairs · Valencia",
quote:
"Customers get a message when their bike is ready and pay from the link. My bench stays clear, and so does my inbox.",
photo: "1675798227643-da319f8ee8f7",
alt: "A mechanic's hands tightening the crank on a black bicycle frame",
},
{
name: "Ewan MacLeod",
role: "Printer, Low Tide Press",
label: "Screen print · Edinburgh",
quote:
"A poster run has twelve proofs and three deposits. Field & Fold keeps all of it straight, so I can keep my hands inky.",
photo: "1456456496250-d5e7c0a9b44d",
alt: "A printmaker pulling ink across a silkscreen by the window",
},
];
const photo = (id: string) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=880&h=840&q=80`;
export function RelayCarouselDemo() {
return (
<div className="flex min-h-[var(--demo-height,auto)] w-full flex-col justify-center overflow-hidden rounded-2xl border border-zinc-200 bg-white py-14 text-zinc-950 sm:py-20 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50">
<RelayCarousel
aria-labelledby="relay-makers-heading"
autoPlay={7000}
heading={
<h2
id="relay-makers-heading"
className="text-[2rem] leading-[1.02] font-semibold tracking-[-0.035em] text-balance @2xl:text-5xl"
>
Small shops,
<br />
<span className="text-zinc-400 dark:text-zinc-500">steady books.</span>
</h2>
}
>
{makers.map((maker) => (
<RelayCarouselItem
key={maker.name}
name={maker.name}
role={maker.role}
label={maker.label}
quote={maker.quote}
image={photo(maker.photo)}
alt={maker.alt}
/>
))}
</RelayCarousel>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Relay Carousel: A looping strip of photo cards where the first card leads: bigger, with its quote showing. Passing the lead on glides the strip, shrinks the old lead out of the way and grows the next card into its place, then sets the new quote in line by line. This demo, Customer Stories: Workshop owners on the invoicing app they run their shop on. It plays by itself and holds while you point at it; pull the strip, use the arrows or click a card to pass the lead. 1. Install it: npx shadcn@latest add @keyframeui/relay-carousel-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/relay-carousel-demo.json instead.) It adds the demo, components/relay-carousel-demo.tsx, and the component, components/ui/relay-carousel.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <RelayCarousel> on its own. 3. These props set its feel. Change them before you change the file: - duration (default 750): Milliseconds the strip takes to glide to a new lead, on a spring with no bounce. A flick's speed carries into it. - stagger (default 80): Milliseconds between the lead's quote lines setting in. Content in `children` follows the last line. - drift (default 14): How far each photo drifts inside its card per card the strip travels, in px. `0` keeps the photos still. Off for reduced motion. 4. Don't change, in components/ui/relay-carousel.tsx: - Every card is placed and sized from one motion value, `place`, through CSS variables (`--offset`, `--grow`, `--ahead`, `--drift`); the sizes live in CSS (`SIZES`), so the server renders the strip already laid out. - The leaving quote lifts off first, the strip glides on one spring, and the new quote sets in line by line only once the strip is within `RELAY.settle` of its slot and nothing is dragging. - A drag lands where the flick was heading and hands its speed to the glide as `velocity`, so a release never snaps. - The strip is endless: slots are virtualized around the lead, and only the copy nearest the lead (`canonical`) is announced and focusable; repeats are `aria-hidden` and `inert`. - Photo drift and zoom are CSS-only behind `motion-safe:`, so reduced motion turns them off without a hydration mismatch; it also jumps the strip and holds autoplay until played. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/relay-carousel.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/relay-carousel.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/relay-carouselInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/relay-carousel.tsx"use client"; import * as React from "react"; import { animate, AnimatePresence, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type AnimationPlaybackControls, type MotionStyle, type MotionValue, } from "motion/react"; import { cn } from "@/lib/utils"; /** * Keep: * - Every card is placed and sized from one motion value, `place`, through CSS variables * (`--offset`, `--grow`, `--ahead`, `--drift`); the sizes live in CSS (`SIZES`), so the server * renders the strip already laid out. * - The leaving quote lifts off first, the strip glides on one spring, and the new quote sets in * line by line only once the strip is within `RELAY.settle` of its slot and nothing is dragging. * - A drag lands where the flick was heading and hands its speed to the glide as `velocity`, so a * release never snaps. * - The strip is endless: slots are virtualized around the lead, and only the copy nearest the * lead (`canonical`) is announced and focusable; repeats are `aria-hidden` and `inert`. * - Photo drift and zoom are CSS-only behind `motion-safe:`, so reduced motion turns them off * without a hydration mismatch; it also jumps the strip and holds autoplay until played. */ const TIMING = { /** The strip gliding to a new lead: a spring with no bounce, over `duration`. */ glide: { type: "spring", bounce: 0 }, /** How long the strip takes to glide to a new lead, in ms (the `duration` prop). */ duration: 750, /** Between the lead's quote lines setting in, in ms (the `stagger` prop). */ stagger: 80, /** The strip leaning toward a hovered arrow, and back. */ lean: { type: "spring", visualDuration: 0.35, bounce: 0 }, /** Release speed carried into where a drag lands, in seconds of travel. */ flick: 0.2, /** How recently the pointer must have moved for a release to carry its speed, in ms. */ fresh: 80, /** Without sideways wheel input before the strip snaps, in ms. */ wheelIdle: 140, /** The autoplay ring filling over `autoPlay`, as an easing. */ ring: "linear", /** The ring fading as autoplay pauses and plays, as a Tailwind class. */ ringFade: "duration-300", /** A quote line, label or extra lifting off when its card stops leading, as Tailwind classes. */ hide: "duration-200 ease-out", /** A quote line setting in from the blur once the strip lands, as Tailwind classes. */ show: "group-data-[shown=true]/relay:duration-700 group-data-[shown=true]/relay:ease-[cubic-bezier(0.16,1,0.3,1)]", /** A resting card's photo brightening on hover, as a Tailwind class. */ brighten: "duration-500", /** A round button's hover and press, as Tailwind classes. */ button: "duration-200 ease-out", /** An arrow nudging on its button's hover, as Tailwind classes. */ nudge: "duration-200 ease-out", /** The counter's digits rolling to the new lead. */ count: { type: "spring", visualDuration: 0.4, bounce: 0 }, } as const; /* ───────────────────────────────────────────────────────── * RELAY CAROUSEL STORYBOARD * * rest a strip of photo cards that runs off the right edge. * The first card is the lead: wider and taller than * the rest, its photo under a scrim, its quote set * above the name. The others show a name and a role. * Cards that already led fade into the left margin * relay next/prev, a click on a card, keys or autoplay pass * the lead on. The leaving quote lifts off first, then * the strip glides on one spring: the old lead shrinks * and slides out while the new one grows into its * place. Photos drift inside their cards as they * travel (14px a card) and the lead's eases back from * a 6% zoom. Once the strip has nearly landed, the new * quote sets in line by line from a soft blur * drag pull the strip with a finger or the mouse, or swipe * sideways on a trackpad. It snaps to the nearest * card, carrying the flick's speed * play with autoPlay, the ring around the pause button fills * as the timer. It holds while the pointer or keyboard * focus is inside, or the carousel is off screen * loop the strip never ends. Cards are virtualized and * repeated when there are too few to fill a wide screen * narrow the lead fills the width with the next card peeking, * and the controls move under the strip * reduced no glide, blur or autoplay: the lead switches at once * ───────────────────────────────────────────────────────── */ const RELAY = { settle: 0.3, // cards from rest at which the new quote starts to set in drag: 6, // px a press travels before it counts as a drag lean: 0.04, // cards the strip leans toward an arrow under the pointer } as const; // Card sizes live in CSS, so the server renders the strip already laid out. cqi is the carousel's width. const SIZES = [ "[--relay-gap:0.75rem]", "[--relay-l:min(calc(100cqi_-_2_*_var(--relay-inset)_-_2.75rem),22rem)]", "[--relay-s:calc(var(--relay-l)_*_0.6)]", "[--relay-hs:calc(var(--relay-s)_*_1.3)]", "[--relay-hl:calc(var(--relay-l)_*_1.12)]", "@2xl:[--relay-gap:1rem]", "@2xl:[--relay-l:clamp(19rem,40cqi,25rem)]", "@2xl:[--relay-s:calc(var(--relay-l)_*_0.62)]", "@2xl:[--relay-hs:calc(var(--relay-s)_*_1.25)]", "@2xl:[--relay-hl:calc(var(--relay-hs)_*_1.2)]", ].join(" "); const INSET = "[--relay-inset:max(1.25rem,calc((100cqi_-_76rem)_/_2))] @2xl:[--relay-inset:max(2.5rem,calc((100cqi_-_76rem)_/_2))]"; const mod = (n: number, m: number) => ((n % m) + m) % m; type Metrics = { width: number; inset: number; s: number; l: number; gap: number }; type Relay = { count: number; lead: number; revealed: number | null; /** ms between quote lines setting in. */ stagger: number; /** px a photo drifts inside its card per card travelled. */ drift: number; /** Where the strip is, in slots: the glide plus any lean toward a hovered arrow. */ place: MotionValue<number>; go: (slot: number) => void; onKeyDown: (event: React.KeyboardEvent) => void; }; type Slot = { slot: number; index: number; canonical: boolean }; const RelayContext = React.createContext<Relay | null>(null); const SlotContext = React.createContext<Slot>({ slot: 0, index: 0, canonical: true }); function useRelay() { const relay = React.useContext(RelayContext); if (!relay) throw new Error("<RelayCarouselItem> must be rendered inside <RelayCarousel>."); return relay; } const subscribeNothing = () => () => {}; /* ── RelayCarousel ──────────────────────────────────────── */ export interface RelayCarouselProps extends Omit<React.HTMLAttributes<HTMLElement>, "onChange"> { /** Shown top left, in line with the lead card. The controls sit opposite it. */ heading?: React.ReactNode; /** The card that leads. Leave unset to let the carousel manage it. */ index?: number; /** The card that leads first. */ defaultIndex?: number; /** Called with the new lead's index, whatever moved it. */ onIndexChange?: (index: number) => void; /** Passes the lead on every this many ms, with a pause button. Off for reduced motion until played. */ autoPlay?: number; /** Left edge of the heading and the lead card, as a CSS length. `cqi` is the carousel's width. */ inset?: string; /** Milliseconds the strip takes to glide to a new lead. */ duration?: number; /** Milliseconds between the lead's quote lines setting in. */ stagger?: number; /** How far each photo drifts inside its card per card the strip travels, in px. `0` keeps the photos still. */ drift?: number; } /** * A looping strip of photo cards. The first card leads, larger, with its quote * showing; passing the lead on slides the strip and grows the next card into place. */ export function RelayCarousel({ heading, index, defaultIndex = 0, onIndexChange, autoPlay, inset, duration = TIMING.duration, stagger = TIMING.stagger, drift = 14, className, children, onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: RelayCarouselProps) { const items = React.Children.toArray(children).filter( (child): child is React.ReactElement<RelayCarouselItemProps> => React.isValidElement(child), ); const count = items.length; const reduced = useReducedMotion() ?? false; const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false); const id = React.useId(); const trackId = `${id}-track`; /* The lead is a slot on an endless strip; its item is the slot modulo the count. */ const start = count ? mod(index ?? defaultIndex, count) : 0; const [lead, setLead] = React.useState({ slot: start, turn: 0 }); const [step, setStep] = React.useState(1); const [announcement, setAnnouncement] = React.useState(""); const active = count ? mod(lead.slot, count) : -1; // A controlled index moves the lead the short way round. const [syncedIndex, setSyncedIndex] = React.useState(index); if (index !== syncedIndex) { setSyncedIndex(index); if (index !== undefined && count && mod(index, count) !== active) { let delta = mod(index - active, count); if (delta > count / 2) delta -= count; setLead({ slot: lead.slot + delta, turn: lead.turn + 1 }); setStep(Math.sign(delta)); } } const pos = useMotionValue(start); // Pointing at an arrow leans the strip a little that way, a hint of where it's about to go. const lean = useMotionValue(0); const place = useTransform(() => pos.get() + lean.get()); const leanTo = (to: number) => { if (!reduced) animate(lean, to, TIMING.lean); }; const glide = React.useRef<AnimationPlaybackControls | null>(null); const release = React.useRef(0); const latest = React.useRef({ lead, count, items, onIndexChange }); React.useLayoutEffect(() => { latest.current = { lead, count, items, onIndexChange }; }); const go = React.useCallback((slot: number, source: "user" | "auto" = "user") => { const { lead, count, items, onIndexChange } = latest.current; if (!count) return; const next = { slot, turn: lead.turn + 1 }; latest.current.lead = next; setLead(next); if (slot !== lead.slot) setStep(Math.sign(slot - lead.slot)); const index = mod(slot, count); if (source === "user") { const { name, role, quote } = items[index].props; setAnnouncement( `${index + 1} of ${count}. ${name}${typeof role === "string" ? `, ${role}` : ""}. “${quote}”`, ); } if (slot !== lead.slot) onIndexChange?.(index); }, []); // Every new lead (or a snap back after a short drag) glides the strip to it. React.useEffect(() => { if (reduced) { pos.jump(lead.slot); return; } const controls = animate(pos, lead.slot, { ...TIMING.glide, visualDuration: duration / 1000, velocity: release.current, }); release.current = 0; glide.current = controls; return () => controls.stop(); }, [lead, reduced, pos, duration]); /* What the strip shows: the slots around the lead and wherever the strip is passing. */ const [span, setSpan] = React.useState<[number, number]>([start, start]); const [revealed, setRevealed] = React.useState<number | null>(start); const [dragging, setDragging] = React.useState(false); useMotionValueEvent(pos, "change", (value) => { const low = Math.floor(value); const high = Math.ceil(value); setSpan((prev) => (prev[0] === low && prev[1] === high ? prev : [low, high])); const slot = latest.current.lead.slot; setRevealed(Math.abs(value - slot) < RELAY.settle ? slot : null); }); const trackRef = React.useRef<HTMLDivElement>(null); const probeRef = React.useRef<HTMLDivElement>(null); const [metrics, setMetrics] = React.useState<Metrics | null>(null); React.useLayoutEffect(() => { const track = trackRef.current; const probe = probeRef.current; if (!track || !probe) return; const [inset, s, l, gap] = Array.from(probe.children) as HTMLElement[]; const observer = new ResizeObserver(() => { const next = { width: track.clientWidth, inset: inset.offsetWidth, s: s.offsetWidth, l: l.offsetWidth, gap: gap.offsetWidth, }; setMetrics((prev) => prev && prev.width === next.width && prev.inset === next.inset && prev.s === next.s && prev.l === next.l && prev.gap === next.gap ? prev : next, ); }); observer.observe(track); for (const el of [inset, s, l, gap]) observer.observe(el); return () => observer.disconnect(); }, []); const pitch = metrics ? metrics.s + metrics.gap : 1; const before = metrics ? Math.ceil(metrics.inset / pitch) + 1 : 2; const after = metrics ? Math.ceil((metrics.width - metrics.inset - metrics.l) / pitch) + 1 : 0; const low = Math.min(span[0], lead.slot) - before; const high = Math.max(Math.max(span[1], lead.slot) + after, low + count - 1); const slots: Slot[] = []; if (count === 1) slots.push({ slot: lead.slot, index: 0, canonical: true }); else if (count) { // Each item is announced once: the copy nearest the lead. Repeats are decoration. const nearest = new Map<number, number>(); for (let slot = low; slot <= high; slot++) { const i = mod(slot, count); const best = nearest.get(i); if (best === undefined || Math.abs(slot - lead.slot) <= Math.abs(best - lead.slot)) { nearest.set(i, slot); } } for (let slot = low; slot <= high; slot++) { const i = mod(slot, count); slots.push({ slot, index: i, canonical: nearest.get(i) === slot }); } } /* Autoplay: the ring fills while nothing holds it, then the lead moves on. */ const progress = useMotionValue(0); const [paused, setPaused] = React.useState<boolean | null>(null); const [hovered, setHovered] = React.useState(false); const [focused, setFocused] = React.useState(false); const [seen, setSeen] = React.useState(true); const canPlay = !!autoPlay && autoPlay > 0 && count > 1; const playing = canPlay && !(paused ?? (hydrated && reduced)); const held = hovered || focused || dragging || !seen; React.useLayoutEffect(() => { progress.jump(0); }, [lead.slot, progress]); React.useEffect(() => { if (!playing || held) return; const controls = animate(progress, 1, { duration: ((autoPlay ?? 0) / 1000) * (1 - progress.get()), ease: TIMING.ring, onComplete: () => go(latest.current.lead.slot + 1, "auto"), }); return () => controls.stop(); }, [playing, held, lead.slot, autoPlay, progress, go]); React.useEffect(() => { const track = trackRef.current; if (!canPlay || !track) return; let inView = true; const update = () => setSeen(inView && document.visibilityState === "visible"); const observer = new IntersectionObserver( ([entry]) => { inView = entry.isIntersecting; update(); }, { threshold: 0.25 }, ); observer.observe(track); document.addEventListener("visibilitychange", update); return () => { observer.disconnect(); document.removeEventListener("visibilitychange", update); }; }, [canPlay]); /* Dragging: the strip follows the pointer, then lands where the flick was heading. */ const pointer = React.useRef({ id: -1, x: 0, from: 0, moved: false, swallow: false, samples: [] as { x: number; t: number }[], }); const unit = () => { const m = metrics; return m ? (m.s + m.l) / 2 + m.gap : 300; }; const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => { const p = pointer.current; p.swallow = false; if (count < 2 || event.button !== 0) return; if ((event.target as HTMLElement).closest("a, input, textarea, select, [data-relay-extra]")) return; p.id = event.pointerId; p.x = event.clientX; p.from = pos.get(); p.moved = false; p.samples = [{ x: event.clientX, t: event.timeStamp }]; }; const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => { const p = pointer.current; if (event.pointerId !== p.id) return; const dx = event.clientX - p.x; if (!p.moved) { if (Math.abs(dx) < RELAY.drag) return; p.moved = true; glide.current?.stop(); p.from = pos.get() + dx / unit(); event.currentTarget.setPointerCapture(event.pointerId); setDragging(true); } pos.set(p.from - dx / unit()); p.samples.push({ x: event.clientX, t: event.timeStamp }); if (p.samples.length > 6) p.samples.shift(); }; const endDrag = (event: React.PointerEvent<HTMLDivElement>) => { const p = pointer.current; if (event.pointerId !== p.id) return; p.id = -1; if (!p.moved) return; p.swallow = true; setDragging(false); const first = p.samples[0]; const last = p.samples[p.samples.length - 1]; const elapsed = last.t - first.t; const speed = elapsed > 0 && event.timeStamp - last.t < TIMING.fresh ? (last.x - first.x) / elapsed : 0; // px/ms const cards = -(speed * 1000) / unit(); // cards/s, positive toward the next card const reach = Math.max(1, Math.ceil((metrics?.width ?? 1000) / pitch) - 1); const landing = Math.round(pos.get() + cards * TIMING.flick); const slot = Math.max(Math.round(p.from) - reach, Math.min(Math.round(p.from) + reach, landing)); release.current = reduced ? 0 : cards; go(slot); }; /* Sideways trackpad swipes scrub the strip and snap once they stop. */ React.useEffect(() => { const track = trackRef.current; if (!track || count < 2) return; let timer = 0; const onWheel = (event: WheelEvent) => { if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; event.preventDefault(); glide.current?.stop(); setDragging(true); const m = metrics; const size = m ? (m.s + m.l) / 2 + m.gap : 300; pos.set(pos.get() + event.deltaX / size); window.clearTimeout(timer); timer = window.setTimeout(() => { setDragging(false); go(Math.round(pos.get())); }, TIMING.wheelIdle); }; track.addEventListener("wheel", onWheel, { passive: false }); return () => { track.removeEventListener("wheel", onWheel); window.clearTimeout(timer); }; }, [count, metrics, pos, go]); /* Keys: ←/→ pass the lead, Home/End go the short way to the first and last card. Focus follows the lead. */ const keyboard = React.useRef(false); const onKeyDown = (event: React.KeyboardEvent) => { const slot = latest.current.lead.slot; const toIndex = (i: number) => { let delta = mod(i - slot, count); if (delta > count / 2) delta -= count; return slot + delta; }; let to: number | null = null; if (event.key === "ArrowRight") to = slot + 1; else if (event.key === "ArrowLeft") to = slot - 1; else if (event.key === "Home") to = toIndex(0); else if (event.key === "End") to = toIndex(count - 1); if (to === null) return; event.preventDefault(); keyboard.current = true; go(to); }; React.useEffect(() => { if (!keyboard.current) return; keyboard.current = false; trackRef.current?.querySelector<HTMLElement>(`[data-relay-slot="${lead.slot}"]`)?.focus(); }, [lead]); return ( <RelayContext.Provider value={{ count, lead: lead.slot, revealed: dragging ? null : revealed, stagger, drift, place, go, onKeyDown }} > <section {...props} aria-roledescription="carousel" className={cn("@container relative w-full", className)} onPointerEnter={(event) => { if (event.pointerType === "mouse") setHovered(true); onPointerEnter?.(event); }} onPointerLeave={(event) => { setHovered(false); onPointerLeave?.(event); }} onFocus={(event) => { if (event.target.matches(":focus-visible")) setFocused(true); onFocus?.(event); }} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false); onBlur?.(event); }} > <div className={cn("grid grid-cols-[minmax(0,1fr)_auto]", inset === undefined && INSET, SIZES)} style={inset === undefined ? undefined : ({ "--relay-inset": inset } as React.CSSProperties)} > {heading != null && ( <div className="col-start-1 row-start-1 min-w-0 self-end pr-4 pl-[var(--relay-inset)] @2xl:pr-10"> {heading} </div> )} <div className={cn( "col-span-2 row-start-3 mt-5 flex items-center gap-2 px-[var(--relay-inset)]", "@2xl:col-span-1 @2xl:col-start-2 @2xl:row-start-1 @2xl:mt-0 @2xl:self-end @2xl:pl-0", count < 2 && "hidden", )} > <Counter active={active} count={count} step={step} reduced={reduced} /> <RoundButton label="Previous" controls={trackId} onClick={() => go(lead.slot - 1)} onHover={(on) => leanTo(on ? -RELAY.lean : 0)} > <Arrow direction={-1} /> </RoundButton> <RoundButton label="Next" controls={trackId} onClick={() => go(lead.slot + 1)} onHover={(on) => leanTo(on ? RELAY.lean : 0)} > <Arrow direction={1} /> </RoundButton> {canPlay && ( <RoundButton label={playing ? "Pause" : "Play"} controls={trackId} onClick={() => setPaused(playing)} > <svg aria-hidden viewBox="0 0 40 40" className="pointer-events-none absolute -inset-px -rotate-90"> <motion.circle cx="20" cy="20" r="19.25" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ pathLength: progress, opacity: playing ? 1 : 0 }} className={`transition-opacity ${TIMING.ringFade}`} /> </svg> {playing ? <PauseIcon /> : <PlayIcon />} </RoundButton> )} </div> <div ref={trackRef} id={trackId} className={cn( "relative col-span-2 row-start-2 -my-2 h-[calc(var(--relay-hl)_+_1rem)] touch-pan-y overflow-hidden select-none", // Cards that already led fade into the margin. Phones have no room for one, so it's hidden there. "[mask-image:linear-gradient(to_right,transparent_calc(var(--relay-inset)_-_var(--relay-gap)),#000_calc(var(--relay-inset)_-_0.25rem))]", "@2xl:[mask-image:linear-gradient(to_right,transparent,#000_calc(var(--relay-inset)_-_0.5rem))]", heading != null ? "mt-6 @2xl:mt-10" : "@2xl:mt-6", count > 1 && (dragging ? "cursor-grabbing" : "cursor-grab"), )} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag} onClickCapture={(event) => { if (!pointer.current.swallow) return; pointer.current.swallow = false; event.preventDefault(); event.stopPropagation(); }} onDragStart={(event) => event.preventDefault()} > <div ref={probeRef} aria-hidden className="invisible absolute h-0"> <div className="w-[var(--relay-inset)]" /> <div className="w-[var(--relay-s)]" /> <div className="w-[var(--relay-l)]" /> <div className="w-[var(--relay-gap)]" /> </div> {slots.map((slot) => ( <SlotContext.Provider key={slot.slot} value={slot}> {items[slot.index]} </SlotContext.Provider> ))} </div> </div> <p aria-live="polite" aria-atomic className="sr-only"> {announcement} </p> </section> </RelayContext.Provider> ); } /* ── RelayCarouselItem ──────────────────────────────────── */ export interface RelayCarouselItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "role"> { /** Who said it. Shown on every card. */ name: string; /** Their role and company, under the name. */ role?: React.ReactNode; /** What they said, without quotation marks. Shown while the card leads. */ quote: string; /** Photo URL. Cut to the lead card's size, so a resting card shows its top middle. */ image: string; /** Alt text for the photo. */ alt?: string; /** Small text in the lead card's top corner, like a place or a product. */ label?: React.ReactNode; /** Classes for the quote, like a serif face. */ quoteClassName?: string; /** Classes for the photo. The card is a `group/relay` with `data-state="lead"` or `"rest"`. */ imageClassName?: string; /** Pinned to the lead card's bottom right corner, like a link to the full story. */ children?: React.ReactNode; } /** One card. Render inside `RelayCarousel`. */ export function RelayCarouselItem({ name, role, quote, image, alt = "", label, quoteClassName, imageClassName, className, children, ...props }: RelayCarouselItemProps) { const { count, lead, revealed, stagger, drift: driftPx, place, go, onKeyDown } = useRelay(); const { slot, index, canonical } = React.useContext(SlotContext); const isLead = slot === lead; const shown = isLead && revealed === slot; // Where this card sits, in cards from the lead slot, and how much of the lead it is. const offset = useTransform(place, (value) => slot - value); const grow = useTransform(offset, (d) => Math.max(0, 1 - Math.abs(d))); const ahead = useTransform(offset, (d) => Math.min(1, Math.max(0, d))); const drift = useTransform(offset, (d) => Math.max(-2, Math.min(2, d))); // Quote lines set in one after another, so each word learns which line it landed on. const quoteRef = React.useRef<HTMLParagraphElement>(null); const cardRef = React.useRef<HTMLDivElement>(null); React.useLayoutEffect(() => { const paragraph = quoteRef.current; const card = cardRef.current; if (!paragraph || !card) return; const assign = () => { let line = -1; let top = -Infinity; for (const word of Array.from(paragraph.children) as HTMLElement[]) { if (word.offsetTop > top + 2) { line += 1; top = word.offsetTop; } word.style.setProperty("--line", String(line)); } card.style.setProperty("--lines", String(line + 1)); }; assign(); const observer = new ResizeObserver(assign); observer.observe(paragraph); document.fonts?.ready.then(assign); return () => observer.disconnect(); }, [quote]); const words = quote.trim().split(/\s+/); const reveal = `opacity-0 transition-[opacity,translate,filter] ${TIMING.hide} motion-safe:translate-y-[0.5em] motion-safe:blur-[6px] group-data-[shown=true]/relay:opacity-100 ${TIMING.show} motion-safe:group-data-[shown=true]/relay:translate-y-0 motion-safe:group-data-[shown=true]/relay:blur-none`; return ( <motion.div ref={cardRef} data-relay-slot={slot} data-state={isLead ? "lead" : "rest"} data-shown={shown} role="group" aria-roledescription="slide" aria-label={`${index + 1} of ${count}: ${name}`} aria-hidden={canonical ? undefined : true} inert={!canonical} tabIndex={isLead && canonical ? 0 : -1} onKeyDown={onKeyDown} className={cn( "group/relay absolute top-2 left-0 outline-none", "h-[calc(var(--relay-hs)_+_(var(--relay-hl)_-_var(--relay-hs))_*_var(--grow))] w-[calc(var(--relay-s)_+_(var(--relay-l)_-_var(--relay-s))_*_var(--grow))]", "[transform:translateX(calc(var(--relay-inset)_+_var(--offset)_*_(var(--relay-s)_+_var(--relay-gap))_+_var(--ahead)_*_(var(--relay-l)_-_var(--relay-s))))]", )} style={ { "--offset": offset, "--grow": grow, "--ahead": ahead, "--drift": drift, "--relay-drift": `${driftPx}px`, } as MotionStyle } > <div {...props} className={cn( "relative isolate size-full overflow-hidden rounded-2xl bg-zinc-800 text-white", "outline-current group-focus-visible/relay:outline-2 group-focus-visible/relay:outline-offset-2", className, )} > {/* eslint-disable-next-line @next/next/no-img-element -- a remote photo in an installable component */} <img src={image} alt={alt} draggable={false} decoding="async" className={cn( "pointer-events-none absolute top-0 left-1/2 h-[var(--relay-hl)] w-[var(--relay-l)] max-w-none object-cover", // Drift and zoom are CSS-only, so reduced motion turns them off without a hydration mismatch. "[--zoom:0] [--drift-px:0px] motion-safe:[--zoom:0.06] motion-safe:[--drift-px:var(--relay-drift)]", "[transform:translateX(calc(-50%_-_var(--drift)_*_var(--drift-px)))_scale(calc(1_+_var(--zoom)_*_(1_-_var(--grow))))] [transform-origin:50%_30%]", `transition-[filter] ${TIMING.brighten} group-data-[state=rest]/relay:brightness-[0.88] group-data-[state=rest]/relay:group-hover/relay:brightness-100`, imageClassName, )} /> {/* A light foot for the caption on every card, and a deeper scrim the lead's quote sits on. */} <div aria-hidden className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgb(0_0_0/0.62),rgb(0_0_0/0.18)_30%,transparent_52%)]" /> <div aria-hidden className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgb(0_0_0/0.82),rgb(0_0_0/0.62)_38%,rgb(0_0_0/0.2)_72%,rgb(0_0_0/0.28))] opacity-[var(--grow)]" /> {/* A hairline inner edge keeps a dark card's corners crisp on a dark page. */} <div aria-hidden className="pointer-events-none absolute inset-0 z-20 rounded-[inherit] ring-1 ring-white/10 ring-inset" /> {!isLead && ( <button type="button" tabIndex={-1} aria-label={`Show ${name}'s quote`} onClick={() => go(slot)} className="absolute inset-0 z-10 cursor-pointer" /> )} {label != null && ( <div className={cn( "pointer-events-none absolute top-0 left-0 z-20 p-5 text-xs font-medium tracking-wide text-white/80 @2xl:p-6", reveal, )} > {label} </div> )} <div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 p-[calc(0.875rem_+_0.375rem_*_var(--grow))] @2xl:p-[calc(0.875rem_+_0.625rem_*_var(--grow))]"> <div className="relative"> <blockquote aria-hidden={isLead ? undefined : true} className={cn( "absolute bottom-full left-0 mb-4 w-[calc(var(--relay-l)_-_2.5rem)] @2xl:mb-5 @2xl:w-[calc(var(--relay-l)_-_3rem)]", "text-[clamp(1rem,calc(var(--relay-l)_*_0.05),1.3125rem)] leading-[1.4] font-medium tracking-[-0.01em] text-pretty", quoteClassName, )} > <p ref={quoteRef}> {words.map((word, i) => ( <React.Fragment key={i}> <span className={cn("relative inline-block", reveal)} style={{ transitionDelay: shown ? `calc(var(--line, 0) * ${stagger}ms)` : undefined }} > {i === 0 && <span className="absolute right-full">“</span>} {word} {i === words.length - 1 && "”"} </span> {i < words.length - 1 && " "} </React.Fragment> ))} </p> </blockquote> <div className="max-w-full pr-12"> <div className="truncate text-sm leading-5 font-medium">{name}</div> {role != null && <div className="truncate text-xs leading-4 text-white/70">{role}</div>} </div> </div> </div> {children != null && ( <div data-relay-extra inert={!isLead} className={cn( "absolute right-0 bottom-0 z-30 p-3.5 @2xl:p-5", reveal, "group-data-[state=rest]/relay:pointer-events-none", )} style={{ transitionDelay: shown ? `calc(var(--lines, 1) * ${stagger}ms)` : undefined }} > {children} </div> )} </div> </motion.div> ); } /* ── Controls ───────────────────────────────────────────── */ function RoundButton({ label, controls, onClick, onHover, children, }: { label: string; controls: string; onClick: () => void; onHover?: (on: boolean) => void; children: React.ReactNode; }) { return ( <button type="button" aria-label={label} aria-controls={controls} onClick={onClick} onPointerEnter={(event) => event.pointerType === "mouse" && onHover?.(true)} onPointerLeave={() => onHover?.(false)} className={cn( // Drawn in the text color around it, so the controls suit any background. "group/button relative grid size-11 shrink-0 cursor-pointer place-items-center rounded-full border border-current/15 @2xl:size-10", `transition-[background-color,border-color,scale] ${TIMING.button} hover:border-current/40 hover:bg-current/[0.07] active:scale-[0.94]`, "outline-current focus-visible:outline-2 focus-visible:outline-offset-2", )} > {children} </button> ); } function Arrow({ direction }: { direction: 1 | -1 }) { return ( <svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className={cn( `size-4 transition-transform ${TIMING.nudge}`, direction > 0 ? "group-hover/button:translate-x-0.5" : "-scale-x-100 group-hover/button:-translate-x-0.5", )} > <path d="M3 8h10M9 4l4 4-4 4" /> </svg> ); } function PauseIcon() { return ( <svg aria-hidden viewBox="0 0 16 16" fill="currentColor" className="size-4"> <rect x="4" y="3" width="2.5" height="10" rx="0.75" /> <rect x="9.5" y="3" width="2.5" height="10" rx="0.75" /> </svg> ); } function PlayIcon() { return ( <svg aria-hidden viewBox="0 0 16 16" fill="currentColor" className="size-4 translate-x-px"> <path d="M5 3.7v8.6a.7.7 0 0 0 1.06.6l6.9-4.3a.7.7 0 0 0 0-1.2l-6.9-4.3A.7.7 0 0 0 5 3.7Z" /> </svg> ); } function Counter({ active, count, step, reduced, }: { active: number; count: number; step: number; reduced: boolean; }) { const pad = (n: number) => String(n).padStart(String(count).length < 2 ? 2 : String(count).length, "0"); return ( <div aria-hidden className="mr-auto flex items-center text-sm leading-5 tabular-nums @2xl:mr-3" > <span className="relative inline-flex h-5 overflow-hidden"> <AnimatePresence mode="popLayout" initial={false} custom={step}> <motion.span key={active} custom={step} variants={{ enter: (dir: number) => ({ y: reduced ? 0 : `${dir * 100}%`, opacity: 0 }), center: { y: 0, opacity: 1 }, exit: (dir: number) => ({ y: reduced ? 0 : `${dir * -100}%`, opacity: 0 }), }} initial="enter" animate="center" exit="exit" transition={TIMING.count} className="block" > {pad(active + 1)} </motion.span> </AnimatePresence> </span> <span className="px-1.5 opacity-30">/</span> <span className="opacity-50">{pad(count)}</span> </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Relay Carousel. Each one installs whole, with the component included.
Careers
A hiring page's team voices on a dark section. Once a quote is in, the lead card offers a link to that team's open roles through `children`. The controls take the section's white text, with no extra setup.
who build Okto.
import { RelayCarousel, RelayCarouselItem } from "@/components/ui/relay-carousel";
const team = [
{
name: "Sofía Herrera",
role: "Product Designer, 3 years",
team: "Design",
city: "Lisbon",
quote:
"I joined for the craft and stayed for the critiques. Nobody here is precious about their pixels, and the work is better for it.",
photo: "1521999693742-4717d76f97cc",
alt: "A designer sketching at her desk under a lamp late at night",
},
{
name: "Daniel Mensah",
role: "Staff Engineer, 5 years",
team: "Platform",
city: "Accra",
quote:
"We ship small and often, and on-call is a rota, not a hero story. A Friday deploy here is just another deploy.",
photo: "1719400471588-575b23e27bd7",
alt: "An engineer working late in a dark room lit by screens of code",
},
{
name: "Hana Kobayashi",
role: "Support Lead, 2 years",
team: "Support",
city: "Remote",
quote:
"Support isn't a queue to clear. When three customers trip on the same thing, I open the issue and watch it get fixed that week.",
photo: "1588873281272-14886ba1f737",
alt: "A support lead gesturing to a customer on a laptop video call",
},
{
name: "Marcus Feld",
role: "Data Scientist, 4 years",
team: "Data",
city: "Berlin",
quote:
"Questions arrive with context and the time to answer them properly. I spend my weeks on analysis, not on dashboards nobody opens.",
photo: "1758876022473-d3d8513f15d3",
alt: "Two colleagues talking in front of a wall of sticky notes",
},
{
name: "Aisha Rahman",
role: "Engineering Manager, 6 years",
team: "Mobile",
city: "London",
quote:
"Focus weeks sounded like a perk when I joined. They turned out to be how we plan, and my team has never shipped calmer.",
photo: "1768471125958-78556538fadc",
alt: "An engineering manager working quietly in a studio full of plants",
},
{
name: "Tomás Ibarra",
role: "Brand Designer, 1 year",
team: "Brand",
city: "Mexico City",
quote:
"In my first month I redrew the icon set and nobody asked me to wait my turn. The bar is high, and so is the trust.",
photo: "1761122827167-159d1d272313",
alt: "A designer in a cap sketching icons on a tablet at his desk",
},
];
const photo = (id: string) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=880&h=840&q=80`;
export function RelayCarouselCareersDemo() {
return (
<div className="flex min-h-[var(--demo-height,auto)] w-full flex-col justify-center overflow-hidden rounded-2xl bg-zinc-950 py-14 text-white sm:py-20">
<RelayCarousel
aria-labelledby="relay-careers-heading"
heading={
<h2
id="relay-careers-heading"
className="text-[2rem] leading-[1.02] font-semibold tracking-[-0.035em] text-balance @2xl:text-5xl"
>
Ask the people{" "}
<br />
<span className="text-zinc-500">who build Okto.</span>
</h2>
}
>
{team.map((person) => (
<RelayCarouselItem
key={person.name}
name={person.name}
role={person.role}
label={`${person.team} · ${person.city}`}
quote={person.quote}
image={photo(person.photo)}
alt={person.alt}
>
<a
href="#open-roles"
aria-label={`Open roles in ${person.team}`}
className="group/link grid size-9 place-items-center rounded-full bg-white text-zinc-950 transition-transform duration-200 ease-out outline-white hover:scale-105 focus-visible:outline-2 focus-visible:outline-offset-2 active:scale-95"
>
<svg
aria-hidden
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4 transition-transform duration-200 ease-out group-hover/link:translate-x-px group-hover/link:-translate-y-px"
>
<path d="M5 11 11 5M6 5h5v5" />
</svg>
</a>
</RelayCarouselItem>
))}
</RelayCarousel>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Relay Carousel: A looping strip of photo cards where the first card leads: bigger, with its quote showing. Passing the lead on glides the strip, shrinks the old lead out of the way and grows the next card into its place, then sets the new quote in line by line. This demo, Careers: A hiring page's team voices on a dark section. Once a quote is in, the lead card offers a link to that team's open roles through `children`. The controls take the section's white text, with no extra setup. 1. Install it: npx shadcn@latest add @keyframeui/relay-carousel-careers-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/relay-carousel-careers-demo.json instead.) It adds the demo, components/relay-carousel-careers-demo.tsx, and the component, components/ui/relay-carousel.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <RelayCarousel> on its own. 3. These props set its feel. Change them before you change the file: - duration (default 750): Milliseconds the strip takes to glide to a new lead, on a spring with no bounce. A flick's speed carries into it. - stagger (default 80): Milliseconds between the lead's quote lines setting in. Content in `children` follows the last line. - drift (default 14): How far each photo drifts inside its card per card the strip travels, in px. `0` keeps the photos still. Off for reduced motion. 4. Don't change, in components/ui/relay-carousel.tsx: - Every card is placed and sized from one motion value, `place`, through CSS variables (`--offset`, `--grow`, `--ahead`, `--drift`); the sizes live in CSS (`SIZES`), so the server renders the strip already laid out. - The leaving quote lifts off first, the strip glides on one spring, and the new quote sets in line by line only once the strip is within `RELAY.settle` of its slot and nothing is dragging. - A drag lands where the flick was heading and hands its speed to the glide as `velocity`, so a release never snaps. - The strip is endless: slots are virtualized around the lead, and only the copy nearest the lead (`canonical`) is announced and focusable; repeats are `aria-hidden` and `inert`. - Photo drift and zoom are CSS-only behind `motion-safe:`, so reduced motion turns them off without a hydration mismatch; it also jumps the strip and holds autoplay until played. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/relay-carousel.md
Conference Speakers
A speaker lineup with each talk's line in a serif through `quoteClassName`. The schedule under the heading follows the lead through `index` and `onIndexChange`, and picking a time moves the strip the short way round.
Northwind Live.
Thu 10:00 · Designing for the second visit
“The first visit is a demo. The second one is the product, and that's where most of us stop designing.”
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { RelayCarousel, RelayCarouselItem } from "@/components/ui/relay-carousel";
const talks = [
{
name: "Ana Duarte",
role: "Head of Design, Tinta",
day: "Thu",
time: "10:00",
title: "Designing for the second visit",
quote:
"The first visit is a demo. The second one is the product, and that's where most of us stop designing.",
photo: "1679579664878-2c025e3f87b0",
alt: "A speaker with a headset microphone gesturing on a pink-lit stage",
},
{
name: "Kwame Asante",
role: "CTO, Okto",
day: "Thu",
time: "11:30",
title: "Boring infrastructure, on purpose",
quote:
"Every clever system I've built has paged me at three in the morning. The boring ones keep running while I sleep.",
photo: "1731395157985-76af28b68afa",
alt: "A speaker in a varsity jacket talking into a handheld microphone",
},
{
name: "Mei Lin",
role: "Research Lead, Northwind",
day: "Thu",
time: "14:00",
title: "What people do when nobody's watching",
quote:
"Interviews tell you what people remember. Session notes tell you what they did, and the two rarely agree.",
photo: "1772419216340-fd8abb4f55de",
alt: "A speaker with a handheld microphone at a lectern against a dark backdrop",
},
{
name: "Jonas Berg",
role: "Founder, Field & Fold",
day: "Thu",
time: "16:30",
title: "Pricing a tool for small shops",
quote:
"Small shops don't want a cheaper plan. They want a price they can explain to their accountant in one sentence.",
photo: "1575707751065-42256084fbb7",
alt: "A speaker in a navy blazer mid-sentence against warm event lights",
},
{
name: "Oliver Grant",
role: "Principal Engineer, Northwind",
day: "Fri",
time: "09:30",
title: "Accessibility is a performance problem",
quote:
"A page that waits on twelve scripts before it answers a screen reader isn't slow for some people. It's broken for them.",
photo: "1762158007969-eb58e74ee3d3",
alt: "A speaker in glasses with his arms open wide at a podium",
},
{
name: "Ravi Menon",
role: "Motion Designer, Tinta",
day: "Fri",
time: "11:00",
title: "Motion with a job to do",
quote:
"Animation earns its place when it explains where something went. The rest is a loading spinner in disguise.",
photo: "1769755409608-e61191208f91",
alt: "A speaker in a black blazer holding a microphone on a dark stage",
},
];
const photo = (id: string) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=880&h=840&q=80`;
export function RelayCarouselSpeakersDemo() {
const [index, setIndex] = React.useState(0);
const talk = talks[index];
return (
<div className="flex min-h-[var(--demo-height,auto)] w-full flex-col justify-center overflow-hidden rounded-2xl border border-stone-200 bg-stone-100 py-14 text-stone-950 sm:py-20 dark:border-stone-800 dark:bg-stone-950 dark:text-stone-50">
<RelayCarousel
aria-labelledby="relay-speakers-heading"
index={index}
onIndexChange={setIndex}
heading={
<div>
<h2
id="relay-speakers-heading"
className="text-[2rem] leading-[1.02] font-semibold tracking-[-0.035em] text-balance @2xl:text-5xl"
>
On stage at{" "}
<br />
<span className="text-stone-400 dark:text-stone-500">Northwind Live.</span>
</h2>
<p className="mt-5 max-w-sm text-sm leading-6 text-stone-600 dark:text-stone-400">
<span className="font-medium text-stone-950 tabular-nums dark:text-stone-50">
{talk.day} {talk.time}
</span>{" "}
· {talk.title}
</p>
<ol aria-label="Schedule" className="mt-4 flex flex-wrap items-center gap-1.5">
{talks.map((item, i) => (
<li key={item.name} className="flex items-center gap-1.5">
{item.day !== talks[i - 1]?.day && (
<span
aria-hidden
className={cn(
"pr-1 text-xs font-medium text-stone-500 dark:text-stone-400",
i > 0 && "pl-2.5",
)}
>
{item.day}
</span>
)}
<button
type="button"
aria-current={i === index ? "true" : undefined}
aria-label={`${item.day} ${item.time}, ${item.name}`}
onClick={() => setIndex(i)}
className={cn(
"h-8 cursor-pointer rounded-full border px-3 text-xs font-medium tabular-nums transition-colors duration-200",
"outline-stone-950 focus-visible:outline-2 focus-visible:outline-offset-2 dark:outline-stone-50",
i === index
? "border-stone-950 bg-stone-950 text-stone-50 dark:border-stone-50 dark:bg-stone-50 dark:text-stone-950"
: "border-stone-300 text-stone-600 hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-400 dark:hover:border-stone-500 dark:hover:text-stone-50",
)}
>
{item.time}
</button>
</li>
))}
</ol>
</div>
}
>
{talks.map((item) => (
<RelayCarouselItem
key={item.name}
name={item.name}
role={item.role}
label={`${item.day} ${item.time} · Main stage`}
quote={item.quote}
quoteClassName="font-serif font-normal tracking-normal text-[clamp(1.0625rem,calc(var(--relay-l)_*_0.056),1.4375rem)] leading-[1.32]"
image={photo(item.photo)}
alt={item.alt}
/>
))}
</RelayCarousel>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Relay Carousel: A looping strip of photo cards where the first card leads: bigger, with its quote showing. Passing the lead on glides the strip, shrinks the old lead out of the way and grows the next card into its place, then sets the new quote in line by line. This demo, Conference Speakers: A speaker lineup with each talk's line in a serif through `quoteClassName`. The schedule under the heading follows the lead through `index` and `onIndexChange`, and picking a time moves the strip the short way round. 1. Install it: npx shadcn@latest add @keyframeui/relay-carousel-speakers-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/relay-carousel-speakers-demo.json instead.) It adds the demo, components/relay-carousel-speakers-demo.tsx, and the component, components/ui/relay-carousel.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <RelayCarousel> on its own. 3. These props set its feel. Change them before you change the file: - duration (default 750): Milliseconds the strip takes to glide to a new lead, on a spring with no bounce. A flick's speed carries into it. - stagger (default 80): Milliseconds between the lead's quote lines setting in. Content in `children` follows the last line. - drift (default 14): How far each photo drifts inside its card per card the strip travels, in px. `0` keeps the photos still. Off for reduced motion. 4. Don't change, in components/ui/relay-carousel.tsx: - Every card is placed and sized from one motion value, `place`, through CSS variables (`--offset`, `--grow`, `--ahead`, `--drift`); the sizes live in CSS (`SIZES`), so the server renders the strip already laid out. - The leaving quote lifts off first, the strip glides on one spring, and the new quote sets in line by line only once the strip is within `RELAY.settle` of its slot and nothing is dragging. - A drag lands where the flick was heading and hands its speed to the glide as `velocity`, so a release never snaps. - The strip is endless: slots are virtualized around the lead, and only the copy nearest the lead (`canonical`) is announced and focusable; repeats are `aria-hidden` and `inert`. - Photo drift and zoom are CSS-only behind `motion-safe:`, so reduced motion turns them off without a hydration mismatch; it also jumps the strip and holds autoplay until played. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/relay-carousel.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children Required | React.ReactNode | None | The cards, as RelayCarouselItems. The strip loops, and repeats them when there are too few to fill a wide screen. |
heading | React.ReactNode | None | Shown top left, lined up with the lead card. The controls sit opposite it, and move under the strip on narrow screens. |
autoPlay | number | None | Passes the lead on every this many ms. Adds a pause button whose ring fills as the timer. Holds while the pointer or keyboard focus is inside or the carousel is off screen. Off for reduced motion until the visitor presses play. |
defaultIndex | number | 0 | The card that leads first. |
index | number | None | The card that leads. Set it with onIndexChange to control the carousel from outside; it moves the short way round. |
onIndexChange | (index: number) => void | None | Called with the new lead's index after a button, a click on a card, a drag, a swipe, a key or autoplay moves it. |
inset | string | "max(2.5rem, calc((100cqi - 76rem) / 2))" | Left edge of the heading and the lead card, as a CSS length (1.25rem minimum on narrow screens). cqi is the carousel's width, so the default lines up with a 76rem page column. Cards that already led fade out in this margin. |
duration | number | 750 | Milliseconds the strip takes to glide to a new lead, on a spring with no bounce. A flick's speed carries into it. |
stagger | number | 80 | Milliseconds between the lead's quote lines setting in. Content in children follows the last line. |
drift | number | 14 | How far each photo drifts inside its card per card the strip travels, in px. 0 keeps the photos still. Off for reduced motion. |
className | string | None | Classes for the section. The controls are drawn in its text color. |
item.name Required | string | None | RelayCarouselItem: who said it. Shown on every card. |
item.role | React.ReactNode | None | RelayCarouselItem: their role and company, under the name. |
item.quote Required | string | None | RelayCarouselItem: what they said, without quotation marks. Shown while the card leads; around 130 characters fits a phone. |
item.image Required | string | None | RelayCarouselItem: photo URL. It's cut to the lead card's size and anchored at the top, so a resting card shows its top middle. |
item.alt | string | "" | RelayCarouselItem: alt text for the photo. |
item.label | React.ReactNode | None | RelayCarouselItem: small text in the lead card's top corner, like a place or a product. |
item.children | React.ReactNode | None | RelayCarouselItem: pinned to the lead card's bottom right corner once its quote is in, like a link to the full story. |
item.quoteClassName | string | None | RelayCarouselItem: classes for the quote, like a serif face or a size. |
item.imageClassName | string | None | RelayCarouselItem: classes for the photo. The card is a group/relay with data-state="lead" or "rest". |
item.className | string | None | RelayCarouselItem: classes for the card, like its corner radius or background. |
childrenRequiredType
React.ReactNodeThe cards, as
RelayCarouselItems. The strip loops, and repeats them when there are too few to fill a wide screen.headingType
React.ReactNodeShown top left, lined up with the lead card. The controls sit opposite it, and move under the strip on narrow screens.
autoPlayType
numberPasses the lead on every this many ms. Adds a pause button whose ring fills as the timer. Holds while the pointer or keyboard focus is inside or the carousel is off screen. Off for reduced motion until the visitor presses play.
defaultIndexType
numberDefault0The card that leads first.
indexType
numberThe card that leads. Set it with
onIndexChangeto control the carousel from outside; it moves the short way round.onIndexChangeType
(index: number) => voidCalled with the new lead's index after a button, a click on a card, a drag, a swipe, a key or autoplay moves it.
insetType
stringDefault"max(2.5rem, calc((100cqi - 76rem) / 2))"Left edge of the heading and the lead card, as a CSS length (1.25rem minimum on narrow screens).
cqiis the carousel's width, so the default lines up with a 76rem page column. Cards that already led fade out in this margin.durationType
numberDefault750Milliseconds the strip takes to glide to a new lead, on a spring with no bounce. A flick's speed carries into it.
staggerType
numberDefault80Milliseconds between the lead's quote lines setting in. Content in
childrenfollows the last line.driftType
numberDefault14How far each photo drifts inside its card per card the strip travels, in px.
0keeps the photos still. Off for reduced motion.classNameType
stringClasses for the section. The controls are drawn in its text color.
item.nameRequiredType
stringRelayCarouselItem: who said it. Shown on every card.item.roleType
React.ReactNodeRelayCarouselItem: their role and company, under the name.item.quoteRequiredType
stringRelayCarouselItem: what they said, without quotation marks. Shown while the card leads; around 130 characters fits a phone.item.imageRequiredType
stringRelayCarouselItem: photo URL. It's cut to the lead card's size and anchored at the top, so a resting card shows its top middle.item.altType
stringDefault""RelayCarouselItem: alt text for the photo.item.labelType
React.ReactNodeRelayCarouselItem: small text in the lead card's top corner, like a place or a product.item.childrenType
React.ReactNodeRelayCarouselItem: pinned to the lead card's bottom right corner once its quote is in, like a link to the full story.item.quoteClassNameType
stringRelayCarouselItem: classes for the quote, like a serif face or a size.item.imageClassNameType
stringRelayCarouselItem: classes for the photo. The card is agroup/relaywithdata-state="lead"or"rest".item.classNameType
stringRelayCarouselItem: classes for the card, like its corner radius or background.