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
- Paste the snippet just before
</head>. - Wrap an image in an element with
data-sketch-to-photoand 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-duration | How long the sketch and the colour take together. Default 5200 ms. |
data-loop | Draw, colour in, hold, rub out and repeat. |
data-hold | How long the finished photo stays when looping. Default 2400 ms. |
data-hatch | false for outlines and soft shading only, no hatching. |
data-paper | The paper colour. Default #fbf8f1. |
With reduced motion the photo simply shows as it is.
<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><figure data-sketch-to-photo style="height:400px">
<img src="photo.jpg" alt="A village seen from a hilltop">
</figure>