Animated QR Codes

When a payment QR changes, nobody can tell. These seven effects show the moment the code updates. Pick one, then copy the code.

How it works

  1. Encode. uqr turns the link into a grid of modules. The mask pattern is fixed, so changing $12.00 to $45.50 changes about 7% of the dots instead of a quarter of them.
  2. Draw. Each module is a dot in one SVG. The three corner eyes are drawn by hand, and the dots under the logo are left out. Error correction is set to high so the code still scans.
  3. Compare. When the value changes, every dot is marked as staying, appearing or leaving.
  4. Animate. Each effect is a set of CSS keyframes. A per-dot delay decides the order: by column for Flip, by row for Scan, by distance from the centre for Coin. Ripple is the exception: it snapshots the code into a WebGL texture and runs a shader.
  5. Settle. Every effect ends on a plain black and white code, so it always scans. With reduced motion turned on, the code just swaps.

Install

npm i uqr motion

Usage

import { QRMorph } from "./QRMorph";
import { QRCode } from "./QRCode";

const COLORS = ["#5cbf62", "#e690d6", "#fde25a", "#f6a54c"];

// Flip, Wipe, Coin, Colours, Scan or Spell.
// The effect plays every time value changes.
<QRMorph value={url} variant="flip" size={272} logo={<Logo />} />

// Spell shows the text first, then the new code
<QRMorph value={url} variant="spell" spell="$45.50" />

// Ripple uses WebGL
<QRCode value={url} eye="circle" colors={COLORS} />

Source

Two components. QRMorph runs the six CSS effects, QRCode runs Ripple. QRMorph reuses the corner eyes from QRCode, so copy both files.

'use client';

import { useMemo, useState } from "react";
import { encode, QrCodeDataType } from "uqr";
import { Eye } from "./QRCode.jsx";
import "./QRMorph.css";

// A fixed mask keeps the pattern stable, so a small change in the value
// only changes a few dots (about 7% for $12.00 → $45.50) instead of a quarter.
const MASK = 0;
const DOT_RADIUS = 0.4;
const BADGE_RATIO = 0.21;
const COIN_LAND_MS = 420;
const WIPE_MS = 900;
const SCAN_MS = 900;
const TEXT_STEP = 0.75; // spacing of the dots that spell the amount, in modules

// Sample text onto a grid of points (module coordinates), centred in the code.
function textPoints(text, n) {
  const cols = Math.round(n / TEXT_STEP);
  const rows = Math.round(cols * 0.42);
  const canvas = document.createElement("canvas");
  canvas.width = cols;
  canvas.height = rows;
  const ctx = canvas.getContext("2d");
  let px = rows * 0.9;
  ctx.font = `900 ${px}px Rubik, Inter, sans-serif`;
  const fit = (cols * 0.94) / ctx.measureText(text).width;
  if (fit < 1) px *= fit;
  ctx.font = `900 ${px}px Rubik, Inter, sans-serif`;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText(text, cols / 2, rows / 2 + px * 0.06);
  const data = ctx.getImageData(0, 0, cols, rows).data;
  const top = n / 2 - (rows * TEXT_STEP) / 2;
  const pts = [];
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      if (data[(r * cols + c) * 4 + 3] > 140) pts.push({ x: c * TEXT_STEP + TEXT_STEP / 2, y: top + r * TEXT_STEP + TEXT_STEP / 2 });
    }
  }
  return pts;
}

// Each new dot starts at an old dot, visits a text point, then lands in place.
// Lists are row-major, so neighbours travel together.
function spellDots(before, next, n, text, colors) {
  const olds = [...before].sort((a, b) => a - b);
  const news = [...next].sort((a, b) => a - b);
  const pts = textPoints(text, n);
  return news.map((i, k) => {
    const x = i % n;
    const y = Math.floor(i / n);
    const o = olds[Math.floor((k * olds.length) / news.length)];
    const t = pts[Math.floor((k * pts.length) / news.length)] ?? { x: x + 0.5, y: y + 0.5 };
    return {
      x,
      y,
      kind: "spell",
      sx: (o % n) - x,
      sy: Math.floor(o / n) - y,
      tx: t.x - (x + 0.5),
      ty: t.y - (y + 0.5),
      c: colors[Math.min(3, Math.floor((t.x / n) * 4))],
      d: 0,
    };
  });
}

function grid(value, hasLogo) {
  const qr = encode(value, { ecc: hasLogo ? "H" : "M", border: 0, maskPattern: MASK });
  const n = qr.size;
  const centre = n / 2;
  const hole = hasLogo ? n * 0.155 + 1 : 0;
  const on = new Set();
  for (let y = 0; y < n; y++) {
    for (let x = 0; x < n; x++) {
      if (!qr.data[y][x] || qr.types[y][x] === QrCodeDataType.Position) continue;
      if (hole && Math.hypot(x + 0.5 - centre, y + 0.5 - centre) < hole) continue;
      on.add(y * n + x);
    }
  }
  return { n, on };
}

// Stable pseudo-random 0..1 per dot.
const hash = (x, y) => (((x * 73856093) ^ (y * 19349663)) >>> 0) % 1000 / 1000;

function timing(variant, x, y, n, colors) {
  const cx = x + 0.5 - n / 2;
  const cy = y + 0.5 - n / 2;
  const dist = Math.hypot(cx, cy);
  const angle = (Math.atan2(cy, cx) + Math.PI) / (2 * Math.PI);
  switch (variant) {
    case "flip":
      // Left to right, column by column, like a split-flap board.
      return { d: x * 22 + (y % 4) * 6, c: colors[Math.min(3, Math.floor((x / n) * 4))] };
    case "wipe":
      // The ring travels corner to corner; a dot changes as it passes.
      return { d: ((x + y) / (2 * n)) * WIPE_MS, c: colors[Math.floor(angle * 4) % 4] };
    case "scan":
      // Row by row, as the scan line passes.
      return { d: (y / n) * SCAN_MS, c: colors[Math.floor(angle * 4) % 4] };
    case "coin": {
      // Dots near the landing point move first and furthest.
      const push = 1.1 / (1 + dist * 0.18);
      const len = dist || 1;
      return { d: COIN_LAND_MS + dist * 16, dx: (cx / len) * push, dy: (cy / len) * push, c: colors[0] };
    }
    default:
      // "colors": the new code assembles in brand colours, then settles to ink.
      return { d: hash(x, y) * 520, c: colors[Math.floor(angle * 4) % 4] };
  }
}

const BRAND = ["#5cbf62", "#e690d6", "#fde25a", "#f6a54c"];

export function QRMorph({ value, size = 200, logo, variant = "flip", colors = BRAND, label, eye = "circle", spell = "", className }) {
  const hasLogo = logo != null;
  // Remember the previous value so we know which dots appear, stay or go.
  const [state, setState] = useState({ value, prev: null, gen: 0, text: spell });
  if (state.value !== value) setState({ value, prev: state.value, gen: state.gen + 1, text: spell });

  const next = useMemo(() => grid(state.value, hasLogo), [state.value, hasLogo]);
  const prev = useMemo(() => (state.prev === null ? null : grid(state.prev, hasLogo)), [state.prev, hasLogo]);
  const { n } = next;
  // A version change (longer value) resizes the grid; then everything is new.
  const before = prev && prev.n === n ? prev.on : null;

  const dots = useMemo(() => {
    if (variant === "spell" && before && state.text) return spellDots(before, next.on, n, state.text, colors);
    const out = [];
    const all = new Set(next.on);
    if (before) for (const i of before) all.add(i);
    for (const i of all) {
      const x = i % n;
      const y = Math.floor(i / n);
      const isOn = next.on.has(i);
      const wasOn = before ? before.has(i) : state.prev === null ? isOn : false;
      const kind = isOn && wasOn ? "stay" : isOn ? "in" : "out";
      out.push({ x, y, kind, ...timing(variant, x, y, n, colors) });
    }
    return out;
  }, [next, before, n, variant, colors, state.prev, state.text]);

  const box = n + 4;
  const far = n - 3.5;
  const moving = state.gen > 0;

  return (
    <div
      role="img"
      aria-label={label ?? value}
      className={["qr", "qm", `qm-${variant}`, moving ? "qm-moving" : "", className].filter(Boolean).join(" ")}
      style={{ "--qr-size": `${size}px`, "--qr-badge": `${size * BADGE_RATIO}px` }}
    >
      <svg viewBox={`-2 -2 ${box} ${box}`} aria-hidden xmlns="http://www.w3.org/2000/svg">
        <defs>
          <linearGradient id="qm-ring" x1="0" y1="0" x2="1" y2="1">
            {colors.map((c, i) => (
              <stop key={c} offset={i / (colors.length - 1)} stopColor={c} />
            ))}
          </linearGradient>
        </defs>
        <rect x={-2} y={-2} width={box} height={box} fill="var(--qr-paper)" />
        <Eye x={3.5} y={3.5} shape={eye} />
        <Eye x={far} y={3.5} shape={eye} />
        <Eye x={3.5} y={far} shape={eye} />
        {dots.map(({ x, y, kind, d, c, dx = 0, dy = 0, sx = 0, sy = 0, tx = 0, ty = 0 }) => (
          <circle
            key={`${state.gen}-${x}-${y}`}
            className={`qm-dot ${kind}`}
            cx={x + 0.5}
            cy={y + 0.5}
            r={DOT_RADIUS}
            style={{
              "--d": `${Math.round(d)}ms`,
              "--c": c,
              "--dx": `${dx}px`,
              "--dy": `${dy}px`,
              "--sx": `${sx}px`,
              "--sy": `${sy}px`,
              "--tx": `${tx}px`,
              "--ty": `${ty}px`,
            }}
          />
        ))}
        {moving && variant === "wipe" && (
          <circle
            key={`ring-${state.gen}`}
            className="qm-ring"
            cx={0}
            cy={0}
            r={n * 0.4}
            strokeWidth={n * 0.22}
            fill="none"
            stroke="url(#qm-ring)"
            style={{ "--from": `${-n * 0.6}px`, "--to": `${n * 1.6}px` }}
          />
        )}
      </svg>

      {hasLogo && (
        <div className="qr-logo-wrap">
          <div className="qr-logo">
            <div key={variant= "coin" || variant= "spell" ? state.gen : 0} className="qm-logo">
              {logo}
            </div>
          </div>
        </div>
      )}

      {moving && variant === "scan" && (
        <div
          key={`scan-${state.gen}`}
          className="qm-scanline"
          style={{
            "--top0": `${(2 / box) * 100 - 14}%`,
            "--top1": `${((n + 2) / box) * 100 - 14}%`,
            "--line": `linear-gradient(90deg, ${colors.join(", ")})`,
          }}
        />
      )}

      {moving && variant === "coin" && (
        <div className="qm-coin-wrap" key={`coin-${state.gen}`}>
          <span className="qm-coin-face">$</span>
        </div>
      )}
    </div>
  );
}

Credits

Ripple is adapted from Kobra's QR Code component. The other six effects are original. QR encoding by uqr.