← Filters & retro

Sketch to Photo

A photo starts life as a pencil drawing. The outlines and shading are sketched onto blank paper in a quick diagonal sweep, with cross-hatching in the shadows, then colour floods in from a few spots until the drawing has become the full photo.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-sketch-to-photo 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 sketch and the colour take together. Default 5200 ms.
data-loopDraw, colour in, hold, rub out and repeat.
data-holdHow long the finished photo stays when looping. Default 2400 ms.
data-hatchfalse for outlines and soft shading only, no hatching.
data-paperThe paper colour. Default #fbf8f1.

With reduced motion the photo simply shows as it is.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Sketch to Photo: a photo starts life as a pencil drawing. First the
    // outlines and shading are sketched onto blank paper in a quick diagonal
    // sweep, with cross-hatching in the shadows, then colour floods in from a
    // few spots until the drawing has become the full photo.
    // Usage: <figure data-sketch-to-photo><img src="photo.jpg" alt="…"></figure>
    // The image (an <img> on the same site, or an inline <svg>) fills the box,
    // cropped to cover, and stays in the page for screen readers.
    // Optional: data-duration (ms for the whole thing, default 5200), data-loop
    // (draw, colour, hold, rub out, repeat), data-hold (ms to hold the photo
    // when looping, default 2400), data-hatch (false for outlines and shading
    // only), data-paper (paper colour, default #fbf8f1).
    var DEFAULTS = { duration: 5200, hold: 2400, paper: '#fbf8f1', maxSize: 1100 };

    var css =
      '.stph{position:relative;overflow:hidden}' +
      '.stph-src{opacity:0}' +
      '.stph-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);
    }

    function hex(c) {
      c = c.trim().replace('#', '');
      if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
      var n = parseInt(c, 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }

    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 hatch = d.hatch !== 'false';
      var paper = hex(d.paper || DEFAULTS.paper);
      var canvas = document.createElement('canvas');
      canvas.className = 'stph-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d', { willReadFrequently: true });
      var pw = 0, ph = 0, photo = null, sketch = null, bg = null, t1 = null, t2 = null, out = null;

      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);
        try { photo = ctx.getImageData(0, 0, pw, ph).data; }
        catch (err) { canvas.remove(); el.querySelector('.stph-src').classList.remove('stph-src'); return false; }

        // A blurred copy of the picture: dividing by it (a "colour dodge") leaves only the edges.
        var small = document.createElement('canvas');
        var f = Math.max(2, Math.round(Math.min(pw, ph) / 90));
        small.width = Math.max(1, Math.round(pw / f)); small.height = Math.max(1, Math.round(ph / f));
        small.getContext('2d').drawImage(canvas, 0, 0, small.width, small.height);
        ctx.clearRect(0, 0, pw, ph);
        ctx.imageSmoothingQuality = 'high';
        ctx.drawImage(small, 0, 0, pw, ph);
        var soft = ctx.getImageData(0, 0, pw, ph).data;

        var n = pw * ph, unit = Math.min(pw, ph), sc = 5 / unit, i;
        sketch = new Uint8ClampedArray(n);
        bg = new Uint8ClampedArray(n * 3);
        t1 = new Float32Array(n);
        t2 = new Float32Array(n);
        var gap = Math.max(4, Math.round(unit / 55));
        var seeds = [[0.3, 0.4], [0.72, 0.62], [0.55, 0.15]];
        var max2 = 0;
        for (var y = 0; y < ph; y++) {
          for (var x = 0; x < pw; x++) {
            i = y * pw + x;
            var k = i * 4;
            var g = 0.3 * photo[k] + 0.59 * photo[k + 1] + 0.11 * photo[k + 2];
            var b = 0.3 * soft[k] + 0.59 * soft[k + 1] + 0.11 * soft[k + 2];
            // Outlines: where the pixel is darker than its surroundings.
            var line = Math.min(1, Math.max(0, (b - g) / Math.max(24, b) * 3.2));
            line = Math.max(line, Math.min(1, Math.max(0, (g - b) / 255 * 2.2)));
            // Shading: light hatching in the mid tones, cross-hatching in the shadows.
            var shade = 0;
            if (hatch) {
              var tone = 1 - g / 255;
              var wob = (noise(x * 0.05, y * 0.05, 3) - 0.5) * gap * 0.8;
              var h1 = ((x + y + wob) % gap + gap) % gap < 1.15 ? 1 : 0;
              var h2 = ((x - y + wob + 10000 * gap) % gap) < 1.15 ? 1 : 0;
              shade = h1 * Math.max(0, tone - 0.28) * 1.3 + h2 * Math.max(0, tone - 0.55) * 1.6;
              shade += Math.max(0, tone - 0.2) * 0.14; // a faint smudge of graphite
            }
            var ink = Math.min(1, line * 0.95 + shade) * (0.75 + noise(x * 0.6, y * 0.6, 5) * 0.25);
            sketch[i] = ink * 255;
            var grain = 1 - noise(x * 0.9, y * 0.9, 6) * 0.035;
            bg[i * 3] = paper[0] * grain; bg[i * 3 + 1] = paper[1] * grain; bg[i * 3 + 2] = paper[2] * grain;
            // The sketch sweeps in diagonally from the top left; colour spreads from a few spots.
            t1[i] = Math.min(1, ((x / pw) * 0.6 + (y / ph) * 0.4) * 0.8 + fbm(x * sc * 2, y * sc * 2, 8) * 0.25);
            var near = 9;
            for (var q = 0; q < seeds.length; q++) {
              var dx = (x / pw - seeds[q][0]) * pw / unit, dy = (y / ph - seeds[q][1]) * ph / unit;
              near = Math.min(near, Math.sqrt(dx * dx + dy * dy));
            }
            t2[i] = near + fbm(x * sc * 1.3, y * sc * 1.3, 12) * 0.4;
            if (t2[i] > max2) max2 = t2[i];
          }
        }
        for (i = 0; i < n; i++) t2[i] /= max2;
        out = ctx.createImageData(pw, ph);
        for (i = 0; i < n; i++) out.data[i * 4 + 3] = 255;
        return true;
      }

      // p runs 0 (blank paper) to 1 (sketched) to 2 (full photo).
      function render(p) {
        if (!out) return;
        var o = out.data, n = pw * ph;
        var P1 = Math.min(1, p) * 1.3, P2 = Math.max(0, p - 1) * 1.35;
        for (var i = 0; i < n; i++) {
          var a1 = (P1 - t1[i]) / 0.3;
          a1 = a1 <= 0 ? 0 : a1 >= 1 ? 1 : a1;
          var a2 = (P2 - t2[i]) / 0.35;
          a2 = a2 <= 0 ? 0 : a2 >= 1 ? 1 : a2 * a2 * (3 - 2 * a2);
          // Graphite over paper.
          var ink = sketch[i] / 255 * a1;
          var j = i * 3, k = i * 4;
          var r = bg[j] + (58 - bg[j]) * ink;
          var g = bg[j + 1] + (60 - bg[j + 1]) * ink;
          var b = bg[j + 2] + (70 - bg[j + 2]) * ink;
          // Colour washes in over the drawing, the pencil lines fading as it settles.
          o[k] = r + (photo[k] - r) * a2;
          o[k + 1] = g + (photo[k + 1] - g) * a2;
          o[k + 2] = b + (photo[k + 2] - b) * a2;
        }
        ctx.putImageData(out, 0, 0);
      }

      var state = { p: 0 };
      function draw() { render(state.p); }
      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()) draw();
        }).observe(el);
      }
      if (reduced()) { state.p = 2; draw(); return; }
      draw();

      var loop = d.loop !== undefined && d.loop !== 'false';
      var anim = null;
      function play() {
        var tl = anime.createTimeline({ loop: loop, delay: 300 })
          .add(state, { p: 1, duration: duration * 0.45, ease: 'inOut(1.5)', onUpdate: draw })
          .add(state, { p: 2, duration: duration * 0.55, delay: 350, ease: 'inOut(1.5)', onUpdate: draw });
        if (loop) {
          // Rub it all out again: colour drains, then the pencil goes.
          tl.add(state, { p: 0, duration: 1400, delay: hold, ease: 'inOut(2)', onUpdate: draw })
            .add(state, { p: 0, duration: 500 });
        }
        anim = tl;
      }

      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-sketch-to-photo]').forEach(function (el) {
        if (el._stph) return;
        var src = el.querySelector('img, svg');
        if (!src) return;
        el._stph = true;
        el.classList.add('stph');
        src.classList.add('stph-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-sketch-to-photo style="height:400px">
  <img src="photo.jpg" alt="A village seen from a hilltop">
</figure>