Compare Slider

Two images stacked, with a handle that wipes between them. Side-by-side thumbnails make the reader diff from memory; a wipe puts both states in the same pixels, so the difference is the only thing that moves.

src/components/ui/compare-slider.tsx

About

From the component's own source, where it was written.

Two images stacked, with a handle that wipes between them.

The honest way to show a before and after. Side-by-side thumbnails make the reader do the diffing from memory and lose every small change; a wipe puts the two states in the same pixels, so the difference is the only thing that moves.

The overlay is clipped with inset() rather than resized, which matters: a resized image re-lays-out and re-samples on every pointer move, so a subtle difference gets buried under scaling artefacts and the drag janks. Clipping moves a rectangle over an image that never changes size.

The handle is an <input type="range"> in disguise. It looks like a divider, but it is a real slider, so arrow keys, Home and End all work and it announces itself properly — none of which is true of the div with a pointer handler that this component usually is.

Install

npx astralyx-ui add compare-slider

Usage

import { CompareSlider } from '@/components/ui/compare-slider'

<CompareSlider
  before={<img src="/before.jpg" alt="Original" />}
  after={<img src="/after.jpg" alt="Upscaled" />}
  beforeLabel="Original"
  afterLabel="Upscaled"
/>

Composer

Change the props and the source below follows.

After restoration
The original scan, degraded
OriginalRestored
Props
tsx
<CompareSlider
  defaultValue={50}
  beforeLabel="Original"
  afterLabel="Restored"
  before={<img src="/before.jpg" alt="Original" />}
  after={<img src="/after.jpg" alt="Restored" />}
/>

Examples

Before and after

API

Generated from the source, so it cannot fall behind the code. One table per exported component, then hooks, functions and types.

CompareSlider

Extends Omit<ComponentProps<'div'>, 'onChange'>.

PropTypeDefaultDescription
defaultValuestring | number | readonly string[]50Controlled and uncontrolled.
afterReactNoderequiredAny node, not just an img — the component stretches whatever you pass to fill the frame.
beforeReactNoderequiredAny node, not just an img — the component stretches whatever you pass to fill the frame.
valuenumber—Controlled and uncontrolled.
onValueChange(value: number) => void—Controlled and uncontrolled.
rationumber16 / 9Reserves the box, so nothing shifts while the images decode.
beforeLabelReactNode——
afterLabelReactNode——
labelstring'Compare before and after'Accessible name for the slider itself.
stepnumber1Arrow-key step, in percent.

Behaviour

TopicDetailDescription
clipping, not resizingclip-path: inset()The overlay is clipped rather than resized. A resized image re-lays-out and re-samples on every pointer move, which buries subtle differences under scaling artefacts and janks the drag.
accessibilityinput[type=range]The handle is a real slider in disguise, so arrows, Home and End work and it announces its position. The visible divider is decoration.