All components

Compare Slider

Before/after wipe with a keyboard-accessible divider.

1.1 KB gzippedZero dependenciesReduced-motion safeBase UI & Radix compatible
After
Before

Installation

npx shadcn@latest add https://innsite.com.mx/r/compare-slider.json

Source

"use client";

import { useRef, useState } from "react";

import { cn } from "@/lib/utils";

interface CompareSliderProps {
  before: React.ReactNode;
  after: React.ReactNode;
  /** Starting divider position, 0–100 */
  initial?: number;
  /** Announced by the slider handle */
  label?: string;
  className?: string;
}

/**
 * Before/after wipe with a draggable divider. The handle is a real range
 * input, so it works with arrow keys and screen readers for free.
 */
export function CompareSlider({
  before,
  after,
  initial = 50,
  label = "Compare before and after",
  className,
}: CompareSliderProps) {
  const [position, setPosition] = useState(initial);
  const ref = useRef<HTMLDivElement>(null);

  const moveTo = (clientX: number) => {
    const rect = ref.current?.getBoundingClientRect();
    if (!rect) return;
    const next = ((clientX - rect.left) / rect.width) * 100;
    setPosition(Math.min(100, Math.max(0, next)));
  };

  return (
    <div
      ref={ref}
      data-slot="compare-slider"
      onPointerMove={(event) => {
        if (event.buttons === 1) moveTo(event.clientX);
      }}
      className={cn(
        "relative aspect-video w-full touch-none overflow-hidden rounded-2xl border select-none",
        className
      )}
    >
      <div className="absolute inset-0">{after}</div>
      <div
        className="absolute inset-0"
        style={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}
      >
        {before}
      </div>

      <div
        aria-hidden
        style={{ left: `${position}%` }}
        className="absolute inset-y-0 w-px -translate-x-1/2 bg-white/90 shadow-[0_0_12px_rgba(0,0,0,0.35)]"
      >
        <span className="absolute top-1/2 left-1/2 grid size-9 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border-2 border-white bg-white/20 backdrop-blur-sm">
          <svg
            width="16"
            height="16"
            viewBox="0 0 16 16"
            fill="none"
            stroke="white"
            strokeWidth="1.75"
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <path d="M6 4L2.5 8 6 12M10 4l3.5 4-3.5 4" />
          </svg>
        </span>
      </div>

      <input
        type="range"
        min={0}
        max={100}
        value={position}
        aria-label={label}
        onChange={(event) => setPosition(Number(event.target.value))}
        className="absolute inset-0 h-full w-full cursor-ew-resize opacity-0"
      />
    </div>
  );
}