WebberUI

Image Compare

Drag the divider in the middle to compare a before and an after layer, clipped with clip-path and following smoothly on a spring.

Loading preview…
npx shadcn@latest add https://webberui.com/r/image-compare.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

50
<ImageCompare />

Installation

npx shadcn@latest add https://webberui.com/r/image-compare.json

Usage

before / after accept any ReactNode — most commonly an image, but a video, a code block, or an entire stretch of UI all work. before is the lower layer, shown to the left of the divider; after is the upper layer, clipped by the divider position and shown to the right. It is best to give the container a size in className (such as aspect-video) and let both layers fill it with h-full w-full:

import { ImageCompare } from "@/components/ui/image-compare";

<ImageCompare
  before={
    <img src="/photo-before.jpg" alt="Before grading" className="h-full w-full object-cover" />
  }
  after={
    <img src="/photo-after.jpg" alt="After grading" className="h-full w-full object-cover" />
  }
  beforeLabel="Before"
  afterLabel="After"
  initial={40}
  onChange={(value) => console.log(value)}
  className="aspect-video w-full max-w-2xl rounded-xl"
/>

Props

PropTypeDefaultDescription
beforeReactNodeThe lower layer (left of the divider), which sizes the container in the document flow
afterReactNodeThe upper layer (right of the divider), clipped with clip-path by the divider position
initialnumber50Initial divider position (0–100 percent)
beforeLabelReactNodeTop-left label: fades in on reveal and fades out to get out of the way as the divider is dragged close
afterLabelReactNodeTop-right label: fades in on reveal and fades out to get out of the way as the divider is dragged close
onChange(value: number) => voidCallback when the divider position changes (0–100)
classNamestringAppended to the outermost container; it is best to set the size and corner radius here

Accessibility

  • The handle is a focusable role="slider" element with aria-valuemin / aria-valuemax / aria-valuenow / aria-valuetext, so assistive technology announces the current split percentage live
  • Keyboard operation: once the handle is focused with Tab, / subtract 2%, / add 2%, Home jumps to 0, and End jumps to 100
  • When the user has "reduce motion" enabled at the system level, the divider does not smooth through a spring and jumps straight to the target position, and the labels do not play their reveal animation either

How it works

  • Smooth spring tracking: the divider position lives in a useMotionValue (a percentage), and the on-screen value is smoothed with useSpring before driving clip-path: inset() and the divider's left, which gives dragging a bit of trailing momentum
  • The whole container is the hot zone: pressing the pointer anywhere jumps to that position and starts dragging (setPointerCapture keeps it from breaking off when you drag outside the container), so you never have to hit the handle precisely
  • Touch support: the handle sets touch-action: none, so dragging from it on a phone does not accidentally scroll the page; the container also intercepts the native image drag (dragstart) to avoid interrupting the gesture
  • Labels getting out of the way: the opacity of the two corner labels is driven by the divider position — they fade out progressively as the divider gets close, within the 8–22% (left) or 78–92% (right) range, so they never cover the content being compared
  • The animation runs only on clip-path and transform, so it never triggers layout

On this page