Before and after
widgetsKostenlosMITZwei Fotos desselben Ortes übereinander mit einem Griff, der von vorher zu nachher gezogen wird. Ziehen oder auf das Bild klicken. Tastatur und Screenreader nutzen einen echten Regler (Pfeile, Bild auf/ab, Pos1, Ende). Nichts animiert sich von selbst.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/before-after.jsonVorschau
Live-Demo im dunklen Theme.
Code
Installieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/before-after.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/before-afterVerwendung
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>
)
}Quellcode
// 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 | Typ | Standard | Was sie tut |
|---|---|---|---|
before erforderlich | { src: string; alt: string } | – | The before photo. |
after erforderlich | { 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
Tastatur
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.
Screenreader
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.
Sonstiges
Focus shows as a 2 px ring with offset on the round handle, through peer-focus-visible on the hidden input.
Sonstiges
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.
Reduzierte Bewegung
Nothing animates by itself. The handle only follows the pointer or the key, so reduced motion needs no change.