Fazekit

Code

"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from "motion/react";
import { ChevronsLeftRight } from "lucide-react";
import { cn } from "@/lib/utils";

export interface ImageCompareProps {
  /** Left / "before" layer. Any node: SVG, HTML mockup, <img>… */
  before: React.ReactNode;
  /** Right / "after" layer. */
  after: React.ReactNode;
  beforeLabel?: string;
  afterLabel?: string;
  /** Start position in percent. */
  initial?: number;
  /** Keyboard step in percent (Shift = ×5). */
  step?: number;
  /** Play a small left-right nudge the first time it mounts, hinting that it can be dragged. */
  hint?: boolean;
  /** Accessible name for the slider. */
  label?: string;
  onChange?: (value: number) => void;
  className?: string;
}

const clamp = (v: number) => Math.min(100, Math.max(0, v));

export function ImageCompare({
  before,
  after,
  beforeLabel = "Before",
  afterLabel = "After",
  initial = 50,
  step = 2,
  hint = true,
  label = "Comparison position",
  onChange,
  className,
}: ImageCompareProps) {
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLDivElement>(null);
  const pos = useMotionValue(clamp(initial));
  const [value, setValue] = React.useState(clamp(initial));
  const [dragging, setDragging] = React.useState(false);

  useMotionValueEvent(pos, "change", (v) => {
    const rounded = Math.round(v);
    setValue((prev) => (prev === rounded ? prev : rounded));
    onChange?.(v);
  });

  const clip = useTransform(pos, (v) => `inset(0 ${100 - v}% 0 0)`);
  const left = useTransform(pos, (v) => `${v}%`);
  const beforeTagOpacity = useTransform(pos, [0, 18], [0, 1]);
  const afterTagOpacity = useTransform(pos, [82, 100], [1, 0]);

  React.useEffect(() => {
    if (!hint || reduce) return;
    const start = pos.get();
    const controls = animate(pos, [start, start + 12, start - 10, start], { duration: 1.6, delay: 0.6, ease: "easeInOut" });
    return () => controls.stop();
  }, [hint, reduce, pos]);

  const fromClientX = (clientX: number) => {
    const r = ref.current?.getBoundingClientRect();
    if (!r) return pos.get();
    return clamp(((clientX - r.left) / r.width) * 100);
  };

  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (e.button !== 0) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    setDragging(true);
    pos.stop();
    // Tap to jump (animated), drag to follow.
    animate(pos, fromClientX(e.clientX), reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 40 });
  };
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!dragging) return;
    pos.stop();
    pos.set(fromClientX(e.clientX));
  };
  const end = () => setDragging(false);

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const s = e.shiftKey ? step * 5 : step;
    const map: Record<string, number> = {
      ArrowLeft: pos.get() - s,
      ArrowDown: pos.get() - s,
      ArrowRight: pos.get() + s,
      ArrowUp: pos.get() + s,
      PageDown: pos.get() - 10,
      PageUp: pos.get() + 10,
      Home: 0,
      End: 100,
    };
    if (!(e.key in map)) return;
    e.preventDefault();
    animate(pos, clamp(map[e.key]), reduce ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 45 });
  };

  return (
    <div
      ref={ref}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={end}
      onPointerCancel={end}
      className={cn(
        "relative w-full select-none overflow-hidden rounded-2xl border bg-muted [touch-action:pan-y]",
        dragging ? "cursor-grabbing" : "cursor-ew-resize",
        className,
      )}
    >
      <div className="relative h-full w-full">{after}</div>
      <motion.div aria-hidden={value < 1} className="absolute inset-0" style={{ clipPath: clip }}>
        {before}
      </motion.div>

      <motion.span
        style={{ opacity: beforeTagOpacity }}
        className="pointer-events-none absolute bottom-3 left-3 rounded-full bg-black/60 px-2.5 py-1 text-xs font-medium text-white backdrop-blur"
      >
        {beforeLabel}
      </motion.span>
      <motion.span
        style={{ opacity: afterTagOpacity }}
        className="pointer-events-none absolute bottom-3 right-3 rounded-full bg-black/60 px-2.5 py-1 text-xs font-medium text-white backdrop-blur"
      >
        {afterLabel}
      </motion.span>

      <motion.div className="pointer-events-none absolute inset-y-0 -ml-px w-0.5 bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.12),0_0_12px_rgba(0,0,0,0.35)]" style={{ left }}>
        <div
          role="slider"
          tabIndex={0}
          aria-label={label}
          aria-valuemin={0}
          aria-valuemax={100}
          aria-valuenow={value}
          aria-valuetext={`${value}% ${beforeLabel.toLowerCase()}`}
          onKeyDown={onKeyDown}
          className={cn(
            "pointer-events-auto absolute left-1/2 top-1/2 grid size-10 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-black/10 bg-white text-slate-900 shadow-lg outline-none transition-transform",
            "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
            dragging && "scale-110",
          )}
        >
          <ChevronsLeftRight className="size-5" />
        </div>
      </motion.div>
    </div>
  );
}

More in Data Display

View all →