Skip to content
motion basics
Back to the collection

STUDY / 001 · Navigation

Simple carousel

A change of scene, with a sense of direction.

AnimatePresence

Preview

Find your flow.

A little room to move.

01 / 04

Live preview · Go ahead, interact with it

Code

"use client";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/lib/utils";

const slides = [
  {
    name: "Find your flow.",
    color: "bg-[#e9eddf]",
    shape: "bg-[#879971]",
    label: "A little room to move.",
  },
  {
    name: "Stay curious.",
    color: "bg-[#eee4dc]",
    shape: "bg-[#b48e75]",
    label: "One small step at a time.",
  },
  {
    name: "Make it feel right.",
    color: "bg-[#e1e7ee]",
    shape: "bg-[#8195b0]",
    label: "The details make the difference.",
  },
  {
    name: "Keep exploring.",
    color: "bg-[#ece3ec]",
    shape: "bg-[#a084a2]",
    label: "There’s always something next.",
  },
];
export default function SimpleCarousel({ className }: { className?: string }) {
  const [state, setState] = useState({ index: 0, direction: 1 });
  const reduced = useReducedMotion();
  const slide = slides[state.index];
  function move(direction: number) {
    setState((current) => ({
      index: (current.index + direction + slides.length) % slides.length,
      direction,
    }));
  }
  return (
    <div className={cn("w-full max-w-72", className)}>
      <section
        className="relative h-56 overflow-hidden rounded-xl border border-black/5"
        aria-roledescription="carousel"
        aria-label="Motion studies"
      >
        <AnimatePresence
          initial={false}
          custom={state.direction}
          mode="popLayout"
        >
          <motion.div
            key={state.index}
            custom={state.direction}
            variants={{
              enter: (direction: number) => ({
                x: reduced ? 0 : `${direction * 100}%`,
                opacity: 0,
              }),
              center: { x: 0, opacity: 1 },
              exit: (direction: number) => ({
                x: reduced ? 0 : `${direction * -100}%`,
                opacity: 0,
              }),
            }}
            initial="enter"
            animate="center"
            exit="exit"
            transition={{ type: "spring", duration: 0.4, bounce: 0.08 }}
            className={cn(
              "absolute inset-0 flex flex-col justify-end p-5",
              "text-neutral-900",
              slide.color,
            )}
          >
            <div
              aria-hidden="true"
              className="absolute inset-x-0 top-6 flex justify-center -space-x-7"
            >
              {[0, 1, 2].map((item) => (
                <div
                  key={item}
                  className={cn(
                    "h-24 w-16 -rotate-30 rounded-full border border-white/35 shadow-sm",
                    slide.shape,
                  )}
                />
              ))}
            </div>
            <p className="text-xl font-medium tracking-tight">{slide.name}</p>
            <p className="mt-1 text-xs text-black/50">{slide.label}</p>
          </motion.div>
        </AnimatePresence>
      </section>
      <div className="mt-4 flex items-center justify-between">
        <span
          className="font-mono text-[10px] text-neutral-400 dark:text-neutral-500"
          aria-live="polite"
        >
          0{state.index + 1} <span className="mx-1 text-neutral-300">/</span> 0
          {slides.length}
        </span>
        <div className="flex gap-2">
          <button
            type="button"
            aria-label="Previous slide"
            onClick={() => move(-1)}
            className="grid size-8 place-items-center rounded-full border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-[#0a0a0a] transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-900 active:scale-95"
          >
            <ArrowLeft size={13} />
          </button>
          <button
            type="button"
            aria-label="Next slide"
            onClick={() => move(1)}
            className="grid size-8 place-items-center rounded-full border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-[#0a0a0a] transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-900 active:scale-95"
          >
            <ArrowRight size={13} />
          </button>
        </div>
      </div>
    </div>
  );
}

React · TypeScript · Tailwind CSS

Copy the component and the cn helper into your project.

bun add motion lucide-react clsx tailwind-merge