VHS Tape
Play a section like a worn videotape. Tracking noise rolls down the picture, colours bleed red and cyan, the image wobbles and jumps now and then, and head-switching noise frays the bottom edge. A “PLAY” label and a running tape counter finish it off.
Summer
’89
How to use it
- Paste the snippet just before
</head>. - Add
data-vhs-tapeto a section. The noise is drawn over your content, and the content itself wobbles.
Options
Add any of these attributes to the same element.
data-label | The corner text. Default “PLAY ▶”. data-label="" hides the label and the counter. |
data-bleed | Pixels of red and cyan colour bleed. Default 2; 0 turns it off. |
data-wobble | How much the picture sways and jumps, from 0 to 2. Default 1. |
data-speed | How fast the tracking band rolls. Default 1. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// VHS Tape: plays a section like a worn videotape. Tracking noise rolls down
// the picture, colours bleed red and cyan at the edges, the image wobbles
// and jumps now and then, and a strip of head-switching noise frays the
// bottom. A "PLAY" label and a running tape counter sit in the corners.
// The noise is drawn on a canvas over your content and pauses off screen.
// Usage: <div data-vhs-tape style="background:#1e1b4b;color:#fff">…</div>
// Optional: data-label (corner text, default "PLAY ▶"; data-label="" hides
// the label and counter), data-bleed (px of colour bleed, default 2),
// data-wobble (0 to 2, default 1), data-speed (tracking roll speed, default 1).
var DEFAULTS = { label: 'PLAY ▶', bleed: 2, wobble: 1, speed: 1 };
var css =
'.vhs{position:relative;overflow:hidden;isolation:isolate}' +
'.vhs-inner{position:relative;will-change:transform,filter}' +
'.vhs-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2}' +
'.vhs-osd{position:absolute;z-index:3;pointer-events:none;font:700 clamp(11px,2.6vw,18px)/1 ui-monospace,Menlo,Consolas,monospace;' +
'letter-spacing:.08em;color:#fff;text-shadow:2px 0 rgba(255,0,80,.7),-2px 0 rgba(0,220,255,.7)}' +
'.vhs-label{left:5%;top:7%}' +
'.vhs-count{left:5%;bottom:9%;font-weight:600}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function pad(n) { return (n < 10 ? '0' : '') + n; }
function setup(el) {
var d = el.dataset;
el.classList.add('vhs');
// Wrap the content, so it can wobble and bleed without moving the noise.
var inner = document.createElement('div');
inner.className = 'vhs-inner';
while (el.firstChild) inner.appendChild(el.firstChild);
el.appendChild(inner);
var bleed = d.bleed !== undefined ? +d.bleed : DEFAULTS.bleed;
if (bleed) {
inner.style.filter = 'drop-shadow(' + bleed + 'px 0 0 rgba(255,0,60,.55)) drop-shadow(' + -bleed + 'px 0 0 rgba(0,210,255,.55))';
}
var canvas = document.createElement('canvas');
canvas.className = 'vhs-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var label = d.label !== undefined ? d.label : DEFAULTS.label;
var count = null;
if (label) {
var l = document.createElement('div');
l.className = 'vhs-osd vhs-label';
l.setAttribute('aria-hidden', 'true');
l.textContent = label;
el.appendChild(l);
count = document.createElement('div');
count.className = 'vhs-osd vhs-count';
count.setAttribute('aria-hidden', 'true');
count.textContent = 'SP 0:00:00';
el.appendChild(count);
}
el._vhs = { inner: inner, canvas: canvas, count: count };
}
function play(el) {
var s = el._vhs, d = el.dataset;
var ctx = s.canvas.getContext('2d');
var wobble = d.wobble !== undefined ? +d.wobble : DEFAULTS.wobble;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var w = 0, h = 0, dpr = 1, t = 0, band = 0.2, jump = 0, sec = -1;
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
s.canvas.width = w * dpr;
s.canvas.height = h * dpr;
}
// Short bright dashes: the snow of a tracking error.
function dashes(y0, y1, density, alpha) {
var n = Math.round(w * (y1 - y0) * density / 400);
for (var i = 0; i < n; i++) {
var y = y0 + Math.random() * (y1 - y0);
ctx.globalAlpha = alpha * Math.random();
ctx.fillRect(Math.random() * w, y, 4 + Math.random() * 46, 1 + Math.random() * 1.5);
}
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#fff';
// The tracking band: a soft light bar full of noise, rolling downwards.
var bh = Math.max(18, h * 0.12);
var by = band * (h + bh * 2) - bh;
var g = ctx.createLinearGradient(0, by - bh / 2, 0, by + bh / 2);
g.addColorStop(0, 'rgba(255,255,255,0)');
g.addColorStop(0.5, 'rgba(255,255,255,.09)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.globalAlpha = 1;
ctx.fillStyle = g;
ctx.fillRect(0, by - bh / 2, w, bh);
ctx.fillStyle = '#fff';
dashes(by - bh * 0.4, by + bh * 0.4, 5, 0.75);
// A thin hard line at its leading edge.
ctx.globalAlpha = 0.25;
ctx.fillRect(0, by + bh * 0.3, w, 1);
// Head-switching noise along the bottom edge.
var hs = Math.max(6, h * 0.035);
dashes(h - hs, h, 9, 0.8);
ctx.globalAlpha = 0.18;
ctx.fillRect(0, h - hs, w, hs);
// Faint snow all over, and soft dark lines.
dashes(0, h, 0.12, 0.35);
ctx.globalAlpha = 0.12;
ctx.fillStyle = '#000';
for (var y = 0; y < h; y += 3) ctx.fillRect(0, y, w, 1);
ctx.globalAlpha = 1;
}
function moveContent() {
// Wobble: a slow sway plus jitter. Now and then the picture jumps.
if (Math.random() < 0.004 * wobble) jump = 1;
jump *= 0.85;
var x = (Math.sin(t / 900) * 0.8 + (Math.random() - 0.5) * 0.9) * wobble + jump * 10 * wobble;
var y = jump * -6 * wobble;
s.inner.style.transform = 'translate(' + x.toFixed(2) + 'px,' + y.toFixed(2) + 'px) skewX(' + (jump * -4 * wobble).toFixed(2) + 'deg)';
}
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
var last = 0;
var timer = anime.createTimer({
loop: true, duration: 1000, autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
t += dt;
band = (band + dt * 0.00011 * speed) % 1;
moveContent();
draw();
if (s.count && Math.floor(t / 1000) !== sec) {
sec = Math.floor(t / 1000);
s.count.textContent = 'SP ' + Math.floor(sec / 3600) + ':' + pad(Math.floor(sec / 60) % 60) + ':' + pad(sec % 60);
}
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function still(el) {
// Reduced motion: keep the colour bleed and labels, draw the scanlines once.
var s = el._vhs, ctx = s.canvas.getContext('2d');
var dpr = Math.min(2, window.devicePixelRatio || 1), w = el.clientWidth, h = el.clientHeight;
s.canvas.width = w * dpr;
s.canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalAlpha = 0.12;
for (var y = 0; y < h; y += 3) ctx.fillRect(0, y, w, 1);
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target);
});
}, { threshold: 0.1 });
document.querySelectorAll('[data-vhs-tape]').forEach(function (el) {
if (el._vhs) return;
setup(el);
if (reduced()) still(el);
else io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-vhs-tape>
<h1>Summer '89</h1>
</section>