Image Effects
ImageStack
A loose pile of pictures. Throw the top one aside, click it, or use the arrow keys, and it slides back under the pile.
Download
- React
- Tailwind CSS v4
- motion
components/ui/image-stack.tsx
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform, type PanInfo } from "motion/react";
export type ImageStackItem = { src: string; alt: string };
type ImageStackProps = {
/** Two or more pictures. The first is on top. */
images: ImageStackItem[];
/** Name read out by screen readers. */
label?: string;
/** Set the size here. The cards fill this box. */
className?: string;
};
type CardProps = {
image: ImageStackItem;
depth: number;
count: number;
index: number;
flick: number;
reduce: boolean;
onThrow: (dir: number) => void;
onLand: () => void;
};
const TILT = [-3, 4, -5, 2, -4, 5];
const SETTLE = { type: "spring", stiffness: 260, damping: 26 } as const;
function Card({ image, depth, count, index, flick, reduce, onThrow, onLand }: CardProps) {
const ref = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
const turn = useTransform(x, [-300, 300], [-14, 14]);
const top = depth === 0;
useEffect(() => {
const width = ref.current?.offsetWidth;
if (!top || !flick || !width) return;
let live = true;
const out = animate(x, flick * width * 1.05, { duration: 0.24, ease: "easeOut" });
out.then(() => {
if (!live) return;
onLand();
animate(x, 0, SETTLE);
});
return () => {
live = false;
out.stop();
};
}, [top, flick, x, onLand]);
const release = (_: unknown, info: PanInfo) => {
const width = ref.current?.offsetWidth ?? 300;
if (Math.abs(info.offset.x) < width * 0.3 && Math.abs(info.velocity.x) < 500) {
animate(x, 0, SETTLE);
return;
}
if (reduce) x.set(0);
onThrow(info.offset.x < 0 ? -1 : 1);
};
return (
<motion.div
className="absolute inset-0"
style={{ zIndex: count - depth }}
initial={false}
animate={{ y: depth * 10, scale: 1 - depth * 0.05, rotate: top ? 0 : TILT[index % TILT.length], opacity: depth < 4 ? 1 : 0 }}
transition={reduce ? { duration: 0 } : SETTLE}
aria-hidden={!top}
>
<motion.div
ref={ref}
drag={top ? "x" : false}
dragMomentum={false}
onDragEnd={release}
onTap={top ? () => Math.abs(x.get()) < 4 && onThrow(1) : undefined}
style={{ x, rotate: turn }}
className={`size-full touch-pan-y overflow-hidden rounded-2xl shadow-xl shadow-black/40 ${top ? "cursor-grab active:cursor-grabbing" : ""}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.src} alt={top ? image.alt : ""} draggable={false} className="pointer-events-none size-full select-none object-cover" />
</motion.div>
</motion.div>
);
}
/* ImageStack: a loose pile of pictures. Throw the top one aside, click it, or use the arrow keys, and it slides back under the pile. */
export function ImageStack({ images, label = "Stack of pictures", className = "aspect-[4/5] w-64" }: ImageStackProps) {
const reduce = useReducedMotion() === true;
const [order, setOrder] = useState(() => images.map((_, i) => i));
const [flick, setFlick] = useState(0);
const land = useCallback(() => {
setOrder(([first, ...rest]) => [...rest, first]);
setFlick(0);
}, []);
const next = (dir: number) => {
if (flick) return;
if (reduce) land();
else setFlick(dir);
};
const back = () => {
if (flick) return;
setOrder((now) => [now[now.length - 1], ...now.slice(0, -1)]);
};
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "ArrowRight") next(1);
else if (e.key === "ArrowLeft") back();
else return;
e.preventDefault();
};
return (
<div
role="group"
aria-label={`${label}. Left and right arrow keys change the picture.`}
tabIndex={0}
onKeyDown={onKey}
className={`relative rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-8 focus-visible:ring-offset-black ${className}`}
>
{images.map((image, i) => (
<Card key={i} image={image} index={i} depth={order.indexOf(i)} count={images.length} flick={flick} reduce={reduce} onThrow={next} onLand={land} />
))}
<span role="status" className="sr-only">
Picture {order[0] + 1} of {images.length}: {images[order[0]].alt}
</span>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/image-stack.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/image-stack-demo.tsx
import { ImageStack } from "@/components/ui/image-stack"; const IMAGES = [ { src: "/demo/dawn.jpg", alt: "Teal hills at dawn" }, { src: "/demo/ember.jpg", alt: "Red hills under a burning sky" }, { src: "/demo/moss.jpg", alt: "Green hills in soft light" }, { src: "/demo/tide.jpg", alt: "Blue hills under a cold moon" }, ]; export default function ImageStackExample() { return ( <div className="grid h-full place-items-center overflow-hidden bg-[#0b0a14] p-8"> <ImageStack images={IMAGES} label="Hills in four colours" className="aspect-[4/5] h-full max-h-[280px]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| images | ImageStackItem[] | — | Two or more pictures, each with src and alt. The first is on top. |
| label | string | "Stack of pictures" | Read by screen readers. |
| className | string | "aspect-[4/5] w-64" | Set the size here. The cards fill this box. |