Teseria
All elements

Image compare

Drag the divider to wipe between two pictures. Fling it and it coasts on, bouncing softly off the edges.

Code

1. Install

Terminal
npm i framer-motion

2. Files

Compare.tsx192 lines
"use client";
// Compare: drag the divider to wipe between two pictures. Fling it and it coasts on, bouncing softly off the edges.
// You pull the divider by its middle, so its ends trail on a spring and the line bows like a string, then wobbles straight.
// While you drag, the position is written straight to a CSS variable; React only hears about it when things settle.
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { motion, useMotionValue, useSpring, useTransform, type MotionStyle } from "framer-motion";

export type CompareCue = "grab" | "place";

export type ComparePicture = {
  /** Image URL. */
  src: string;
  alt: string;
};

export type CompareProps = {
  /** Picture left of the divider. The default points at Teseria's demo art (`/art/*.svg`); pass your own. */
  before?: ComparePicture;
  /** Picture right of the divider. The default points at Teseria's demo art; pass your own. */
  after?: ComparePicture;
  /** CSS filter applied to the "before" picture, so one image can stand in for both. */
  beforeFilter?: string;
  /** The two corner tags. */
  labels?: [string, string];
  /** Accessible name of the slider. */
  label?: string;
  /** Controlled divider position, 0-1. */
  value?: number;
  /** Uncontrolled initial position, 0-1. */
  defaultValue?: number;
  /** Called with the position when a drag, coast or key press settles. */
  onChange?: (value: number) => void;
  /** Width in px. */
  width?: number;
  /** Height in px. */
  height?: number;
  /** Corner radius in px. */
  corner?: number;
  /** Sound hook: "grab" on grab, "place" on release. */
  onSound?: (cue: CompareCue) => void;
  className?: string;
  style?: CSSProperties;
};

const DEMO = "/art/mountain.svg";
const FRICTION = 0.93; // per 1/60s frame
const WALL_KEEP = 0.3; // share of speed kept after hitting an edge
const MAX_BOW = 0.035; // furthest the ends may trail the knob, as a share of the width
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));

export function Compare({
  before = { src: DEMO, alt: "Mountain, before" },
  after = { src: DEMO, alt: "Mountain, after" },
  beforeFilter = "grayscale(1) contrast(.9)",
  labels = ["Before", "After"],
  label = "Compare",
  value,
  defaultValue = 0.5,
  onChange,
  width = 280,
  height = 180,
  corner = 16,
  onSound,
  className,
  style,
}: CompareProps) {
  const box = useRef<HTMLDivElement>(null);
  const pos = useMotionValue(value ?? defaultValue);
  const [settled, setSettled] = useState(value ?? defaultValue);
  const ends = useSpring(pos, { stiffness: 1400, damping: 80, mass: 0.6 });
  // the line through the knob at mid-height: ends at x - lag, and a control point at x + lag puts the curve's middle on x
  const curve = (k: number, e: number) => {
    const x = k * width, lag = Math.min(MAX_BOW, Math.max(-MAX_BOW, k - e)) * width;
    return `${x - lag} 0Q${x + lag} ${height / 2} ${x - lag} ${height}`;
  };
  const line = useTransform([pos, ends], ([k, e]: number[]) => `M${curve(k, e)}`);
  const clip = useTransform([pos, ends], ([k, e]: number[]) => `path("M0 0L${curve(k, e)}L0 ${height}Z")`);
  const report = useRef(onChange);
  report.current = onChange;
  // one gesture at a time: whether the pointer is down, its speed (per frame), and the last sample's time
  const g = useRef({ down: false, speed: 0, at: 0, frame: 0 });

  const land = () => {
    const v = pos.get();
    setSettled(v);
    report.current?.(v);
  };
  const toUnits = (clientX: number) => {
    const r = box.current?.getBoundingClientRect();
    return r && r.width ? clamp01((clientX - r.left) / r.width) : pos.get();
  };
  const glide = () => {
    cancelAnimationFrame(g.current.frame);
    let prev = 0;
    const tick = (now: number) => {
      const frames = prev ? Math.min(2.5, Math.max(0, (now - prev) / 16.67)) : 1;
      prev = now;
      g.current.speed *= FRICTION ** frames;
      let next = pos.get() + g.current.speed * frames;
      if (next < 0 || next > 1) {
        next = clamp01(next);
        g.current.speed *= -WALL_KEEP;
      }
      pos.set(next);
      if (Math.abs(g.current.speed) > 0.0004) g.current.frame = requestAnimationFrame(tick);
      else land();
    };
    g.current.frame = requestAnimationFrame(tick);
  };
  useEffect(() => () => cancelAnimationFrame(g.current.frame), []);
  useEffect(() => {
    if (value === undefined || g.current.down) return;
    cancelAnimationFrame(g.current.frame);
    pos.set(value);
    setSettled(value);
  }, [value, pos]);

  const grab = (ev: PointerEvent<HTMLDivElement>) => {
    try {
      ev.currentTarget.setPointerCapture(ev.pointerId);
    } catch {
      // pointer already gone; dragging still works
    }
    cancelAnimationFrame(g.current.frame);
    Object.assign(g.current, { down: true, speed: 0, at: performance.now() });
    onSound?.("grab");
    pos.set(toUnits(ev.clientX));
  };
  const drag = (ev: PointerEvent<HTMLDivElement>) => {
    if (!g.current.down) return;
    const now = performance.now();
    const to = toUnits(ev.clientX);
    const frames = Math.max(1, now - g.current.at) / 16.67;
    g.current.speed = (g.current.speed + (to - pos.get()) / frames) / 2;
    g.current.at = now;
    pos.set(to);
  };
  const release = () => {
    if (!g.current.down) return;
    g.current.down = false;
    if (performance.now() - g.current.at > 80) g.current.speed = 0; // paused before letting go: no fling
    onSound?.("place");
    if (Math.abs(g.current.speed) > 0.002) glide();
    else land();
  };
  const nudge = (ev: KeyboardEvent<HTMLDivElement>) => {
    const step = ({ ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 } as Record<string, number>)[ev.key];
    const v = pos.get();
    const next = step ? v + step * (ev.shiftKey ? 0.25 : 0.05) : ev.key === "PageUp" ? v + 0.25 : ev.key === "PageDown" ? v - 0.25 : ev.key === "Home" ? 0 : ev.key === "End" ? 1 : null;
    if (next === null) return;
    ev.preventDefault();
    cancelAnimationFrame(g.current.frame);
    pos.set(clamp01(next));
    land();
  };

  return (
    <motion.div
      ref={box}
      className={["tcp-root", className].filter(Boolean).join(" ")}
      style={{ width, height, borderRadius: corner, "--tcp-p": pos, ...style } as MotionStyle}
      role="slider"
      tabIndex={0}
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={Math.round(settled * 100)}
      onPointerDown={grab}
      onPointerMove={drag}
      onPointerUp={release}
      onPointerCancel={() => void (g.current.down = false)}
      onKeyDown={nudge}
    >
      <div className="tcp-after">
        <img src={after.src} alt={after.alt} draggable={false} />
      </div>
      <motion.div className="tcp-before" style={{ clipPath: clip }}>
        <img src={before.src} alt={before.alt} draggable={false} style={{ filter: beforeFilter }} />
      </motion.div>
      <span className="tcp-tag tcp-l">{labels[0]}</span>
      <span className="tcp-tag tcp-r">{labels[1]}</span>
      <svg className="tcp-line" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" aria-hidden>
        <motion.path d={line} />
      </svg>
      <span className="tcp-knob" aria-hidden>
        <svg viewBox="0 0 16 16" width="14" height="14">
          <path d="M6 3 2 8l4 5M10 3l4 5-4 5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </span>
    </motion.div>
  );
}
Compare.css83 lines
.tcp-root,
.tcp-root * {
  box-sizing: border-box;
}
.tcp-root {
  --tcp-p: 0.5;
  position: relative;
  overflow: hidden;
  cursor: col-resize;
  touch-action: none;
  user-select: none;
  box-shadow: var(--ts-edge);
  -webkit-tap-highlight-color: transparent;
}
.tcp-root:focus-visible {
  outline: 0;
  box-shadow: 0 0 0 2px var(--ts-raised), 0 0 0 4px var(--ts-accent);
}
.tcp-after,
.tcp-before {
  position: absolute;
  inset: 0;
}
.tcp-after > *,
.tcp-before > * {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.tcp-tag {
  position: absolute;
  top: 10px;
  padding: 4px 9px;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.45);
  color: #fff;
  font-size: 12px;
  font-weight: 500;
  backdrop-filter: blur(6px);
  pointer-events: none;
  transition: opacity 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tcp-l {
  left: 10px;
  opacity: clamp(0, calc(var(--tcp-p) * 4 - 0.4), 1);
}
.tcp-r {
  right: 10px;
  opacity: clamp(0, calc((1 - var(--tcp-p)) * 4 - 0.4), 1);
}
.tcp-line {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}
.tcp-line path {
  fill: none;
  stroke: #fff;
  stroke-width: 2;
  filter: drop-shadow(0 0 0.5px rgba(0, 0, 0, 0.3));
}
.tcp-knob {
  position: absolute;
  top: 50%;
  left: calc(var(--tcp-p) * 100%);
  width: 30px;
  height: 30px;
  translate: -50% -50%;
  border-radius: 50%;
  background: #fff;
  color: #161514;
  display: grid;
  place-items: center;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  transition: scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tcp-root:active .tcp-knob {
  scale: 0.92;
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { Compare } from "./Compare";
import "./Compare.css";

export default function Demo() {
  return <Compare />;
}