Before and after images under one draggable divider, with a native slider for keyboard and touch.


pnpm dlx shadcn@latest add @latheui/image-compareFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-react motionAdd the Lathe items this component imports.
hooks/use-controllable-state.tslib/motion.tsCopy the source into your project.
"use client"
import * as React from "react"
import { ChevronsLeftRightIcon } from "lucide-react"
import { motion, useMotionTemplate, useMotionValue, useSpring, useTransform } from "motion/react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { useMotionSafe } from "@/lib/motion"
type CompareImage = { src: string; alt: string }
export type ImageCompareProps = Omit<
React.ComponentProps<"div">,
"onChange" | "defaultValue" | "children"
> & {
/** Shown on the left of the divider. */
before: CompareImage
/** Shown on the right of the divider. */
after: CompareImage
/** Divider position from 0 to 100. */
value?: number
defaultValue?: number
onValueChange?: (value: number) => void
/** Accessible name of the slider. */
label?: string
/** Corner captions. Pass `null` to hide them. */
labels?: { before: string; after: string } | null
}
// Light and slightly under-damped: the divider trails the pointer a touch and settles.
const follow = { stiffness: 600, damping: 32, mass: 0.6 }
const clamp = (value: number) => Math.min(100, Math.max(0, value))
function ImageCompare({
before,
after,
value: valueProp,
defaultValue = 50,
onValueChange,
label = "Comparison position",
labels = { before: "Before", after: "After" },
className,
...props
}: ImageCompareProps) {
const [value, setValue] = useControllableState({
value: valueProp,
defaultValue,
onChange: onValueChange,
})
const { reduced } = useMotionSafe()
// `value` is where the divider is going; `position` is where it is drawn.
const target = useMotionValue(value)
const position = useSpring(target, follow)
React.useEffect(() => {
target.set(value)
if (reduced) position.jump(value)
}, [value, reduced, target, position])
const hidden = useTransform(position, (p) => 100 - p)
const clipPath = useMotionTemplate`inset(0 ${hidden}% 0 0)`
// Clamped so the line and the handle stay whole at either end.
const lineLeft = useMotionTemplate`clamp(0px, ${position}%, calc(100% - 1px))`
const handleLeft = useMotionTemplate`clamp(1.375rem, ${position}%, calc(100% - 1.375rem))`
function onKeyDown(event: React.KeyboardEvent) {
// The input's step is fine for smooth dragging, so the keys move in whole percents here.
const steps: Record<string, number> = {
ArrowLeft: -1,
ArrowDown: -1,
ArrowRight: 1,
ArrowUp: 1,
PageDown: -10,
PageUp: 10,
Home: -100,
End: 100,
}
const delta = steps[event.key]
if (delta === undefined) return
event.preventDefault()
setValue(clamp(Math.round(value) + delta))
}
const caption =
"pointer-events-none absolute top-3 rounded-sm bg-background/90 px-2 py-1 text-xs font-medium text-foreground"
return (
<div
data-slot="image-compare"
className={cn(
// No background: it would show as a light fringe where a dark image meets the corners.
// The mask makes the browser clip the layered images in one pass; plain rounded overflow
// leaves a one-pixel pale arc on the corners.
"group relative w-full overflow-hidden rounded-2xl select-none [mask-image:linear-gradient(#000,#000)]",
className
)}
{...props}
>
<img src={after.src} alt={after.alt} draggable={false} className="block h-auto w-full" />
<motion.img
src={before.src}
alt={before.alt}
draggable={false}
className="absolute inset-0 size-full object-cover"
style={{ clipPath }}
/>
{/* The outline is drawn over the images, so they run cleanly to the rounded edge. It is
always dark: a light one shows as a pale rim where dark images meet the edge. */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-[inherit] inset-ring inset-ring-black/10"
/>
{labels && (
<>
<span aria-hidden="true" className={cn(caption, "left-3")}>
{labels.before}
</span>
<span aria-hidden="true" className={cn(caption, "right-3")}>
{labels.after}
</span>
</>
)}
{/* A native range input does the work: pointer, touch and keyboard all come for free. */}
<input
type="range"
min={0}
max={100}
step={0.1}
value={value}
aria-label={label}
aria-valuetext={`${Math.round(value)}% ${labels?.before ?? "before"}`}
onChange={(event) => setValue(Number(event.target.value))}
onKeyDown={onKeyDown}
className="peer absolute inset-0 size-full cursor-ew-resize touch-pan-y appearance-none opacity-0"
/>
<motion.div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 w-px bg-background shadow-[0_0_0_0.5px_rgb(0_0_0/0.25)]"
style={{ left: lineLeft }}
/>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute top-1/2 grid size-9 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-border bg-background text-foreground shadow-md transition-[scale] duration-150 group-active:scale-90 peer-focus-visible:ring-2 peer-focus-visible:ring-ring motion-reduce:transition-none"
style={{ left: handleLeft }}
>
<ChevronsLeftRightIcon className="size-4" />
</motion.span>
</div>
)
}
export { ImageCompare }Depends on
Also accepts native div attributes.
| Prop | Type | Default | Description |
|---|---|---|---|
| before* (required) | { src: string; alt: string } | — | Image shown left of the divider. |
| after* (required) | { src: string; alt: string } | — | Image shown right of the divider. It sets the component's aspect ratio. |
| value | number | — | Controlled divider position, 0 to 100. |
| defaultValue | number | 50 | Initial position when uncontrolled. |
| onValueChange | (value: number) => void | — | Called as the divider moves. |
| label | string | "Comparison position" | Accessible name of the slider. |
| labels | { before: string; after: string } | null | { before: "Before", after: "After" } | Corner captions. null hides them. |
<ImageCompare defaultValue={80} labels={{ before: "2019", after: "2026" }} before={before} after={after} />