Image compare
A before and after slider built on a real range input, with full keyboard support.
npx shadcn@latest add https://ui.goodtabs.co/r/image-compare.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/image-compare

BeforeAfterTwo images split at the middle, with labels on both sides.
Offset 16:9

DraftShippedStarts at 25 percent, in a wider frame, with a custom slider name.
Usage
import { ImageCompare } from "@/components/ui/image-compare"
<ImageCompare beforeSrc="/old.png" afterSrc="/new.png" beforeAlt="The old header" afterAlt="The new header" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| beforeSrc | string | Image on the left of the divider. | |
| afterSrc | string | Image on the right of the divider. | |
| beforeAlt | string | Alt text of the before image. | |
| afterAlt | string | Alt text of the after image. | |
| beforeLabel | string | Small label over the before side. | |
| afterLabel | string | Small label over the after side. | |
| defaultPosition | number | 50 | Starting position, 0 to 100. |
| aspect | string | "3 / 2" | Aspect ratio of the frame. |
| label | string | "Compare before and after" | Accessible name of the range input. |
Source
image-compare/image-compare.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type ImageCompareProps = Omit<React.ComponentProps<"div">, "children"> & {
/** Source of the before image, shown on the left of the divider. */
beforeSrc: string;
/** Source of the after image, shown on the right. */
afterSrc: string;
/** Alt text of the before image. */
beforeAlt: string;
/** Alt text of the after image. */
afterAlt: string;
/** Small label over the before side. */
beforeLabel?: string;
/** Small label over the after side. */
afterLabel?: string;
/** Starting position of the divider, 0 to 100. */
defaultPosition?: number;
/** Aspect ratio of the frame, as width / height. */
aspect?: string;
/** Accessible name of the slider. */
label?: string;
};
/**
* A before and after slider. The control is a real range input laid over the images,
* so arrow keys, Home, End, touch and mouse all work natively.
*/
export function ImageCompare({
beforeSrc,
afterSrc,
beforeAlt,
afterAlt,
beforeLabel,
afterLabel,
defaultPosition = 50,
aspect = "3 / 2",
label = "Compare before and after",
className,
style,
...props
}: ImageCompareProps) {
const [pos, setPos] = React.useState(defaultPosition);
const tag =
"absolute top-2 z-10 rounded-full border bg-background/80 px-2 py-0.5 font-mono text-[10px] text-foreground";
return (
<div
className={cn("relative w-full overflow-hidden rounded-lg border bg-muted select-none", className)}
style={{ aspectRatio: aspect, ...style }}
{...props}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={afterSrc} alt={afterAlt} draggable={false} className="absolute inset-0 size-full object-cover" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={beforeSrc}
alt={beforeAlt}
draggable={false}
className="absolute inset-0 size-full object-cover"
style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }}
/>
{beforeLabel && <span className={cn(tag, "left-2")}>{beforeLabel}</span>}
{afterLabel && <span className={cn(tag, "right-2")}>{afterLabel}</span>}
<input
type="range"
min={0}
max={100}
step={1}
value={pos}
onChange={(e) => setPos(Number(e.target.value))}
aria-label={label}
aria-valuetext={`${pos}% before, ${100 - pos}% after`}
className="peer absolute inset-0 z-20 m-0 size-full cursor-ew-resize appearance-none bg-transparent opacity-0"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 z-10 w-px -translate-x-1/2 bg-foreground peer-focus-visible:[&>div]:ring-3 peer-focus-visible:[&>div]:ring-ring/50"
style={{ left: `${pos}%` }}
>
<div className="absolute top-1/2 left-1/2 flex size-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border bg-background text-foreground shadow-xs">
<svg viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 4 2 8l4 4M10 4l4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/image-compare/prompt.md, or the Copy AI prompt button above.
When to use
- Showing a redesign, an edit or a render change on one spot
- Two images of the same size and framing
When not to use
- Images with different framing; the split will not line up
- More than two states, where tabs or a gallery work better
Agent notes
- Client component; the control is a transparent range input over the images, so keys, touch and pointer are native
- Arrow keys move 1 percent, Home and End jump to the edges, and aria-valuetext reads the split aloud
- Both images use object-cover in the same frame, so give them the same aspect ratio
- The reveal uses clip-path on the before image; nothing animates, so reduced motion needs no handling
The maths
- The before image is clipped with inset(0 (100 - p)% 0 0), so at p = 30 it shows the left 30 percent.
- The divider sits at left = p%, and the range input maps its 0 to 100 value to that same p.