Carousels

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.en

New project or installation trouble? Start here →

Perspective 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.

1200
45
120
2
0.12
Active edited usageDefault props
<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 autoplay switching 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.json

Or, 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

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

PropTypeDefaultDescription
itemsPerspectiveCarouselItem[]—The array of slides, each with an id and content
indexnumber—Controlled current index (0-based); when provided, control sits outside
defaultIndexnumber0Initial index in uncontrolled mode
onIndexChange(index: number) => void—Fires when the index changes
loopbooleanfalseWhether to loop (a ring-shaped 3D arrangement joining head to tail)
itemWidthnumber240Slide width (px)
itemHeightnumber320Slide height (px)
visiblenumber2Number of neighboring slides visible on each side
perspectivenumber1200CSS perspective distance (px); the smaller it is, the stronger the perspective
rotatenumber45Rotation of neighboring slides about the Y axis (deg)
depthnumber120Z offset pushed back per step (px)
spreadnumberitemWidth * 0.6Horizontal spacing per step (px)
scaleStepnumber0.12Scale reduction per step
autoplaybooleanfalseAutoplay (disabled under reduce motion)
autoplayIntervalnumber3500Autoplay interval (ms)
showArrowsbooleantrueWhether to show the left and right arrows
velocityThresholdnumber320Flick velocity threshold that triggers paging (px/s)
ariaLabelstring"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 velocityThreshold it advances one more slide in the direction of the flick.
  • With loop on, 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 focusable tabIndex; 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 marked aria-hidden.
  • The left and right arrows are native buttons with an aria-label, and they are disabled at 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.