STUDY / 001 · Navigation
Simple carousel
A change of scene, with a sense of direction.
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