UIData Displaymotion/react

Carousel

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

Motion

Design at the speed of thought

Compose interfaces from motion-first primitives.

Customize

11 props
0
4500
340
14

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add carousel

Writes 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.

example.tsx
1import { Carousel } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Carousel />6  );7}

Props

PropTypeDefault
items

Slides to render; background accepts any CSS background value (color, gradient or url()).

CarouselItem[] // { title: string; description?: string; eyebrow?: string; background?: string }five gradient feature slides
label

Accessible name of the carousel region.

string'Product highlights'
value

Controlled active slide index.

numberundefined
defaultValue

Initial slide index when uncontrolled.

number0
onValueChange

Called with the new slide index.

(index: number) => voidundefined
autoplay

Advance automatically; pauses on hover, focus and drag, and renders a play/pause button.

booleantrue
interval

Autoplay interval in ms (minimum 1000).

number4500
loop

Wrap from the last slide to the first and back; when false the arrows hide at the ends.

booleantrue
showArrows

Show the previous/next arrow buttons.

booleantrue
showDots

Show the dot pagination with autoplay progress.

booleantrue
draggable

Allow drag/swipe navigation (distance or flick velocity).

booleantrue
accentColor

Active dot and progress fill color.

string'#6366f1'
height

Slide height in px.

number340
borderRadius

Viewport corner radius in px.

number14
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/carousel.tsx
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}