Web InnoventixFreeCode

Before After Effect

Original · free

A before/after slider comparing two treatments of a photo.

byWeb InnoventixReact + Tailwind
beforeafterimageeffect
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-before-after.json
imgfx-before-after.tsx
"use client";

import { useId, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

export default function ImgfxBeforeAfter() {
  const reduceMotion = useReducedMotion();
  const [pos, setPos] = useState(50);
  const [dragging, setDragging] = useState(false);
  const labelId = useId();
  const frameRef = useRef<HTMLDivElement>(null);

  const setFromClientX = (clientX: number) => {
    const el = frameRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const raw = ((clientX - rect.left) / rect.width) * 100;
    setPos(Math.min(100, Math.max(0, raw)));
  };

  return (
    <section className="relative w-full overflow-hidden bg-zinc-50 px-6 py-20 text-zinc-900 sm:px-10 sm:py-28 dark:bg-zinc-950 dark:text-zinc-50">
      <style>{`
        @keyframes baslide-hint {
          0%, 100% { transform: translateX(-50%) scale(1); }
          50% { transform: translateX(-50%) scale(1.12); }
        }
        .baslide-pulse {
          animation: baslide-hint 2.6s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .baslide-pulse { animation: none; }
        }
      `}</style>

      <div className="mx-auto max-w-4xl">
        <motion.div
          initial={reduceMotion ? false : { opacity: 0, y: 16 }}
          whileInView={reduceMotion ? undefined : { opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-80px" }}
          transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
          className="mb-8 text-center"
        >
          <p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
            Before / After
          </p>
          <h2 id={labelId} className="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl">
            Slide to compare the color grade
          </h2>
          <p className="mx-auto mt-3 max-w-md text-sm text-zinc-600 dark:text-zinc-400">
            Drag the handle, or focus it and use the arrow keys, to reveal the graded treatment beneath the raw original.
          </p>
        </motion.div>

        <motion.div
          initial={reduceMotion ? false : { opacity: 0, scale: 0.98 }}
          whileInView={reduceMotion ? undefined : { opacity: 1, scale: 1 }}
          viewport={{ once: true, margin: "-80px" }}
          transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1], delay: 0.05 }}
          className="relative"
        >
          <div
            ref={frameRef}
            className="relative aspect-[4/3] w-full select-none overflow-hidden rounded-2xl bg-zinc-200 shadow-xl ring-1 ring-zinc-900/10 dark:bg-zinc-800 dark:ring-white/10"
            onPointerDown={(e) => {
              (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
              setDragging(true);
              setFromClientX(e.clientX);
            }}
            onPointerMove={(e) => {
              if (dragging) setFromClientX(e.clientX);
            }}
            onPointerUp={() => setDragging(false)}
            onPointerCancel={() => setDragging(false)}
          >
            {/* AFTER: graded treatment sits as the base layer */}
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src="/img/gallery/g07.webp"
              alt="Graded treatment of the photograph with rich, saturated color"
              loading="lazy"
              draggable={false}
              className="pointer-events-none absolute inset-0 h-full w-full object-cover"
              style={{ filter: "saturate(1.35) contrast(1.08) brightness(1.02)" }}
            />

            {/* BEFORE: raw treatment, clipped from the right by the slider */}
            <div
              className="absolute inset-0 overflow-hidden"
              style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }}
            >
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src="/img/gallery/g07.webp"
                alt="Raw, ungraded original of the same photograph in muted tones"
                loading="lazy"
                draggable={false}
                className="pointer-events-none absolute inset-0 h-full w-full object-cover"
                style={{ filter: "saturate(0.35) contrast(0.92) brightness(0.98) sepia(0.15)" }}
              />
            </div>

            {/* Corner labels */}
            <span className="pointer-events-none absolute left-3 top-3 rounded-full bg-zinc-900/70 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-white backdrop-blur-sm">
              Before
            </span>
            <span className="pointer-events-none absolute right-3 top-3 rounded-full bg-indigo-600/85 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-white backdrop-blur-sm">
              After
            </span>

            {/* Divider line + handle */}
            <div
              className="pointer-events-none absolute inset-y-0 z-10 w-px bg-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.15)]"
              style={{ left: `${pos}%` }}
            >
              <div
                className={`absolute top-1/2 left-1/2 flex h-11 w-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white text-zinc-900 shadow-lg ring-1 ring-zinc-900/10 ${
                  dragging ? "" : "baslide-pulse"
                }`}
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" aria-hidden="true">
                  <path d="M9 6 4 12l5 6M15 6l5 6-5 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </div>
            </div>

            {/* Accessible range control layered on top */}
            <input
              type="range"
              min={0}
              max={100}
              step={0.5}
              value={pos}
              aria-labelledby={labelId}
              aria-valuetext={`${Math.round(pos)}% graded`}
              onChange={(e) => setPos(Number(e.target.value))}
              className="absolute inset-0 z-20 h-full w-full cursor-ew-resize opacity-0"
            />
          </div>

          <p className="mt-4 text-center text-xs text-zinc-500 dark:text-zinc-500">
            {Math.round(pos)}% graded &middot; original photography, two grades, one frame
          </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 →