All patterns

Carousel Slide Advance

Slides advance with the neighbours peeking, dots tracking position.

navigationelegantenergeticinteraction · finite · intermediate · ~0.4s
Interactive · click to play
Variant

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.

424 lines · react + motion only
import { useEffect, useId, useState } from "react";
import { motion, useAnimationControls, useReducedMotion } from "motion/react";

/**
 * Vibary · Carousel Slide Advance
 *
 * A gallery that moves one slide at a time. The neighbours stay visible
 * at the edges so the strip reads as a row you are moving along rather
 * than a stack of screens, and the accent dot travels to the position you
 * landed on. Arrows, dots and a horizontal drag all drive the same move.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the frame reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `defaultIndex`, `slides`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type CarouselSlide = {
  id: string;
  title: string;
  price: string;
  /** Real photograph for the slide; omit for the gradient stand-in. */
  imageSrc?: string;
  /** Gradient shown while there is no photo. */
  art?: string;
};

export type CarouselSlideAdvanceProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Slide shown on first render. */
  defaultIndex?: number;
  /** Slides to walk through; each may carry a photo via `imageSrc`. */
  slides?: readonly CarouselSlide[];
  /** Notified with the index the carousel settles on. */
  onChange?: (index: number) => void;
};

type VariantConfig = {
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Opacity of the slides peeking at either edge. */
  neighbourOpacity: number;
  /** How much a flick's speed counts toward advancing, in seconds. */
  velocityWeight: number;
};

// Quality rule: the whole strip is in motion, so overshoot here moves
// every slide at once and reads as slack. Springs stay at or above a 0.8
// damping ratio (ζ = damping / 2√stiffness) and land once. Variants
// change how briskly the strip crosses, never how much it wobbles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.00 — arrives flat. For a dense product grid.
  subtle: {
    spring: { type: "spring", stiffness: 630, damping: 50 },
    neighbourOpacity: 0.58,
    velocityWeight: 0.12,
  },
  // ζ ≈ 0.90 — one soft settle, the weight that makes the strip feel
  // physical. The all-purpose setting.
  default: {
    spring: { type: "spring", stiffness: 360, damping: 34 },
    neighbourOpacity: 0.42,
    velocityWeight: 0.18,
  },
  // ζ ≈ 0.81 — quicker to leave, still one settle, and a flick carries
  // further. For a hero gallery that is the point of the page.
  playful: {
    spring: { type: "spring", stiffness: 290, damping: 28 },
    neighbourOpacity: 0.26,
    velocityWeight: 0.24,
  },
};

const ACCENT = "#7C7CF0";

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const FRAME = 320;
const SLIDE = 234;
const GAP = 12;
const STEP = SLIDE + GAP;
/** px of drag (plus a weighted flick) that commits to the next slide. */
const COMMIT = 52;

// Stand-ins for photography, not UI surfaces: they keep their own colors
// in both themes, exactly as real images would. Pass `imageSrc` on a
// slide and the photo takes the gradient's place.
export const DEFAULT_SLIDES: readonly CarouselSlide[] = [
  {
    id: "lamp",
    title: "Arc floor lamp",
    price: "$248",
    art: "linear-gradient(150deg, #3E4C86 0%, #6D7FD1 55%, #C8A78B 100%)",
  },
  {
    id: "chair",
    title: "Shell lounge chair",
    price: "$690",
    art: "linear-gradient(150deg, #1F4E4A 0%, #4C8C7B 60%, #D3C08A 100%)",
  },
  {
    id: "desk",
    title: "Oak writing desk",
    price: "$1,120",
    art: "linear-gradient(150deg, #5A3B4C 0%, #A2687A 55%, #E2B79A 100%)",
  },
  {
    id: "rug",
    title: "Wool floor rug",
    price: "$430",
    art: "linear-gradient(150deg, #2C3A57 0%, #55719B 50%, #9FC0CE 100%)",
  },
];

export default function CarouselSlideAdvance({
  variant = "default",
  defaultIndex = 0,
  slides = DEFAULT_SLIDES,
  onChange,
}: CarouselSlideAdvanceProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [index, setIndex] = useState(defaultIndex);
  const controls = useAnimationControls();

  // Scoped per instance: a hard-coded layout id would make two carousels
  // on the same page trade the accent dot across the document.
  const dotId = useId();
  const last = slides.length - 1;

  // Reduced motion: the strip changes position without travelling. The
  // slide, the counter and the dot all still say where you are.
  const travel = reduceMotion ? { duration: 0 } : cfg.spring;

  // One place decides where the strip rests, so a drag that does not
  // commit snaps back through exactly the same spring as an arrow press.
  useEffect(() => {
    controls.start({
      x: -index * STEP,
      transition: reduceMotion ? { duration: 0 } : cfg.spring,
    });
  }, [index, controls, cfg, reduceMotion]);

  const go = (next: number) => {
    const clamped = Math.max(0, Math.min(last, next));
    if (clamped === index) {
      // Nothing changed — settle the strip back where it belongs.
      controls.start({ x: -index * STEP, transition: travel });
      return;
    }
    setIndex(clamped);
    onChange?.(clamped);
  };

  return (
    <div
      style={{
        width: FRAME,
        borderRadius: 20,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        boxShadow: "0 14px 36px rgba(0,0,0,0.16)",
        padding: "14px 0 12px",
        // The strip is clipped by this frame rather than the viewport, so
        // the pattern drops into a card, a sheet or a page column
        // unchanged.
        overflow: "hidden",
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "baseline",
          justifyContent: "space-between",
          padding: "0 16px 10px",
        }}
      >
        <span style={{ fontSize: 13, fontWeight: 650 }}>New this week</span>
        <span
          style={{ fontSize: 11.5, opacity: 0.5, fontVariantNumeric: "tabular-nums" }}
        >
          {index + 1} of {slides.length}
        </span>
      </div>

      {/* The viewport is narrower than the track by design: the slide
          before and the slide after stay on screen at the edges, which is
          what tells you this is a row and not a stack. */}
      <div style={{ overflow: "hidden", padding: "0 0 2px" }}>
        <motion.div
          drag={reduceMotion ? false : "x"}
          dragConstraints={{ left: -last * STEP, right: 0 }}
          dragElastic={0.14}
          dragMomentum={false}
          onDragEnd={(_event, info) => {
            // A short fast flick should advance as readily as a long slow
            // drag, so distance and speed are weighed together rather
            // than tested separately.
            const intent = info.offset.x + info.velocity.x * cfg.velocityWeight;
            if (intent <= -COMMIT) go(index + 1);
            else if (intent >= COMMIT) go(index - 1);
            else go(index);
          }}
          animate={controls}
          initial={{ x: -defaultIndex * STEP }}
          style={{
            display: "flex",
            gap: GAP,
            paddingLeft: (FRAME - SLIDE) / 2,
            paddingRight: (FRAME - SLIDE) / 2,
            cursor: reduceMotion ? "default" : "grab",
            touchAction: "pan-y",
          }}
        >
          {slides.map((slide, slideIndex) => {
            const isCurrent = slideIndex === index;
            return (
              <motion.div
                key={slide.id}
                aria-hidden={!isCurrent}
                // Neighbours recede with opacity alone. Scaling them down
                // is the usual trick, but it scales their captions too,
                // and text that resizes as it drifts past is the tell of
                // a cheap carousel.
                animate={{ opacity: isCurrent ? 1 : cfg.neighbourOpacity }}
                transition={{ duration: reduceMotion ? 0 : 0.26, ease: "easeOut" }}
                style={{
                  flex: `0 0 ${SLIDE}px`,
                  borderRadius: 16,
                  overflow: "hidden",
                  background: tone(8),
                  border: `1px solid ${tone(12)}`,
                }}
              >
                <div
                  style={{
                    position: "relative",
                    height: 132,
                    background: slide.art ?? tone(8),
                  }}
                >
                  {slide.imageSrc ? (
                    <img
                      src={slide.imageSrc}
                      alt=""
                      draggable={false}
                      style={{
                        width: "100%",
                        height: "100%",
                        objectFit: "cover",
                        display: "block",
                      }}
                    />
                  ) : (
                    // Without a photo the imagery is synthesized: an inline
                    // SVG motif over a gradient keeps the file portable.
                    <svg
                      viewBox="0 0 240 132"
                      width="100%"
                      height="100%"
                      aria-hidden
                      style={{ display: "block", opacity: 0.5 }}
                    >
                      <circle cx="188" cy="30" r="30" fill="rgba(255,255,255,0.22)" />
                      <path
                        d="M0 118 L58 74 L104 106 L158 60 L240 112 L240 132 L0 132 Z"
                        fill="rgba(12,16,28,0.34)"
                      />
                    </svg>
                  )}
                </div>
                <div
                  style={{
                    display: "flex",
                    alignItems: "baseline",
                    justifyContent: "space-between",
                    gap: 8,
                    padding: "10px 12px 12px",
                  }}
                >
                  <span style={{ fontSize: 13, fontWeight: 600 }}>{slide.title}</span>
                  <span style={{ fontSize: 12.5, opacity: 0.6 }}>{slide.price}</span>
                </div>
              </motion.div>
            );
          })}
        </motion.div>
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          padding: "12px 16px 0",
        }}
      >
        <Arrow
          direction="prev"
          disabled={index === 0}
          onClick={() => go(index - 1)}
        />

        <div
          style={{ display: "flex", alignItems: "center", gap: 9 }}
          role="tablist"
          aria-label="Slides"
        >
          {slides.map((slide, slideIndex) => {
            const isCurrent = slideIndex === index;
            return (
              <button
                key={slide.id}
                type="button"
                role="tab"
                aria-selected={isCurrent}
                aria-label={slide.title}
                onClick={() => go(slideIndex)}
                style={{
                  position: "relative",
                  display: "grid",
                  placeItems: "center",
                  width: 6,
                  height: 14,
                  padding: 0,
                  border: 0,
                  background: "none",
                  cursor: "pointer",
                }}
              >
                <span
                  aria-hidden
                  style={{
                    width: 6,
                    height: 6,
                    borderRadius: 999,
                    background: tone(24),
                  }}
                />
                {isCurrent && (
                  // The accent marker is removed from the old dot and
                  // mounted on the new one; the shared layout id makes
                  // Motion travel it across the row instead of blinking
                  // it out and back.
                  <motion.span
                    layoutId={dotId}
                    transition={travel}
                    aria-hidden
                    style={{
                      position: "absolute",
                      width: 16,
                      height: 6,
                      borderRadius: 999,
                      background: ACCENT,
                    }}
                  />
                )}
              </button>
            );
          })}
        </div>

        <Arrow
          direction="next"
          disabled={index === last}
          onClick={() => go(index + 1)}
        />
      </div>
    </div>
  );
}

function Arrow({
  direction,
  disabled,
  onClick,
}: {
  direction: "prev" | "next";
  disabled: boolean;
  onClick: () => void;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      aria-label={direction === "prev" ? "Previous slide" : "Next slide"}
      style={{
        display: "grid",
        placeItems: "center",
        width: 30,
        height: 30,
        borderRadius: 999,
        border: `1px solid ${tone(14)}`,
        background: tone(8),
        color: "inherit",
        opacity: disabled ? 0.32 : 1,
        cursor: disabled ? "default" : "pointer",
      }}
    >
      <svg
        width="15"
        height="15"
        viewBox="0 0 20 20"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.8"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden
        style={{ transform: direction === "prev" ? "rotate(180deg)" : "none" }}
      >
        <path d="M7.5 4.5 13 10l-5.5 5.5" />
      </svg>
    </button>
  );
}

About this pattern

A gallery you move along rather than page through. Two choices do the work: the viewport is narrower than the track so the neighbour on each side stays visible, and the accent dot is a single marker with a shared layout id, so it travels to the position you landed on instead of blinking out and back. Arrows, dots and a horizontal drag all funnel into one settle function, which is why a flick that does not commit snaps back through exactly the same spring as a button press. Neighbours recede with opacity alone — scaling them down would scale their captions, and text that resizes as it drifts past is the tell of a cheap gallery.

Product image galleryPromo strip on a home pageTestimonial rotatorMedia browser

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • NewMenWomenSale
    Men's trail shoeRidgeline GT$132Bone
    88.599.510
    Add to bag
    Free delivery and returns
    Product details
    Product page

    Photos advance one at a time with dots underneath marking the position.

Related patterns