Drag Reveal Effect
Original · freeDrag a divider to reveal a second image.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/imgfx-drag-reveal.json"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type {
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
export default function ImgfxDragReveal() {
const reduceMotion = useReducedMotion();
const containerRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef(false);
const [pos, setPos] = useState(55);
const setFromClientX = useCallback((clientX: number) => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const next = ((clientX - rect.left) / rect.width) * 100;
setPos(Math.max(0, Math.min(100, next)));
}, []);
const onPointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
draggingRef.current = true;
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
setFromClientX(e.clientX);
},
[setFromClientX],
);
const onPointerMove = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (!draggingRef.current) return;
setFromClientX(e.clientX);
},
[setFromClientX],
);
const onPointerUp = useCallback(() => {
draggingRef.current = false;
}, []);
const onKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
setPos((p) => Math.max(0, p - 4));
} else if (e.key === "ArrowRight") {
e.preventDefault();
setPos((p) => Math.min(100, p + 4));
} else if (e.key === "Home") {
e.preventDefault();
setPos(0);
} else if (e.key === "End") {
e.preventDefault();
setPos(100);
}
}, []);
useEffect(() => {
const stop = () => {
draggingRef.current = false;
};
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
return () => {
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
}, []);
return (
<section className="relative w-full overflow-hidden bg-neutral-50 px-6 py-20 sm:py-28 dark:bg-neutral-950">
<style>{`
@keyframes idr_nudge {
0%, 100% { transform: translateX(-50%); }
50% { transform: translateX(calc(-50% + 6px)); }
}
.idr-nudge { animation: idr_nudge 2.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.idr-nudge { animation: none; }
}
`}</style>
<div className="mx-auto max-w-3xl">
<motion.div
initial={reduceMotion ? false : { opacity: 0, y: 16 }}
whileInView={reduceMotion ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Before / After
</p>
<h2 className="mt-2 text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl dark:text-neutral-50">
Drag to Reveal
</h2>
<p className="mt-3 max-w-xl text-sm text-neutral-600 dark:text-neutral-400">
One photograph, two treatments. Slide the divider to compare the
graded edit against the untouched original.
</p>
</motion.div>
<motion.div
initial={reduceMotion ? false : { opacity: 0, y: 24 }}
whileInView={reduceMotion ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1], delay: 0.08 }}
className="mt-8"
>
<div
ref={containerRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
className="group relative aspect-[4/3] w-full touch-none select-none overflow-hidden rounded-2xl bg-neutral-200 shadow-xl ring-1 ring-neutral-900/10 dark:bg-neutral-800 dark:ring-white/10"
>
{/* Bottom image: the "after" / graded version */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/img/gallery/g14.webp"
alt="Graded, color-corrected edit of the coastal scene"
loading="lazy"
draggable={false}
className="absolute inset-0 h-full w-full object-cover"
/>
<span className="pointer-events-none absolute right-3 top-3 rounded-full bg-neutral-900/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-white backdrop-blur">
After
</span>
{/* Top image: the "before" / original, clipped from the left */}
<div
className="absolute inset-0 overflow-hidden will-change-[clip-path]"
style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/img/gallery/g07.webp"
alt="Original, unedited version of the coastal scene"
loading="lazy"
draggable={false}
className="absolute inset-0 h-full w-full object-cover [filter:saturate(0.55)_contrast(0.92)_brightness(1.02)]"
/>
<span className="pointer-events-none absolute left-3 top-3 rounded-full bg-white/80 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-neutral-900 backdrop-blur">
Before
</span>
</div>
{/* Divider + handle */}
<div
role="slider"
tabIndex={0}
aria-label="Reveal amount"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(pos)}
aria-valuetext={`${Math.round(pos)}% revealed`}
onKeyDown={onKeyDown}
className="absolute inset-y-0 z-10 -ml-5 flex w-10 cursor-ew-resize items-center justify-center focus:outline-none"
style={{ left: `${pos}%` }}
>
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.15)]" />
<div className="idr-nudge relative flex h-11 w-11 -translate-x-1/2 items-center justify-center rounded-full bg-white text-neutral-900 shadow-lg ring-1 ring-neutral-900/10 transition-transform group-hover:scale-105 group-focus-within:ring-2 group-focus-within:ring-indigo-500 dark:bg-neutral-100">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m9 7-5 5 5 5" />
<path d="m15 7 5 5-5 5" />
</svg>
</div>
</div>
</div>
<p className="mt-4 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m9 7-5 5 5 5" />
<path d="m15 7 5 5-5 5" />
</svg>
Drag the handle, or focus it and use the arrow keys.
</p>
</motion.div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Hover Zoom Effect
OriginalImage zooms in smoothly inside a fixed rounded frame on hover.

Hover Zoom Out Effect
OriginalImage starts zoomed and settles to normal on hover.

Zoom Rotate Effect
OriginalImage zooms and rotates slightly on hover.

Grayscale Color Effect
OriginalGreyscale image turns to full colour on hover.

Color Grayscale Effect
OriginalFull-colour image fades to greyscale on hover.

Blur Sharp Effect
OriginalBlurred image sharpens on hover.

Sharp Blur Effect
OriginalSharp image blurs with a caption appearing on hover.

Sepia Clear Effect
OriginalSepia-toned image clears to full colour on hover.

Saturate Pop Effect
OriginalMuted image saturates and pops on hover.

Contrast Pop Effect
OriginalLow-contrast image gains punchy contrast on hover.

Brightness Lift Effect
OriginalDim image brightens on hover.

Hue Rotate Effect
OriginalImage hue-shifts through colours on hover.

