Web InnoventixFreeCode

Drag Reveal Effect

Original · free

Drag a divider to reveal a second image.

byWeb InnoventixReact + Tailwind
dragrevealimageeffect
Open

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
imgfx-drag-reveal.tsx
"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

motion

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 quote

Similar components

Browse all →