Slideshow
Endless carousel that auto-plays, can be dragged and peeks at the next slide.
From the Nitip template — see it in context"use client";
import { Slideshow } from "@/components/ui/slideshow";
// Demo photos from Unsplash (free under the Unsplash License), loaded from its image CDN.
const photo = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=900&q=80`;
const slides = [
{ title: "Harbour", image: photo("photo-1545214766-04116e13521c"), alt: "Fishing boats reflected in a still harbour" },
{ title: "Field notes", image: photo("photo-1597563146687-ffbb906b394e"), alt: "Ears of wheat against a pale sky" },
{ title: "Long exposure", image: photo("photo-1444044205806-38f3ed106c10"), alt: "Smooth sea under a golden evening sky" },
{ title: "Off season", image: photo("photo-1769516035722-2902997f96be"), alt: "Empty beach pergola under snow" },
];
export default function SlideshowDemo() {
return (
<Slideshow
items={slides}
getKey={(slide) => slide.title}
label="Recent work"
gap={12}
className="w-full max-w-lg"
slideClassName="w-[78%] sm:w-[58%]"
renderItem={(slide) => (
<div className="relative h-48 overflow-hidden rounded-lg bg-muted">
<img src={slide.image} alt={slide.alt} className="size-full object-cover" draggable={false} />
<span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 text-sm font-medium text-white">
{slide.title}
</span>
</div>
)}
/>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/slideshow.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/slideshow.tsx."use client"; import * as React from "react"; import { animate, motion, useInView, useMotionValue, type PanInfo, type Transition } from "motion/react"; import { cn } from "@/lib/utils"; /* * Slideshow — an endless carousel that auto-plays, can be dragged and peeks at the next slide * (from the Nitip "Slideshow"). Three copies of the items let it wrap without a visible seam. * Autoplay pauses while the slideshow is hovered, focused, dragged or scrolled out of view. * Arrow keys move one slide; the arrows sit at the bottom right. * * Size the slides with `slideClassName` (e.g. "w-[80%] sm:w-[45%]") and the spacing with `gap`. */ const slide: Transition = { type: "spring", stiffness: 200, damping: 40, mass: 1 }; export type SlideshowProps<T> = { items: T[]; renderItem: (item: T, index: number) => React.ReactNode; getKey: (item: T, index: number) => string; /** Accessible name of the carousel. */ label: string; /** Milliseconds between slides; 0 turns autoplay off. */ interval?: number; gap?: number; className?: string; slideClassName?: string; }; function Arrow({ direction, onClick }: { direction: "left" | "right"; onClick: () => void }) { return ( <button type="button" onClick={onClick} aria-label={direction === "left" ? "Previous slide" : "Next slide"} className="grid size-8 cursor-pointer place-items-center rounded-full border bg-background/80 text-foreground shadow-sm backdrop-blur transition-colors hover:bg-accent focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" > <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" className="size-4" aria-hidden> <path d={direction === "left" ? "M10 3.5L5.5 8l4.5 4.5" : "M6 3.5L10.5 8 6 12.5"} /> </svg> </button> ); } export function Slideshow<T>({ items, renderItem, getKey, label, interval = 5000, gap = 16, className, slideClassName, }: SlideshowProps<T>) { const count = items.length; const slides = [...items, ...items, ...items]; const region = React.useRef<HTMLDivElement>(null); const track = React.useRef<HTMLDivElement>(null); const [index, setIndex] = React.useState(count); const [step, setStep] = React.useState(0); const [interacting, setInteracting] = React.useState(false); const [dragging, setDragging] = React.useState(false); const inView = useInView(region, { amount: 0.3 }); const paused = interacting || dragging || !inView || interval === 0; const x = useMotionValue(0); React.useLayoutEffect(() => { const el = track.current; if (!el) return; const measure = () => { const first = el.children[0] as HTMLElement | undefined; if (first) setStep(first.offsetWidth + gap); }; measure(); const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); }, [gap]); /* Animate to the current slide, then jump back to the middle copy when the loop runs off either end. */ React.useEffect(() => { if (!step) return; const controls = animate(x, -index * step, slide); void controls.then(() => { if (index >= count * 2 || index < count) { const wrapped = index >= count * 2 ? index - count : index + count; x.jump(-wrapped * step); setIndex(wrapped); } }); return () => controls.stop(); }, [index, step, count, x]); React.useEffect(() => { if (paused) return; const timer = setTimeout(() => setIndex((i) => i + 1), interval); return () => clearTimeout(timer); }, [index, paused, interval]); function onDragEnd(_: PointerEvent, info: PanInfo) { setDragging(false); const swiped = Math.abs(info.offset.x) > step * 0.2 || Math.abs(info.velocity.x) > 500; if (swiped) setIndex((i) => i + (info.offset.x < 0 ? 1 : -1)); else animate(x, -index * step, slide); } return ( <div ref={region} role="region" aria-roledescription="carousel" aria-label={label} tabIndex={0} onKeyDown={(e) => { if (e.key === "ArrowRight") setIndex((i) => i + 1); if (e.key === "ArrowLeft") setIndex((i) => i - 1); }} onPointerEnter={(e) => e.pointerType === "mouse" && setInteracting(true)} onPointerLeave={() => setInteracting(false)} onFocus={() => setInteracting(true)} onBlur={() => setInteracting(false)} className={cn( "relative overflow-hidden rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", className, )} > <motion.div ref={track} style={{ x, gap }} drag="x" dragMomentum={false} onDragStart={() => setDragging(true)} onDragEnd={onDragEnd} className="flex cursor-grab select-none active:cursor-grabbing" > {slides.map((item, i) => ( <div key={`${getKey(item, i % count)}-${i}`} aria-hidden={i !== index} className={cn("shrink-0", slideClassName)}> {renderItem(item, i % count)} </div> ))} </motion.div> <div className="absolute right-3 bottom-3 flex gap-2"> <Arrow direction="left" onClick={() => setIndex((i) => i - 1)} /> <Arrow direction="right" onClick={() => setIndex((i) => i + 1)} /> </div> </div> ); }
Usage
import { Slideshow } from "@/components/ui/slideshow";
<Slideshow
items={projects}
getKey={(p) => p.slug}
label="Recent work"
slideClassName="w-[78%] sm:w-[58%]"
renderItem={(p) => <ProjectCard project={p} />}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | T[] | — | The slides' data. |
| renderItem | (item, index) => ReactNode | — | Renders one slide. |
| getKey | (item, index) => string | — | Stable key per item. |
| label | string | — | Accessible name of the carousel. |
| interval | number | 5000 | Milliseconds between slides; 0 turns autoplay off. |
| gap | number | 16 | Space between slides, in px. |
| slideClassName | string | — | Width of one slide — under 100% the next slide peeks in. |
- Autoplay pauses while the slideshow is hovered, focused, dragged or scrolled out of view.
- Drag or swipe to move; ← and → move one slide when the carousel has focus.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS + Motion (motion/react) component called Slideshow — an infinite carousel. - Render three copies of the items in a flex track; keep an index starting in the middle copy, animate the track's x motion value to -index * step with a spring (stiffness 200, damping 40, mass 1), and when the index passes either end, jump silently back into the middle copy (x.jump) and reset the index. - step = slide width + gap, measured with a ResizeObserver on the track. - Autoplay every `interval` ms, paused while hovered (mouse only), focused, dragged, or out of view (useInView 0.3). - drag="x" with dragMomentum false; on drag end, advance if the offset is over 20% of a slide or the velocity over 500, otherwise spring back. - Arrow buttons bottom-right: size-8 rounded-full border bg-background/80 shadow-sm. - Accessibility: role region, aria-roledescription carousel, aria-label, tabIndex 0 with ArrowLeft/ArrowRight, aria-hidden on slides that are not current.











