Skip to content
goodtabs UI
BrowseBrowse components

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

Concentric ringsDiagonal linesBeforeAfter

Two images split at the middle, with labels on both sides.

Offset 16:9

Vertical stripesSoft dunesDraftShipped

Starts 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

PropTypeDefaultDescription
beforeSrcstringImage on the left of the divider.
afterSrcstringImage on the right of the divider.
beforeAltstringAlt text of the before image.
afterAltstringAlt text of the after image.
beforeLabelstringSmall label over the before side.
afterLabelstringSmall label over the after side.
defaultPositionnumber50Starting position, 0 to 100.
aspectstring"3 / 2"Aspect ratio of the frame.
labelstring"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.