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.

Tweak it

Code

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() };
}