Skip to content
gnosticaignostic-ui
Esc
↑↓navigate↵open⌘Jpreview
On this page

Image Compare

Astro before-and-after pictures with a pointer and keyboard divider.

A wizard at a cave entrance looks over a town in daylight.A wizard at a cave entrance looks over a town at sunset.SunsetDaylight

Installation

bunx shadcn@latest add @gnostic/image-compare
npx shadcn@latest add @gnostic/image-compare
pnpm dlx shadcn@latest add @gnostic/image-compare

Complete the Installation guide first. Then copy the component files below.

No extra packages are required.

Copy the files

src/components/ui/image-compare/image-compare-handle.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
---
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
---
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
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
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
}

Add a preset (optional)

Default Coss works with the component classes. To use another design, inspect and save its CSS to src/styles/presets/. Import it after the global stylesheet, then set data-preset on your layout or a container. These files also work with JavaScript disabled.

Usage

---
import {
  ImageCompareHandle,
  ImageCompareImage,
  ImageCompare,
} from "@/components/ui/image-compare"
---
<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.

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

PropType
defaultValue?number

Divider position at first render, from 0 to 100. `0` hides the clipped picture. `100` covers the frame with it.

Typenumber
Default50
label?string

Accessible name for the range control.

Typestring
Default"Amount of the clipped image shown"

Div attributes such as class and id pass through.

ImageCompareImage

PropType
srcImageMetadata | string

Picture file or address.

TypeImageMetadata | string
altstring

Text alternative for this picture.

Typestring
clip?boolean

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.

Typeboolean
Defaultfalse

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 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.

Use Slider for a numeric range. Use Lightbox to open one picture.

Was this page helpful?