Old Film Reel
Make a section look like a scratchy reel of old film. The picture turns sepia, the lamp flickers, the frame weaves in the gate, and dust, hairs and long scratches flash past at 18 frames a second inside a dark vignette.
Summerat the lake, 1924
How to use it
- Paste the snippet just before
</head>. - Add
data-old-film-reelto a section, image or title card. Its content sits centred in the frame.
Options
Add any of these attributes to the same element.
data-tone | sepia (default) or mono for black and white. |
data-dust | How much dust and how many scratches, from 0 to 2. Default 1. |
data-flicker | How much the light flickers, from 0 to 2. Default 1. |
data-fps | Frames per second. Default 18. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Old Film Reel: makes a section look like a scratchy reel of old film.
// The picture turns sepia, the light flickers, the frame weaves gently in
// the gate, and dust, hairs and long vertical scratches flash past at 18
// frames a second, all inside a dark vignette. Paused off screen.
// Usage: <div data-old-film-reel>…an image, a title card, anything…</div>
// The content sits centred in the frame.
// Optional: data-tone ("sepia", the default, or "mono" for black and white),
// data-dust (amount, 0 to 2, default 1), data-flicker (0 to 2, default 1),
// data-fps (default 18).
var DEFAULTS = { tone: 'sepia', dust: 1, flicker: 1, fps: 18 };
var css =
'.ofr{position:relative;overflow:hidden;isolation:isolate;display:grid;grid-template:1fr/1fr}' +
'.ofr-inner{grid-area:1/1;position:relative;display:grid;place-items:center;align-self:stretch;justify-self:stretch;min-width:0;will-change:transform,filter}' +
'.ofr-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
el.classList.add('ofr');
var inner = document.createElement('div');
inner.className = 'ofr-inner';
// The background moves onto the frame, so it tones and weaves with it.
var cs = getComputedStyle(el);
inner.style.backgroundColor = cs.backgroundColor;
inner.style.backgroundImage = cs.backgroundImage;
inner.style.padding = cs.padding;
el.style.backgroundImage = 'none';
el.style.padding = '0';
while (el.firstChild) inner.appendChild(el.firstChild);
el.appendChild(inner);
var canvas = document.createElement('canvas');
canvas.className = 'ofr-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var tone = (d.tone || DEFAULTS.tone) === 'mono' ? 'grayscale(1) contrast(1.15)' : 'sepia(.85) contrast(1.1) saturate(1.1)';
el._ofr = { inner: inner, canvas: canvas, tone: tone };
filterFor(el, 1);
}
function filterFor(el, b) {
el._ofr.inner.style.filter = el._ofr.tone + ' brightness(' + b.toFixed(3) + ')';
}
function play(el, still) {
var s = el._ofr, d = el.dataset, canvas = s.canvas;
var ctx = canvas.getContext('2d');
var dust = d.dust !== undefined ? +d.dust : DEFAULTS.dust;
var flicker = d.flicker !== undefined ? +d.flicker : DEFAULTS.flicker;
var fps = +d.fps || DEFAULTS.fps;
var R = Math.random;
var w = 0, h = 0, dpr = 1;
var scratches = [], hairs = [];
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
}
function vignette() {
var g = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.25, w / 2, h / 2, Math.hypot(w, h) * 0.6);
g.addColorStop(0, 'rgba(20,12,4,0)');
g.addColorStop(1, 'rgba(20,12,4,.75)');
ctx.globalAlpha = 1;
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
}
function frame() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var area = (w * h) / 100000;
// Dust: specks and blobs, dark and light, a new set every frame.
var n = Math.round((2 + R() * 8) * dust * Math.max(0.6, area));
for (var i = 0; i < n; i++) {
ctx.globalAlpha = 0.35 + R() * 0.5;
ctx.fillStyle = R() < 0.7 ? '#140c04' : '#fff8e6';
ctx.beginPath();
var r = R() < 0.85 ? 0.6 + R() * 1.6 : 2 + R() * 3;
ctx.ellipse(R() * w, R() * h, r, r * (0.5 + R() * 0.8), R() * 3, 0, Math.PI * 2);
ctx.fill();
}
// Hairs: curly dark threads that stay for a few frames.
if (R() < 0.05 * dust) hairs.push({ x: R() * w, y: R() * h, len: 14 + R() * 40, a: R() * 6, life: 2 + ((R() * 8) | 0) });
ctx.strokeStyle = '#140c04';
ctx.lineWidth = 1;
hairs = hairs.filter(function (hr) {
ctx.globalAlpha = 0.55;
ctx.beginPath();
ctx.moveTo(hr.x, hr.y);
ctx.bezierCurveTo(hr.x + Math.cos(hr.a) * hr.len, hr.y + Math.sin(hr.a) * hr.len,
hr.x + Math.cos(hr.a + 2) * hr.len, hr.y + Math.sin(hr.a + 2) * hr.len,
hr.x + Math.cos(hr.a + 1) * hr.len * 1.2, hr.y + Math.sin(hr.a + 1) * hr.len * 1.2);
ctx.stroke();
return --hr.life > 0;
});
// Scratches: long vertical lines that drift sideways and last a while.
if (scratches.length < 3 && R() < 0.06 * dust) {
scratches.push({ x: R() * w, v: (R() - 0.5) * 0.8, life: 10 + ((R() * 50) | 0), light: R() < 0.6, top: R() * 0.3, bot: 0.7 + R() * 0.3 });
}
scratches = scratches.filter(function (sc) {
sc.x += sc.v + (R() - 0.5) * 0.6;
if (R() < 0.85) {
ctx.globalAlpha = 0.25 + R() * 0.35;
ctx.fillStyle = sc.light ? '#fff8e6' : '#140c04';
ctx.fillRect(sc.x, sc.top * h, R() < 0.3 ? 1.6 : 0.9, (sc.bot - sc.top) * h);
}
return --sc.life > 0;
});
vignette();
// The frame weaves in the gate and the lamp flickers.
var b = 1 - R() * 0.12 * flicker;
if (R() < 0.03 * flicker) b += 0.18;
filterFor(el, b);
var dx = (R() - 0.5) * 1.2, dy = (R() - 0.5) * 1.6;
if (R() < 0.008) dy += (R() < 0.5 ? -1 : 1) * 8; // the odd skipped frame
s.inner.style.transform = 'translate(' + dx.toFixed(2) + 'px,' + dy.toFixed(2) + 'px)';
}
resize();
if (still) {
// Reduced motion: one frame of dust in its vignette, held.
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
vignette();
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
var acc = 0, last = 0;
var timer = anime.createTimer({
loop: true, duration: 1000, autoplay: false,
onUpdate: function () {
var now = performance.now();
acc += last ? now - last : 0;
last = now;
if (acc < 1000 / fps) return;
acc = 0;
frame();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-old-film-reel]').forEach(function (el) {
if (el._ofr) return;
setup(el);
play(el, reduced());
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-old-film-reel>
<h1>The End</h1>
</section>