Image Effects
MaskTransition
Click the picture, or use the arrow keys, and the next one opens through a shape over the old one. Circle and diamond open from the click point.
Download
- React
- Tailwind CSS v4
- motion
components/ui/mask-transition.tsx
"use client";
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";
export type MaskTransitionItem = { src: string; alt: string };
type MaskTransitionShape = "circle" | "diamond" | "wipe";
type MaskTransitionProps = {
/** Two or more pictures. */
images: MaskTransitionItem[];
/** Shape of the opening. Circle and diamond open from the click point. */
shape?: MaskTransitionShape;
/** Seconds one change takes. */
duration?: number;
/** Name read out by screen readers. */
label?: string;
/** Set the size here. */
className?: string;
};
const mask = (shape: MaskTransitionShape, x: number, y: number, open: boolean) => {
if (shape === "wipe") return `inset(0% ${open ? 0 : 100}% 0% 0%)`;
if (shape === "circle") return `circle(${open ? 150 : 0}% at ${x}% ${y}%)`;
const r = open ? 200 : 0;
return `polygon(${x}% ${y - r}%, ${x + r}% ${y}%, ${x}% ${y + r}%, ${x - r}% ${y}%)`;
};
/* MaskTransition: click the picture, or use the arrow keys, and the next one opens through a shape over the old one. */
export function MaskTransition({ images, shape = "circle", duration = 0.9, label = "Pictures", className = "aspect-video" }: MaskTransitionProps) {
const reduce = useReducedMotion();
const [view, setView] = useState({ now: 0, before: 0, x: 50, y: 50, run: 0 });
const go = (dir: number, x = 50, y = 50) => {
setView(({ now, run }) => ({ now: (now + dir + images.length) % images.length, before: now, x, y, run: run + 1 }));
};
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
go(1, ((e.clientX - r.left) / r.width) * 100, ((e.clientY - r.top) / r.height) * 100);
};
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "ArrowRight") go(1);
else if (e.key === "ArrowLeft") go(-1);
else return;
e.preventDefault();
};
return (
<div
role="group"
aria-label={`${label}. Left and right arrow keys change the picture.`}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
className={`relative cursor-pointer select-none overflow-hidden outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${className}`}
>
{/* eslint-disable @next/next/no-img-element */}
{images.map((image, i) => (
<img key={image.src} src={image.src} alt="" draggable={false} className={`absolute inset-0 size-full object-cover ${i === view.before ? "" : "opacity-0"}`} />
))}
<motion.img
key={view.run}
src={images[view.now].src}
alt={images[view.now].alt}
draggable={false}
className="absolute inset-0 size-full object-cover"
initial={view.run ? { clipPath: mask(shape, view.x, view.y, false) } : false}
animate={{ clipPath: mask(shape, view.x, view.y, true) }}
transition={{ duration: reduce ? 0 : duration, ease: [0.76, 0, 0.24, 1] }}
/>
{/* eslint-enable @next/next/no-img-element */}
<span role="status" className="sr-only">
Picture {view.now + 1} of {images.length}: {images[view.now].alt}
</span>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/mask-transition.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/mask-transition-demo.tsx
"use client"; import { useState } from "react"; import { MaskTransition } from "@/components/ui/mask-transition"; 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" }, ]; const SHAPES = ["circle", "diamond", "wipe"] as const; export default function MaskTransitionExample() { const [shape, setShape] = useState<(typeof SHAPES)[number]>("circle"); return ( <div className="flex h-full flex-col items-center justify-center gap-3 bg-[#0b0a14] p-4"> <MaskTransition images={IMAGES} shape={shape} label="Hills in four colours" className="aspect-video min-h-0 w-full max-w-[460px] rounded-2xl" /> <div className="flex gap-1"> {SHAPES.map((s) => ( <button key={s} type="button" onClick={() => setShape(s)} aria-pressed={s === shape} className={`rounded-full px-3 py-1 text-sm capitalize ${s === shape ? "bg-white text-black" : "bg-white/10 text-white hover:bg-white/20"}`} > {s} </button> ))} </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| images | MaskTransitionItem[] | — | Two or more pictures, each with src and alt. |
| shape | "circle" | "diamond" | "wipe" | "circle" | Shape of the opening. |
| duration | number | 0.9 | Seconds one change takes. |
| label | string | "Pictures" | Read by screen readers. |
| className | string | "aspect-video" | Set the size here. |