Skip to content
ARDENT / MOTION LAB
Library

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→