# Image Compare (Moonarc)

Compares a before and an after image with a line you drag between them. A native range input lies over the box, invisible, so dragging, clicking to jump, the arrow keys, touch and the screen reader are the browser's; the script writes its value to one custom property and the after image is clipped to it. Held, it follows with no lag; stepped, it glides on the fast token.

- Import: `import ImageCompare from '@moonarc/core/ImageCompare'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/image-compare.json`
- Tier B · category ui · trigger pointer, click
- Readout: `<ImageCompare start={50}>`
- Browser support: newly (Chrome 124 · Firefox 121 · Safari 16.5); elsewhere: without :has() the line glides while held too; without vertical ranges, orientation="vertical" keeps a horizontal range over the box, so its line follows a sideways drag
- Measured cost: 205 B raw JS · 177 B gzip · + runtime (with dependencies 1.9 kB raw; CSS 7.5 kB raw)
- Page: https://moonarc.dev/components/image-compare/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `before` | `string` | none | The image on the inline start (the left, or the right on a right-to-left page), or the top. |
| `after` | `string` | none | The image on the inline end, or the bottom. |
| `beforeAlt` | `string` | `''` | Alt text of the before image. |
| `afterAlt` | `string` | `''` | Alt text of the after image. |
| `start` | `number` | `50` | Initial position of the line, 0–100. Without JavaScript the line stands here. |
| `orientation` | `'horizontal' | 'vertical'` | `'horizontal'` | Which way the line runs; vertical writes the range top to bottom. |
| `labels` | `[string, string] | false` | `['Before', 'After']` | Corner labels. |
| `label` | `string` | `'Compare'` | Accessible name of the slider. |

## Usage

```astro
<ImageCompare
  before="/work/sketch.jpg"
  after="/work/final.jpg"
  beforeAlt="Pencil sketch of the poster"
  afterAlt="The printed poster"
  start={40}
/>

<ImageCompare before={a} after={b} orientation="vertical" labels={['Day', 'Night']} />
```

## Reduced motion

No gliding: the line moves at once on a key or a click too.

## With ClientRouter

Bound through the shared runtime; one input listener per instance, aborted before the swap.

## Craft

- The control is a real range input over the whole box: keyboard, screen reader ("Compare, slider, 50 %"), touch and click-to-jump come with it, and the drag needs no pointer capture of its own.
- The script is one line (the value into a custom property), and the geometry is CSS: clip-path: inset() on the after image and the handle's inset read the same property.
- Held, the clip must not lag: :has(input:active) removes the transition while the thumb is down and restores it for a keyboard step or a click, which then glide on the fast token.
- Both images are laid in one grid cell so the box is the size of the picture and the two can never drift apart; clip-path costs no layout.
- touch-action: pan-y on the horizontal range: a sideways drag moves the line, a vertical one scrolls the page.
- Right to left, the native range runs from the right, so the clip and the handle are measured from the right under a dir="rtl" ancestor and the line stays under the pointer; the vertical range is pinned to direction: ltr so it always runs top to bottom.

## Replaces

- Compare (Aceternity)
- ImageComparison (Motion Primitives)
- react-compare-image
- Juxtapose

## Source

```astro
---
/**
 * ImageCompare — before and after, a line you drag between them. A native
 * <input type="range"> lies over the whole box, invisible: the drag, the
 * click-to-jump, the arrow keys, the touch and the screen reader are the
 * browser's. The script writes the value to one custom property; the
 * "after" image is clipped to it with clip-path: inset() and the handle
 * sits on it. While the thumb is held there is no transition; a keyboard
 * step or a click glides on the fast token. Without JavaScript the line
 * stands at `start` and both halves show. Right to left, the range runs
 * from the right, so "before" sits on the inline start (the right) and the
 * line still follows the pointer.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** The image under the inline-start (left; right on a right-to-left page) or top half. */
  before: string;
  /** The image under the inline-end or bottom half. */
  after: string;
  beforeAlt?: string;
  afterAlt?: string;
  /** Initial position, 0–100. */
  start?: number;
  orientation?: 'horizontal' | 'vertical';
  /** Corner labels; false hides them. */
  labels?: [string, string] | false;
  /** Accessible name of the slider. */
  label?: string;
}

const { before, after, beforeAlt = '', afterAlt = '', start = 50, orientation = 'horizontal', labels = ['Before', 'After'], label = 'Compare', class: className, style, ...rest } = Astro.props;
const inline = [`--ma-imgcmp:${Math.max(0, Math.min(100, start))}%`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-imgcmp', className]} data-ma-imgcmp data-orientation={orientation} style={inline} {...rest}>
  <img class="ma-imgcmp__before" src={before} alt={beforeAlt} draggable="false" />
  <img class="ma-imgcmp__after" src={after} alt={afterAlt} draggable="false" />
  {labels && (
    <>
      <span class="ma-imgcmp__label ma-imgcmp__label--a" aria-hidden="true">{labels[0]}</span>
      <span class="ma-imgcmp__label ma-imgcmp__label--b" aria-hidden="true">{labels[1]}</span>
    </>
  )}
  <span class="ma-imgcmp__handle" aria-hidden="true">
    <span class="ma-imgcmp__knob">
      <svg viewBox="0 0 20 20" width="16" height="16"><path d="M7 6l-4 4 4 4M13 6l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /></svg>
    </span>
  </span>
  <input type="range" class="ma-imgcmp__range" min="0" max="100" step="0.5" value={start} aria-label={label} />
</div>

<style is:global>
  @layer components {
    :where(.ma-imgcmp) {
      position: relative;
      display: grid;
      overflow: clip;
      border-radius: 0.75rem;
      background: var(--ma-edge);
      user-select: none;
      -webkit-user-select: none;
    }
    :where(.ma-imgcmp__before),
    :where(.ma-imgcmp__after) {
      grid-area: 1 / 1;
      display: block;
      width: 100%;
      height: auto;
      object-fit: cover;
    }
    /* the after image shows from the line to the right; while the thumb is held the clip follows with no lag */
    :where(.ma-imgcmp__after) {
      clip-path: inset(0 0 0 var(--ma-imgcmp, 50%));
      transition: clip-path var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-imgcmp__handle) {
      position: absolute;
      top: 0;
      bottom: 0;
      left: var(--ma-imgcmp, 50%);
      display: grid;
      place-items: center;
      width: 2px;
      background: var(--ma-panel);
      box-shadow: 0 0 0 1px rgb(0 0 0 / 0.15);
      translate: -50% 0;
      pointer-events: none;
      transition: left var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-imgcmp:has(.ma-imgcmp__range:active) .ma-imgcmp__after),
    :where(.ma-imgcmp:has(.ma-imgcmp__range:active) .ma-imgcmp__handle) {
      transition: none;
    }
    :where(.ma-imgcmp__knob) {
      display: grid;
      place-items: center;
      width: 2.5rem;
      height: 2.5rem;
      border: 1px solid var(--ma-edge);
      border-radius: 999px;
      background: var(--ma-panel);
      color: var(--ma-ink);
      box-shadow: 0 8px 24px -12px rgb(0 0 0 / 0.5);
      transition: scale var(--ma-duration) var(--ma-ease), box-shadow var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-imgcmp:has(.ma-imgcmp__range:active) .ma-imgcmp__knob),
    :where(.ma-imgcmp:has(.ma-imgcmp__range:hover) .ma-imgcmp__knob) {
      scale: 1.08;
    }
    :where(.ma-imgcmp:has(.ma-imgcmp__range:focus-visible) .ma-imgcmp__knob) {
      box-shadow: 0 0 0 3px var(--ma-ink);
    }
    /* the real control, invisible over the whole box: drag, click, keys, touch and the screen reader are native */
    :where(.ma-imgcmp__range) {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      margin: 0;
      opacity: 0;
      cursor: ew-resize;
      touch-action: pan-y;
    }
    :where(.ma-imgcmp__label) {
      position: absolute;
      top: 0.75rem;
      padding: 0.25em 0.6em;
      border-radius: 999px;
      background: var(--ma-panel);
      color: var(--ma-ink);
      font-family: ui-monospace, monospace;
      font-size: 0.6875rem;
      letter-spacing: 0.06em;
      text-transform: uppercase;
      pointer-events: none;
    }
    :where(.ma-imgcmp__label--a) {
      inset-inline-start: 0.75rem;
    }
    :where(.ma-imgcmp__label--b) {
      inset-inline-end: 0.75rem;
    }
    /* right to left: a horizontal range runs from the right, so the value is measured from the right edge and "after" shows to
       the left of the line (inset() and translate have no logical form). [dir] and not :dir(): Chrome has :dir() only
       from 120, and :where() drops it silently before that. An ltr island one level inside an rtl page is set back */
    :where([dir='rtl'] .ma-imgcmp:not([data-orientation='vertical']) .ma-imgcmp__after, .ma-imgcmp[dir='rtl']:not([data-orientation='vertical']) .ma-imgcmp__after) {
      clip-path: inset(0 var(--ma-imgcmp, 50%) 0 0);
    }
    :where([dir='rtl'] .ma-imgcmp:not([data-orientation='vertical']) .ma-imgcmp__handle, .ma-imgcmp[dir='rtl']:not([data-orientation='vertical']) .ma-imgcmp__handle) {
      left: auto;
      right: var(--ma-imgcmp, 50%);
      translate: 50% 0;
      transition-property: right;
    }
    :where([dir='rtl'] [dir='ltr'] .ma-imgcmp:not([data-orientation='vertical']) .ma-imgcmp__after, [dir='rtl'] .ma-imgcmp[dir='ltr']:not([data-orientation='vertical']) .ma-imgcmp__after) {
      clip-path: inset(0 0 0 var(--ma-imgcmp, 50%));
    }
    :where([dir='rtl'] [dir='ltr'] .ma-imgcmp:not([data-orientation='vertical']) .ma-imgcmp__handle, [dir='rtl'] .ma-imgcmp[dir='ltr']:not([data-orientation='vertical']) .ma-imgcmp__handle) {
      left: var(--ma-imgcmp, 50%);
      right: auto;
      translate: -50% 0;
      transition-property: left;
    }
    /* vertical: the line runs across; the range is written top to bottom */
    :where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__after) {
      clip-path: inset(var(--ma-imgcmp, 50%) 0 0 0);
    }
    :where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__handle) {
      top: var(--ma-imgcmp, 50%);
      bottom: auto;
      left: 0;
      right: 0;
      width: auto;
      height: 2px;
      translate: 0 -50%;
      transition: top var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__knob svg) {
      rotate: 90deg;
    }
    /* direction: ltr keeps the vertical range top to bottom on a right-to-left page too (rtl would run it bottom to top) */
    :where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__range) {
      writing-mode: vertical-lr;
      direction: ltr;
      cursor: ns-resize;
      touch-action: pan-x;
    }
    :where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__label--b) {
      top: auto;
      bottom: 0.75rem;
      inset-inline-end: auto;
      inset-inline-start: 0.75rem;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-imgcmp__after),
      :where(.ma-imgcmp__handle),
      :where(.ma-imgcmp__knob) {
        transition: none;
      }
    }
  }
</style>

<script>
  import { onMount } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-imgcmp]', (el, { signal }) => {
    const range = el.querySelector('input')!;
    range.addEventListener('input', () => el.style.setProperty('--ma-imgcmp', `${range.value}%`), { signal });
  });
</script>

```
