A spring-driven slide carousel with drag/swipe, previous/next arrows and pill dots whose active pill fills as the autoplay timer runs. Autoplay pauses on hover, focus and drag, can be stopped with a play/pause button, and optionally loops.
Preview
Customize
11 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add carouselWrites components/ui/carousel.tsx and components/styles/carousel.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Carousel } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Carousel />6 );7}Props
Accessibility
Follows the WAI-ARIA carousel pattern: a <section> with aria-roledescription="carousel" and an aria-label; each slide is role="group" with aria-roledescription="slide" and an "n of total" label, and inactive slides are aria-hidden. The slide container is a live region (aria-live="off" while auto-rotating, "polite" otherwise). Autoplay pauses whenever the pointer hovers or keyboard focus is inside, and a labelled play/pause button stops rotation entirely. Arrow buttons and dot buttons (aria-current on the active one) are labelled and controls the track; Left/Right arrow keys inside the carousel change slides. Focus is shown with a 2px outline. Under prefers-reduced-motion slides change instantly and autoplay is disabled.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { animate, motion, useMotionValue, useReducedMotion, type PanInfo } from "motion/react";5import styles from "../styles/carousel.module.css";6 7export interface CarouselItem {8 /** Slide heading */9 title: string;10 /** Supporting line */11 description?: string;12 /** Small label above the heading */13 eyebrow?: string;14 /** Any CSS background (color, gradient or url()) */15 background?: string;16}17 18export interface CarouselProps {19 /** Slides to show (defaults to five gradient feature slides) */20 items?: CarouselItem[];21 /** Accessible name of the carousel */22 label?: string;23 /** Controlled active slide index */24 value?: number;25 /** Initial slide index when uncontrolled */26 defaultValue?: number;27 /** Called with the new slide index */28 onValueChange?: (index: number) => void;29 /** Advance automatically (pauses on hover, focus and drag) */30 autoplay?: boolean;31 /** Autoplay interval in ms */32 interval?: number;33 /** Wrap from the last slide to the first and back */34 loop?: boolean;35 /** Show previous/next arrow buttons */36 showArrows?: boolean;37 /** Show the dot pagination */38 showDots?: boolean;39 /** Allow drag/swipe navigation */40 draggable?: boolean;41 /** Color of the active dot and its progress fill */42 accentColor?: string;43 /** Height in px */44 height?: number;45 /** Corner radius in px */46 borderRadius?: number;47 /** Optional className merged onto the root */48 className?: string;49 /** Optional inline styles merged onto the root */50 style?: React.CSSProperties;51}52 53const DEFAULT_ITEMS: CarouselItem[] = [54 { eyebrow: "Motion", title: "Design at the speed of thought", description: "Compose interfaces from motion-first primitives.", background: "linear-gradient(135deg, #4f46e5 0%, #9333ea 100%)" },55 { eyebrow: "Quality", title: "Ship with confidence", description: "Typed, tested, and tuned for every screen size.", background: "linear-gradient(135deg, #0369a1 0%, #0891b2 100%)" },56 { eyebrow: "Access", title: "Accessible by default", description: "Keyboard, screen reader and reduced-motion ready.", background: "linear-gradient(135deg, #be123c 0%, #ea580c 100%)" },57 { eyebrow: "Theming", title: "Themeable everywhere", description: "One set of tokens drives light and dark modes.", background: "linear-gradient(135deg, #047857 0%, #4d7c0f 100%)" },58 { eyebrow: "Platform", title: "Built for the web", description: "Zero heavy dependencies, server-render safe.", background: "linear-gradient(135deg, #b45309 0%, #b91c1c 100%)" },59];60 61export function Carousel({62 items = DEFAULT_ITEMS,63 label = "Product highlights",64 value: valueProp,65 defaultValue = 0,66 onValueChange,67 autoplay = true,68 interval = 4500,69 loop = true,70 showArrows = true,71 showDots = true,72 draggable = true,73 accentColor = "#6366f1",74 height = 340,75 borderRadius = 14,76 className = "",77 style,78}: CarouselProps) {79 const reduce = useReducedMotion();80 const n = items.length;81 const [inner, setInner] = React.useState(defaultValue);82 const controlled = valueProp !== undefined;83 const index = Math.max(0, Math.min(n - 1, controlled ? valueProp : inner));84 const viewportRef = React.useRef<HTMLDivElement>(null);85 const [width, setWidth] = React.useState(0);86 const x = useMotionValue(0);87 const [hovered, setHovered] = React.useState(false);88 const [focused, setFocused] = React.useState(false);89 const [dragging, setDragging] = React.useState(false);90 const [playing, setPlaying] = React.useState(autoplay);91 const id = React.useId();92 93 const go = React.useCallback(94 (i: number) => {95 const next = loop ? ((i % n) + n) % n : Math.max(0, Math.min(n - 1, i));96 if (!controlled) setInner(next);97 if (next !== index) onValueChange?.(next);98 },99 [controlled, index, loop, n, onValueChange],100 );101 102 React.useEffect(() => {103 const el = viewportRef.current;104 if (!el || typeof ResizeObserver === "undefined") return;105 const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));106 ro.observe(el);107 return () => ro.disconnect();108 }, []);109 110 React.useEffect(() => {111 if (!width) return;112 const target = -index * width;113 if (reduce) {114 x.set(target);115 return;116 }117 const ctrl = animate(x, target, { type: "spring", stiffness: 260, damping: 32, mass: 0.9 });118 return () => ctrl.stop();119 }, [index, width, reduce, x]);120 121 const paused = !playing || hovered || focused || dragging || Boolean(reduce) || n < 2;122 React.useEffect(() => {123 if (paused) return;124 if (!loop && index >= n - 1) return;125 const t = window.setTimeout(() => go(index + 1), Math.max(1000, interval));126 return () => window.clearTimeout(t);127 }, [paused, index, interval, loop, n, go]);128 129 const onDragEnd = (_: unknown, info: PanInfo) => {130 setDragging(false);131 const th = width * 0.18;132 if (info.offset.x < -th || info.velocity.x < -500) go(index + 1);133 else if (info.offset.x > th || info.velocity.x > 500) go(index - 1);134 else animate(x, -index * width, { type: "spring", stiffness: 320, damping: 34 });135 };136 137 const onKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {138 if (e.key === "ArrowRight") go(index + 1);139 else if (e.key === "ArrowLeft") go(index - 1);140 else return;141 e.preventDefault();142 };143 144 const canPrev = loop || index > 0;145 const canNext = loop || index < n - 1;146 const chevron = (d: string) => (147 <svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">148 <path d={d} />149 </svg>150 );151 152 return (153 <section154 className={`${styles.root} ${className}`.trim()}155 aria-roledescription="carousel"156 aria-label={label}157 data-paused={paused ? "" : undefined}158 style={{ "--mf-cr-accent": accentColor, "--mf-cr-interval": `${Math.max(1000, interval)}ms`, "--mf-cr-radius": `${borderRadius}px`, ...style } as React.CSSProperties}159 onPointerEnter={() => setHovered(true)}160 onPointerLeave={() => setHovered(false)}161 onFocus={() => setFocused(true)}162 onBlur={(e) => {163 if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);164 }}165 onKeyDown={onKeyDown}166 >167 <div ref={viewportRef} className={styles.viewport} style={{ height }}>168 <motion.div169 id={`${id}-track`}170 className={styles.track}171 aria-live={playing && autoplay && !reduce ? "off" : "polite"}172 style={{ x }}173 drag={draggable && n > 1 ? "x" : false}174 dragConstraints={{ left: -(n - 1) * width, right: 0 }}175 dragElastic={0.18}176 dragMomentum={false}177 onDragStart={() => setDragging(true)}178 onDragEnd={onDragEnd}179 >180 {items.map((item, i) => (181 <div182 key={`${item.title}-${i}`}183 role="group"184 aria-roledescription="slide"185 aria-label={`${i + 1} of ${n}`}186 aria-hidden={i !== index}187 className={styles.slide}188 data-active={i === index ? "" : undefined}189 style={{ background: item.background ?? "var(--mf-surface-raised, #1b1b22)" }}190 >191 <div className={styles.content}>192 {item.eyebrow && <span className={styles.eyebrow}>{item.eyebrow}</span>}193 <h3 className={styles.title}>{item.title}</h3>194 {item.description && <p className={styles.desc}>{item.description}</p>}195 </div>196 </div>197 ))}198 </motion.div>199 {showArrows && n > 1 && (200 <>201 <button type="button" className={`${styles.arrow} ${styles.prev}`} aria-controls={`${id}-track`} aria-label="Previous slide" disabled={!canPrev} onClick={() => go(index - 1)}>202 {chevron("m15 6-6 6 6 6")}203 </button>204 <button type="button" className={`${styles.arrow} ${styles.next}`} aria-controls={`${id}-track`} aria-label="Next slide" disabled={!canNext} onClick={() => go(index + 1)}>205 {chevron("m9 6 6 6-6 6")}206 </button>207 </>208 )}209 </div>210 <div className={styles.controls}>211 {autoplay && n > 1 && (212 <button type="button" className={styles.play} aria-label={playing ? "Stop automatic slide show" : "Start automatic slide show"} onClick={() => setPlaying((p) => !p)}>213 {playing ? (214 <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1" /><rect x="14" y="5" width="4" height="14" rx="1" /></svg>215 ) : (216 <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5.5v13a1 1 0 0 0 1.5.86l11-6.5a1 1 0 0 0 0-1.72l-11-6.5A1 1 0 0 0 7 5.5z" /></svg>217 )}218 </button>219 )}220 {showDots && n > 1 && (221 <div className={styles.dots}>222 {items.map((item, i) => (223 <button224 key={`${item.title}-${i}`}225 type="button"226 className={styles.dot}227 aria-label={`Go to slide ${i + 1}: ${item.title}`}228 aria-current={i === index ? "true" : undefined}229 aria-controls={`${id}-track`}230 onClick={() => go(i)}231 >232 {i === index && <span key={`fill-${index}-${paused ? "p" : "r"}`} className={styles.fill} data-run={autoplay && !paused ? "" : undefined} />}233 </button>234 ))}235 </div>236 )}237 </div>238 </section>239 );240}