← Filters & retro

Photo Ageing

Decades pass in a few seconds. Starting at the edges and creeping inwards, the photo fades to a warm sepia, its contrast drops, a brown vignette and age spots appear, a crease and fine scratches show up and the borders wear away to bare paper.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-photo-ageing and give the wrapper a size. The image fills it, cropped to cover.
  3. Use an image from your own site (or an inline SVG): the script has to read its pixels.

Options

Add any of these attributes to the same element.

data-durationHow long the ageing takes. Default 4200 ms.
data-loopAge, hold, turn young again and repeat.
data-holdHow long it holds each way when looping. Default 2400 ms.
data-wearHow worn the edges get, from 0 to 2. Default 1.
data-creasefalse leaves out the crease.

With reduced motion the photo simply shows already aged.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Photo Ageing: decades pass in a few seconds. Starting at the edges and
    // creeping inwards, the photo fades to a warm sepia, its contrast drops, a
    // brown vignette and age spots appear, a crease and fine scratches show up
    // and the borders wear away to bare paper.
    // Usage: <figure data-photo-ageing><img src="photo.jpg" alt="…"></figure>
    // The image (an <img> on the same site, or an inline <svg>) fills the box,
    // cropped to cover.
    // Optional: data-duration (ms to age, default 4200), data-loop (age, hold,
    // turn young again, repeat), data-hold (ms to hold each way when looping,
    // default 2400), data-wear (how worn the edges get, 0 to 2, default 1),
    // data-crease (false leaves out the crease).
    var DEFAULTS = { duration: 4200, hold: 2400, wear: 1, maxSize: 1100 };

    var css =
      '.pag{position:relative;overflow:hidden}' +
      '.pag-src{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}' +
      '.pag-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function loadSource(node, done) {
      if (node.tagName.toLowerCase() === 'svg') {
        var vb = node.viewBox && node.viewBox.baseVal, r = node.getBoundingClientRect();
        var vw = (vb && vb.width) || r.width || 300, vh = (vb && vb.height) || r.height || 150;
        var k = 1000 / Math.max(vw, vh);
        var clone = node.cloneNode(true);
        clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        clone.setAttribute('width', Math.round(vw * k));
        clone.setAttribute('height', Math.round(vh * k));
        var im = new Image();
        im.onload = function () { done(im, im.width, im.height); };
        im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(new XMLSerializer().serializeToString(clone));
      } else if (node.complete && node.naturalWidth) {
        done(node, node.naturalWidth, node.naturalHeight);
      } else {
        node.addEventListener('load', function () { done(node, node.naturalWidth, node.naturalHeight); });
      }
    }

    function cover(ctx, img, iw, ih, w, h) {
      var s = Math.max(w / iw, h / ih);
      ctx.drawImage(img, (w - iw * s) / 2, (h - ih * s) / 2, iw * s, ih * s);
    }

    // Smooth value noise, for stains and ragged edges.
    function hash(x, y, seed) {
      var n = Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 1442695041);
      n = Math.imul(n ^ (n >>> 13), 1274126177);
      return ((n ^ (n >>> 16)) >>> 0) / 4294967295;
    }
    function noise(x, y, seed) {
      var xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
      var u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
      var a = hash(xi, yi, seed), b = hash(xi + 1, yi, seed), c = hash(xi, yi + 1, seed), d = hash(xi + 1, yi + 1, seed);
      return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
    }
    function fbm(x, y, seed) {
      return noise(x, y, seed) * 0.55 + noise(x * 2.1, y * 2.1, seed + 1) * 0.3 + noise(x * 4.3, y * 4.3, seed + 2) * 0.15;
    }

    function setup(el, img, iw, ih) {
      var d = el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var hold = +d.hold || DEFAULTS.hold;
      var wear = d.wear !== undefined ? +d.wear : DEFAULTS.wear;
      var crease = d.crease !== 'false';
      var canvas = document.createElement('canvas');
      canvas.className = 'pag-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d', { willReadFrequently: true });
      var pw = 0, ph = 0, out = null, when = null;

      // Work out the fully aged photo once, and when each pixel starts to age.
      function build() {
        var w = el.clientWidth, h = el.clientHeight;
        if (!w || !h) return false;
        var s = Math.min(Math.min(2, window.devicePixelRatio || 1), DEFAULTS.maxSize / Math.max(w, h));
        pw = Math.max(1, Math.round(w * s));
        ph = Math.max(1, Math.round(h * s));
        canvas.width = pw;
        canvas.height = ph;
        cover(ctx, img, iw, ih, pw, ph);
        var data;
        try { data = ctx.getImageData(0, 0, pw, ph); }
        catch (err) { canvas.remove(); return false; }
        var p = data.data, n = pw * ph;
        when = new Float32Array(n);
        var unit = Math.min(pw, ph);
        var sc = 6 / unit; // noise scale

        // Age spots and scratches, placed the same way every time.
        var spots = [], scratches = [], i;
        var nSpots = Math.round(pw * ph / 9000);
        for (i = 0; i < nSpots; i++) {
          spots.push([hash(i, 1, 7) * pw, hash(i, 2, 7) * ph, (0.4 + hash(i, 3, 7) * 1.6) * unit / 160]);
        }
        for (i = 0; i < 7; i++) {
          var sx = hash(i, 4, 9) * pw, sy = hash(i, 5, 9) * ph, ang = 1.2 + hash(i, 6, 9) * 0.7, len = (0.15 + hash(i, 7, 9) * 0.4) * unit;
          scratches.push([sx, sy, Math.cos(ang), Math.sin(ang), len]);
        }
        // The crease runs corner to corner-ish.
        var cx0 = pw * 0.62, cy0 = 0, cx1 = pw * 0.38, cy1 = ph;
        var cdx = cx1 - cx0, cdy = cy1 - cy0, clen = Math.sqrt(cdx * cdx + cdy * cdy);

        for (var y = 0; y < ph; y++) {
          for (var x = 0; x < pw; x++) {
            i = y * pw + x;
            var k = i * 4;
            var R = p[k], G = p[k + 1], B = p[k + 2];
            // Sepia, then flatten the contrast and lift the blacks, as old prints do.
            var r = 0.393 * R + 0.769 * G + 0.189 * B;
            var g = 0.349 * R + 0.686 * G + 0.168 * B;
            var b = 0.272 * R + 0.534 * G + 0.131 * B;
            r = r * 0.74 + 52; g = g * 0.72 + 44; b = b * 0.66 + 28;
            // Blotchy stains and a brown vignette.
            var stain = fbm(x * sc * 0.6, y * sc * 0.6, 3);
            var vx = (x / pw - 0.5) * 2, vy = (y / ph - 0.5) * 2;
            var vig = Math.max(0, Math.sqrt(vx * vx * 0.8 + vy * vy * 0.8) - 0.45) * 0.75;
            var dark = 1 - vig * 0.7 - Math.max(0, stain - 0.55) * 0.5;
            r *= dark; g *= dark * 0.96; b *= dark * 0.88;
            // Fine paper grain.
            var grain = (hash(x, y, 11) - 0.5) * 16;
            r += grain; g += grain; b += grain;
            // Age spots.
            for (var sIdx = 0; sIdx < spots.length; sIdx++) {
              var sp = spots[sIdx], ddx = x - sp[0], ddy = y - sp[1];
              var dd = ddx * ddx + ddy * ddy, rr = sp[2] * sp[2];
              if (dd < rr * 4) {
                var f = Math.max(0, 1 - dd / (rr * 4)) * 0.55;
                r = r + (110 - r) * f; g = g + (70 - g) * f; b = b + (35 - b) * f;
              }
            }
            // Scratches: thin pale lines.
            for (var q = 0; q < scratches.length; q++) {
              var sc2 = scratches[q], px = x - sc2[0], py = y - sc2[1];
              var along = px * sc2[2] + py * sc2[3], across = Math.abs(-px * sc2[3] + py * sc2[2]);
              if (along > 0 && along < sc2[4] && across < 0.9) {
                var fa = (1 - across / 0.9) * 0.6;
                r += (240 - r) * fa; g += (228 - g) * fa; b += (200 - b) * fa;
              }
            }
            // The crease: a pale line with a shadow along one side.
            if (crease) {
              var side = ((x - cx0) * cdy - (y - cy0) * cdx) / clen;
              var wob = (noise(y * sc * 2, 0, 5) - 0.5) * unit * 0.012;
              var cd = side + wob;
              if (Math.abs(cd) < unit * 0.02) {
                var fc = 1 - Math.abs(cd) / (unit * 0.02);
                if (cd < 0) { r += (235 - r) * fc * 0.45; g += (222 - g) * fc * 0.45; b += (190 - b) * fc * 0.45; }
                else { r *= 1 - fc * 0.2; g *= 1 - fc * 0.2; b *= 1 - fc * 0.2; }
              }
            }
            // Worn edges: bare paper eats in from the border, more at the corners.
            var ex = Math.min(x, pw - 1 - x), ey = Math.min(y, ph - 1 - y);
            var edgeD = Math.min(ex, ey) - Math.max(0, unit * 0.08 - Math.sqrt(ex * ex + ey * ey)) * 0.6;
            var ragged = unit * 0.035 * wear * (0.3 + fbm(x * sc * 2.5, y * sc * 2.5, 13) * 1.4);
            if (edgeD < ragged) {
              var paper = Math.min(1, (ragged - edgeD) / 2);
              var pg = 228 + (hash(x, y, 17) - 0.5) * 18;
              r += (pg + 8 - r) * paper; g += (pg - 2 - g) * paper; b += (pg - 28 - b) * paper;
            }
            p[k] = r; p[k + 1] = g; p[k + 2] = b;
            // Edges age first, then it spreads in with a ragged front.
            var toEdge = Math.min(ex, ey) / (unit * 0.5);
            when[i] = Math.min(1, toEdge * 0.75 + fbm(x * sc, y * sc, 21) * 0.35);
          }
        }
        out = data;
        return true;
      }

      // p runs 0 (new) to 1 (fully aged).
      function render(p) {
        if (!out) return;
        var o = out.data, n = pw * ph, P = p * 1.45;
        for (var i = 0; i < n; i++) {
          var a = (P - when[i]) / 0.45;
          o[i * 4 + 3] = a <= 0 ? 0 : a >= 1 ? 255 : a * a * (3 - 2 * a) * 255;
        }
        ctx.putImageData(out, 0, 0);
      }

      var state = { p: 0 };
      if (!build()) return;
      if (window.ResizeObserver) {
        var lw = el.clientWidth, lh = el.clientHeight;
        new ResizeObserver(function () {
          if (el.clientWidth === lw && el.clientHeight === lh) return;
          lw = el.clientWidth; lh = el.clientHeight;
          if (build()) render(state.p);
        }).observe(el);
      }
      if (reduced()) { render(1); return; }
      render(0);

      function draw() { render(state.p); }
      var loop = d.loop !== undefined && d.loop !== 'false';
      var anim = null;
      function play() {
        if (!loop) {
          anim = anime.animate(state, { p: 1, duration: duration, ease: 'inOut(1.6)', onUpdate: draw });
          return;
        }
        anim = anime.createTimeline({ loop: true, delay: 300 })
          .add(state, { p: 1, duration: duration, ease: 'inOut(1.6)', onUpdate: draw })
          .add(state, { p: 0, duration: 1300, delay: hold, ease: 'inOut(2)', onUpdate: draw })
          .add(state, { p: 0, duration: hold * 0.5 });
      }

      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          if (!anim) play();
          else if (loop) anim.play();
        } else if (anim && loop) anim.pause();
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-photo-ageing]').forEach(function (el) {
        if (el._pag) return;
        var src = el.querySelector('img, svg');
        if (!src) return;
        el._pag = true;
        el.classList.add('pag');
        src.classList.add('pag-src');
        loadSource(src, function (img, iw, ih) { setup(el, img, iw, ih); });
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-photo-ageing style="height:400px">
  <img src="photo.jpg" alt="A mountain lake">
</figure>