Perspective Carousel
A 3D perspective carousel (coverflow style) with drag to page, looping, autoplay, and controlled and uncontrolled modes.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add perspective-carousel.enPerspective Carousel is a React coverflow 3D carousel built with Motion and CSS 3D transforms. The center slide faces the viewer while neighbors rotate by rotate and recede by depth; switch slides by dragging, flicking or clicking a side slide.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<PerspectiveCarousel/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- Portfolio or case study pages that show project covers side by side.
- Featured or new-arrival product rotation on a store homepage, with
autoplayswitching on a timer. - Cover-driven browsing rows for music, video or books.
- Speaker or ticket-tier cards on an event page where the previous and next items should stay visible.
- Not a good fit: browsing many images with a zoomed view, use Lightbox Gallery; for a flat scrolling row of covers, see Cover Carousel.
Installation
npx webberui@latest add https://webberui.com/r/perspective-carousel.en.jsonOr, once registries are configured in components.json, install it as @webberui/perspective-carousel.
Usage
import { PerspectiveCarousel } from "@/components/ui/perspective-carousel";
const items = [
{ id: "a", content: <img src="/1.jpg" alt="" className="h-full w-full object-cover" /> },
{ id: "b", content: <img src="/2.jpg" alt="" className="h-full w-full object-cover" /> },
{ id: "c", content: <img src="/3.jpg" alt="" className="h-full w-full object-cover" /> },
];
<PerspectiveCarousel items={items} loop />Controlled mode
Passing index and onIndexChange switches to controlled mode, so it can stay in sync with outside state (indicator dots, a thumbnail strip, and so on):
const [active, setActive] = React.useState(0);
<PerspectiveCarousel items={items} index={active} onIndexChange={setActive} loop />Programmatic control
Get next / prev / goTo through the ref:
const ref = React.useRef<PerspectiveCarouselHandle>(null);
<PerspectiveCarousel ref={ref} items={items} />
<button onClick={() => ref.current?.next()}>Next</button>Examples
Featured products
Turn on loop and autoplay to advance every 5 seconds; it pauses while the pointer hovers or focus is inside.
import { PerspectiveCarousel } from "@/components/ui/perspective-carousel";
const products = [
{ id: "p1", name: "Noise-cancelling headphones", image: "/products/headphones.jpg" },
{ id: "p2", name: "Mechanical keyboard", image: "/products/keyboard.jpg" },
{ id: "p3", name: "27-inch monitor", image: "/products/monitor.jpg" },
{ id: "p4", name: "Ergonomic chair", image: "/products/chair.jpg" },
];
export function FeaturedProducts() {
return (
<PerspectiveCarousel
ariaLabel="Featured products"
loop
autoplay
autoplayInterval={5000}
items={products.map((p) => ({
id: p.id,
content: (
<figure className="relative h-full w-full">
<img src={p.image} alt={p.name} className="h-full w-full object-cover" />
<figcaption className="absolute inset-x-0 bottom-0 bg-black/60 p-3 text-sm text-white">
{p.name}
</figcaption>
</figure>
),
}))}
/>
);
}A gentler perspective
Lower rotate and depth and show one neighbor per side for narrow layouts.
<PerspectiveCarousel
items={items}
itemWidth={200}
itemHeight={260}
visible={1}
rotate={25}
depth={60}
perspective={1600}
/>With dot indicators
Use controlled mode to sync external dots and hide the built-in arrows.
const [active, setActive] = React.useState(0);
<PerspectiveCarousel
items={items}
index={active}
onIndexChange={setActive}
showArrows={false}
/>
<div className="mt-4 flex justify-center gap-2">
{items.map((item, i) => (
<button
key={item.id}
aria-label={`Go to slide ${i + 1}`}
onClick={() => setActive(i)}
className={i === active ? "size-2 rounded-full bg-neutral-900" : "size-2 rounded-full bg-neutral-300"}
/>
))}
</div>Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | PerspectiveCarouselItem[] | — | The array of slides, each with an id and content |
index | number | — | Controlled current index (0-based); when provided, control sits outside |
defaultIndex | number | 0 | Initial index in uncontrolled mode |
onIndexChange | (index: number) => void | — | Fires when the index changes |
loop | boolean | false | Whether to loop (a ring-shaped 3D arrangement joining head to tail) |
itemWidth | number | 240 | Slide width (px) |
itemHeight | number | 320 | Slide height (px) |
visible | number | 2 | Number of neighboring slides visible on each side |
perspective | number | 1200 | CSS perspective distance (px); the smaller it is, the stronger the perspective |
rotate | number | 45 | Rotation of neighboring slides about the Y axis (deg) |
depth | number | 120 | Z offset pushed back per step (px) |
spread | number | itemWidth * 0.6 | Horizontal spacing per step (px) |
scaleStep | number | 0.12 | Scale reduction per step |
autoplay | boolean | false | Autoplay (disabled under reduce motion) |
autoplayInterval | number | 3500 | Autoplay interval (ms) |
showArrows | boolean | true | Whether to show the left and right arrows |
velocityThreshold | number | 320 | Flick velocity threshold that triggers paging (px/s) |
ariaLabel | string | "3D Perspective Carousel" | Accessible label of the carousel region — the English source includes this default, so pass this prop to localize it |
Types
interface PerspectiveCarouselItem {
id: string | number;
content: React.ReactNode;
}
interface PerspectiveCarouselHandle {
next: () => void;
prev: () => void;
goTo: (index: number) => void;
}How it works
- Coverflow perspective is built with pure CSS 3D transforms (
perspective+rotateY+translateZ), with no WebGL library involved. - Every slide shares one floating-point position motion value, and the offset, rotation, scale, opacity, and dimming from the center outward are all mapped continuously from that value, so the drag tracks the pointer in real time.
- On release it pages by rounding to the nearest landing point, and if the flick velocity exceeds
velocityThresholdit advances one more slide in the direction of the flick. - With
loopon, it settles along the shortest ring path and normalizes the position once the animation ends, so drift does not accumulate over a long run.
Accessibility
- The carousel container carries
role="group",aria-roledescription="Carousel"(meaning "carousel"), and a focusabletabIndex; the arrow keys page through, and Home / End jump straight to the first and last slide. - The current slide number is announced through a hidden region with
aria-live="polite"("slide N of M"); slides other than the current one are markedaria-hidden. - The left and right arrows are native
buttons with anaria-label, and they aredisabledat the boundaries (when not looping). - When the user has "reduce motion" enabled at the system level, the drag and the autoplay are disabled and paging switches instantly with no tween animation.
- Autoplay pauses automatically when the pointer hovers or focus enters the carousel region.
FAQ
Does it need WebGL or Three.js?
No. The 3D look comes from CSS perspective, rotateY and translateZ, animated with Motion motion values. Dependencies are just motion, lucide-react, clsx and tailwind-merge.
Does it work with the Next.js App Router?
Yes. The source starts with "use client", so import it from a Server Component page as usual. Each item's content can be next/image or any React node.
How do I change the size and perspective strength?
itemWidth and itemHeight set the slide size, and a smaller perspective means a stronger effect. rotate, depth, spread and scaleStep control the neighbors' rotation, push-back, spacing and shrink, and visible sets how many neighbors show per side.
When does autoplay stop?
autoplay does not advance while the pointer hovers, focus is inside the carousel, there is only one slide, or the user has "reduce motion" enabled. With reduced motion, dragging is also disabled and slides switch instantly.
Is it keyboard accessible?
Yes. The carousel container is focusable: Left and Right arrows change slides, Home and End jump to the first and last. The arrow controls are native button elements and become disabled at the ends when loop is off.
Pixel UI Kit
A pixel-art UI trio — a stepped-border card, a pixel button that sinks on press, and a block-by-block progress bar, all drawn with box-shadow and CSS.
Swipe Cards
A Tinder-style swipe card stack that can be dragged, flies out by velocity, and offers programmatic left/right swipes and an empty state.