Photo Ageing
Decades pass in a few seconds. Starting at the edges and creeping inwards, the photo fades to a warm sepia, its contrast drops, a brown vignette and age spots appear, a crease and fine scratches show up and the borders wear away to bare paper.
How to use it
- Paste the snippet just before
</head>. - Wrap an image in an element with
data-photo-ageingand 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 ageing takes. Default 4200 ms. |
data-loop | Age, hold, turn young again and repeat. |
data-hold | How long it holds each way when looping. Default 2400 ms. |
data-wear | How worn the edges get, from 0 to 2. Default 1. |
data-crease | false leaves out the crease. |
With reduced motion the photo simply shows already aged.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Photo Ageing: decades pass in a few seconds. Starting at the edges and
// creeping inwards, the photo fades to a warm sepia, its contrast drops, a
// brown vignette and age spots appear, a crease and fine scratches show up
// and the borders wear away to bare paper.
// Usage: <figure data-photo-ageing><img src="photo.jpg" alt="…"></figure>
// The image (an <img> on the same site, or an inline <svg>) fills the box,
// cropped to cover.
// Optional: data-duration (ms to age, default 4200), data-loop (age, hold,
// turn young again, repeat), data-hold (ms to hold each way when looping,
// default 2400), data-wear (how worn the edges get, 0 to 2, default 1),
// data-crease (false leaves out the crease).
var DEFAULTS = { duration: 4200, hold: 2400, wear: 1, maxSize: 1100 };
var css =
'.pag{position:relative;overflow:hidden}' +
'.pag-src{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}' +
'.pag-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);
}
// Smooth value noise, for stains and ragged edges.
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 wear = d.wear !== undefined ? +d.wear : DEFAULTS.wear;
var crease = d.crease !== 'false';
var canvas = document.createElement('canvas');
canvas.className = 'pag-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var ctx = canvas.getContext('2d', { willReadFrequently: true });
var pw = 0, ph = 0, out = null, when = null;
// Work out the fully aged photo once, and when each pixel starts to age.
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);
var data;
try { data = ctx.getImageData(0, 0, pw, ph); }
catch (err) { canvas.remove(); return false; }
var p = data.data, n = pw * ph;
when = new Float32Array(n);
var unit = Math.min(pw, ph);
var sc = 6 / unit; // noise scale
// Age spots and scratches, placed the same way every time.
var spots = [], scratches = [], i;
var nSpots = Math.round(pw * ph / 9000);
for (i = 0; i < nSpots; i++) {
spots.push([hash(i, 1, 7) * pw, hash(i, 2, 7) * ph, (0.4 + hash(i, 3, 7) * 1.6) * unit / 160]);
}
for (i = 0; i < 7; i++) {
var sx = hash(i, 4, 9) * pw, sy = hash(i, 5, 9) * ph, ang = 1.2 + hash(i, 6, 9) * 0.7, len = (0.15 + hash(i, 7, 9) * 0.4) * unit;
scratches.push([sx, sy, Math.cos(ang), Math.sin(ang), len]);
}
// The crease runs corner to corner-ish.
var cx0 = pw * 0.62, cy0 = 0, cx1 = pw * 0.38, cy1 = ph;
var cdx = cx1 - cx0, cdy = cy1 - cy0, clen = Math.sqrt(cdx * cdx + cdy * cdy);
for (var y = 0; y < ph; y++) {
for (var x = 0; x < pw; x++) {
i = y * pw + x;
var k = i * 4;
var R = p[k], G = p[k + 1], B = p[k + 2];
// Sepia, then flatten the contrast and lift the blacks, as old prints do.
var r = 0.393 * R + 0.769 * G + 0.189 * B;
var g = 0.349 * R + 0.686 * G + 0.168 * B;
var b = 0.272 * R + 0.534 * G + 0.131 * B;
r = r * 0.74 + 52; g = g * 0.72 + 44; b = b * 0.66 + 28;
// Blotchy stains and a brown vignette.
var stain = fbm(x * sc * 0.6, y * sc * 0.6, 3);
var vx = (x / pw - 0.5) * 2, vy = (y / ph - 0.5) * 2;
var vig = Math.max(0, Math.sqrt(vx * vx * 0.8 + vy * vy * 0.8) - 0.45) * 0.75;
var dark = 1 - vig * 0.7 - Math.max(0, stain - 0.55) * 0.5;
r *= dark; g *= dark * 0.96; b *= dark * 0.88;
// Fine paper grain.
var grain = (hash(x, y, 11) - 0.5) * 16;
r += grain; g += grain; b += grain;
// Age spots.
for (var sIdx = 0; sIdx < spots.length; sIdx++) {
var sp = spots[sIdx], ddx = x - sp[0], ddy = y - sp[1];
var dd = ddx * ddx + ddy * ddy, rr = sp[2] * sp[2];
if (dd < rr * 4) {
var f = Math.max(0, 1 - dd / (rr * 4)) * 0.55;
r = r + (110 - r) * f; g = g + (70 - g) * f; b = b + (35 - b) * f;
}
}
// Scratches: thin pale lines.
for (var q = 0; q < scratches.length; q++) {
var sc2 = scratches[q], px = x - sc2[0], py = y - sc2[1];
var along = px * sc2[2] + py * sc2[3], across = Math.abs(-px * sc2[3] + py * sc2[2]);
if (along > 0 && along < sc2[4] && across < 0.9) {
var fa = (1 - across / 0.9) * 0.6;
r += (240 - r) * fa; g += (228 - g) * fa; b += (200 - b) * fa;
}
}
// The crease: a pale line with a shadow along one side.
if (crease) {
var side = ((x - cx0) * cdy - (y - cy0) * cdx) / clen;
var wob = (noise(y * sc * 2, 0, 5) - 0.5) * unit * 0.012;
var cd = side + wob;
if (Math.abs(cd) < unit * 0.02) {
var fc = 1 - Math.abs(cd) / (unit * 0.02);
if (cd < 0) { r += (235 - r) * fc * 0.45; g += (222 - g) * fc * 0.45; b += (190 - b) * fc * 0.45; }
else { r *= 1 - fc * 0.2; g *= 1 - fc * 0.2; b *= 1 - fc * 0.2; }
}
}
// Worn edges: bare paper eats in from the border, more at the corners.
var ex = Math.min(x, pw - 1 - x), ey = Math.min(y, ph - 1 - y);
var edgeD = Math.min(ex, ey) - Math.max(0, unit * 0.08 - Math.sqrt(ex * ex + ey * ey)) * 0.6;
var ragged = unit * 0.035 * wear * (0.3 + fbm(x * sc * 2.5, y * sc * 2.5, 13) * 1.4);
if (edgeD < ragged) {
var paper = Math.min(1, (ragged - edgeD) / 2);
var pg = 228 + (hash(x, y, 17) - 0.5) * 18;
r += (pg + 8 - r) * paper; g += (pg - 2 - g) * paper; b += (pg - 28 - b) * paper;
}
p[k] = r; p[k + 1] = g; p[k + 2] = b;
// Edges age first, then it spreads in with a ragged front.
var toEdge = Math.min(ex, ey) / (unit * 0.5);
when[i] = Math.min(1, toEdge * 0.75 + fbm(x * sc, y * sc, 21) * 0.35);
}
}
out = data;
return true;
}
// p runs 0 (new) to 1 (fully aged).
function render(p) {
if (!out) return;
var o = out.data, n = pw * ph, P = p * 1.45;
for (var i = 0; i < n; i++) {
var a = (P - when[i]) / 0.45;
o[i * 4 + 3] = a <= 0 ? 0 : a >= 1 ? 255 : a * a * (3 - 2 * a) * 255;
}
ctx.putImageData(out, 0, 0);
}
var state = { p: 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()) render(state.p);
}).observe(el);
}
if (reduced()) { render(1); return; }
render(0);
function draw() { render(state.p); }
var loop = d.loop !== undefined && d.loop !== 'false';
var anim = null;
function play() {
if (!loop) {
anim = anime.animate(state, { p: 1, duration: duration, ease: 'inOut(1.6)', onUpdate: draw });
return;
}
anim = anime.createTimeline({ loop: true, delay: 300 })
.add(state, { p: 1, duration: duration, ease: 'inOut(1.6)', onUpdate: draw })
.add(state, { p: 0, duration: 1300, delay: hold, ease: 'inOut(2)', onUpdate: draw })
.add(state, { p: 0, duration: hold * 0.5 });
}
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-photo-ageing]').forEach(function (el) {
if (el._pag) return;
var src = el.querySelector('img, svg');
if (!src) return;
el._pag = true;
el.classList.add('pag');
src.classList.add('pag-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-photo-ageing style="height:400px">
<img src="photo.jpg" alt="A mountain lake">
</figure>