---
title: Image Compare
description: "Astro before-and-after pictures with a pointer and keyboard divider."
seo:
    title: "Image Compare for Astro — examples and usage"
---

```astro
---
import { getImage } from "astro:assets"
import {
  ImageCompareHandle,
  ImageCompareImage,
  ImageCompare,
} from "@/components/ui/image-compare"
import cave from "@/assets/wizard/gnostic-ai-wizard-on-cave-edge.png"
import town from "@/assets/wizard/gnostic-ai-wizard-overlook-town.png"

const [sunset, daylight] = await Promise.all([
  getImage({ src: cave, width: 1600, format: "webp" }),
  getImage({ src: town, width: 1600, format: "webp" }),
])
---

<ImageCompare class="rounded-xl" label="Amount of the sunset picture shown">
  <ImageCompareImage
    src={daylight.src}
    alt="A wizard at a cave entrance looks over a town in daylight."
    width={daylight.attributes.width}
    height={daylight.attributes.height}
  />
  <ImageCompareImage
    src={sunset.src}
    alt="A wizard at a cave entrance looks over a town at sunset."
    width={sunset.attributes.width}
    height={sunset.attributes.height}
    clip
  />
  <span class="bg-background/80 text-foreground pointer-events-none absolute top-4 left-4 z-20 rounded px-3 py-1 text-sm">
    Sunset
  </span>
  <span class="bg-background/80 text-foreground pointer-events-none absolute top-4 right-4 z-20 rounded px-3 py-1 text-sm">
    Daylight
  </span>
  <ImageCompareHandle />
</ImageCompare>
```

## Installation

### Command

```sh
bunx --bun shadcn@latest add @gnostic/image-compare
```

### Manual

Complete the [Installation guide](/docs/installation) first. Then copy the component files below.

No extra packages are required.

#### src/components/ui/image-compare/image-compare-handle.astro

```astro
---
import type { HTMLAttributes } from "astro/types"

import { cn } from "cn"
import { Button } from "../button/index.ts"
import { Icon } from "../icon/index.ts"

/**
 * Decorative divider between the two pictures.
 * Div attributes such as `class` pass through.
 * The default slot replaces the arrows icon.
 * Keyboard input stays on the range control in `ImageCompare`.
 */
type Props = HTMLAttributes<"div">

const { class: className, ...props } = Astro.props
---

<div
  data-slot="image-compare-handle"
  aria-hidden="true"
  inert
  class={cn(
    "image-compare-divider pointer-events-none absolute inset-y-0 z-30 w-0.5 -translate-x-1/2 bg-background/72 shadow-md",
    className,
  )}
  {...props}
>
  <Button
    variant="glass"
    size="icon-xl"
    tabindex="-1"
    class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full"
  >
    <slot>
      <Icon
        name="lucide:MoveHorizontal"
        class="text-white/32 group-hover/image-compare:text-white/64"
      />
    </slot>
  </Button>
</div>

<style>
  /* Follow the same position variable as the clipped picture. */
  .image-compare-divider {
    left: var(--image-compare-position, 50%);
  }
</style>
```

#### src/components/ui/image-compare/image-compare-image.astro

```astro
---
import type { HTMLAttributes } from "astro/types"

import { cn } from "cn"
import type { ImageCompareImageProps } from "./types.ts"

/** Image attributes pass through. `src`, `alt`, and `clip` come from `ImageCompareImageProps`. */
type Props = Omit<HTMLAttributes<"img">, "src" | "alt"> & ImageCompareImageProps

const {
  class: className,
  src,
  alt,
  width,
  height,
  clip = false,
  draggable = false,
  ...props
} = Astro.props

/**
 * An imported picture carries its own pixel size.
 * A plain address uses the 16:9 fallback unless the caller sets a size.
 * `draggable` is the string "true" or "false". A boolean false would omit the attribute.
 */
const resolvedSrc = typeof src === "string" ? src : src.src
const resolvedWidth = width ?? (typeof src === "string" ? 1200 : src.width)
const resolvedHeight = height ?? (typeof src === "string" ? 675 : src.height)
const imageDraggable =
  draggable === true || draggable === "true" ? "true" : "false"
---

<img
  data-slot="image-compare-image"
  data-clip={clip ? "true" : undefined}
  src={resolvedSrc}
  alt={alt}
  width={resolvedWidth}
  height={resolvedHeight}
  draggable={imageDraggable}
  class={cn(
    "pointer-events-none absolute inset-0 size-full object-cover",
    clip ? "image-compare-clip z-10" : "z-0",
    className,
  )}
  {...props}
/>

<style>
  /* Reveal the left portion. The root sets --image-compare-position. */
  .image-compare-clip {
    clip-path: inset(0 calc(100% - var(--image-compare-position, 50%)) 0 0);
  }
</style>
```

#### src/components/ui/image-compare/image-compare.astro

```astro
---
import type { HTMLAttributes } from "astro/types"

import { cn } from "cn"
import type { ImageCompareProps } from "./types.ts"

/**
 * Comparison frame.
 * Place two `ImageCompareImage` parts and one `ImageCompareHandle` in the slot.
 * Div attributes pass through. `defaultValue` and `label` come from `ImageCompareProps`.
 */
type Props = HTMLAttributes<"div"> & ImageCompareProps

const {
  class: className,
  defaultValue = 50,
  label = "Amount of the clipped image shown",
  style,
  ...props
} = Astro.props

/**
 * Keep the divider inside the frame.
 * A non-numeric value falls back to the center.
 */
const position = Number.isFinite(defaultValue)
  ? Math.min(100, Math.max(0, defaultValue))
  : 50

/** String form so a value of 0 stays on the range control. */
const positionValue = String(position)
const positionStyle = `--image-compare-position:${positionValue}%`
const mergedStyle =
  typeof style === "string"
    ? `${style};${positionStyle}`
    : { ...style, "--image-compare-position": `${positionValue}%` }
---

<div
  data-slot="image-compare"
  style={mergedStyle}
  class={cn(
    "group/image-compare relative block aspect-video w-full cursor-ew-resize touch-pan-y overflow-hidden select-none",
    className,
  )}
  {...props}
>
  <slot />
  <input
    data-slot="image-compare-input"
    type="range"
    min="0"
    max="100"
    step="1"
    value={positionValue}
    aria-label={label}
    class="pointer-events-none sr-only"
  />
</div>

<style>
  /* The range control is hidden. Show its focus on the visible handle. */
  :global(
    [data-slot="image-compare"]:has(
        [data-slot="image-compare-input"]:focus-visible
      )
      [data-slot="image-compare-handle"]
      [data-slot="button"]
  ) {
    outline: 3px solid var(--ring);
    outline-offset: 3px;
  }
</style>

<script>
  /** Roots already bound on this page. A later page load skips them. */
  const bound = new WeakSet<HTMLElement>()

  /**
   * Drag on the frame, or arrow keys on the range control, moves the divider.
   * The pictures read `--image-compare-position` from this root.
   */
  function bindImageCompare(root: HTMLElement) {
    if (bound.has(root)) return
    const input = root.querySelector<HTMLInputElement>(
      "[data-slot='image-compare-input']",
    )
    if (!input) return
    bound.add(root)

    const updatePosition = () => {
      const value = Math.min(100, Math.max(0, Number(input.value)))
      root.style.setProperty("--image-compare-position", `${value}%`)
    }
    input.addEventListener("input", updatePosition)
    updatePosition()

    /** Map a pointer x position to a percent of the frame width. */
    const updateFromPointer = (clientX: number) => {
      const bounds = root.getBoundingClientRect()
      if (bounds.width === 0) return
      const ratio = (clientX - bounds.left) / bounds.width
      const value = Math.round(Math.min(1, Math.max(0, ratio)) * 100)
      input.value = String(value)
      input.dispatchEvent(new Event("input", { bubbles: true }))
    }

    root.addEventListener("pointerdown", (event) => {
      if (event.pointerType === "mouse" && event.button !== 0) return
      const target = event.target
      if (target instanceof Element) {
        const interactive = target.closest(
          "a, button, input, textarea, select, label",
        )
        if (interactive && interactive !== input) return
      }
      if (event.pointerType === "mouse") event.preventDefault()
      input.focus({ preventScroll: true })
      root.setPointerCapture(event.pointerId)
      updateFromPointer(event.clientX)
    })

    root.addEventListener("pointermove", (event) => {
      if (!root.hasPointerCapture(event.pointerId)) return
      updateFromPointer(event.clientX)
    })
  }

  const initialize = () => {
    for (const root of document.querySelectorAll<HTMLElement>(
      "[data-slot='image-compare']",
    ))
      bindImageCompare(root)
  }

  initialize()
  document.addEventListener("astro:page-load", initialize)
</script>
```

#### src/components/ui/image-compare/index.ts

```ts
export { default as ImageCompareHandle } from './image-compare-handle.astro'
export { default as ImageCompareImage } from './image-compare-image.astro'
export { default as ImageCompare } from './image-compare.astro'

export type { ImageCompareImageProps, ImageCompareProps } from './types.ts'
```

#### src/components/ui/image-compare/types.ts

```ts
import type { ImageMetadata } from 'astro'

/**
 * Props for the comparison frame.
 * Div attributes such as `class` and `id` pass through.
 * Put both pictures and the handle in the default slot.
 */
export interface ImageCompareProps {
    /**
     * Divider position at first render, from 0 to 100.
     * `0` hides the clipped picture. `100` covers the frame with it.
     * @default 50
     */
    defaultValue?: number
    /**
     * Accessible name for the range control.
     * @default "Amount of the clipped image shown"
     */
    label?: string
}

/**
 * Props for one picture in a comparison.
 * Image attributes such as `class`, `width`, `height`, and `loading` pass through.
 */
export interface ImageCompareImageProps {
    /**
     * Picture file or address.
     */
    src: ImageMetadata | string
    /**
     * Text alternative for this picture.
     */
    alt: string
    /**
     * Show only the part of this picture to the left of the divider.
     * Set this on one picture. Leave it unset on the picture that fills the frame.
     * @default false
     */
    clip?: boolean
}
```

Optional preset: inspect a stylesheet at /presets/{style}.css, save it to src/styles/presets/{style}.css, and import it after the global stylesheet. Set data-preset="{style}" on your layout or a component container. See [Theming](/docs/theming) for details.


## Usage

```astro
---
import {
  ImageCompareHandle,
  ImageCompareImage,
  ImageCompare,
} from "@/components/ui/image-compare"
---
```

```astro
<ImageCompare>
  <ImageCompareImage src={daylight} alt="Town in daylight" />
  <ImageCompareImage src={sunset} alt="Town at sunset" clip />
  <ImageCompareHandle />
</ImageCompare>
```

## Composition

Put the full picture first. Set `clip` on the picture that shows to the left of the divider. Place `ImageCompareHandle` after the pictures. Corner labels are normal elements in the same slot.

```text
ImageCompare
├── ImageCompareImage
├── ImageCompareImage clip
└── ImageCompareHandle
```

The handle slot replaces the arrows icon. The divider does not take keyboard focus. The range control on `ImageCompare` does.

## API Reference

### ImageCompare

<AutoTypeTable path="../../packages/ui/src/components/ui/image-compare/types.ts" name="ImageCompareProps" />

Div attributes such as `class` and `id` pass through.

### ImageCompareImage

<AutoTypeTable path="../../packages/ui/src/components/ui/image-compare/types.ts" name="ImageCompareImageProps" />

Image attributes such as `class`, `width`, `height`, and `loading` pass through.

### ImageCompareHandle

`ImageCompareHandle` accepts div attributes such as `class`. Put a custom icon in its default slot.

Read the [installed source](/r/image-compare.json) for the component source.

## When to use Image Compare

Use Image Compare for two pictures of the same subject. Drag across the frame, or focus the control and use the arrow keys. Set `defaultValue` when the comparison should start away from the center.

## Keyboard and behavior

Tab reaches the range control. Arrow keys move the divider. A pointer drag on the frame does the same. The control name comes from `label`. Give each picture its own `alt` text.

## Theme and layout

The frame uses a 16:9 ratio. Pass another `aspect-*` class to change it. The divider uses the background color, and the handle uses the glass button. Check both pictures in light and dark mode.

## Related choices

Use [Slider](/components/slider) for a numeric range. Use [Lightbox](/components/lightbox) to open one picture.
