Zum Inhalt springen
UI Kit
Accent

Bildvergleich

Vorher und nachher mit verschiebbarer Trennlinie. Funktioniert mit zwei beliebigen Elementen, seitlich oder von oben nach unten.

Eine von 5 Components in Media

3 Variants

Variants

Website-Relaunch

Links die alte Seite, rechts die neue. Ziehen, klicken oder mit den Pfeiltasten schieben.

Bildbearbeitung

Die Rohaufnahme gegen die bearbeitete. Dasselbe Bild mit und ohne Bearbeitung.

Senkrecht

Oben vorher, unten nachher. Passt zu hohen Bildern und Telefonen.

Details

Was Bildvergleich mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Media
Bereich
Elements
Variants
3
Version
1.0.0 · seit 1.0.0
Datei
components/ui/image-compare.tsx
Bringt mit
utilskiticon
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 3 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle ImageCompareProps aus components/ui/image-compare.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • beforePflicht
    React.ReactNode

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

  • afterPflicht
    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.

Eingebaute Texte

Diese Texte schreibt Bildvergleich selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 3 Texte anzeigen
beforeEnglisch BeforeDeutsch Vorher
afterEnglisch AfterDeutsch Nachher
sliderEnglisch Compare before and afterDeutsch Vorher und nachher vergleichen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Bildvergleich reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende, Bild hoch, Bild runter und Leertaste.
  • Setzt aria-label, aria-orientation, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle slider.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 217 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Bildvergleich?

Mit aiw add @ai-website/image-compare. Der Befehl legt die Datei components/ui/image-compare.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Bildvergleich?

3 Varianten, Website-Relaunch, Bildbearbeitung und Senkrecht.

Kann ich die Texte von Bildvergleich übersetzen?

Ja. Bildvergleich bringt 3 eigene Texte auf Deutsch und Englisch mit, etwa „Vorher“, „Nachher“ und „Vorher und nachher vergleichen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Bildvergleich einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Bildvergleich mit aiw_components und installieren sie mit aiw_add @ai-website/image-compare.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert