# Image Compare

> Before and after slider split by a lit tube. Drag, press, keys or hover to scrub; horizontal or vertical.

Category: Data Display. Docs: https://neonkit.heyaadi.com/components/image-compare

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/image-compare.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/image-compare`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { ImageCompare } from "@/components/ui/image-compare"

<ImageCompare
  className="aspect-[3/2]"
  beforeLabel="Raw"
  afterLabel="Graded"
  before={<img src="/raw.jpg" alt="" />}
  after={<img src="/graded.jpg" alt="Shibuya at night" />}
/>
```

## Examples

### Raw and graded

The divider is a lit tube with a handle riding on it; the tube stops short of the handle on both sides, like blockout paint. Press anywhere to glide it there, then drag.

```tsx
/* eslint-disable @next/next/no-img-element -- examples are pasted into any React app, not only Next.js */
import { ImageCompare } from "@/components/ui/image-compare";

const photo =
  "https://images.unsplash.com/photo-1542051841857-5f90071e7989?auto=format&fit=crop&w=1600&q=80";

// The raw file is the same photo, flattened the way a log profile records it.
export default function ImageCompareDemo() {
  return (
    <ImageCompare
      className="aspect-[3/2] w-full max-w-[760px]"
      defaultValue={42}
      beforeLabel="Raw"
      afterLabel="Graded"
      before={<img src={photo} alt="" className="[filter:saturate(0.32)_contrast(0.74)_brightness(0.9)_sepia(0.08)]" />}
      after={<img src={photo} alt="Shibuya crossing at night, colour graded" />}
    />
  );
}
```

### Vertical

Any children work, here a grain overlay drawn in SVG. Labels fade as the divider covers them.

```tsx
/* eslint-disable @next/next/no-img-element -- examples are pasted into any React app, not only Next.js */
import { ImageCompare } from "@/components/ui/image-compare";

const photo =
  "https://images.unsplash.com/photo-1503899036084-c55cdd92da26?auto=format&fit=crop&w=1400&q=80";

/** The handheld frame: underexposed with sensor noise laid over it. */
function Handheld() {
  return (
    <div className="relative h-full w-full">
      <img src={photo} alt="" className="block h-full w-full object-cover [filter:brightness(0.42)_saturate(0.7)_contrast(0.9)]" />
      <svg aria-hidden className="absolute inset-0 h-full w-full opacity-[0.55] mix-blend-screen">
        <filter id="image-compare-grain">
          <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7" />
          <feColorMatrix values="0 0 0 0 0.6  0 0 0 0 0.6  0 0 0 0 0.7  0 0 0 1.4 -0.55" />
        </filter>
        <rect width="100%" height="100%" filter="url(#image-compare-grain)" />
      </svg>
    </div>
  );
}

export default function ImageCompareVertical() {
  return (
    <ImageCompare
      orientation="vertical"
      className="aspect-[4/5] w-full max-w-[420px]"
      defaultValue={46}
      beforeLabel="Handheld, ISO 6400"
      afterLabel="Night mode"
      before={<Handheld />}
      after={<img src={photo} alt="Shinjuku street signs at night" />}
    />
  );
}
```

### Scrub on hover

With hover, a mouse moves the divider without pressing. Touch still drags.

```tsx
import { ImageCompare } from "@/components/ui/image-compare";
import { NeonSign } from "@/components/ui/neon-sign";

function Sign({ on }: { on: boolean }) {
  return (
    <div className="grid h-full w-full place-items-center bg-[radial-gradient(ellipse_at_50%_40%,#141722,#07080c_70%)]">
      <NeonSign
        on={on}
        color="pink"
        frame="pill"
        size="lg"
        flicker="clean"
        style={{ fontFamily: "var(--font-tilt-neon)" }}
        lines={["Night shift"]}
      />
    </div>
  );
}

export default function ImageCompareHover() {
  return (
    <ImageCompare
      hover
      color="pink"
      className="aspect-[16/9] w-full max-w-[640px]"
      defaultValue={50}
      beforeLabel="Unlit"
      afterLabel="Lit"
      before={<Sign on={false} />}
      after={<Sign on />}
    />
  );
}
```

### Controlled

value and onValueChange, with presets driving it from outside.

```tsx
"use client";

/* eslint-disable @next/next/no-img-element -- examples are pasted into any React app, not only Next.js */
import * as React from "react";
import { ImageCompare } from "@/components/ui/image-compare";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

const photo = (width: number) =>
  `https://images.unsplash.com/photo-1542051841857-5f90071e7989?auto=format&fit=crop&crop=entropy&w=${width}&h=${Math.round(width * 0.625)}&q=80`;

const stops = [0, 50, 100];

export default function ImageCompareControlled() {
  const [value, setValue] = React.useState(50);

  return (
    <div className="flex w-full max-w-[640px] flex-col gap-4">
      <ImageCompare
        value={value}
        onValueChange={setValue}
        className="aspect-[16/10] w-full"
        beforeLabel="Original, 240 px"
        afterLabel="Upscaled 4×"
        before={<img src={photo(240)} alt="" className="[image-rendering:pixelated]" />}
        after={<img src={photo(1280)} alt="A Tokyo street at night" />}
      />
      <div className="flex items-center justify-between gap-4">
        <span className="font-mono text-[13px] tabular-nums text-white/60">{Math.round(value)}% original</span>
        <ToggleGroup
          type="single"
          size="sm"
          value={stops.includes(value) ? String(value) : ""}
          onValueChange={(next) => next && setValue(Number(next))}
          aria-label="Divider position"
        >
          <ToggleGroupItem value="100">Original</ToggleGroupItem>
          <ToggleGroupItem value="50">Split</ToggleGroupItem>
          <ToggleGroupItem value="0">Upscaled</ToggleGroupItem>
        </ToggleGroup>
      </div>
    </div>
  );
}
```

## API reference

### ImageCompare

Give the root a size or an aspect ratio, or let the after layer set it. Images inside fill and cover their layer.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `before` | `ReactNode` | — | Shown on the left (or top), up to the divider. |
| `after` | `ReactNode` | — | Shown on the right (or bottom). Its alt text is the one read; leave the before image's alt empty. |
| `beforeLabel` | `ReactNode` | — | Caption on the before side. Strings also name the slider. |
| `afterLabel` | `ReactNode` | — | Caption on the after side. |
| `value` | `number` | — | Controlled divider position, 0 to 100. |
| `defaultValue` | `number` | `50` | Initial position when uncontrolled. |
| `onValueChange` | `(value: number) => void` | — | Called on every move. |
| `onValueCommit` | `(value: number) => void` | — | Called once a drag or key press ends. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Vertical splits top from bottom. |
| `hover` | `boolean` | `false` | A mouse moves the divider without pressing. |
| `step` | `number` | `1` | Percent per arrow key. Page Up and Page Down move ten steps. |
| `disabled` | `boolean` | `false` | Blocks input and takes the handle out of the tab order. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

## Accessibility

- The handle is a slider with aria-valuetext like "42% Raw, 58% Graded".
- Arrows move by step (Right and Down increase in both orientations), Page Up and Page Down by ten steps, Home and End go to the edges.
- Horizontal compares keep vertical touch scrolling for the page, and vertical ones keep horizontal.
- With reduced motion the divider never glides; it goes straight to the press.

## Source

`components/ui/image-compare.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const clamp = (value: number) => Math.min(100, Math.max(0, value));
const round = (value: number) => Math.round(value * 10) / 10;

const HANDLE = 40;
// The tube stops short of the handle on both sides, like blockout paint where a tube passes behind.
const BLOCKOUT = 6;

function textOf(node: React.ReactNode) {
  return typeof node === "string" || typeof node === "number" ? String(node) : undefined;
}

export interface ImageCompareProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color" | "defaultValue" | "onChange"> {
  /** Shown on the left (or top), up to the divider. */
  before: React.ReactNode;
  /** Shown on the right (or bottom). It sets the frame's size unless you give the root one. */
  after: React.ReactNode;
  /** Caption pinned to the before side. Also used in the slider's value text. */
  beforeLabel?: React.ReactNode;
  afterLabel?: React.ReactNode;
  /** Where the divider sits, 0 to 100, from the left (or top). */
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  /** Fires once a drag or key press ends. */
  onValueCommit?: (value: number) => void;
  orientation?: "horizontal" | "vertical";
  /** Follow the pointer without pressing. Touch still drags. */
  hover?: boolean;
  /** Percent moved by an arrow key. Page Up and Page Down move ten times as far. */
  step?: number;
  disabled?: boolean;
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
}

const ImageCompare = React.forwardRef<HTMLDivElement, ImageCompareProps>(
  (
    {
      className,
      style,
      before,
      after,
      beforeLabel,
      afterLabel,
      value: valueProp,
      defaultValue = 50,
      onValueChange,
      onValueCommit,
      orientation = "horizontal",
      hover = false,
      step = 1,
      disabled = false,
      color,
      "aria-label": ariaLabel,
      onPointerDown,
      onPointerMove,
      onPointerUp,
      onPointerCancel,
      onPointerLeave,
      ...props
    },
    ref,
  ) => {
    const rootRef = React.useRef<HTMLDivElement>(null);
    React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
    const handleRef = React.useRef<HTMLDivElement>(null);
    const [uncontrolled, setUncontrolled] = React.useState(() => clamp(defaultValue));
    const value = clamp(valueProp ?? uncontrolled);
    const [dragging, setDragging] = React.useState(false);
    // A press away from the divider glides it over; dragging after that tracks 1:1.
    const [gliding, setGliding] = React.useState(false);
    const drag = React.useRef<{ id: number; start: number } | null>(null);
    const keyStart = React.useRef<number | null>(null);
    const vertical = orientation === "vertical";

    const setValue = (next: number) => {
      const v = round(clamp(next));
      if (v === value) return;
      if (valueProp === undefined) setUncontrolled(v);
      onValueChange?.(v);
    };

    const valueAt = (event: React.PointerEvent) => {
      const box = rootRef.current?.getBoundingClientRect();
      if (!box) return value;
      return vertical
        ? ((event.clientY - box.top) / box.height) * 100
        : ((event.clientX - box.left) / box.width) * 100;
    };

    const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
      onPointerDown?.(event);
      if (disabled || event.defaultPrevented || event.button !== 0) return;
      event.preventDefault();
      handleRef.current?.focus({ preventScroll: true });
      event.currentTarget.setPointerCapture(event.pointerId);
      drag.current = { id: event.pointerId, start: value };
      setDragging(true);
      const next = valueAt(event);
      const far = Math.abs(next - value) > 4;
      setGliding(far);
      setValue(next);
    };

    const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
      onPointerMove?.(event);
      if (disabled) return;
      if (drag.current?.id === event.pointerId) {
        setGliding(false);
        setValue(valueAt(event));
        return;
      }
      if (hover && event.pointerType === "mouse" && !drag.current) {
        setGliding(false);
        setValue(valueAt(event));
      }
    };

    const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
      const d = drag.current;
      if (!d || d.id !== event.pointerId) return;
      drag.current = null;
      setDragging(false);
      if (value !== d.start) onValueCommit?.(value);
    };

    const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
      if (disabled) return;
      const big = step * 10;
      const delta: Record<string, number> = {
        ArrowLeft: -step,
        ArrowUp: -step,
        ArrowRight: step,
        ArrowDown: step,
        PageUp: -big,
        PageDown: big,
        Home: -Infinity,
        End: Infinity,
      };
      if (!(event.key in delta)) return;
      event.preventDefault();
      if (keyStart.current === null) keyStart.current = value;
      setGliding(event.key === "Home" || event.key === "End" || event.key.startsWith("Page"));
      setValue(value + delta[event.key]);
    };

    const handleKeyUp = () => {
      if (keyStart.current === null) return;
      if (value !== keyStart.current) onValueCommit?.(value);
      keyStart.current = null;
    };

    const beforeText = textOf(beforeLabel) ?? "Before";
    const afterText = textOf(afterLabel) ?? "After";
    const shown = Math.round(value);
    const ease = gliding
      ? "duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:duration-0"
      : "duration-0";
    const position = `${value}%`;
    // Clip is what shows the before layer: everything up to the divider.
    const clip = vertical ? `inset(0 0 ${100 - value}% 0)` : `inset(0 ${100 - value}% 0 0)`;
    const gapFromCenter = `calc(50% - ${HANDLE / 2 + BLOCKOUT}px)`;
    const label = "pointer-events-none absolute z-10 rounded-md bg-black/55 px-2 py-1 text-[12px] font-medium leading-4 text-white/90 backdrop-blur-md transition-opacity duration-200";

    return (
      <div
        ref={rootRef}
        data-orientation={orientation}
        data-state={dragging ? "dragging" : undefined}
        data-disabled={disabled ? "" : undefined}
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "group/compare relative isolate select-none overflow-hidden rounded-xl bg-[#0a0b10]",
          "[&_img]:pointer-events-none [&_img]:select-none",
          vertical ? "touch-pan-x" : "touch-pan-y",
          disabled ? "cursor-default" : vertical ? "cursor-row-resize" : "cursor-col-resize",
          className,
        )}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={(event) => {
          onPointerUp?.(event);
          endDrag(event);
        }}
        onPointerCancel={(event) => {
          onPointerCancel?.(event);
          endDrag(event);
        }}
        onPointerLeave={onPointerLeave}
        onDragStart={(event) => event.preventDefault()}
        {...props}
      >
        <div className="relative h-full w-full [&>img]:block [&>img]:h-full [&>img]:w-full [&>img]:object-cover">{after}</div>
        <div
          aria-hidden
          className={cn(
            "absolute inset-0 transition-[clip-path] [&>img]:block [&>img]:h-full [&>img]:w-full [&>img]:object-cover",
            ease,
          )}
          style={{ clipPath: clip }}
        >
          {before}
        </div>

        {beforeLabel != null && (
          <span
            aria-hidden
            className={cn(label, "left-3 top-3")}
            style={{ opacity: value > (vertical ? 14 : 18) ? 1 : 0 }}
          >
            {beforeLabel}
          </span>
        )}
        {afterLabel != null && (
          <span
            aria-hidden
            className={cn(label, vertical ? "bottom-3 left-3" : "right-3 top-3")}
            style={{ opacity: value < (vertical ? 86 : 82) ? 1 : 0 }}
          >
            {afterLabel}
          </span>
        )}

        {/* The divider: a lit tube, end to end, with the handle riding on it */}
        <div
          aria-hidden
          className={cn(
            "pointer-events-none absolute z-20 transition-[left,top]",
            vertical ? "inset-x-0 h-0" : "inset-y-0 w-0",
            ease,
          )}
          style={vertical ? { top: position } : { left: position }}
        >
          {[0, 1].map((side) => (
            <span
              key={side}
              className={cn(
                "absolute rounded-full transition-[box-shadow] duration-200",
                "[background:color-mix(in_srgb,rgb(var(--neon))_35%,white)]",
                "shadow-[0_0_2px_rgb(var(--neon)/var(--neon-glow,1)),0_0_8px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1))),0_0_22px_rgb(var(--neon)/CALC(0.4*var(--neon-glow,1)))]",
                "group-data-[state=dragging]/compare:shadow-[0_0_2px_rgb(var(--neon)/var(--neon-glow,1)),0_0_10px_rgb(var(--neon)/var(--neon-glow,1)),0_0_32px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1)))]",
                "group-data-[disabled]/compare:shadow-none group-data-[disabled]/compare:opacity-40",
                vertical ? "-top-px h-0.5" : "-left-px w-0.5",
              )}
              style={
                vertical
                  ? side === 0
                    ? { left: 0, right: `calc(100% - ${gapFromCenter})` }
                    : { right: 0, left: `calc(100% - ${gapFromCenter})` }
                  : side === 0
                    ? { top: 0, bottom: `calc(100% - ${gapFromCenter})` }
                    : { bottom: 0, top: `calc(100% - ${gapFromCenter})` }
              }
            />
          ))}
        </div>

        <div
          ref={handleRef}
          role="slider"
          tabIndex={disabled ? -1 : 0}
          aria-label={ariaLabel ?? `${beforeText} and ${afterText}`}
          aria-orientation={orientation}
          aria-valuemin={0}
          aria-valuemax={100}
          aria-valuenow={shown}
          aria-valuetext={`${shown}% ${beforeText}, ${100 - shown}% ${afterText}`}
          aria-disabled={disabled || undefined}
          onKeyDown={handleKeyDown}
          onKeyUp={handleKeyUp}
          onBlur={() => setGliding(false)}
          className={cn(
            "absolute z-30 grid -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full outline-none transition-[left,top,box-shadow,transform]",
            "border border-white/15 bg-[#0b0c11]/80 backdrop-blur-md",
            "shadow-[0_6px_20px_-6px_rgb(0_0_0/0.9),inset_0_1px_0_rgb(255_255_255/0.08)]",
            "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
            "group-data-[state=dragging]/compare:scale-[0.94]",
            ease,
          )}
          style={{ width: HANDLE, height: HANDLE, ...(vertical ? { top: position, left: "50%" } : { left: position, top: "50%" }) }}
        >
          {/* A ring of tube inside the puck, and two grip arrows */}
          <span
            aria-hidden
            className={cn(
              "absolute inset-[5px] rounded-full border-[1.5px] border-[color-mix(in_srgb,rgb(var(--neon))_40%,white)] transition-[box-shadow,opacity] duration-200",
              "shadow-[0_0_6px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1))),inset_0_0_6px_rgb(var(--neon)/CALC(0.5*var(--neon-glow,1)))]",
              "group-data-[disabled]/compare:opacity-30 group-data-[disabled]/compare:shadow-none",
            )}
          />
          <svg aria-hidden viewBox="0 0 24 24" className={cn("size-5 text-white", vertical && "rotate-90")}>
            <path d="M9 7.5 4.5 12 9 16.5M15 7.5l4.5 4.5-4.5 4.5" fill="none" stroke="currentColor" strokeWidth={2.25} strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        </div>
      </div>
    );
  },
);
ImageCompare.displayName = "ImageCompare";

export { ImageCompare };
```
