Interactive
HoverCompare
Two versions of one picture with a line between them. The line follows the mouse without a press, a finger on touch, and the arrow keys. The labels hide as the line passes them.
Download
- React
- Tailwind CSS v4
- motion
components/ui/hover-compare.tsx
"use client";
import { useState, type ReactNode } from "react";
import { motion, useMotionTemplate, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring, useTransform } from "motion/react";
type HoverCompareProps = {
/** Shown left of the line. Same size as `after`. */
before: ReactNode;
/** Shown right of the line. */
after: ReactNode;
/** Where the line starts, 0 to 100. */
start?: number;
beforeLabel?: string;
afterLabel?: string;
/** Name read out by screen readers. */
label?: string;
/** Set the size here. */
className?: string;
};
const FOLLOW = { stiffness: 500, damping: 45 };
const PILL = "pointer-events-none absolute top-3 rounded-full bg-black/55 px-2.5 py-1 text-xs font-medium text-white";
/* HoverCompare: two versions of one picture with a line between them. The line follows the mouse, a finger or the arrow keys. */
export function HoverCompare({
before,
after,
start = 50,
beforeLabel = "Before",
afterLabel = "After",
label = "Compare two versions",
className = "aspect-video",
}: HoverCompareProps) {
const reduce = useReducedMotion();
const target = useMotionValue(start);
const smooth = useSpring(target, FOLLOW);
const line = reduce ? target : smooth;
const rest = useTransform(line, (v) => 100 - v);
const clipPath = useMotionTemplate`inset(0 ${rest}% 0 0)`;
const left = useMotionTemplate`${line}%`;
const [value, setValue] = useState(start);
useMotionValueEvent(target, "change", (v) => setValue(Math.round(v)));
const place = (e: React.PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
target.set(Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100)));
};
const onKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
const keys: Record<string, number> = {
ArrowLeft: value - 5,
ArrowDown: value - 5,
ArrowRight: value + 5,
ArrowUp: value + 5,
Home: 0,
End: 100,
};
if (!(e.key in keys)) return;
e.preventDefault();
target.set(Math.max(0, Math.min(100, keys[e.key])));
};
return (
<div onPointerDown={place} onPointerMove={place} className={`relative cursor-ew-resize touch-pan-y select-none overflow-hidden ${className}`}>
<div className="absolute inset-0 *:h-full *:w-full *:object-cover">{after}</div>
<span className={`${PILL} right-3`}>{afterLabel}</span>
<motion.div className="absolute inset-0" style={{ clipPath }}>
<div className="absolute inset-0 *:h-full *:w-full *:object-cover">{before}</div>
<span className={`${PILL} left-3`}>{beforeLabel}</span>
</motion.div>
<motion.div
role="slider"
tabIndex={0}
aria-label={label}
aria-orientation="horizontal"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
onKeyDown={onKey}
className="group absolute inset-y-0 w-px -translate-x-1/2 bg-white outline-none"
style={{ left }}
>
<span className="absolute top-1/2 left-1/2 grid size-9 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-white text-black shadow-lg group-focus-visible:ring-2 group-focus-visible:ring-white/70 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-black">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="m9 7-5 5 5 5M15 7l5 5-5 5" />
</svg>
</span>
</motion.div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/hover-compare.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/hover-compare-demo.tsx
import { HoverCompare } from "@/components/ui/hover-compare"; // One scene twice. Pass two <img> of the same size instead. function Scene({ id, filter }: { id: string; filter?: string }) { return ( <svg viewBox="0 0 640 360" preserveAspectRatio="xMidYMid slice" style={{ filter }} aria-hidden> <defs> <linearGradient id={id} x1="0" x2="0" y1="0" y2="1"> <stop offset="0" stopColor="#312e81" /> <stop offset="0.65" stopColor="#f472b6" /> <stop offset="1" stopColor="#fdba74" /> </linearGradient> </defs> <rect width="640" height="360" fill={`url(#${id})`} /> <circle cx="430" cy="190" r="56" fill="#fef3c7" /> <path d="M0 240 C 140 170 260 280 400 220 S 560 190 640 240 V 360 H 0 Z" fill="#581c87" /> <path d="M0 300 C 180 240 320 340 640 280 V 360 H 0 Z" fill="#1e1b4b" /> </svg> ); } export default function HoverCompareExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-4"> <HoverCompare before={<Scene id="raw-sky" filter="grayscale(1) contrast(0.7) brightness(0.8)" />} after={<Scene id="graded-sky" />} beforeLabel="Raw" afterLabel="Graded" label="Compare the raw and the graded photo" className="aspect-video w-full max-w-[520px] rounded-2xl" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| before | ReactNode | — | Shown left of the line. Same size as after. |
| after | ReactNode | — | Shown right of the line. |
| start | number | 50 | Where the line starts, 0 to 100. |
| beforeLabel | string | "Before" | Pill on the left. |
| afterLabel | string | "After" | Pill on the right. |
| label | string | "Compare two versions" | Read by screen readers. |
| className | string | "aspect-video" | Set the size here. |