Before and after
widgetsFreeMITTwo photos of the same place on top of each other, with a handle that wipes from before to after.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/before-after.jsonPreview
Live demo in the dark theme.
Code
Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/before-after.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/before-afterUsage
import { useState } from 'react'
import { BeforeAfter } from '@/components/before-after'
// A garden before and after a new terrace. Both photos are small SVG drawings made here from the same viewpoint,
// so the demo fetches nothing and the wipe lines up. The Danish labels show how to replace the text.
const svg = (body: string) => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 720">${body}</svg>`)}`
const HOUSE = `<rect width="960" height="720" fill="#cfdbe6"/>
<rect x="120" y="150" width="720" height="300" fill="#b5654a"/>
<path d="M90 160 480 40 870 160z" fill="#4a4b50"/>
<rect x="220" y="230" width="120" height="110" fill="#e8eef3" stroke="#fff" stroke-width="10"/>
<rect x="620" y="230" width="120" height="110" fill="#e8eef3" stroke="#fff" stroke-width="10"/>
<rect x="420" y="250" width="120" height="200" fill="#e8eef3" stroke="#fff" stroke-width="10"/>`
const BEFORE = svg(
`${HOUSE}
<rect y="450" width="960" height="270" fill="#8a8a62"/>
${Array.from({ length: 40 }, (_, i) => `<path d="M${(i * 97) % 960} ${470 + ((i * 53) % 240)}l10 -28 10 28" fill="none" stroke="#6b6b47" stroke-width="5"/>`).join('')}
<rect x="300" y="520" width="160" height="70" rx="10" fill="#9a958c"/>
<rect x="560" y="600" width="120" height="50" rx="8" fill="#a29d93"/>`,
)
const AFTER = svg(
`${HOUSE}
<rect y="450" width="960" height="270" fill="#6f9a52"/>
<path d="M60 450h840l40 170H20z" fill="#9b6a41"/>
${Array.from({ length: 12 }, (_, i) => `<path d="M${55 - i * 3} ${464 + i * 14}h${850 + i * 6}" stroke="#7d5232" stroke-width="3"/>`).join('')}
<rect x="20" y="620" width="920" height="26" fill="#5f3d23"/>
<rect x="700" y="400" width="160" height="60" fill="#5f3d23"/>
<circle cx="740" cy="390" r="30" fill="#5b8f3e"/><circle cx="800" cy="380" r="36" fill="#4f8236"/>`,
)
export default function BeforeAfterDemo() {
const [pos, setPos] = useState(50)
return (
<div className="grid w-full max-w-2xl gap-6">
<BeforeAfter
before={{ src: BEFORE, alt: 'The garden before: worn grass and loose paving stones in front of the house.' }}
after={{ src: AFTER, alt: 'The garden after: a wooden terrace with a step and a planter in front of the house.' }}
onChange={setPos}
/>
<p className="text-sm text-muted-foreground">Handle at {Math.round(pos)} %. Drag it, click the picture, or Tab to it and use the arrow keys.</p>
<BeforeAfter
before={{ src: BEFORE, alt: 'Haven før: slidt græs og løse fliser foran huset.' }}
after={{ src: AFTER, alt: 'Haven efter: en træterrasse med trin og plantekasse foran huset.' }}
initial={30}
aspectRatio="16 / 9"
labels={{ before: 'Før', after: 'Efter', slider: 'Sammenlign før og efter', value: '{n} % før' }}
/>
</div>
)
}Source
// Before and after: two photos of the same place on top of each other, with a handle that wipes between them.
// The after photo fills the frame; the before photo lies over it and is cut off at the handle, so before is on the
// left and after on the right.
//
// Drag anywhere on the picture, or click to jump the handle there. The frame keeps `touch-action: pan-y`, so on a
// phone a vertical swipe still scrolls the page and only a sideways drag moves the handle. The keyboard and screen
// readers use a real range input (hidden, but focusable): arrows move 1 %, Page Up/Down 10 %, Home and End go to the
// ends, and its value is read as "40 % before". Focus shows as a ring on the handle.
//
// Motion: none of its own. The handle follows the pointer or the key, and nothing animates by itself.
import { useId, useRef, useState, type PointerEvent } from 'react'
import { cn } from '@/lib/utils'
export type BeforeAfterLabels = {
/** The chip on the before photo. */
before: string
/** The chip on the after photo. */
after: string
/** The name of the slider, read by screen readers. */
slider: string
/** The slider's spoken value; `{n}` is the share of the before photo in per cent. */
value: string
}
const LABELS: BeforeAfterLabels = {
before: 'Before',
after: 'After',
slider: 'Compare before and after',
value: '{n} % before',
}
export type BeforeAfterProps = {
/** The before photo. */
before: { src: string; alt: string }
/** The after photo, taken from the same spot. */
after: { src: string; alt: string }
/** Where the handle starts, in per cent from the left. */
initial?: number
/** Width to height of the frame, as CSS `aspect-ratio`. Both photos are cropped to it. */
aspectRatio?: string
/** Called with the new position (0–100) when it changes. */
onChange?: (position: number) => void
/** Visible and spoken text. */
labels?: Partial<BeforeAfterLabels>
/** Classes for the frame. */
className?: string
}
const clamp = (n: number) => Math.min(100, Math.max(0, n))
/** Two photos of the same place with a handle that wipes from before to after. */
export function BeforeAfter({ before, after, initial = 50, aspectRatio = '4 / 3', onChange, labels = {}, className }: BeforeAfterProps) {
const l = { ...LABELS, ...labels }
const id = useId()
const frame = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState(() => clamp(initial))
const set = (n: number) => {
const next = clamp(Math.round(n * 10) / 10)
setPos(next)
onChange?.(next)
}
const fromPointer = (e: PointerEvent) => {
const box = frame.current?.getBoundingClientRect()
if (box && box.width > 0) set(((e.clientX - box.left) / box.width) * 100)
}
return (
<div
ref={frame}
className={cn('relative isolate cursor-ew-resize touch-pan-y overflow-hidden rounded-xl border border-border bg-muted select-none', className)}
style={{ aspectRatio }}
onPointerDown={(e) => {
if (e.button !== 0) return
e.currentTarget.setPointerCapture(e.pointerId)
fromPointer(e)
}}
onPointerMove={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) fromPointer(e)
}}
>
<img src={after.src} alt={after.alt} draggable={false} className="absolute inset-0 size-full object-cover" />
<img
src={before.src}
alt={before.alt}
draggable={false}
className="absolute inset-0 size-full object-cover"
style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }}
/>
<span aria-hidden className="pointer-events-none absolute top-3 left-3 rounded-md bg-background/85 px-2 py-1 text-xs font-medium text-foreground">
{l.before}
</span>
<span aria-hidden className="pointer-events-none absolute top-3 right-3 rounded-md bg-background/85 px-2 py-1 text-xs font-medium text-foreground">
{l.after}
</span>
<input
id={id}
type="range"
min={0}
max={100}
step={1}
value={Math.round(pos)}
aria-label={l.slider}
aria-valuetext={l.value.replace('{n}', String(Math.round(pos)))}
onChange={(e) => set(Number(e.target.value))}
className="peer sr-only"
/>
<div aria-hidden className="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-background shadow-[0_0_0_1px_rgb(0_0_0/0.15)]" style={{ left: `${pos}%` }} />
<div
aria-hidden
className="pointer-events-none absolute top-1/2 flex size-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-md peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background"
style={{ left: `${pos}%` }}
>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="m9 7-5 5 5 5M15 7l5 5-5 5" />
</svg>
</div>
</div>
)
}Props
BeforeAfterProps
| Prop | Type | Default | What it does |
|---|---|---|---|
before required | { src: string; alt: string } | – | The before photo. |
after required | { src: string; alt: string } | – | The after photo, taken from the same spot. |
initial | number | 50 | Where the handle starts, in per cent from the left. |
aspectRatio | string | '4 / 3' | Width to height of the frame, as CSS aspect-ratio. Both photos are cropped to it. |
onChange | (position: number) => void | – | Called with the new position (0–100) when it changes. |
labels | Partial<BeforeAfterLabels> | {} | Visible and spoken text. |
className | string | – | Classes for the frame. |
A11y
Keyboard
The handle is a real range input, hidden but focusable. Tab focuses it, arrows move 1 %, Page Up and Page Down 10 %, Home and End go to the ends.
Screen reader
The input is named by labels.slider and its value is read as labels.value, "40 % before" by default. Both photos keep their own alt text, so each is described. The chips and the handle drawing are aria-hidden.
Other
Focus shows as a 2 px ring with offset on the round handle, through peer-focus-visible on the hidden input.
Other
The handle is 44 px. The frame has touch-action pan-y, so a vertical swipe scrolls the page and only a sideways drag moves the handle.
Reduced motion
Nothing animates by itself. The handle only follows the pointer or the key, so reduced motion needs no change.