How to build a haunted broadcast: one fullscreen WebGL noise shader, a five-layer CSS CRT overlay stack, a pixel-level hidden-message tuner, a channel guide of impossible programs, four redacted archive fragments, and a decoder that only unlocks at 87.30 MHz. Everything is procedural — no images, no video, no AI-generated assets.
The site pretends to be a CRT television receiving a transmission that should not exist. Instead of decorating a normal landing page with "glitch effects", every element is diegetic — part of the fiction. The sticky navigation is a PROG channel selector (eight channels, each an anchor), the OSD corners hold a channel indicator and a PAL timecode counter, sections are channels, and scrolling between them fires a tracking error, as if the tuner slipped. Even the footer is an end-of-tape label: station of record, channel index, licence expired 1992, carrier current.
The art direction is precise decay: one phosphor green (#5dffa8) on a
blue-black tube (#04080a), a single alert red and a single warning amber. Nothing else.
Restraint is what separates "eerie broadcast" from "Halloween template".
| Token | Hex | Role |
|---|---|---|
| bg | #04080a | tube glass — never pure black; depth comes from the shader behind it |
| deep | #0a1512 | panel fills |
| phosphor | #5dffa8 | the one voice of the machine — titles, OSD, traces |
| bright | #d8ffe9 | body text, near-white with green memory |
| dim | #3e6b57 | secondary text, borders |
| alert | #ff4d3d | REC dot, tape-damage line — used twice on the whole site |
| amber | #ffc857 | corrupted / recovered fragments |
Type: VT323 (a genuine DEC VT320 terminal digitization) for everything the
"television" says — huge display sizes at line-height: .86 — and IBM Plex Mono for
everything a human wrote. Two fonts, two narrators.
RAW WEBGL 1.0 CANVAS 2D ×2 GSAP 3.12.5 SCROLLTRIGGER WEB AUDIO ZERO IMAGES
ImageData loop at 232×124 chunky pixels hiding a text mask inside the noise.clearRect).AUDIO toggle.The entire background is one shader on a single fullscreen triangle. Three ideas do all the work:
a hash-based snow (two octaves of white noise at different scales), a slow vertical
interference drift, and a band-displacement glitch — the screen is cut
into 36 horizontal bands and each band gets a random X offset scaled by a u_glitch uniform
that JavaScript spikes on channel changes and random "tracking errors", then decays by
×0.9 per frame.
// fragment shader — the whole trick
float hash(vec2 p){
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
// tracking error: slice into bands, shear them while u_glitch > 0
float band = floor(uv.y * 36.0);
float shift = (hash(vec2(band, floor(t*14.0))) - 0.5) * u_glitch * 0.18;
uv.x += shift;
// two octaves of snow + drifting interference wave
float snow = hash(uv*u_res*0.5 + vec2(t*97.0, t*31.0)) * 0.72
+ hash(uv*u_res*0.23 + vec2(t*13.0, -t*57.0)) * 0.28;
float drift = sin(uv.y*7.0 - t*0.35) * 0.5 + 0.5;
float v = snow * (0.055 + drift*0.03 + u_glitch*0.35);
vec3 col = vec3(0.016, 0.031, 0.039) + vec3(0.36, 1.0, 0.66) * v;
And the JavaScript side of the handshake:
// spiked by the channel spy, decays every frame
glitchAmt *= 0.9;
if (Math.random() < 0.004) glitchAmt = Math.min(1, glitchAmt + 0.55);
gl.uniform1f(uGlitch, glitchAmt);
Performance notes: the canvas renders at half resolution — it is noise,
nobody can tell, and it halves fragment work four-fold. devicePixelRatio is capped at 2,
context loss is handled (webglcontextlost / restored), and if WebGL is missing
entirely the canvas falls back to a flat tube color.
Five fixed, pointer-transparent layers sit on top of everything and sell the tube:
repeating-linear-gradient multiplied onto the page.cubic-bezier(.7,0,.3,1) curve.steps(1) to blink at 1.5–3% opacity twice per 4.2 s cycle. Sub-perceptual, but the page feels powered./* the layer nobody notices and everybody feels */
.flicker{ background:#eafff3; opacity:0; animation:flick 4.2s steps(1) infinite }
@keyframes flick{
0%,93%,100%{opacity:0} 94%{opacity:.015}
95%{opacity:0} 97%{opacity:.03} 97.6%{opacity:0}
}
The big headline uses the classic dual-pseudo-element glitch — ::before in red,
::after in cyan, both clipped with animated clip-path insets so the tear only
fires for ~8% of each 3.8 s cycle. Idle most of the time, alive forever.
Channel 04 is the heart of the site. A 232×124 ImageData buffer is filled with random
snow every frame (upscaled with image-rendering: pixelated for honest chunky pixels).
A second, offscreen canvas holds the message rendered once in VT323 — its alpha channel becomes a
mask. Where the mask is on, the phosphor text is mixed in proportionally to
signal²; where it is off, pure noise. Dragging horizontally moves a virtual tuning knob;
the sweet spot sits at 87.30 MHz.
// signal strength: 1 at the sweet spot, 0 far away
var sig = Math.max(0, 1 - Math.abs(tune - TARGET) * 7);
var noiseAmp = 1 - sig*sig * 0.85;
for (var i = 0, p = 0; i < W*H; i++, p += 4) {
var n = Math.random();
var v = n * noiseAmp * 110 + 8; // green-tinted snow
var r = v*0.35, g = v*0.95, b = v*0.6;
if (mask[p+3] > 128) { // hidden carrier
var e = sig*sig * (0.75 + 0.25*n);
r += 40*e; g += 235*e; b += 140*e;
}
px[p]=r; px[p+1]=g; px[p+2]=b; px[p+3]=255;
}
Two care details: an idle autopilot drifts the knob toward the sweet spot after
4 seconds untouched, so every visitor eventually sees the message even if they never interact; and
under prefers-reduced-motion the tuner starts locked with the message fully visible.
The scope trace is two summed sines plus per-sample jitter, redrawn at 30 fps. The CRT phosphor persistence comes from never clearing the canvas — each frame paints a translucent background rectangle, so old traces decay exponentially:
// afterglow: translucent wipe instead of clearRect
ctx.fillStyle = 'rgba(1,4,3,0.28)';
ctx.fillRect(0, 0, W, H);
// ...then stroke the new trace with a glow
ctx.shadowColor = 'rgba(93,255,168,0.8)'; ctx.shadowBlur = 12;
y = sin(x·f1·2π + 2.1t)·0.32 + sin(x·f2·2π − 3.4t)·0.11 + jitter
The cursor bends f1 and f2, eased with a simple
bend += (goal − bend) × 0.08 — the trace feels like it resists you, the way real
instruments do.
Three sections exist purely to deepen the story, and each one carries its own mechanic:
A broadcast schedule of impossible programs — WEATHER FOR THE INTERIOR,
STATIC HOUR, an AFTERNOON FILM with a 34-year runtime. Three entries carry an
unstable class whose program titles flicker on offset steps(1) keyframes,
one row is a red [SIGNAL INTRUSION], and JavaScript reads new Date().getHours()
against each row's data-h attribute to pin a blinking ON AIR badge on
whatever is "broadcasting" at the visitor's local time. The page is different at 3 AM. That is the point.
Four paper fragments (maintenance log, viewer letter, internal memo, final shift log) with
inked-out redactions. Each redaction is a run of block glyphs whose true text lives in a
data-plain attribute; hover or focus scrambles the blocks into amber plaintext through
the shared scramble() routine plus a 4-step jitter keyframe — the ink glitches off,
it doesn't fade. On touch devices redactions resolve themselves when scrolled fully into view.
The deep payoff for the tuner. CH 05 initially shows four lines of leetspeak-garbled
ciphertext under an ■ ENCRYPTED badge and an unknown key. When the CH 04 tuner holds
a lock at 87.30 MHz for ~1.1 s, it dispatches a carrier-lock CustomEvent; the
decoder catches it, spikes the global glitch bus, scrambles the badge to ▲ DECODED,
fills in the key, and demodulates the four lines one by one. Two systems, one event — the site
rewards the person who tuned the radio with a message that was sitting in plain sight all along.
// CH 04 — holding the lock hands the key to CH 05
if (locked) {
lockHold += 1/30;
if (lockHold > 1.1 && !lockSent) {
lockSent = true;
window.dispatchEvent(new CustomEvent('carrier-lock'));
}
}
hash() above, and a u_glitch uniform. If the background is right, the site is already 60% haunted.frames = ⌊(s % 1) × 25⌋), REC dot. Fixed corners, VT323, phosphor text-shadow.glitchAmt variable that anything can spike — channel changes, boot, random timers — and the shader consumes. Systems that share one nervous system feel alive.prefers-reduced-motion: freeze the shader time, disable glitch keyframes, start the tuner locked. The mood must survive with the motion off.None. Every pixel on the site is generated at runtime by the code above — the honest way to fake a broken machine.
TX 87.3 MHz · FIELD GUIDE · 57 45 20 41 52 45 20 53 54 49 4C 4C 20 48 45 52 45