Image Reveals · Starter
Before / After Reveal
The top image is clipped with clip-path inset driven by a pointer position or a native range input.
Live preview
Drag across the image, or use the slider below it.
Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
* Before / After
* A keyboard-accessible comparison between monochrome and colour.
* Requires: motion, Tailwind with shadcn semantic tokens.
*/
import { useState } from "react";
import { useReducedMotion } from "motion/react";
export default function BeforeAfter({
reduced: reducedProp,
intensity = 1,
}: {
reduced?: boolean;
intensity?: number;
}) {
const systemReduced = useReducedMotion();
const reduced = reducedProp ?? systemReduced ?? false;
const amount = Math.min(1.5, Math.max(0.5, intensity));
const [position, setPosition] = useState(50);
const fromPointer = (el: HTMLElement, clientX: number) => {
const rect = el.getBoundingClientRect();
setPosition(Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)));
};
return (
<section className="flex min-h-[80vh] items-center justify-center bg-background px-6 py-20 text-foreground">
<div className="w-full max-w-5xl">
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
A change in perspective / 07
</p>
<h2 className="mb-8 mt-5 font-serif text-5xl tracking-tight">Same scene. New feeling.</h2>
<div
className="relative aspect-[4/3] w-full touch-pan-y select-none overflow-hidden rounded-2xl border border-border sm:aspect-[16/10]"
onPointerDown={(event) => {
if (event.pointerType !== "mouse" || event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
fromPointer(event.currentTarget, event.clientX);
}}
onPointerMove={(event) => {
if (
event.pointerType !== "mouse" ||
!event.currentTarget.hasPointerCapture(event.pointerId)
)
return;
fromPointer(event.currentTarget, event.clientX);
}}
onPointerUp={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId))
event.currentTarget.releasePointerCapture(event.pointerId);
}}
>
<img
src="https://picsum.photos/seed/ardent-interior/1600/1000"
alt="Colour photograph used as the finished comparison."
draggable={false}
className="absolute inset-0 h-full w-full object-cover"
/>
<img
src="https://picsum.photos/seed/ardent-interior/1600/1000"
alt="The same photograph presented in monochrome."
draggable={false}
className="absolute inset-0 h-full w-full object-cover"
style={{
clipPath: `inset(0 ${100 - position}% 0 0)`,
filter: `grayscale(1) contrast(${reduced ? 1 : 1 + 0.15 * amount})`,
}}
/>
<span className="absolute left-4 top-4 rounded-full bg-background px-4 py-2 text-sm">
Before
</span>
<span className="absolute right-4 top-4 rounded-full bg-background px-4 py-2 text-sm">
After
</span>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 w-px bg-background"
style={{ left: `${position}%` }}
>
<span className="absolute top-1/2 grid h-12 w-12 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-border bg-background font-mono text-xs text-foreground">
||
</span>
</div>
</div>
<label className="mt-6 flex flex-wrap items-center gap-4">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
Comparison / {Math.round(position)}%
</span>
<input
type="range"
min={0}
max={100}
value={position}
onChange={(event) => setPosition(Number(event.target.value))}
aria-label="Percentage of monochrome photograph revealed"
aria-valuetext={`${Math.round(position)} percent monochrome`}
className="min-w-40 flex-1 accent-accent"
/>
</label>
</div>
</section>
);
}
Next patternHover Image Distortion→