Image compare
Before and after with a draggable divider. Works with any two nodes, sideways or up and down.
One of 5 components in Media
Variants
Details
What Image compare brings along, read from the registry and the source.
- Category
- Media
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/image-compare.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 3 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ImageCompareProps interface from components/ui/image-compare.tsx, with the comments from the code.
beforerequiredReact.ReactNodeShown left (horizontal) or on top (vertical). Any node: an <img>, a drawing, a component.
afterrequiredReact.ReactNodeShown right (horizontal) or at the bottom (vertical)
orientationoptional'horizontal' | 'vertical'horizontal: drag sideways. vertical: drag up and down.
initialoptionalnumberStart position in percent, default 50
showLabelsoptionalbooleanBefore and After chips in the corners. Default true.
labelPositionoptional'top' | 'bottom'Where the chips sit when horizontal: top (default) or bottom, for pictures with a busy top edge
onChangeoptional(percent: number) => voidCalled with the new position in percent
hintoptionalbooleanWhen it scrolls into view the handle swings once to both sides and settles at the start position, as a hint. Default true, never with reduced motion.
Built-in texts
Image compare writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 3 texts
| Key | English | German |
|---|---|---|
| before | English Before | German Vorher |
| after | English After | German Nachher |
| slider | English Compare before and after | German Vorher und nachher vergleichen |
Accessibility
Read from the source of the component.
- Image compare handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home, End, Page up, Page down and Space itself.
- Sets aria-label, aria-orientation, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the role slider.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 217 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
const TEXTS = {
en: { before: 'Before', after: 'After', slider: 'Compare before and after' },
de: { before: 'Vorher', after: 'Nachher', slider: 'Vorher und nachher vergleichen' },
};
type Texts = (typeof TEXTS)['en'];
const intlLocale = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const clamp = (n: number) => Math.min(100, Math.max(0, n));
/** The spring of the handle: a jump to a click overshoots a little and settles */
const SPRING = 'cubic-bezier(0.34,1.56,0.64,1)';
export interface ImageCompareProps {
/** Shown left (horizontal) or on top (vertical). Any node: an <img>, a drawing, a component. */
before: React.ReactNode;
/** Shown right (horizontal) or at the bottom (vertical) */
after: React.ReactNode;
/** horizontal: drag sideways. vertical: drag up and down. */
orientation?: 'horizontal' | 'vertical';
/** Start position in percent, default 50 */
initial?: number;
/** Before and After chips in the corners. Default true. */
showLabels?: boolean;
/** Where the chips sit when horizontal: top (default) or bottom, for pictures with a busy top edge */
labelPosition?: 'top' | 'bottom';
/** Called with the new position in percent */
onChange?: (percent: number) => void;
/** When it scrolls into view the handle swings once to both sides and settles at the start position, as a hint. Default true, never with reduced motion. */
hint?: boolean;
labels?: Partial<Texts>;
/** Size it here, e.g. aspect-video. Default aspect 16:10. */
className?: string;
}
/**
* Before and after with a draggable divider. Click or tap jumps there, arrows, Page keys, Home and End move it.
* <ImageCompare before={<img src="/old.jpg" alt="Old site" />} after={<img src="/new.jpg" alt="New site" />} />
*/
export function ImageCompare({ before, after, orientation = 'horizontal', initial = 50, showLabels = true, labelPosition = 'top', onChange, hint = true, labels, className }: ImageCompareProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const ref = useRef<HTMLDivElement>(null);
const knobRef = useRef<HTMLDivElement>(null);
const start = clamp(initial);
const [pos, setPos] = useState(start);
const [drag, setDrag] = useState(false);
const [jump, setJump] = useState(false);
const touched = useRef(false);
const vertical = orientation === 'vertical';
const set = (p: number) => {
touched.current = true;
const next = round(clamp(p), 2);
setPos(next);
onChange?.(next);
};
const at = (e: React.PointerEvent) => {
const r = ref.current!.getBoundingClientRect();
return vertical ? ((e.clientY - r.top) / r.height) * 100 : ((e.clientX - r.left) / r.width) * 100;
};
/* The hint: once in view, the handle swings out to both sides and comes back to the start */
useEffect(() => {
const el = ref.current;
if (!hint || !el || prefersReducedMotion() || typeof IntersectionObserver === 'undefined') return;
let raf = 0;
let timer = 0;
const swing = Math.min(14, start - 4, 96 - start);
if (swing < 4) return;
// Waypoints in time (0 to 1) and offset from the start
const path: [number, number][] = [[0, 0], [0.34, swing], [0.72, -swing * 0.7], [1, 0]];
const ease = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
const run = () => {
const t0 = performance.now();
const tick = (now: number) => {
if (touched.current) return;
const p = Math.min(1, (now - t0) / 1500);
let k = 1;
while (k < path.length - 1 && p > path[k][0]) k++;
const [a, va] = path[k - 1];
const [b, vb] = path[k];
const off = va + (vb - va) * ease((p - a) / (b - a));
setPos(round(start + off, 2));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(
([e]) => {
if (!e.isIntersecting) return;
io.disconnect();
timer = window.setTimeout(run, 350);
},
{ threshold: 0.6 },
);
io.observe(el);
return () => {
io.disconnect();
window.clearTimeout(timer);
cancelAnimationFrame(raf);
};
}, [hint, start]);
const onKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
const big = e.shiftKey ? 10 : 1;
const plus = vertical ? ['ArrowDown', 'ArrowRight'] : ['ArrowRight', 'ArrowUp'];
const minus = vertical ? ['ArrowUp', 'ArrowLeft'] : ['ArrowLeft', 'ArrowDown'];
let next: number | null = null;
if (plus.includes(e.key)) next = pos + big;
else if (minus.includes(e.key)) next = pos - big;
else if (e.key === 'PageUp') next = pos + 10;
else if (e.key === 'PageDown') next = pos - 10;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = 100;
if (next === null) return;
e.preventDefault();
// Page keys, Home and End jump with the spring, single steps follow directly
setJump(Math.abs(next - pos) > 2);
set(next);
};
const pct = new Intl.NumberFormat(intlLocale(locale), { style: 'percent', maximumFractionDigits: 0 });
const p = round(pos, 2);
const clip = vertical ? `inset(0 0 ${round(100 - p, 2)}% 0)` : `inset(0 ${round(100 - p, 2)}% 0 0)`;
const ease = jump ? 'transition-[clip-path,transform] duration-500 motion-reduce:transition-none' : '';
const easeStyle = jump ? { transitionTimingFunction: SPRING } : undefined;
const chip =
'pointer-events-none absolute rounded-md border border-[color-mix(in_oklch,var(--foreground)_10%,transparent)] bg-[color-mix(in_oklch,var(--background)_80%,transparent)] px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.06em] text-foreground shadow-[var(--shadow-sm)] backdrop-blur-md transition-[opacity,transform] duration-300 ease-[var(--ease-kit)]';
// The handle layer covers the frame and moves as a whole, so line and knob travel by transform only
const layer = vertical ? `translateY(${round(p - 50, 2)}%)` : `translateX(${round(p - 50, 2)}%)`;
return (
<div
ref={ref}
// Both pictures are cropped to the frame on purpose
data-clip-ok
className={cn(
'group/compare relative isolate w-full select-none overflow-hidden rounded-xl border border-border bg-surface-2 shadow-[var(--highlight),var(--shadow-md)]',
vertical ? 'aspect-[4/5] cursor-row-resize touch-pan-x' : 'aspect-[16/10] cursor-col-resize touch-pan-y',
className,
)}
onPointerDown={(e) => {
if (e.button !== 0) return;
touched.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
setDrag(true);
// A tap on touch may start a scroll, only a mouse or pen jumps right away
if (e.pointerType !== 'touch') {
setJump(e.target !== knobRef.current && !knobRef.current?.contains(e.target as Node));
set(at(e));
}
knobRef.current?.focus({ preventScroll: true });
}}
onPointerMove={(e) => {
if (!drag) return;
setJump(false);
set(at(e));
}}
onPointerUp={() => setDrag(false)}
onPointerCancel={() => setDrag(false)}
>
{/* After, the full picture */}
<div className="absolute inset-0 [&>*]:size-full">{after}</div>
{/* Before, clipped to the handle */}
<div className={cn('absolute inset-0 [&>*]:size-full', ease)} style={{ clipPath: clip, ...easeStyle }}>
{before}
</div>
{showLabels && (
<>
<span aria-hidden className={cn(chip, 'left-3', !vertical && labelPosition === 'bottom' ? 'bottom-3' : 'top-3', p < 16 ? '-translate-x-1 opacity-0' : 'opacity-100')}>
{t.before}
</span>
<span aria-hidden className={cn(chip, vertical ? 'bottom-3 left-3' : labelPosition === 'bottom' ? 'bottom-3 right-3' : 'right-3 top-3', p > 84 ? 'translate-x-1 opacity-0' : 'opacity-100')}>
{t.after}
</span>
</>
)}
{/* Divider and knob on one layer that moves by transform */}
<div className={cn('pointer-events-none absolute inset-0', ease)} style={{ transform: layer, ...easeStyle }}>
<div
aria-hidden
className={cn(
'absolute bg-background shadow-[0_0_0_1px_color-mix(in_oklch,var(--foreground)_22%,transparent),var(--shadow-md)]',
vertical ? 'inset-x-0 top-1/2 h-0.5 -translate-y-1/2' : 'inset-y-0 left-1/2 w-0.5 -translate-x-1/2',
)}
/>
<div
ref={knobRef}
role="slider"
tabIndex={0}
aria-label={t.slider}
aria-orientation={orientation}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(p)}
aria-valuetext={`${t.before} ${pct.format(p / 100)}, ${t.after} ${pct.format(1 - p / 100)}`}
onKeyDown={onKey}
className={cn(
'pointer-events-auto absolute left-1/2 top-1/2 grid size-11 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-[color-mix(in_oklch,var(--foreground)_14%,transparent)] bg-[color-mix(in_oklch,var(--background)_88%,transparent)] text-foreground shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-md transition-[scale,background-color,box-shadow] duration-300 hover:bg-background motion-reduce:transition-none',
drag ? 'scale-110' : 'scale-100 group-hover/compare:scale-105',
)}
style={{ transitionTimingFunction: SPRING }}
>
<Icon name={vertical ? 'arrows-up-down' : 'arrows-left-right'} size={18} strokeWidth={1.8} className={cn('transition-transform duration-300 motion-reduce:transition-none', drag && (vertical ? 'scale-y-90' : 'scale-x-90'))} />
</div>
</div>
</div>
);
}Frequent questions
How do I install Image compare?
With aiw add @ai-website/image-compare. The command puts components/ui/image-compare.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Image compare have?
3 variants, Website redesign, Photo edit and Vertical.
Can I translate the texts of Image compare?
Yes. Image compare carries 3 texts of its own in English and German, such as “Before”, “After” and “Compare before and after”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Image compare?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Image compare with aiw_components and install it with aiw_add @ai-website/image-compare.