Interactive
DragCarousel
Throw the strip and it glides, leans with its speed and lands on a slide. Works by mouse, touch, arrow keys and dots. A click on a link inside still works, a drag does not fire it.
Download
- React
- Tailwind CSS v4
- motion
components/ui/drag-carousel.tsx
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
animate,
motion,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
useSpring,
useTransform,
useVelocity,
} from "motion/react";
export type DragCarouselItem = {
id: string;
/** Read out by screen readers. */
label: string;
content: ReactNode;
};
type DragCarouselProps = {
items: DragCarouselItem[];
/** Slide width in pixels. */
itemWidth?: number;
/** Space between slides in pixels. */
gap?: number;
accent?: string;
/** Name read out by screen readers. */
label?: string;
className?: string;
};
const GLIDE = { type: "spring", stiffness: 260, damping: 30 } as const;
const nearest = (value: number, stops: number[]) =>
stops.reduce((best, stop, i) => (Math.abs(stop + value) < Math.abs(stops[best] + value) ? i : best), 0);
/* DragCarousel: throw the strip and it glides, leans with its speed, and lands on a slide. Works by mouse, touch, keys and dots. */
export function DragCarousel({ items, itemWidth = 260, gap = 16, accent = "#a78bfa", label = "Carousel", className = "" }: DragCarouselProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLDivElement>(null);
const track = useRef<HTMLUListElement>(null);
const flung = useRef(false);
const current = useRef(0);
const [stops, setStops] = useState([0]);
const [index, setIndex] = useState(0);
const x = useMotionValue(0);
const speed = useSpring(useVelocity(x), { stiffness: 200, damping: 40 });
const lean = useTransform(speed, [-2500, 2500], [5, -5]);
useEffect(() => {
const measure = () => {
if (!box.current || !track.current) return;
const spare = Math.max(0, track.current.scrollWidth - box.current.clientWidth);
const cells = Array.from(track.current.children) as HTMLElement[];
const next = [...new Set(cells.map((cell) => Math.min(cell.offsetLeft, spare)))];
const at = Math.min(current.current, next.length - 1);
current.current = at;
setStops(next);
setIndex(at);
x.jump(-next[at]);
};
const observer = new ResizeObserver(measure);
if (box.current) observer.observe(box.current);
if (track.current) observer.observe(track.current);
return () => observer.disconnect();
}, [x]);
useMotionValueEvent(x, "change", (value) => {
const at = nearest(value, stops);
current.current = at;
setIndex(at);
});
const go = (to: number) => {
const at = Math.max(0, Math.min(stops.length - 1, to));
animate(x, -stops[at], reduce ? { duration: 0 } : GLIDE);
};
const onKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "ArrowRight") go(index + 1);
else if (e.key === "ArrowLeft") go(index - 1);
else return;
e.preventDefault();
};
return (
<div className={className}>
<div
ref={box}
role="group"
aria-roledescription="carousel"
aria-label={label}
tabIndex={0}
onKeyDown={onKey}
className="overflow-hidden rounded-2xl outline-none [contain:inline-size] focus-visible:ring-2 focus-visible:ring-white/70"
>
<motion.ul
ref={track}
drag={stops.length > 1 ? "x" : false}
dragConstraints={{ left: -stops[stops.length - 1], right: 0 }}
dragElastic={0.12}
dragTransition={{ power: 0.25, timeConstant: 180, modifyTarget: (target) => -stops[nearest(target, stops)] }}
onPointerDownCapture={() => {
flung.current = false;
}}
onDragStart={() => {
flung.current = true;
}}
onClickCapture={(e) => {
if (!flung.current) return;
e.preventDefault();
e.stopPropagation();
}}
className="relative flex w-max cursor-grab select-none active:cursor-grabbing [&_img]:pointer-events-none"
style={{ x, gap, skewX: reduce ? 0 : lean }}
>
{items.map((item, i) => (
<li
key={item.id}
aria-label={`${item.label}, ${i + 1} of ${items.length}`}
onFocus={(e) => {
if (box.current) box.current.scrollLeft = 0;
go(nearest(-e.currentTarget.offsetLeft, stops));
}}
className="shrink-0"
style={{ width: itemWidth }}
>
{item.content}
</li>
))}
</motion.ul>
</div>
{stops.length > 1 && (
<div className="mt-2 flex justify-center">
{stops.map((_, i) => (
<button
key={i}
type="button"
aria-label={`Go to stop ${i + 1}`}
aria-current={i === index}
onClick={() => go(i)}
className="grid size-6 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
<span
className="size-2 rounded-full transition-[background-color,scale] duration-300"
style={{ backgroundColor: i === index ? accent : "rgb(255 255 255 / 0.25)", scale: i === index ? 1.3 : 1 }}
/>
</button>
))}
</div>
)}
<p role="status" className="sr-only">{`Stop ${index + 1} of ${stops.length}`}</p>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/drag-carousel.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/drag-carousel-demo.tsx
import { DragCarousel } from "@/components/ui/drag-carousel"; const STORIES = [ ["Monsoon", "#0e7490"], ["Harvest", "#a16207"], ["Harbour", "#3730a3"], ["Market", "#9f1239"], ["Temple", "#7c2d12"], ["Night train", "#4c1d95"], ].map(([name, colour]) => ({ id: name, label: name, content: ( <div className="flex h-36 items-end rounded-2xl p-4" style={{ background: `linear-gradient(150deg, ${colour}, #0b0a14)` }}> <p className="font-semibold text-white">{name}</p> </div> ), })); export default function DragCarouselExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-4"> <DragCarousel items={STORIES} itemWidth={190} gap={12} label="Stories" className="w-full max-w-[560px]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | DragCarouselItem[] | — | Slides: id, label and content. |
| itemWidth | number | 260 | Slide width in pixels. |
| gap | number | 16 | Space between slides in pixels. |
| accent | string | "#a78bfa" | Colour of the live dot. |
| label | string | "Carousel" | Read by screen readers. |
| className | string | "" | On the wrapper. Set the width here. |