CRT Glitch
A photo playing on a glitching ’90s CRT television. The picture is redrawn in thin horizontal slices that wobble, tear and roll, with split colour channels, static, scanlines and a warm tint. Drop in your own image to put it on the screen.
Copy markdown includes the prompt, your current settings and a single HTML file that runs on its own.
/* CRT Glitch — a photo playing on a glitching ’90s CRT television.
*
* mount(root, settings) renders into `root` and returns { onChange(key), upload() }.
* `settings` is a plain object (start from DEFAULTS); change a value, then call
* onChange(key) to apply it live. CONTROLS describes the tweakable values for
* the playground's control panel. Drop an image anywhere on it, or call
* upload(), to use your own picture.
*/
/* SETTINGS — every tweakable value lives here. */
export const DEFAULTS = {
// Master
intensity: 1, // scales every glitch at once
trigger: 'always', // 'always' | 'hover' (calm until hovered)
resolution: 0.7, // internal render scale (lower = chunkier, faster)
pixelated: false,
// Vertical (V-hold roll)
rollSpeed: 0, // screen-heights per second the picture rolls (0 = picture stays put)
vblank: 0.06, // black sync bar between frames, fraction of height
rollSlip: 0, // chance per second of a sudden vertical jump
// Horizontal (H-sync)
wave: 2, // gentle sideways wobble, px
waveFreq: 0.025, // wobble frequency down the screen
jitter: 2, // per-line shake, px
topSkew: 16, // the classic "flagging" bend at the top of the picture, px
tearRate: 1.4, // horizontal tears per second
tearShift: 70, // max sideways shift of a tear, px
tearHeight: 50, // max tear band height, px
// Bursts (full signal drop-outs)
burstRate: 0.5, // bursts per second
burstLength: 0.32, // seconds
// Colour & signal
rgbShift: 2.5, // chromatic separation, px
burstRgb: 12, // extra separation during a burst, px
ghost: 0.12, // echo/ghost image strength
ghostOffset: 9, // px
saturation: 1.2,
contrast: 1.12,
brightness: 1.0,
flicker: 0.05,
tintColor: '#ff9e4a',
tint: 0.18,
// Screen
noise: 0.07, // static snow
scanlines: 0.35,
scanlineSize: 3, // px between lines
rollingBar: 0.1, // bright band that rolls up the screen
barSpeed: 0.18,
vignette: 0.6,
curvature: 6, // corner rounding of the glass, %
bezel: true,
bezelColor: '#1d1b18',
pageColor: '#0e0e0e',
reducedMotion: false, // the host turns this on when the visitor prefers reduced motion
};
// The default photo ships with the site; a copy of this file running anywhere
// else (like the exported single-file version) loads it from afisolami.com.
const DEFAULT_IMAGE =
(/^(localhost|127\.0\.0\.1|.*\.pages\.dev|(www\.)?afisolami\.com)$/.test(location.hostname) ? '' : 'https://afisolami.com') +
'/experiments/crt-glitch/photo.jpg';
/* Control panel schema */
export const CONTROLS = [
{ group: 'Signal', items: [
{ type: 'button', label: 'Image', text: 'Upload', action: 'upload' },
{ key: 'intensity', type: 'range', label: 'Intensity', min: 0, max: 3, step: 0.05, unit: '×' },
{ key: 'trigger', type: 'select', label: 'Trigger', options: [['always', 'Always'], ['hover', 'On hover']] },
]},
{ group: 'Glitch', items: [
{ key: 'tearRate', type: 'range', label: 'Tears / s', min: 0, max: 10, step: 0.1 },
{ key: 'burstRate', type: 'range', label: 'Bursts / s', min: 0, max: 3, step: 0.01 },
{ key: 'rgbShift', type: 'range', label: 'RGB split', min: 0, max: 30, step: 0.5, unit: 'px' },
{ key: 'noise', type: 'range', label: 'Static', min: 0, max: 0.8, step: 0.01 },
]},
{ group: 'Screen', items: [
{ key: 'scanlines', type: 'range', label: 'Scanlines', min: 0, max: 1, step: 0.01 },
{ key: 'tintColor', type: 'color', label: 'Tint colour' },
{ key: 'tint', type: 'range', label: 'Tint', min: 0, max: 1, step: 0.01 },
{ key: 'bezel', type: 'toggle', label: 'TV bezel' },
]},
];
/* =========================================================
CRT glitch renderer (canvas 2D)
========================================================= */
export function mount(root, S) {
root.classList.add('crt-root');
root.innerHTML = `
<div class="crt-tv">
<div class="crt-screen">
<canvas aria-hidden="true"></canvas>
<div class="crt-layer crt-tint"></div>
<div class="crt-layer crt-scan"></div>
<div class="crt-layer crt-glass"></div>
<div class="crt-drop">Drop image</div>
</div>
</div>
<div class="crt-paused" role="status"><span>Paused — your device has reduced motion on</span><button type="button">Play</button></div>
<input type="file" accept="image/*" hidden>`;
const tv = root.querySelector('.crt-tv');
const screen = root.querySelector('.crt-screen');
const canvas = root.querySelector('canvas');
const ctx = canvas.getContext('2d');
const tintEl = root.querySelector('.crt-tint');
const scanEl = root.querySelector('.crt-scan');
const glassEl = root.querySelector('.crt-glass');
const fileInput = root.querySelector('input[type=file]');
screen.setAttribute('role', 'img');
screen.setAttribute('aria-label', 'Photo shown on a glitching CRT television');
const mk = () => { const c = document.createElement('canvas'); return [c, c.getContext('2d')]; };
const [src, sctx] = mk(); // image fitted to screen
const [buf, bctx] = mk(); // after roll + tears
const [ch, cctx] = mk(); // single colour channel
const noiseTiles = [];
let img = null, W = 2, H = 2, aspect = 640 / 945;
let t = 0, last = 0, raf = 0, running = false, visible = true;
let roll = 0, slipV = 0, tears = [], burst = 0, barY = 0, hovered = false, calm = 0;
const rnd = (a = 1) => (Math.random() - 0.5) * 2 * a;
/* ---- image ---- */
function loadImage(url) {
const im = new Image();
im.onload = () => { img = im; aspect = im.naturalWidth / im.naturalHeight; layout(); };
im.src = url;
}
/* ---- layout / sizing ---- */
function layout() {
if (!root.clientWidth || !root.clientHeight) return;
const availW = root.clientWidth - 32 - (S.bezel ? 70 : 0);
const availH = root.clientHeight * 0.86 - (S.bezel ? 70 : 0);
let w = Math.max(20, Math.min(availW, availH * aspect));
let h = w / aspect;
screen.style.width = Math.round(w) + 'px';
screen.style.height = Math.round(h) + 'px';
const dpr = Math.min(2, window.devicePixelRatio || 1);
W = Math.max(2, Math.round(w * dpr * S.resolution));
H = Math.max(2, Math.round(h * dpr * S.resolution));
for (const c of [canvas, src, buf, ch]) { c.width = W; c.height = H; }
if (img) sctx.drawImage(img, 0, 0, W, H);
makeNoise();
paintStatic();
frame(0);
}
function makeNoise() {
noiseTiles.length = 0;
const nw = Math.ceil(W / 2), nh = Math.ceil(H / 2);
for (let k = 0; k < 4; k++) {
const [c, x] = mk(); c.width = nw; c.height = nh;
const id = x.createImageData(nw, nh);
for (let i = 0; i < id.data.length; i += 4) {
const v = Math.random() * 255;
id.data[i] = id.data[i + 1] = id.data[i + 2] = v; id.data[i + 3] = 255;
}
x.putImageData(id, 0, 0);
noiseTiles.push(c);
}
}
/* ---- CSS-driven layers ---- */
function paintStatic() {
root.style.setProperty('--page', S.pageColor);
root.style.setProperty('--bezel', S.bezelColor);
root.style.setProperty('--radius', S.curvature + '%');
tv.classList.toggle('bezel', !!S.bezel);
screen.classList.toggle('pixelated', !!S.pixelated);
const gap = S.scanlineSize;
scanEl.style.background = `repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0, rgba(0,0,0,0) ${gap / 2}px, rgba(0,0,0,${S.scanlines}) ${gap / 2}px, rgba(0,0,0,${S.scanlines}) ${gap}px)`;
tintEl.style.background = S.tintColor;
tintEl.style.opacity = S.tint;
glassEl.style.setProperty('--vig', S.vignette);
}
/* ---- one frame ---- */
function frame(dt) {
if (!img) return;
const k = S.intensity * (S.trigger === 'hover' ? calm : 1);
const scale = W / screen.clientWidth || 1; // px settings are CSS px
const animate = !S.reducedMotion;
if (animate) {
t += dt;
// bursts
if (burst > 0) burst -= dt;
else if (Math.random() < S.burstRate * k * dt) burst = S.burstLength * (0.6 + Math.random() * 0.8);
// vertical: steady drift + sudden slips
if (Math.random() < S.rollSlip * k * dt * (burst > 0 ? 4 : 1)) slipV = rnd(H * 0.35);
roll += S.rollSpeed * k * H * dt + slipV * Math.min(1, dt * 12);
slipV *= Math.exp(-dt * 12);
if (S.rollSpeed === 0 && S.rollSlip === 0) {
const period0 = H * (1 + S.vblank);
let r = ((roll % period0) + period0) % period0;
if (r > period0 / 2) r -= period0;
roll = r * Math.exp(-dt * 8);
if (Math.abs(roll) < 0.5) roll = 0;
}
// horizontal tears
if (Math.random() < S.tearRate * k * dt * (burst > 0 ? 6 : 1)) {
tears.push({ y: Math.random() * H, h: (4 + Math.random() * S.tearHeight) * scale, shift: rnd(S.tearShift * scale * k), life: 0.06 + Math.random() * 0.25 });
}
tears = tears.filter((tr) => (tr.life -= dt) > 0);
barY = (barY + S.barSpeed * dt) % 1.4;
}
const bursting = burst > 0;
const period = H * (1 + S.vblank);
const rollY = ((roll % period) + period) % period;
// 1) rolled, torn picture -> buf
bctx.fillStyle = '#000';
bctx.fillRect(0, 0, W, H);
const slice = Math.max(1, Math.round(2 * scale));
const jit = S.jitter * scale * k, wav = S.wave * scale * k, skew = S.topSkew * scale * k;
const skewPhase = Math.sin(t * 1.7) * 0.6 + 0.4;
for (let y = 0; y < H; y += slice) {
let sy = (y + rollY) % period;
if (sy >= H) continue; // inside the black sync bar
let dx = wav * Math.sin(y * S.waveFreq / scale + t * 2.3) + (animate ? rnd(jit) : 0);
const topDist = sy / (H * 0.18); // bend near the top of each frame
if (topDist < 1) dx += skew * skewPhase * (1 - topDist) * (1 - topDist);
for (const tr of tears) if (y >= tr.y && y < tr.y + tr.h) dx += tr.shift;
if (bursting) dx += rnd(14 * scale * k) * (Math.random() < 0.3 ? 4 : 1);
dx = Math.round(dx);
const sh = Math.min(slice, H - sy);
bctx.drawImage(src, 0, sy, W, sh, dx, y, W, sh);
if (dx > 0) bctx.drawImage(src, 0, sy, W, sh, dx - W, y, W, sh);
else if (dx < 0) bctx.drawImage(src, 0, sy, W, sh, dx + W, y, W, sh);
}
// 2) RGB separation -> canvas
const split = (S.rgbShift + (bursting ? S.burstRgb : 0)) * scale * k;
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
if (split < 0.5) {
ctx.drawImage(buf, 0, 0);
} else {
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'lighter';
const vy = bursting ? rnd(split * 0.3) : 0;
[['#ff0000', split, vy], ['#00ff00', 0, 0], ['#0000ff', -split, -vy]].forEach(([col, ox, oy]) => {
cctx.globalCompositeOperation = 'source-over';
cctx.drawImage(buf, 0, 0);
cctx.globalCompositeOperation = 'multiply';
cctx.fillStyle = col; cctx.fillRect(0, 0, W, H);
ctx.drawImage(ch, ox, oy);
});
}
// 3) ghost echo
if (S.ghost > 0) {
ctx.globalCompositeOperation = 'screen';
ctx.globalAlpha = Math.min(1, S.ghost * (0.5 + 0.5 * Math.min(2, k)));
ctx.drawImage(buf, S.ghostOffset * scale, 0);
}
// 4) rolling bright band
if (S.rollingBar > 0) {
const bh = H * 0.22, by = H * 1.2 - barY * H;
const g = ctx.createLinearGradient(0, by - bh, 0, by + bh);
g.addColorStop(0, 'rgba(255,255,255,0)');
g.addColorStop(0.5, `rgba(255,255,255,${S.rollingBar})`);
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.globalCompositeOperation = 'screen';
ctx.globalAlpha = 1;
ctx.fillStyle = g; ctx.fillRect(0, by - bh, W, bh * 2);
}
// 5) static snow
const nz = S.noise * (bursting ? 2.6 : 1) * Math.max(0.35, Math.min(1.5, k));
if (nz > 0 && noiseTiles.length) {
ctx.globalCompositeOperation = 'overlay';
ctx.globalAlpha = Math.min(1, nz);
const tile = noiseTiles[(Math.random() * noiseTiles.length) | 0];
ctx.drawImage(tile, animate ? -Math.random() * 4 : 0, 0, W + 4, H);
}
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
// 6) tone + flicker
const fl = animate ? 1 + rnd(S.flicker * Math.min(1.5, k)) : 1;
canvas.style.filter = `saturate(${S.saturation}) contrast(${S.contrast}) brightness(${(S.brightness * fl * (bursting ? 1.12 : 1)).toFixed(3)})`;
}
/* ---- loop (pauses while off screen) ---- */
function tick(now) {
const dt = Math.min(0.05, (now - last) / 1000 || 0);
last = now;
const target = S.trigger === 'hover' ? (hovered ? 1 : 0) : 1;
calm += (target - calm) * Math.min(1, dt * 5);
frame(dt);
if (S.reducedMotion || !visible) { running = false; return; }
raf = requestAnimationFrame(tick);
}
function start() {
if (running || !visible) return;
if (S.reducedMotion) { frame(0); return; }
running = true; last = performance.now(); raf = requestAnimationFrame(tick);
}
/* ---- input ---- */
screen.addEventListener('pointerenter', () => (hovered = true));
screen.addEventListener('pointerleave', () => (hovered = false));
const useFile = (f) => { if (f && f.type.startsWith('image/')) { const r = new FileReader(); r.onload = () => loadImage(r.result); r.readAsDataURL(f); } };
fileInput.addEventListener('change', () => useFile(fileInput.files[0]));
root.addEventListener('dragover', (e) => { e.preventDefault(); root.classList.add('dragging'); });
root.addEventListener('dragleave', (e) => { if (e.target === root || !root.contains(e.relatedTarget)) root.classList.remove('dragging'); });
root.addEventListener('drop', (e) => { e.preventDefault(); root.classList.remove('dragging'); useFile(e.dataTransfer.files[0]); });
document.addEventListener('visibilitychange', () => { if (!document.hidden) last = performance.now(); });
let rt;
const onResize = () => { clearTimeout(rt); rt = setTimeout(layout, 80); };
if (window.ResizeObserver) new ResizeObserver(onResize).observe(root);
else window.addEventListener('resize', onResize);
if (window.IntersectionObserver) {
new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) start(); }).observe(root);
}
function onChange(key) {
if (key === 'resolution' || key === 'bezel') layout();
else paintStatic();
if (key === 'trigger') calm = S.trigger === 'hover' ? 0 : 1;
if (key === 'reducedMotion') { syncRM(); if (S.reducedMotion) { cancelAnimationFrame(raf); running = false; } }
if (S.reducedMotion) frame(0); else start();
}
const syncRM = () => root.classList.toggle('rm', !!S.reducedMotion);
root.querySelector('.crt-paused button').addEventListener('click', () => {
S.reducedMotion = false; syncRM(); onChange('reducedMotion');
});
calm = S.trigger === 'hover' ? 0 : 1;
syncRM();
loadImage(DEFAULT_IMAGE);
start();
return { onChange, upload: () => fileInput.click() };
}/* ---------- Experiment root (mount anywhere) ---------- */
.crt-root {
--page: #0e0e0e;
--bezel: #1d1b18;
--radius: 6%;
position: relative;
width: 100%;
height: 100%;
min-height: 100%;
background: var(--page);
display: grid;
place-items: center;
overflow: hidden;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.crt-tv {
position: relative;
max-width: calc(100% - 32px);
box-sizing: border-box;
}
.crt-tv.bezel {
padding: clamp(14px, 3.2vmin, 34px);
border-radius: clamp(18px, 4vmin, 44px);
background:
linear-gradient(160deg, rgba(255,255,255,.07), rgba(255,255,255,0) 40%),
var(--bezel);
box-shadow:
0 30px 80px rgba(0,0,0,.6),
inset 0 2px 0 rgba(255,255,255,.06),
inset 0 -6px 14px rgba(0,0,0,.5);
}
.crt-screen {
position: relative;
overflow: hidden;
background: #000;
border-radius: var(--radius) / calc(var(--radius) * 1.2);
}
.crt-tv.bezel .crt-screen { box-shadow: 0 0 0 3px #0a0a0a, inset 0 0 30px rgba(0,0,0,.8); }
.crt-screen canvas { display: block; width: 100%; height: 100%; }
.crt-screen.pixelated canvas { image-rendering: pixelated; }
.crt-layer { position: absolute; inset: 0; pointer-events: none; border-radius: inherit; }
.crt-scan { mix-blend-mode: multiply; }
.crt-tint { mix-blend-mode: soft-light; }
.crt-glass {
background:
radial-gradient(120% 90% at 30% 15%, rgba(255,255,255,.10), rgba(255,255,255,0) 45%),
radial-gradient(ellipse at center, rgba(0,0,0,0) 55%, rgba(0,0,0,var(--vig, .55)) 100%);
}
.crt-drop {
position: absolute; inset: 0; display: none; place-items: center;
background: rgba(0,0,0,.55); color: #fff; font-size: 14px; letter-spacing: .08em; text-transform: uppercase;
border-radius: inherit; z-index: 3;
}
.crt-root.dragging .crt-drop { display: grid; }
.crt-paused {
position: absolute; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: 5;
display: none; align-items: center; gap: 10px; white-space: nowrap;
padding: 8px 8px 8px 14px; border-radius: 999px;
background: rgba(20,20,22,.88); color: #e8e8ea; font-size: 12px;
border: 1px solid rgba(255,255,255,.1);
}
.crt-root.rm .crt-paused { display: flex; }
.crt-paused button {
all: unset; cursor: pointer; padding: 5px 12px; border-radius: 999px; background: #fff; color: #111; font-weight: 600;
}
.crt-paused button:focus-visible { outline: 2px solid #5b9dff; outline-offset: 2px; }Build a single self-contained HTML file (vanilla JS canvas, no libraries) that plays an image on a glitching ’90s CRT TV. Each frame, redraw the image in 2px horizontal slices. Add a vertical roll (V-hold) that drifts upward with a black sync bar between frames and occasionally jumps. Offset each slice sideways with a sine wobble, per-line jitter, a bend at the top of the picture and random horizontal tear bands that wrap around the edge. Add random burst moments that briefly increase tearing, static and colour separation. Split the red, green and blue channels with offsets. Layer a ghost echo, a rolling bright band, static noise, CSS scanlines, a warm tint overlay, a vignette, rounded glass and an optional plastic TV frame, with brightness flicker from a CSS filter. Let people drop in or upload their own image. Put every value in one settings object with a compact live control panel (sliders, colour pickers, toggles, copy/reset buttons), keep it inside one root element, and show a single still frame under prefers-reduced-motion.