Skip to content
38

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

Usage

import {
  ImageCompare,
  ImageCompareBefore,
  ImageCompareAfter,
  ImageCompareHandle,
  ImageCompareLabel,
} from "@/components/image-compare"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<ImageCompare />

+ native element props
PropTypeDefault
position

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

numbernone
defaultPositionnumber50
onPositionChange((position: number) => void)none
orientationImageCompareOrientation'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

Accessible name of the slider. Defaults to "Comparison slider".

stringnone
getValueText

Announced value for a position, 0–100. Defaults to "50%".

((position: number) => string)defaultValueText

<ImageCompareLabel />

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

Component source

'use client';

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

import { cn } from '@/lib/utils';
import { composeRefs } from '@/registry/hirael/lib/compose-refs';

type ImageCompareOrientation = 'horizontal' | 'vertical';

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

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

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

  return context;
};

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

export interface ImageCompareProps extends React.ComponentProps<'div'> {
  /** Controlled position of the boundary, 0–100 from the reading start. */
  position?: number;
  defaultPosition?: number;
  onPositionChange?: (position: number) => void;
  orientation?: ImageCompareOrientation;
  /** Follow the hovering pointer instead of requiring a drag. */
  followPointer?: boolean;
  disabled?: boolean;
}

const ImageCompare = ({
  position: positionProp,
  defaultPosition = 50,
  onPositionChange,
  orientation = 'horizontal',
  followPointer = false,
  disabled = false,
  onPointerMove,
  className,
  children,
  ref,
  ...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<HTMLDivElement>) => {
    onPointerMove?.(event);
    if (event.defaultPrevented || !followPointer || disabled || dragging) return;
    const next = positionFromPointer(event);
    if (next !== null) setPosition(next);
  };

  const ctx = React.useMemo<ImageCompareContextValue>(
    () => ({
      position,
      setPosition,
      orientation,
      disabled,
      dragging,
      setDragging,
      rtl,
      positionFromPointer,
    }),
    [position, setPosition, orientation, disabled, dragging, rtl, positionFromPointer],
  );

  const composedRef = React.useMemo(() => composeRefs(containerRef, ref), [ref]);

  return (
    <ImageCompareContext.Provider value={ctx}>
      <div
        ref={composedRef}
        data-slot="image-compare"
        data-orientation={orientation}
        data-dragging={dragging || undefined}
        data-disabled={disabled || undefined}
        onPointerMove={handlePointerMove}
        className={cn('relative isolate w-full overflow-hidden select-none', className)}
        {...props}
      >
        {children}
      </div>
    </ImageCompareContext.Provider>
  );
};

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

const 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'>;

const 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 interface ImageCompareHandleProps extends React.ComponentProps<'div'> {
  /** Accessible name of the slider. Defaults to "Comparison slider". */
  'aria-label'?: string;
  /** Announced value for a position, 0–100. Defaults to "50%". */
  getValueText?: (position: number) => string;
}

const defaultValueText = (position: number) => `${position}%`;

const ImageCompareHandle = ({
  getValueText = defaultValueText,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  onKeyDown,
  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>) => {
    onPointerDown?.(event);
    if (event.defaultPrevented || 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>) => {
    onPointerMove?.(event);
    if (!dragging || disabled) return;
    const next = positionFromPointer(event);
    if (next !== null) setPosition(next);
  };

  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.type === 'pointercancel') onPointerCancel?.(event);
    else onPointerUp?.(event);
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    setDragging(false);
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(event);
    if (event.defaultPrevented || disabled) return;
    const step = event.shiftKey ? 10 : 1;
    // Up raises the announced value; vertically that value is measured from the bottom.
    const next = (
      {
        ArrowRight: position + (rtl ? -step : step),
        ArrowLeft: position + (rtl ? step : -step),
        ArrowUp: position + (vertical ? -step : step),
        ArrowDown: position + (vertical ? step : -step),
        Home: vertical ? 100 : 0,
        End: vertical ? 0 : 100,
      } as Record<string, number>
    )[event.key];
    if (next === undefined) return;
    event.preventDefault();
    setPosition(next);
  };

  const value = Math.round(vertical ? 100 - position : position);

  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={value}
      aria-valuetext={getValueText(value)}
      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}
    >
      {/* A contrasting ring on both edges keeps the hairline visible over light and dark images alike. */}
      <span
        aria-hidden
        data-slot="image-compare-handle-line"
        className={cn(
          'absolute bg-background ring-1 ring-foreground/40',
          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 className={cn('size-4', vertical && 'rotate-90')} />
        </span>
      )}
    </div>
  );
};

export interface ImageCompareLabelProps extends React.ComponentProps<'span'> {
  side: 'before' | 'after';
}

const 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-background/70 px-2 py-1 text-xs font-medium text-foreground backdrop-blur-sm transition-opacity duration-200',
        orientation === 'vertical'
          ? side === 'before'
            ? 'start-3 top-3'
            : 'start-3 bottom-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

shadcn registry

compose-refs

npm

lucide-react