Duotone Shift
Print an image in just two colours, one for the shadows and one for the highlights, like a gig poster. The pair drifts slowly from one scheme to the next, purple and pink to teal and mint to wine and gold, so the picture never quite settles. It washes in from the original colours the first time it’s seen.
How to use it
- Paste the snippet just before
</head>. - Wrap an image in an element with
data-duotone-shiftand 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-colors | Your own colour pairs, dark then light, pairs separated by |, e.g. #1e1b4b,#f472b6|#052e16,#facc15. One pair keeps it still. |
data-duration | Time on each pair, including the drift to the next. Default 4500 ms. |
data-contrast | 1 is natural, higher is punchier. Default 1.15. |
With reduced motion the image shows in the first pair and stays put.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Duotone Shift: an image redrawn in just two colours, one for the shadows
// and one for the highlights, like a poster print. The pair drifts slowly
// from one colour scheme to the next, so the picture never quite settles.
// Usage: <figure data-duotone-shift><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-colors (colour pairs, dark then light, pairs separated by
// "|", e.g. "#1e1b4b,#f472b6|#052e16,#facc15"), data-duration (ms on each
// pair, including the drift to the next, default 4500), data-contrast
// (1 is natural, higher is punchier, default 1.15).
var DEFAULTS = {
colors: '#2a0a5e,#ff6ec7|#062a3f,#6ff0c9|#4a0d1e,#ffcc4d|#14215a,#ff8a5c',
duration: 4500, contrast: 1.15, maxSize: 640
};
var css =
'.dts{position:relative;overflow:hidden}' +
'.dts-src{opacity:0}' +
'.dts-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 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 setup(el, img, iw, ih) {
var d = el.dataset;
var pairs = (d.colors || DEFAULTS.colors).split('|').map(function (p) {
var c = p.split(',');
return [hex(c[0]), hex(c[1] || '#fff')];
});
var duration = +d.duration || DEFAULTS.duration;
var contrast = d.contrast !== undefined ? +d.contrast : DEFAULTS.contrast;
var canvas = document.createElement('canvas');
canvas.className = 'dts-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var ctx = canvas.getContext('2d');
var work = document.createElement('canvas');
var wctx = work.getContext('2d', { willReadFrequently: true });
var pw = 0, ph = 0, tone = null, src = null, out = null;
var lut = new Uint8ClampedArray(256 * 3);
// Work out each pixel's tone once: stretched to use the full range, then contrast.
function build() {
var w = el.clientWidth, h = el.clientHeight;
if (!w || !h) return false;
var s = Math.min(1, DEFAULTS.maxSize / Math.max(w, h));
pw = Math.max(1, Math.round(w * s));
ph = Math.max(1, Math.round(h * s));
work.width = pw;
work.height = ph;
cover(wctx, img, iw, ih, pw, ph);
try { src = wctx.getImageData(0, 0, pw, ph); }
catch (err) { canvas.remove(); el.querySelector('.dts-src').classList.remove('dts-src'); return false; }
var p = src.data, n = pw * ph, lo = 1, hi = 0, i;
var lum = new Float32Array(n);
for (i = 0; i < n; i++) {
lum[i] = (0.299 * p[i * 4] + 0.587 * p[i * 4 + 1] + 0.114 * p[i * 4 + 2]) / 255;
if (lum[i] < lo) lo = lum[i];
if (lum[i] > hi) hi = lum[i];
}
tone = new Uint8Array(n);
var span = Math.max(0.05, hi - lo);
for (i = 0; i < n; i++) {
var v = (lum[i] - lo) / span;
v = 0.5 + (v - 0.5) * contrast;
tone[i] = Math.max(0, Math.min(255, Math.round(v * 255)));
}
out = wctx.createImageData(pw, ph);
for (i = 0; i < n; i++) out.data[i * 4 + 3] = 255;
canvas.width = Math.round(w * Math.min(2, window.devicePixelRatio || 1));
canvas.height = Math.round(h * Math.min(2, window.devicePixelRatio || 1));
return true;
}
// phase runs 0 → pairs.length; mix is how far from the original colours (0) to duotone (1).
function draw(phase, mix) {
if (!tone) return;
var a = Math.floor(phase) % pairs.length, b = (a + 1) % pairs.length;
var f = phase - Math.floor(phase);
// Hold each pair, then drift to the next over the second half.
f = f < 0.45 ? 0 : (f - 0.45) / 0.55;
f = f * f * (3 - 2 * f);
var dark = [], light = [], k;
for (k = 0; k < 3; k++) {
dark[k] = pairs[a][0][k] + (pairs[b][0][k] - pairs[a][0][k]) * f;
light[k] = pairs[a][1][k] + (pairs[b][1][k] - pairs[a][1][k]) * f;
}
for (var v = 0; v < 256; v++) {
var q = v / 255;
for (k = 0; k < 3; k++) lut[v * 3 + k] = dark[k] + (light[k] - dark[k]) * q;
}
var o = out.data, s = src.data, n = pw * ph, i, j;
if (mix >= 1) {
for (i = 0; i < n; i++) {
j = tone[i] * 3;
o[i * 4] = lut[j]; o[i * 4 + 1] = lut[j + 1]; o[i * 4 + 2] = lut[j + 2];
}
} else {
for (i = 0; i < n; i++) {
j = tone[i] * 3;
o[i * 4] = s[i * 4] + (lut[j] - s[i * 4]) * mix;
o[i * 4 + 1] = s[i * 4 + 1] + (lut[j + 1] - s[i * 4 + 1]) * mix;
o[i * 4 + 2] = s[i * 4 + 2] + (lut[j + 2] - s[i * 4 + 2]) * mix;
}
}
wctx.putImageData(out, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(work, 0, 0, canvas.width, canvas.height);
}
var state = { phase: 0, mix: 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()) draw(state.phase, state.mix);
}).observe(el);
}
if (reduced()) { draw(0, 1); return; }
draw(0, 0);
// Redraw about 30 times a second: the drift is slow, so that is plenty.
var last = 0, acc = 0;
var timer = anime.createTimer({
loop: true, duration: 1000, autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(60, now - last) : 16;
last = now;
state.phase = (state.phase + dt / duration) % pairs.length;
acc += dt;
if (acc < 32) return;
acc = 0;
draw(state.phase, state.mix);
}
});
var shown = false;
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) {
if (!shown) {
shown = true;
// The original colours wash into the duotone the first time it's seen.
anime.animate(state, { mix: 1, duration: 1400, delay: 200, ease: 'inOut(2)' });
}
last = 0;
timer.play();
} else timer.pause();
}, { threshold: 0.2 }).observe(el);
}
function init() {
document.querySelectorAll('[data-duotone-shift]').forEach(function (el) {
if (el._dts) return;
var src = el.querySelector('img, svg');
if (!src) return;
el._dts = true;
el.classList.add('dts');
src.classList.add('dts-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-duotone-shift style="height:400px">
<img src="photo.jpg" alt="Sand dunes at sunset">
</figure>