Compare slider

Two states of one screen in a single frame, split by a half-pixel line you can grab anywhere; a click or key glides it, and each label fades as the line nears it.

Show code
"use client";

import {
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";

const STEP = 5;
/** How close the handle's centre can ride to the frame's top and bottom. */
const HANDLE_INSET = 24;
/** The glide for clicks and keys: --compare-value is a registered number, so it transitions. */
const GLIDE = "480ms cubic-bezier(0.16, 1, 0.3, 1)";

function clamp(value: number) {
  return Math.min(100, Math.max(0, value));
}

export type CompareSliderProps = {
  /** The state left of the line. */
  before: ReactNode;
  /** The state right of the line, revealed as the line moves left. */
  after: ReactNode;
  /** Short state name shown on its half, e.g. "At rest". */
  beforeLabel?: string;
  afterLabel?: string;
  /** Where the line starts, as a percentage from the left. */
  initial?: number;
  className?: string;
};

/**
 * Two states of one screen in a single frame: a vertical line splits them,
 * and dragging it left or right trades one for the other. The line can be
 * grabbed anywhere in the frame; on touch a vertical swipe still scrolls the
 * page. The handle is a slider for keyboards and screen readers.
 */
export function CompareSlider({
  before,
  after,
  beforeLabel,
  afterLabel,
  initial = 50,
  className,
}: CompareSliderProps) {
  const frameRef = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState(clamp(initial));
  // Pressed holds the pointer; dragging starts on the first move, so a click
  // still glides the line to the spot instead of jumping.
  const [pressed, setPressed] = useState(false);
  const [dragging, setDragging] = useState(false);
  // The handle rides its line at the pointer's height; null rests it centred.
  const [handleY, setHandleY] = useState<number | null>(null);

  const moveTo = (clientX: number) => {
    const frame = frameRef.current;
    if (!frame) return;
    const box = frame.getBoundingClientRect();
    setPosition(clamp(((clientX - box.left) / box.width) * 100));
  };

  const trackY = (clientY: number) => {
    const frame = frameRef.current;
    if (!frame) return;
    const box = frame.getBoundingClientRect();
    const y = clientY - box.top;
    setHandleY(Math.min(box.height - HANDLE_INSET, Math.max(HANDLE_INSET, y)));
  };

  const startDrag = (event: PointerEvent<HTMLElement>) => {
    if (event.button !== 0) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    setPressed(true);
    moveTo(event.clientX);
    trackY(event.clientY);
  };

  const drag = (event: PointerEvent<HTMLElement>) => {
    if (event.pointerType === "mouse" || pressed) trackY(event.clientY);
    if (!pressed) return;
    setDragging(true);
    moveTo(event.clientX);
  };

  const endDrag = (event: PointerEvent<HTMLElement>) => {
    setPressed(false);
    setDragging(false);
    // A finger lifts away, so the handle settles back to centre.
    if (event.pointerType !== "mouse") setHandleY(null);
  };

  const leave = () => {
    if (!pressed) setHandleY(null);
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const next: ((current: number) => number) | null =
      event.key === "ArrowLeft" || event.key === "ArrowDown"
        ? (current) => current - STEP
        : event.key === "ArrowRight" || event.key === "ArrowUp"
          ? (current) => current + STEP
          : event.key === "Home"
            ? () => 0
            : event.key === "End"
              ? () => 100
              : null;
    if (!next) return;
    event.preventDefault();
    setPosition((current) => clamp(next(current)));
  };

  const label =
    "pointer-events-none absolute top-3 z-[2] inline-flex h-6 items-center whitespace-nowrap rounded-full bg-muted/80 px-2.5 text-xs font-medium text-foreground shadow-[inset_0_0_0_0.5px_var(--border)] backdrop-blur-sm";

  return (
    <div
      ref={frameRef}
      data-compare=""
      data-pressed={pressed ? "" : undefined}
      data-dragging={dragging ? "" : undefined}
      data-tracking={handleY === null ? undefined : ""}
      className={cn(
        "group/compare relative isolate touch-pan-y select-none overflow-hidden",
        "[@media(hover:hover)_and_(pointer:fine)]:cursor-ew-resize motion-reduce:transition-none!",
        className,
      )}
      style={
        {
          "--compare-value": position,
          "--compare-position": "calc(var(--compare-value) * 1%)",
          "--compare-handle-y": handleY === null ? "50%" : `${handleY}px`,
          // Clicks and keys glide; a drag follows the pointer exactly.
          transition: dragging ? "none" : `--compare-value ${GLIDE}`,
        } as CSSProperties
      }
      onPointerDown={startDrag}
      onPointerMove={drag}
      onPointerUp={endDrag}
      onPointerCancel={endDrag}
      onPointerLeave={leave}
    >
      {before}
      {/* The after state is clipped to right of the line, so dragging left reveals more of it. */}
      <div className="absolute inset-0 [clip-path:inset(0_0_0_var(--compare-position))]">{after}</div>

      {beforeLabel ? (
        <span className={cn(label, "left-3")} style={{ opacity: "clamp(0, (var(--compare-value) - 14) / 10, 1)" }}>
          {beforeLabel}
        </span>
      ) : null}
      {afterLabel ? (
        <span className={cn(label, "right-3")} style={{ opacity: "clamp(0, (86 - var(--compare-value)) / 10, 1)" }}>
          {afterLabel}
        </span>
      ) : null}

      {/* A short frosted band either side of the seam, strongest at the line and gone 20px out. */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-y-0 left-(--compare-position) z-[1] w-10 -translate-x-1/2 backdrop-blur-[6px] [mask-image:linear-gradient(to_right,transparent,#000_50%,transparent)]"
      />
      {/* A half-pixel line with a soft glow, so it reads on light and dark screens. */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-y-0 left-(--compare-position) z-[2] w-px -translate-x-1/2 scale-x-50 bg-white/90 shadow-[0_0_6px_rgb(255_255_255/0.35)]"
      />
      <div
        role="slider"
        tabIndex={0}
        aria-label={`Compare ${beforeLabel ?? "before"} and ${afterLabel ?? "after"}`}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={Math.round(position)}
        aria-valuetext={`${Math.round(100 - position)}% ${afterLabel ?? "after"}`}
        onKeyDown={onKeyDown}
        className={cn(
          "absolute top-(--compare-handle-y) left-(--compare-position) z-[3] grid h-6 w-9 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full",
          "bg-background/70 text-foreground backdrop-blur-md backdrop-saturate-[1.4]",
          "shadow-[inset_0_0_0_0.5px_var(--border),0_1px_2px_rgb(0_0_0/0.2),0_4px_12px_rgb(0_0_0/0.18)]",
          "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
          // Off the pointer the handle glides back to centre; on it, it rides the pointer with no lag.
          "[transition:top_480ms_cubic-bezier(0.16,1,0.3,1),scale_160ms_ease-out]",
          "group-data-[tracking]/compare:[transition:scale_160ms_ease-out] group-data-[pressed]/compare:scale-[0.94]",
          "motion-reduce:transition-none",
        )}
      >
        <svg
          viewBox="0 0 20 12"
          aria-hidden
          className="h-3 w-5 overflow-visible fill-none stroke-current [stroke-linecap:round] [stroke-linejoin:round] [stroke-width:1.5]"
        >
          {/* On hover the arrows ease apart to say which way it goes. */}
          <path
            d="M7 3L4 6l3 3"
            className="transition-[translate] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover/compare:-translate-x-[1.5px] group-data-[dragging]/compare:-translate-x-[1.5px] motion-reduce:transition-none"
          />
          <path
            d="M13 3l3 3-3 3"
            className="transition-[translate] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover/compare:translate-x-[1.5px] group-data-[dragging]/compare:translate-x-[1.5px] motion-reduce:transition-none"
          />
        </svg>
      </div>
    </div>
  );
}

Used in