Layout

Comparison Slider

Before/after comparison where a captured drag handle drives a clip-path inset over the top layer, with full keyboard slider semantics.

Install

npx shadcn@latest add @paragon/comparison-slider

comparison-slider.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface ComparisonSliderProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  /** Content shown left of the handle (the top, clipped layer). */
  before: React.ReactNode;
  /** Content shown right of the handle (the base layer). */
  after: React.ReactNode;
  /** Initial handle position, 0–100. */
  defaultPosition?: number;
  /** Fires with the position (0–100) as the handle moves. */
  onPositionChange?: (position: number) => void;
  /** Optional corner chips labelling each side. */
  beforeLabel?: React.ReactNode;
  afterLabel?: React.ReactNode;
  /** Accessible name for the slider handle. */
  label?: string;
  /** Disables the eased keyboard/tap glide; position still updates. */
  static?: boolean;
}

/**
 * Before/after comparison. Both layers stay in the DOM; the top layer is
 * clipped with a `clip-path` inset driven by one registered custom property,
 * so dragging animates compositor-only work with zero extra DOM. Pointer
 * drags are captured and single-touch guarded; the handle is a real slider —
 * arrow keys move 5%, Home/End jump to the ends. Keyboard and tap moves
 * glide on a short eased transition (removed under prefers-reduced-motion);
 * drags track the pointer 1:1.
 */
export function ComparisonSlider({
  before,
  after,
  defaultPosition = 50,
  onPositionChange,
  beforeLabel,
  afterLabel,
  label = "Comparison",
  static: isStatic = false,
  className,
  style,
  ...props
}: ComparisonSliderProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const rootRef = React.useRef<HTMLDivElement>(null);
  const handleRef = React.useRef<HTMLDivElement>(null);
  const position = React.useRef(Math.min(100, Math.max(0, defaultPosition)));
  const activePointer = React.useRef<number | null>(null);

  const setPosition = React.useCallback(
    (next: number) => {
      const clamped = Math.min(100, Math.max(0, next));
      position.current = clamped;
      rootRef.current?.style.setProperty(`--cmp-pos-${id}`, `${clamped}%`);
      handleRef.current?.setAttribute("aria-valuenow", String(Math.round(clamped)));
      onPositionChange?.(clamped);
    },
    [id, onPositionChange],
  );

  const positionFromEvent = (event: React.PointerEvent) => {
    const rect = rootRef.current?.getBoundingClientRect();
    if (!rect || rect.width === 0) return position.current;
    return ((event.clientX - rect.left) / rect.width) * 100;
  };

  return (
    <>
      <style href={`paragon-comparison-slider-${id}`} precedence="paragon">{`
        @property --cmp-pos-${id} {
          syntax: "<percentage>";
          inherits: true;
          initial-value: 50%;
        }
        [data-cmp="${id}"] {
          transition: --cmp-pos-${id} 150ms var(--ease-out);
        }
        [data-cmp="${id}"][data-dragging] {
          transition: none;
        }
        @media (prefers-reduced-motion: reduce) {
          [data-cmp="${id}"] { transition: none; }
        }
      `}</style>
      <div
        ref={rootRef}
        data-cmp={id}
        data-slot="comparison-slider"
        data-static={isStatic || undefined}
        className={cn(
          "relative touch-none overflow-hidden rounded-xl select-none",
          isStatic && "transition-none",
          className,
        )}
        style={
          {
            [`--cmp-pos-${id}`]: `${position.current}%`,
            ...style,
          } as React.CSSProperties
        }
        onPointerDown={(event) => {
          if (activePointer.current !== null || event.button > 0) return;
          activePointer.current = event.pointerId;
          event.currentTarget.setPointerCapture(event.pointerId);
          event.currentTarget.setAttribute("data-dragging", "");
          setPosition(positionFromEvent(event));
        }}
        onPointerMove={(event) => {
          if (activePointer.current !== event.pointerId) return;
          setPosition(positionFromEvent(event));
        }}
        onPointerUp={(event) => {
          if (activePointer.current !== event.pointerId) return;
          activePointer.current = null;
          event.currentTarget.removeAttribute("data-dragging");
        }}
        onPointerCancel={(event) => {
          if (activePointer.current !== event.pointerId) return;
          activePointer.current = null;
          event.currentTarget.removeAttribute("data-dragging");
        }}
        {...props}
      >
        {/* Base layer — the "after" side. */}
        <div className="pointer-events-none">
          {after}
          {afterLabel != null && (
            <span className="absolute top-3 right-3 rounded-full bg-background/80 px-2 py-0.5 text-[11px] font-medium text-foreground shadow-border backdrop-blur-sm">
              {afterLabel}
            </span>
          )}
        </div>
        {/* Top layer — clipped to the left of the handle. */}
        <div
          className="pointer-events-none absolute inset-0"
          style={{
            clipPath: `inset(0 calc(100% - var(--cmp-pos-${id})) 0 0)`,
          }}
        >
          {before}
          {beforeLabel != null && (
            <span className="absolute top-3 left-3 rounded-full bg-background/80 px-2 py-0.5 text-[11px] font-medium text-foreground shadow-border backdrop-blur-sm">
              {beforeLabel}
            </span>
          )}
        </div>
        {/* Handle — a full-width layer translated so its right edge sits at
            the position; percentage translate is compositor-only. */}
        <div
          className="pointer-events-none absolute inset-0"
          style={{
            transform: `translate3d(calc(var(--cmp-pos-${id}) - 100%), 0, 0)`,
          }}
        >
          <div
            aria-hidden
            className="absolute inset-y-0 right-0 w-px translate-x-1/2 bg-background/90 shadow-[0_0_0_0.5px_rgba(0,0,0,0.2)]"
          />
          <div
            ref={handleRef}
            role="slider"
            tabIndex={0}
            aria-label={label}
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={Math.round(position.current)}
            aria-orientation="horizontal"
            className="pointer-events-auto absolute top-1/2 right-0 size-3.5 -translate-y-1/2 translate-x-1/2 cursor-ew-resize rounded-full border border-black/15 bg-white shadow-sm after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2"
            onKeyDown={(event) => {
              const step =
                event.key === "ArrowLeft" || event.key === "ArrowDown"
                  ? -5
                  : event.key === "ArrowRight" || event.key === "ArrowUp"
                    ? 5
                    : null;
              if (step !== null) {
                event.preventDefault();
                setPosition(position.current + step);
              } else if (event.key === "Home") {
                event.preventDefault();
                setPosition(0);
              } else if (event.key === "End") {
                event.preventDefault();
                setPosition(100);
              }
            }}
          />
        </div>
      </div>
    </>
  );
}