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.
How to use it
- Paste the snippet just before
</head>. - Wrap an image in an element with
data-risograph-printand give the wrapper a size. The image fills it, cropped to cover. - 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-inks | Up to three ink colours, lightest first. Default #ffe800,#ff48b0,#0078bf. |
data-offset | How far the inks miss each other. Default 4 px. |
data-paper | The paper colour. Default #f6f1e7. |
data-duration | Time for each ink to roll down the page. Default 700 ms. |
data-loop | Print, hold, fade back to blank paper and print again. |
data-hold | How long the finished print stays when looping. Default 2600 ms. |
With reduced motion the finished print shows straight away.
<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><figure data-risograph-print style="height:400px">
<img src="poster.jpg" alt="Summer Sounds, Pier 9, 21 June">
</figure>