Image Effects
PerspectiveGallery
A row of pictures in 3D. One faces you and the rest turn away. Drag, click a side picture, or use the arrow keys.
Download
- React
- Tailwind CSS v4
- motion
components/ui/perspective-gallery.tsx
"use client";
import { useRef, useState } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue, type PanInfo } from "motion/react";
export type PerspectiveGalleryItem = { src: string; alt: string };
type PerspectiveGalleryProps = {
/** Two or more pictures. The middle one starts in front. */
images: PerspectiveGalleryItem[];
/** How far the side pictures turn away, in degrees. */
tilt?: number;
/** Name read out by screen readers. */
label?: string;
/** Set the size here. The pictures fill its height. */
className?: string;
};
type CardProps = {
image: PerspectiveGalleryItem;
index: number;
front: boolean;
place: MotionValue<number>;
tilt: number;
onPick: (index: number) => void;
};
const SETTLE = { type: "spring", stiffness: 180, damping: 24 } as const;
const clamp = (v: number, low: number, high: number) => Math.max(low, Math.min(high, v));
function Card({ image, index, front, place, tilt, onPick }: CardProps) {
const transform = useTransform(place, (p) => {
const away = index - p;
const side = clamp(away, -1, 1);
return `translateX(${away * 34 + side * 44}%) translateZ(${-Math.abs(side) * 160 - Math.abs(away) * 40}px) rotateY(${-side * tilt}deg)`;
});
const zIndex = useTransform(place, (p) => 100 - Math.round(Math.abs(index - p) * 10));
const opacity = useTransform(place, (p) => clamp(3.5 - Math.abs(index - p), 0, 1));
return (
<motion.div
onClick={() => onPick(index)}
style={{ transform, zIndex, opacity }}
aria-hidden={!front}
className={`absolute inset-y-0 left-[27%] w-[46%] overflow-hidden rounded-2xl shadow-xl shadow-black/50 ${front ? "" : "cursor-pointer"}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.src} alt={front ? image.alt : ""} draggable={false} className="pointer-events-none size-full object-cover" />
</motion.div>
);
}
/* PerspectiveGallery: a row of pictures in 3D. One faces you and the rest turn away. Drag, click a side picture, or use the arrow keys. */
export function PerspectiveGallery({ images, tilt = 52, label = "Picture gallery", className = "h-72 w-full" }: PerspectiveGalleryProps) {
const reduce = useReducedMotion();
const last = images.length - 1;
const [now, setNow] = useState(() => Math.floor(last / 2));
const place = useMotionValue(now);
const box = useRef<HTMLDivElement>(null);
const dragged = useRef(false);
const go = (index: number) => {
const to = clamp(index, 0, last);
setNow(to);
if (reduce) place.set(to);
else animate(place, to, SETTLE);
};
const pan = (_: unknown, info: PanInfo) => {
const step = (box.current?.offsetWidth ?? 600) * 0.46;
place.set(clamp(place.get() - info.delta.x / step, -0.3, last + 0.3));
};
const pick = (index: number) => {
if (!dragged.current) go(index);
};
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "ArrowRight") go(now + 1);
else if (e.key === "ArrowLeft") go(now - 1);
else return;
e.preventDefault();
};
return (
<motion.div
ref={box}
role="group"
aria-label={`${label}. Left and right arrow keys change the picture.`}
tabIndex={0}
onKeyDown={onKey}
onPointerDownCapture={() => (dragged.current = false)}
onPanStart={() => {
dragged.current = true;
place.stop();
}}
onPan={pan}
onPanEnd={(_, info) => go(Math.round(place.get() - info.velocity.x / 1500))}
style={{ perspective: 1000 }}
className={`relative touch-pan-y select-none overflow-hidden outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${className}`}
>
{images.map((image, i) => (
<Card key={i} image={image} index={i} front={i === now} place={place} tilt={tilt} onPick={pick} />
))}
<span role="status" className="sr-only">
Picture {now + 1} of {images.length}: {images[now].alt}
</span>
</motion.div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/perspective-gallery.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/perspective-gallery-demo.tsx
import { PerspectiveGallery } from "@/components/ui/perspective-gallery"; const IMAGES = [ { src: "/demo/dawn.jpg", alt: "Teal hills at dawn" }, { src: "/demo/ember.jpg", alt: "Red hills under a burning sky" }, { src: "/demo/dusk.jpg", alt: "Hills at dusk under a pale sun" }, { src: "/demo/moss.jpg", alt: "Green hills in soft light" }, { src: "/demo/tide.jpg", alt: "Blue hills under a cold moon" }, ]; export default function PerspectiveGalleryExample() { return ( <div className="grid h-full place-items-center overflow-hidden bg-[#0b0a14] p-6"> <PerspectiveGallery images={IMAGES} label="Hills in five colours" className="h-[210px] w-full max-w-[640px]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| images | PerspectiveGalleryItem[] | — | Two or more pictures, each with src and alt. The middle one starts in front. |
| tilt | number | 52 | How far the side pictures turn away, in degrees. |
| label | string | "Picture gallery" | Read by screen readers. |
| className | string | "h-72 w-full" | Set the size here. The pictures fill its height. |