The complete technical field manual for “Brutalist Broadcast” — a pirate-radio zine built as a single static HTML page with GSAP, ScrollTrigger and two raw canvases. No build step. No framework. No license.
An unlicensed FM station that prints its own zine. Pirate radio and brutalism share one ethic: show the structure. Nothing is decorated — every border is a real 1px rule, every section is a labelled slab (“01 / MANIFESTO”), and the only image on the whole site is noise the page generates itself.
The discipline is the trick. Brutalism fails when it's sloppy; it lands when the grid is merciless and the type is set with care. So: one display face, one mono face, five colors, tabular numerals everywhere a frequency appears, and all “chaos” (scramble, glitch, static) is tightly quantized so it reads as signal interference, not bugs.
Warm paper instead of white, warm near-black instead of #000 — the multiply-blended grain canvas ties them together like cheap offset printing. SIGNAL red is rationed: needle, stamps, one word per paragraph.
Scale contrast does the heavy lifting: hero lines at clamp(4.5rem, 17.5vw, 17rem) against 10px tracked mono labels. Alternating solid / outlined (-webkit-text-stroke) / red lines keeps a three-line stack from becoming a wall.
Every ease is power3/power4 — nothing linear except marquees and scrubbed values (which inherit their easing from your scroll finger). Idle motion (waveform, grain, blinking ON–AIR dot, marquees) keeps the page alive between interactions. Everything respects prefers-reduced-motion.
Static HTML + CSS + vanilla JS. Two pinned CDN scripts: GSAP 3.12.5 and ScrollTrigger. Two <canvas> elements (grain overlay, hero oscilloscope). Google Fonts. That's the whole stack.
The centerpiece: scrolling ~2600px sweeps the FM band 88.0→108.0. The section is pinned; a scrubbed tween drives one number, and everything — the giant readout, the tick-strip transform, station lock states — derives from it. One source of truth, zero drift.
index.html — tuner scrub// scroll progress → frequency → everything else
var fobj = {f: 88};
gsap.to(fobj, {
f: 108, ease: "none",
onUpdate: function(){ setFreq(fobj.f); },
scrollTrigger: {
trigger: "#tuner-wrap",
start: "top top",
end: "+=2600",
pin: true, scrub: 0.4
}
});
function setFreq(f){
// tabular-nums readout: "091.7"
track.style.transform =
"translateX(" + (-(f-88)*90) + "px)";
stationData.forEach(function(st){
st.el.classList.toggle("live",
Math.abs(st.f - f) < 0.45);
});
}
The tick strip is ~1800px of absolutely-positioned 1px <i> elements generated once at load (a tick every 0.2 MHz, taller every 0.5 and 1.0). Moving it is a single transform — cheap, 60fps.
Hero lines shear with scroll speed — the classic “paper being dragged” feel, done with quickSetter so no tweens are allocated per scroll event.
var setters = gsap.utils.toArray(".hline")
.map(el => gsap.quickSetter(el, "skewX", "deg"));
var clamp = gsap.utils.clamp(-8, 8);
ScrollTrigger.create({
onUpdate: self => {
var s = clamp(self.getVelocity() / -260);
setters.forEach(set => set(s));
}
});
No noise texture is downloaded. A 128×128 canvas is refilled with random luminance ~11 times per second, tiled across a half-resolution fixed canvas, and blended with mix-blend-mode: multiply at 7% opacity. It reads as cheap paper, and it costs almost nothing.
function makeNoise(){
var img = tctx.createImageData(128, 128);
for (var i=0; i<img.data.length; i+=4){
var v = (Math.random()*255)|0;
img.data[i]=img.data[i+1]=img.data[i+2]=v;
img.data[i+3]=255;
}
tctx.putImageData(img, 0, 0);
}
// throttled to ~11fps — film flicker, not strobe
if (t - last > 90){ makeNoise(); tile(); }
GSAP's ScrambleText is a paid Club plugin — this site ships a 15-line free one. Characters resolve left to right; the charset is themed (▓▒░#%/ + the station's call digits).
var CHARS = "▓▒░#%/\\|<>+=*XZKQ0917";
function scramble(el){
var t = el.dataset.txt, frame = 0;
var iv = setInterval(function(){
frame++;
el.textContent = t.split("").map((ch,i) =>
frame > (i/t.length)*11 || ch === " "
? ch
: CHARS[(Math.random()*CHARS.length)|0]
).join("");
if (frame >= 14){ clearInterval(iv);
el.textContent = t; }
}, 30);
}
Section 06 is a six-channel console built from borders and rectangles. Each channel is 12 VU segments (the top three turn SIGNAL red), a draggable fader, and a mute. One gsap.ticker callback, throttled to ~18fps, wobbles every channel around its fader level with a per-channel sine phase — so the desk looks like it's passing programme audio, not white noise.
// pseudo-audio: level × slow sine × jitter
var wob = 0.62
+ 0.28 * Math.abs(Math.sin(t*c.speed + c.phase))
+ (Math.random()*0.14 - 0.07);
paint(c, c.lvl * wob); // lights round(x*12) segs
// faders are real pointer-event drags
c.fader.addEventListener("pointerdown", function(e){
var r = c.fader.getBoundingClientRect();
c.lvl = 1 - (e.clientY - r.top) / r.height;
knob.style.top = ((1 - c.lvl) * 100) + "%";
});
Muting all six flips the status line to OUTPUT: DEAD AIR (COWARD). Every good console judges its operator.
An ink-black overlay plays a 2.3s receiver boot: the frequency counter eases 088.0→091.7 with power3.inOut, checklist lines print, then the overlay slides up with power4.inOut while the hero lines rise from below with a 0.09s stagger. It doubles as a font-loading mask — no flash of unstyled text, ever.
Marquees: CSS translateX(-50%) keyframes on duplicated content — GSAP is saved for what needs easing. Manifesto: text is split into word-spans at runtime, then a scrubbed stagger lifts opacity .14→1. Archive cards: red wipe panels on cubic-bezier(.7,0,.2,1); clicking one “plays the tape” — the card inverts to ink and a 4-bar CSS equalizer starts (one card at a time, keyboard-operable). Back-page small ads: a 3×2 classifieds grid with ink-invert hovers and rubber stamps. Scrollspy: one ScrollTrigger per section toggles the red underline in the nav. Waveform: three sine-product lines with per-vertex jitter on gsap.ticker. DPR capped at 2. No AI-generated assets — every pixel is procedural.
clamp() lines — solid / outlined / accent. Add side annotations in 11px tracked mono.xPercent drifts on the hero lines; add the velocity-skew snippet.prefers-reduced-motion, cap devicePixelRatio at 2, and test at 375px — brutalism is not an excuse for broken mobile.AI assets used: none. Everything on both pages is procedural code — type, rules, noise, waveforms.