# Image Compare

> Compare any two layers (screenshots, themes, a plain component and its polished twin) with one divider. A flick coasts to 0, 50 or 100, a slow release stays where it lands, the tags fade as the line passes under them, and the knob is a keyboard slider.

- Tier: Free (MIT)
- Interaction: Drag
- Page: https://eased.dev/components/image-compare
- Installs: motion
- Registry: https://eased.dev/r/image-compare.json

## Install

```bash
npx shadcn add https://eased.dev/r/image-compare.json
```

Two layers of anything, one divider between them: screenshots, two themes of one screen, a plain component next to its Eased twin. Built on the Eased gesture kit and inertia core.

## When to use

- Before and after: a redesign, a photo edit, a renovation, a plain kit next to Eased.
- Showing a state change side by side: a skeleton and the loaded card, light and dark.
- Launch posts and changelogs, where one divider tells the story.

## When not to use

- Comparing numbers or many variants: use a table or Tabs.
- When both sides must stay interactive at once: put them side by side instead.

## Usage

```tsx example.tsx
import { ImageCompare } from "@/components/eased/image-compare/image-compare";

export function Example() {
  return (
    <ImageCompare
      aria-label="Compare the old and new dashboard"
      beforeLabel="Old"
      afterLabel="New"
      className="aspect-video rounded-panel"
      before={<img src="/old.png" alt="" className="size-full object-cover" />}
      after={<img src="/new.png" alt="" className="size-full object-cover" />}
    />
  );
}
```

## Examples

### Two components

Layers can be any element. Both are inert by default, so their buttons are pictures, not controls.

```tsx
<ImageCompare before={<ShareCard variant="plain" />} after={<ShareCard />} beforeLabel="Plain" afterLabel="Eased" />
```

### Vertical, no snapping

```tsx
<ImageCompare orientation="vertical" snap={false} before={…} after={…} />
```

## API reference

### ImageCompare

| Prop | Type | Default | Description |
|---|---|---|---|
| `before` | `ReactNode` | | The layer left of (or above) the divider. |
| `after` | `ReactNode` | | The layer right of (or below) it. It sizes the frame unless the root is sized. |
| `beforeLabel` / `afterLabel` | `ReactNode` | `"Before"` / `"After"` | Tags in the corners; `null` hides one. |
| `value` | `number` | | Divider position 0–100 (controlled). |
| `defaultValue` | `number` | `50` | Uncontrolled start. |
| `onValueChange` | `(value: number) => void` | | Fires when the divider comes to rest. |
| `snap` | `number[] \| false` | `[0, 50, 100]` | Where a flick may rest, in %. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | |
| `interactive` | `boolean` | `false` | Let pointer and keyboard reach the layers. |
| `aria-label` | `string` | `"Compare"` | Names the slider. |
| `className` | `string` | `""` | Size and round the root here. Set `--compare-line` to recolour the line. |

## Keyboard

| Key | Action |
|---|---|
| `Tab` | Focuses the knob. |
| `←` `→` (`↑` `↓` when vertical) | Move 1%; with `Shift`, 10%. |
| `Page Up` / `Page Down` | Move 10%. |
| `Home` / `End` | Jump to 0% or 100%. |

## Accessibility

- The knob is `role="slider"` with `aria-valuetext` such as "62% Plain, 38% Eased", updated as the line moves.
- Both layers are `inert` by default, so duplicated UI is never focusable or read twice; give images real `alt` text if the comparison carries meaning.
- The tags are decorative (`aria-hidden`); the slider's value text names both sides.

## Motion

The top layer is cut with `clip-path` from a MotionValue, so a drag never re-renders. Past an end the line rubber-bands; on release a flick (over 380px/s) coasts on a soft spring to the snap point its momentum reaches, while a slow release rests where it lands unless it is within 3.5% of a snap point. A tap glides the line to the pointer; keys move it on the snappy spring. Under a fine pointer the line leans up to 2% toward the cursor before a drag. Tags fade over 28px as the line passes under them. The knob presses to 0.92 while held and stops 22px short of the ends so it never clips. Reduced motion: no lean, no coast, moves jump.

## Notes for AI

Pick it whenever a before/after or A/B view is wanted, including for components rather than images: pass two complete renders as `before`/`after` and size the root (`aspect-video`, or `flex-1` in a column). Use `defaultValue` and read `onValueChange` only if the position matters to the app.

Do not wrap it in a drag library or add your own pointer handlers to move the line; it already handles drag, flick, tap, keys and resize. Do not put focusable content inside without `interactive`.

## Related

- [Scrub Label](/components/scrub-label): drag a label to change a number.
- [Segmented](/components/segmented): switch between two states instead of revealing both.

## Example

`components/eased/image-compare/image-compare-demo.tsx`

```tsx
"use client";

import { useState } from "react";
import { Avatar } from "../avatar/avatar";
import { Button } from "../button/button";
import { DemoFrame } from "../lib/demo-frame";
import { rowGrid, rowGridRow } from "../lib/layout";
import { Segmented } from "../segmented/segmented";
import { Skeleton } from "../skeleton/skeleton";
import { ImageCompare } from "./image-compare";

type Mode = "polish" | "loading";

const PEOPLE = [
  { name: "Maya Okafor", email: "maya@atlas.so", role: "Owner" },
  { name: "Jonas Weber", email: "jonas@atlas.so", role: "Can edit" },
  { name: "Priya Raman", email: "priya@atlas.so", role: "Can view" },
];

/** The stage both layers share: one canvas, the card centred on it. */
function Stage({ children, tone = "plain" }: { children: React.ReactNode; tone?: "plain" | "eased" }) {
  return (
    <div className={`flex h-full w-full items-center justify-center p-4 ${tone === "eased" ? "bg-bg" : "bg-surface-sunken"}`}>
      {children}
    </div>
  );
}

/** The share card as a default kit ships it: square corners, ragged roles, mixed heights. */
function PlainCard() {
  return (
    <div className="w-full max-w-72 rounded-control-2xs border border-line-strong bg-surface p-3 text-ui-sm">
      <p className="font-medium text-text">Share “Q4 roadmap”</p>
      <div className="mt-2 flex gap-1.5">
        <div className="flex h-8 min-w-0 flex-1 items-center rounded-control-2xs border border-line-strong px-2 text-text-muted">
          Add people…
        </div>
        <div className="flex h-9 items-center rounded-control-2xs bg-brand px-2.5 text-text-on-brand">Invite</div>
      </div>
      <ul className="mt-3 flex flex-col gap-2.5">
        {PEOPLE.map((p) => (
          <li key={p.email} className="flex items-center justify-between gap-2">
            <div className="flex min-w-0 items-center gap-2">
              <span className="flex size-7 shrink-0 items-center justify-center rounded-control-2xs bg-surface-hover text-ui-2xs text-text-secondary">
                {p.name[0]}
              </span>
              <span className="truncate text-text-secondary">{p.name}</span>
            </div>
            <span className="shrink-0 text-text-secondary">{p.role}</span>
          </li>
        ))}
      </ul>
      <div className="mt-3 flex items-center justify-between border-t border-line-strong pt-3">
        <span className="px-2 text-text-secondary">Copy link</span>
        <div className="flex h-8 items-center rounded-control-2xs border border-line-strong px-4 text-text">Done</div>
      </div>
    </div>
  );
}

/** The same card on Eased: concentric corners, one leading edge, roles in a fixed track. */
function EasedCard({ loading = false }: { loading?: boolean }) {
  return (
    <div className="w-full max-w-72 rounded-panel border border-line bg-surface-raised p-(--pad) shadow-floating [--pad:12px] [--radius-outer:var(--eased-radius-panel)]">
      <Skeleton loading={loading} text block>
        <p className="text-ui-sm font-medium text-text">Share “Q4 roadmap”</p>
      </Skeleton>
      <div className="mt-2 flex gap-1.5">
        <Skeleton loading={loading} block className="min-w-0 flex-1 radius-nested">
          <div className="flex h-control-sm min-w-0 items-center rounded-control-sm border border-line-strong px-2.5 text-ui-sm text-text-muted">
            Add people…
          </div>
        </Skeleton>
        <Skeleton loading={loading} block className="radius-nested">
          <Button size="sm" variant="primary" tabIndex={-1}>
            Invite
          </Button>
        </Skeleton>
      </div>
      <ul className={`${rowGrid} mt-3 gap-x-2.5 gap-y-2 [--col:3.6875rem]`}>
        {PEOPLE.map((p) => (
          <li key={p.email} className={`${rowGridRow} h-7`}>
            <Skeleton loading={loading} className="size-6 rounded-pill">
              <Avatar name={p.name} size="sm" />
            </Skeleton>
            <Skeleton loading={loading} text>
              <span className="block truncate text-ui-sm text-text">{p.name}</span>
            </Skeleton>
            <Skeleton loading={loading} text>
              <span className="block text-ui-sm text-text-muted">{p.role}</span>
            </Skeleton>
          </li>
        ))}
      </ul>
      <div className="mt-3 flex items-center justify-between border-t border-line-subtle pt-3">
        <Skeleton loading={loading} text>
          <Button size="sm" variant="ghost" edge="start" tabIndex={-1}>
            Copy link
          </Button>
        </Skeleton>
        <Skeleton loading={loading} block className="rounded-control-sm">
          <Button size="sm" tabIndex={-1}>
            Done
          </Button>
        </Skeleton>
      </div>
    </div>
  );
}

/**
 * "Plain vs Eased": the same share card as a default kit draws it and as Eased does, with
 * one divider between them. Switch to Loading to compare the skeleton with the loaded card.
 */
export function ImageCompareDemo() {
  const [mode, setMode] = useState<Mode>("polish");
  const [value, setValue] = useState(50);

  return (
    <DemoFrame
      title="Compare"
      actions={
        <Segmented<Mode>
          aria-label="What to compare"
          value={mode}
          onValueChange={setMode}
          options={[
            { value: "polish", label: "Polish" },
            { value: "loading", label: "Loading" },
          ]}
        />
      }
    >
      <div className="flex min-h-0 flex-1 flex-col p-(--pad)">
        <ImageCompare
          key={mode}
          aria-label={mode === "polish" ? "Compare the plain card with the Eased card" : "Compare the loading card with the loaded card"}
          defaultValue={value}
          onValueChange={setValue}
          beforeLabel={mode === "polish" ? "Plain" : "Loading"}
          afterLabel={mode === "polish" ? "Eased" : "Loaded"}
          className="min-h-0 flex-1 rounded-control border border-line-subtle"
          before={
            mode === "polish" ? (
              <Stage>
                <PlainCard />
              </Stage>
            ) : (
              <Stage tone="eased">
                <EasedCard loading />
              </Stage>
            )
          }
          after={
            <Stage tone="eased">
              <EasedCard />
            </Stage>
          }
        />
      </div>
    </DemoFrame>
  );
}
```
