Skip to content
UI Kit
Accent color

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

3 variants

Variants

Website redesign

The old site on the left, the new one on the right. Drag, click or use the arrow keys.

Photo edit

The raw shot against the graded one. The same picture with and without the edit.

Vertical

Before on top, after below. Suits tall pictures and phones.

Details

What Image compare brings along, read from the registry and the source.

Category
Media
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

Props

The ImageCompareProps interface from components/ui/image-compare.tsx, with the comments from the code.

  • beforerequired
    React.ReactNode

    Shown left (horizontal) or on top (vertical). Any node: an <img>, a drawing, a component.

  • afterrequired
    React.ReactNode

    Shown right (horizontal) or at the bottom (vertical)

  • orientationoptional
    'horizontal' | 'vertical'

    horizontal: drag sideways. vertical: drag up and down.

  • initialoptional
    number

    Start position in percent, default 50

  • showLabelsoptional
    boolean

    Before 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) => void

    Called with the new position in percent

  • hintoptional
    boolean

    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.

Built-in texts

Image compare writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 3 texts
beforeEnglish BeforeGerman Vorher
afterEnglish AfterGerman Nachher
sliderEnglish Compare before and afterGerman 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
components/ui/image-compare.tsx
'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.

Often searched

Jump to

Copied to the clipboard