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

SunsetDaylight---
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
bunx shadcn@latest add @gnostic/image-comparenpx shadcn@latest add @gnostic/image-comparepnpm dlx shadcn@latest add @gnostic/image-compareComplete 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
defaultValue?number
Divider position at first render, from 0 to 100. `0` hides the clipped picture. `100` covers the frame with it.
number50label?string
Accessible name for the range control.
string"Amount of the clipped image shown"Div attributes such as class and id pass through.
ImageCompareImage
srcImageMetadata | string
Picture file or address.
ImageMetadata | stringaltstring
Text alternative for this picture.
stringclip?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.
booleanfalseImage 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.
Related choices
Use Slider for a numeric range. Use Lightbox to open one picture.