← Filters & retro

Risograph Print

Reprint any picture as a risograph. It is split into flat spot-colour inks, yellow, fluorescent pink and blue by default, each with a coarse, grainy texture. The inks roll down the paper one pass at a time and land slightly off register, so the colours overlap and the edges fringe just like the real thing.

SUMMER SOUNDS PIER 9 · 21 JUNE

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-risograph-print 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-inksUp to three ink colours, lightest first. Default #ffe800,#ff48b0,#0078bf.
data-offsetHow far the inks miss each other. Default 4 px.
data-paperThe paper colour. Default #f6f1e7.
data-durationTime for each ink to roll down the page. Default 700 ms.
data-loopPrint, hold, fade back to blank paper and print again.
data-holdHow long the finished print stays when looping. Default 2600 ms.

With reduced motion the finished print shows straight away.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Risograph Print: reprints an image as a risograph. The picture is split
    // into a few flat spot-colour inks with a grainy texture, and each ink is
    // printed in its own pass, rolling down the paper one after another and
    // landing slightly off register, so colours overlap and edges fringe.
    // Usage: <figure data-risograph-print><img src="photo.jpg" alt="…"></figure>
    // The image (an <img> on the same site, or an inline <svg>) stays in the
    // page for screen readers; give the wrapper a size.
    // Optional: data-inks (up to three ink colours, light to dark, default
    // "#ffe800,#ff48b0,#0078bf": yellow, fluorescent pink and blue),
    // data-offset (how far off register, in px, default 4), data-paper (the
    // paper colour, default "#f6f1e7"), data-duration (ms for each ink pass,
    // default 700), data-loop (fade back to paper and print again),
    // data-hold (ms the finished print stays when looping, default 2600).
    var DEFAULTS = { inks: '#ffe800,#ff48b0,#0078bf', offset: 4, paper: '#f6f1e7', duration: 700, hold: 2600, maxSize: 700 };

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

    // Turn the source (<img> or inline <svg>) into something drawImage can use.
    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 hex(c) {
      var m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((c || '').trim());
      if (!m) return [0, 0, 0];
      var s = m[1].length === 3 ? m[1].replace(/./g, '$&$&') : m[1];
      return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)];
    }

    function setup(el, img, iw, ih) {
      var d = el.dataset;
      var inks = (d.inks || DEFAULTS.inks).split(',').slice(0, 3).map(hex);
      var offset = d.offset !== undefined ? +d.offset : DEFAULTS.offset;
      var paper = d.paper || DEFAULTS.paper;
      var duration = +d.duration || DEFAULTS.duration;
      var hold = +d.hold || DEFAULTS.hold;
      var loop = d.loop !== undefined && d.loop !== 'false';
      el.style.background = paper;
      var canvas = document.createElement('canvas');
      canvas.className = 'rso-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      var layers = [], grain = null;
      var w = 0, h = 0, dpr = 1;
      // Where each ink lands: a small, fixed miss for every pass.
      var miss = inks.map(function (c, i) {
        var a = i * 2.1 + 0.6;
        return { x: Math.cos(a), y: Math.sin(a), r: (i - 1) * 0.0035 };
      });

      function build() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        if (!w || !h) return false;
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        // Work at a modest size: riso grain is coarse anyway.
        var s = Math.min(1, DEFAULTS.maxSize / Math.max(w, h)) * Math.min(1.5, dpr);
        var lw = Math.max(1, Math.round(w * s)), lh = Math.max(1, Math.round(h * s));
        var src = document.createElement('canvas');
        src.width = lw;
        src.height = lh;
        var sx = src.getContext('2d', { willReadFrequently: true });
        var k = Math.max(lw / iw, lh / ih);
        sx.drawImage(img, (lw - iw * k) / 2, (lh - ih * k) / 2, iw * k, ih * k);
        var px;
        try { px = sx.getImageData(0, 0, lw, lh).data; }
        catch (err) { canvas.remove(); el.querySelector('.rso-src').classList.remove('rso-src'); return false; }

        // How much of each ink every pixel needs. Each ink "absorbs" some of
        // red, green and blue; we pick amounts so the inks multiplied together
        // come close to the original colour, with a few quick refining steps.
        var n = inks.length, N = lw * lh;
        var absorb = inks.map(function (c) { return [1 - c[0] / 255, 1 - c[1] / 255, 1 - c[2] / 255]; });
        var amt = inks.map(function () { return new Float32Array(N); });
        // Photos repeat colours a lot, so solve each (slightly rounded) colour once.
        var cache = new Float32Array(262144 * 3), seen = new Uint8Array(262144);
        for (var p = 0; p < N; p++) {
          var key = ((px[p * 4] >> 2) << 12) | ((px[p * 4 + 1] >> 2) << 6) | (px[p * 4 + 2] >> 2);
          var i;
          if (!seen[key]) {
            seen[key] = 1;
            var t = [px[p * 4] / 255, px[p * 4 + 1] / 255, px[p * 4 + 2] / 255];
            var a = [0, 0, 0], ch, it;
            for (it = 0; it < 6; it++) {
              for (i = 0; i < n; i++) {
                // Colour the other inks already give, then how much of this one fits best.
                var num = 0, den = 0;
                for (ch = 0; ch < 3; ch++) {
                  var rest = 1;
                  for (var j = 0; j < n; j++) if (j !== i) rest *= 1 - a[j] * absorb[j][ch];
                  var want = 1 - t[ch] / Math.max(0.02, rest);
                  num += want * absorb[i][ch];
                  den += absorb[i][ch] * absorb[i][ch];
                }
                a[i] = Math.max(0, Math.min(1, den ? num / den : 0));
              }
            }
            for (i = 0; i < n; i++) cache[key * 3 + i] = a[i];
          }
          for (i = 0; i < n; i++) amt[i][p] = cache[key * 3 + i];
        }

        // Each ink as a grainy layer: a dot wherever random grain is below the amount.
        layers = inks.map(function (c, i) {
          var lc = document.createElement('canvas');
          lc.width = lw;
          lc.height = lh;
          var x = lc.getContext('2d');
          var id = x.createImageData(lw, lh), q = id.data, A = amt[i];
          for (var p = 0; p < N; p++) {
            // Soft threshold, so solid areas stay solid and light ones break up into speckle.
            var v = A[p] * 1.05 - (Math.random() + Math.random()) * 0.5;
            var al = Math.max(0, Math.min(1, v * 5 + 0.5));
            q[p * 4] = c[0];
            q[p * 4 + 1] = c[1];
            q[p * 4 + 2] = c[2];
            q[p * 4 + 3] = al * 235;
          }
          x.putImageData(id, 0, 0);
          return lc;
        });

        // Paper fibres and the odd speck of stray ink.
        grain = document.createElement('canvas');
        grain.width = lw;
        grain.height = lh;
        var gx = grain.getContext('2d');
        var gd = gx.createImageData(lw, lh), g = gd.data;
        for (p = 0; p < N; p++) {
          var r = Math.random();
          g[p * 4] = g[p * 4 + 1] = g[p * 4 + 2] = r < 0.5 ? 0 : 255;
          g[p * 4 + 3] = r < 0.003 ? 120 : Math.random() * 14;
        }
        gx.putImageData(gd, 0, 0);
        return true;
      }

      // progress[i]: how far down the paper ink i has rolled (0 to 1); fade: the whole print's strength.
      function draw(progress, fade, jitter) {
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.globalCompositeOperation = 'source-over';
        ctx.globalAlpha = 1;
        ctx.fillStyle = paper;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        var off = offset * dpr;
        ctx.globalCompositeOperation = 'multiply';
        for (var i = 0; i < layers.length; i++) {
          var pr = progress[i];
          if (pr <= 0) continue;
          var m = miss[i];
          // While rolling, the ink is further off; it settles to its final small miss.
          var sl = 1 + (1 - Math.min(1, pr)) * 2.5 + (jitter || 0);
          ctx.save();
          ctx.globalAlpha = fade;
          ctx.beginPath();
          ctx.rect(0, 0, canvas.width, canvas.height * Math.min(1, pr) + 1);
          ctx.clip();
          ctx.translate(canvas.width / 2 + m.x * off * sl, canvas.height / 2 + m.y * off * sl);
          ctx.rotate(m.r);
          ctx.drawImage(layers[i], -canvas.width / 2 - off, -canvas.height / 2 - off, canvas.width + off * 2, canvas.height + off * 2);
          ctx.restore();
        }
        ctx.globalCompositeOperation = 'source-over';
        ctx.globalAlpha = 1;
        if (grain) ctx.drawImage(grain, 0, 0, canvas.width, canvas.height);
      }

      function done() { return layers.map(function () { return 1; }); }

      if (!build()) return;
      var state = { p: reduced() ? 1 : 0, fade: 1 };
      function render() {
        draw(layers.map(function (l, i) { return state.p * layers.length - i; }), state.fade, 0);
      }
      render();
      if (window.ResizeObserver) {
        var lw0 = el.clientWidth, lh0 = el.clientHeight;
        new ResizeObserver(function () {
          if (el.clientWidth === lw0 && el.clientHeight === lh0) return;
          lw0 = el.clientWidth; lh0 = el.clientHeight;
          if (build()) render();
        }).observe(el);
      }
      if (reduced()) { draw(done(), 1, 0); return; }

      var tl = null;
      function play() {
        var passes = layers.length * duration;
        tl = anime.createTimeline({ loop: loop, onUpdate: render });
        tl.add(state, { p: [0, 1], fade: 1, duration: passes, ease: 'linear' });
        if (loop) {
          tl.add(state, { fade: 0, duration: 700, delay: hold, ease: 'in(2)' });
          tl.add(state, { p: 0, duration: 1 });
          tl.add(state, { fade: 1, duration: 1, delay: 400 });
        }
      }

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

    function init() {
      document.querySelectorAll('[data-risograph-print]').forEach(function (el) {
        if (el._rso) return;
        var src = el.querySelector('img, svg');
        if (!src) return;
        el._rso = true;
        el.classList.add('rso');
        src.classList.add('rso-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-risograph-print style="height:400px">
  <img src="poster.jpg" alt="Summer Sounds, Pier 9, 21 June">
</figure>