Image Compare

Before/after comparison slider with a draggable, keyboard-accessible divider, horizontal or vertical orientation and hover-follow mode.

Example

Installation

npx shadcn@latest add https://hirael.com/r/image-compare.json

API

<ImageCompare />

+ native element props
PropTypeDefault
position

Controlled position of the boundary, 0–100 from the reading start.

number
defaultPositionnumber50
onPositionChange((position: number) => void)
orientation"horizontal" | "vertical""horizontal"
followPointer

Follow the hovering pointer instead of requiring a drag.

booleanfalse
disabledbooleanfalse

<ImageCompareBefore />

+ native element props

No props of its own — forwards everything to the underlying element.

<ImageCompareAfter />

+ native element props

No props of its own — forwards everything to the underlying element.

<ImageCompareHandle />

+ native element props
PropTypeDefault
aria-label

Defines a string value that labels the current element.

string

<ImageCompareLabel />

+ native element props
PropTypeDefault
side*"before" | "after"

Component source

"use client";

import * as React from "react";
import { ChevronsLeftRight } from "lucide-react";

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

type ImageCompareContextValue = {
  position: number;
  setPosition: (next: number) => void;
  orientation: "horizontal" | "vertical";
  disabled: boolean;
  dragging: boolean;
  setDragging: (dragging: boolean) => void;
  rtl: boolean;
  positionFromPointer: (event: React.PointerEvent) => number | null;
};

const ImageCompareContext =
  React.createContext<ImageCompareContextValue | null>(null);

function useImageCompare() {
  const context = React.useContext(ImageCompareContext);
  if (!context) {
    throw new Error(
      "ImageCompare components must be used within <ImageCompare>",
    );
  }
  return context;
}

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

export type ImageCompareProps = Omit<
  React.ComponentProps<"div">,
  "onPointerMove"
> & {
  /** Controlled position of the boundary, 0–100 from the reading start. */
  position?: number;
  defaultPosition?: number;
  onPositionChange?: (position: number) => void;
  orientation?: "horizontal" | "vertical";
  /** Follow the hovering pointer instead of requiring a drag. */
  followPointer?: boolean;
  disabled?: boolean;
};

function ImageCompare({
  position: positionProp,
  defaultPosition = 50,
  onPositionChange,
  orientation = "horizontal",
  followPointer = false,
  disabled = false,
  className,
  children,
  ...props
}: ImageCompareProps) {
  const containerRef = React.useRef<HTMLDivElement | null>(null);
  const [rtl, setRtl] = React.useState(false);
  const [dragging, setDragging] = React.useState(false);
  const [uncontrolled, setUncontrolled] = React.useState(() =>
    clamp(defaultPosition),
  );

  const position = clamp(positionProp ?? uncontrolled);

  const setPosition = React.useCallback(
    (next: number) => {
      const value = clamp(next);
      if (positionProp === undefined) setUncontrolled(value);
      onPositionChange?.(value);
    },
    [positionProp, onPositionChange],
  );

  React.useLayoutEffect(() => {
    const node = containerRef.current;
    if (!node) return;
    const update = () => {
      setRtl(getComputedStyle(node).direction === "rtl");
    };
    update();
    const observer = new MutationObserver(update);
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["dir"],
      subtree: true,
    });
    return () => observer.disconnect();
  }, []);

  const positionFromPointer = React.useCallback(
    (event: React.PointerEvent) => {
      const node = containerRef.current;
      if (!node) return null;
      const rect = node.getBoundingClientRect();
      if (orientation === "vertical") {
        if (rect.height === 0) return null;
        return clamp(((event.clientY - rect.top) / rect.height) * 100);
      }
      if (rect.width === 0) return null;
      const raw = ((event.clientX - rect.left) / rect.width) * 100;
      return clamp(rtl ? 100 - raw : raw);
    },
    [orientation, rtl],
  );

  const handlePointerMove = (event: React.PointerEvent) => {
    if (!followPointer || disabled || dragging) return;
    const next = positionFromPointer(event);
    if (next !== null) setPosition(next);
  };

  return (
    <ImageCompareContext.Provider
      value={{
        position,
        setPosition,
        orientation,
        disabled,
        dragging,
        setDragging,
        rtl,
        positionFromPointer,
      }}
    >
      <div
        ref={containerRef}
        data-slot="image-compare"
        data-orientation={orientation}
        data-dragging={dragging || undefined}
        data-disabled={disabled || undefined}
        onPointerMove={followPointer ? handlePointerMove : undefined}
        className={cn(
          "relative isolate w-full select-none overflow-hidden",
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </ImageCompareContext.Provider>
  );
}

export type ImageCompareBeforeProps = React.ComponentProps<"div">;

function ImageCompareBefore({
  className,
  children,
  ...props
}: ImageCompareBeforeProps) {
  return (
    <div
      data-slot="image-compare-before"
      className={cn("pointer-events-none absolute inset-0", className)}
      {...props}
    >
      {children}
    </div>
  );
}

export type ImageCompareAfterProps = React.ComponentProps<"div">;

function ImageCompareAfter({
  className,
  style,
  children,
  ...props
}: ImageCompareAfterProps) {
  const { position, orientation, dragging, rtl } = useImageCompare();

  const clipPath =
    orientation === "vertical"
      ? `inset(${position}% 0 0 0)`
      : rtl
        ? `inset(0 ${position}% 0 0)`
        : `inset(0 0 0 ${position}%)`;

  return (
    <div
      data-slot="image-compare-after"
      style={{
        ...style,
        clipPath,
        transition: dragging ? "none" : "clip-path 200ms ease-out",
      }}
      className={cn("pointer-events-none absolute inset-0 z-10", className)}
      {...props}
    >
      {children}
    </div>
  );
}

export type ImageCompareHandleProps = React.ComponentProps<"div"> & {
  "aria-label"?: string;
};

function ImageCompareHandle({
  className,
  style,
  children,
  ...props
}: ImageCompareHandleProps) {
  const {
    position,
    setPosition,
    orientation,
    disabled,
    dragging,
    setDragging,
    rtl,
    positionFromPointer,
  } = useImageCompare();

  const vertical = orientation === "vertical";

  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (disabled) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    event.currentTarget.focus();
    setDragging(true);
    const next = positionFromPointer(event);
    if (next !== null) setPosition(next);
  };

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!dragging || disabled) return;
    const next = positionFromPointer(event);
    if (next !== null) setPosition(next);
  };

  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    setDragging(false);
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (disabled) return;
    const step = event.shiftKey ? 10 : 1;
    let next: number | null = null;
    switch (event.key) {
      case "ArrowRight":
        next = position + (rtl ? -step : step);
        break;
      case "ArrowLeft":
        next = position + (rtl ? step : -step);
        break;
      case "ArrowDown":
        next = position + step;
        break;
      case "ArrowUp":
        next = position - step;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = 100;
        break;
    }
    if (next === null) return;
    event.preventDefault();
    setPosition(next);
  };

  return (
    <div
      data-slot="image-compare-handle"
      role="slider"
      aria-label={props["aria-label"] ?? "Comparison slider"}
      aria-orientation={orientation}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={Math.round(position)}
      aria-disabled={disabled || undefined}
      data-orientation={orientation}
      data-dragging={dragging || undefined}
      tabIndex={disabled ? -1 : 0}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={endDrag}
      onPointerCancel={endDrag}
      onKeyDown={handleKeyDown}
      style={{
        ...style,
        ...(vertical
          ? { top: `${position}%` }
          : { insetInlineStart: `${position}%` }),
        transition: dragging
          ? "none"
          : vertical
            ? "top 200ms ease-out"
            : "inset-inline-start 200ms ease-out",
      }}
      className={cn(
        "group absolute z-20 flex touch-none items-center justify-center outline-none",
        vertical
          ? "inset-x-0 h-8 -translate-y-1/2 cursor-row-resize flex-col"
          : cn(
              "inset-y-0 w-8 cursor-col-resize",
              rtl ? "translate-x-1/2" : "-translate-x-1/2",
            ),
        disabled && "cursor-default",
        className,
      )}
      {...props}
    >
      <span
        aria-hidden
        data-slot="image-compare-handle-line"
        className={cn(
          "absolute bg-white/90 shadow-[0_0_4px_rgba(0,0,0,0.4)]",
          vertical ? "inset-x-0 h-px" : "inset-y-0 w-px",
        )}
      />
      {children ?? (
        <span
          data-slot="image-compare-handle-grip"
          className={cn(
            "relative z-10 flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-transform",
            "group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2",
            dragging && "scale-110",
          )}
        >
          <ChevronsLeftRight
            aria-hidden
            className={cn("size-4", vertical && "rotate-90")}
          />
        </span>
      )}
    </div>
  );
}

export type ImageCompareLabelProps = React.ComponentProps<"span"> & {
  side: "before" | "after";
};

function ImageCompareLabel({
  side,
  className,
  children,
  ...props
}: ImageCompareLabelProps) {
  const { orientation, dragging } = useImageCompare();

  return (
    <span
      data-slot="image-compare-label"
      data-side={side}
      className={cn(
        "pointer-events-none absolute z-30 rounded-md bg-black/60 px-2 py-1 text-xs font-medium text-white backdrop-blur-sm transition-opacity duration-200",
        orientation === "vertical"
          ? side === "before"
            ? "start-3 top-3"
            : "bottom-3 start-3"
          : side === "before"
            ? "start-3 top-3"
            : "end-3 top-3",
        dragging && "opacity-0",
        className,
      )}
      {...props}
    >
      {children}
    </span>
  );
}

export {
  ImageCompare,
  ImageCompareBefore,
  ImageCompareAfter,
  ImageCompareHandle,
  ImageCompareLabel,
};

Dependencies

npm

lucide-react