Perspective Carousel
A 3D perspective carousel (coverflow style) with drag to page, looping, autoplay, and controlled and uncontrolled modes.
Loading preview…
npx shadcn@latest add https://webberui.com/r/perspective-carousel.jsonPlayground
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
<PerspectiveCarousel />
Installation
npx shadcn@latest add https://webberui.com/r/perspective-carousel.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>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 透視輪播" | Accessible label of the carousel region — the built-in default is Traditional Chinese for "3D perspective carousel", 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="輪播"(Traditional Chinese for "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.