Before After Slider

Drag handle to compare two views with a smooth wipe reveal

interactiveslidercomparebefore-afterdrag
Preview

Source Code

"use client";

import { useRef, useState, useCallback, useEffect, Children } from "react";

interface BeforeAfterSliderProps {
  children: React.ReactNode;
  initialPosition?: number;
  handleColor?: string;
  className?: string;
}

export default function BeforeAfterSlider({
  children,
  initialPosition = 50,
  handleColor = "#fff",
  className = "",
}: BeforeAfterSliderProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState(initialPosition);
  const [isDragging, setIsDragging] = useState(false);

  const childArray = Children.toArray(children);
  const beforeContent = childArray[0];
  const afterContent = childArray[1];

  const updatePosition = useCallback(
    (clientX: number) => {
      if (!containerRef.current) return;
      const rect = containerRef.current.getBoundingClientRect();
      const x = clientX - rect.left;
      const percent = Math.max(0, Math.min(100, (x / rect.width) * 100));
      setPosition(percent);
    },
    []
  );

  useEffect(() => {
    if (!isDragging) return;

    const handleMove = (e: MouseEvent) => {
      e.preventDefault();
      updatePosition(e.clientX);
    };
    const handleTouchMove = (e: TouchEvent) => {
      updatePosition(e.touches[0].clientX);
    };
    const handleUp = () => setIsDragging(false);

    window.addEventListener("mousemove", handleMove);
    window.addEventListener("mouseup", handleUp);
    window.addEventListener("touchmove", handleTouchMove);
    window.addEventListener("touchend", handleUp);
    return () => {
      window.removeEventListener("mousemove", handleMove);
      window.removeEventListener("mouseup", handleUp);
      window.removeEventListener("touchmove", handleTouchMove);
      window.removeEventListener("touchend", handleUp);
    };
  }, [isDragging, updatePosition]);

  return (
    <div
      ref={containerRef}
      className={`relative select-none overflow-hidden rounded-xl ${className}`}
      style={{ cursor: isDragging ? "ew-resize" : "default" }}
    >
      {/* After layer (full width, behind) */}
      <div className="relative w-full">
        {afterContent}
      </div>

      {/* Before layer (clipped) */}
      <div
        className="absolute inset-0 overflow-hidden"
        style={{ width: `${position}%` }}
      >
        <div
          className="h-full"
          style={{
            width: containerRef.current
              ? `${containerRef.current.clientWidth}px`
              : "100vw",
          }}
        >
          {beforeContent}
        </div>
      </div>

      {/* Handle */}
      <div
        className="absolute inset-y-0 z-10"
        style={{ left: `${position}%`, transform: "translateX(-50%)" }}
      >
        {/* Vertical line */}
        <div
          className="h-full w-[2px]"
          style={{ backgroundColor: handleColor }}
        />

        {/* Drag handle circle */}
        <div
          className="absolute top-1/2 left-1/2 flex h-10 w-10 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize items-center justify-center rounded-full border-2 bg-gray-950/60 backdrop-blur-sm"
          style={{ borderColor: handleColor }}
          onMouseDown={(e) => {
            e.preventDefault();
            setIsDragging(true);
          }}
          onTouchStart={() => setIsDragging(true)}
        >
          <svg
            width="16"
            height="16"
            viewBox="0 0 24 24"
            fill="none"
            stroke={handleColor}
            strokeWidth="2.5"
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <path d="M18 8l4 4-4 4" />
            <path d="M6 8l-4 4 4 4" />
          </svg>
        </div>
      </div>
    </div>
  );
}