
The colour columns
Hover, SVG
NewFree
In the rich style: the player of a web radio made as a forties valve radio in polished walnut. The On key warms the valves slowly, with a flicker; the bakelite keys, the knurled knob, the mouse wheel or a finger on the glass move the red needle across the lit dial. Between stations the glass crackles and the light trembles; on the right station the green magic eye closes, the name and the song appear on the glass and the speaker cloth beats in time.
A folder that runs on its own: index.html, style.css, script.js and the libraries from a CDN. The React and Vue tabs hold the same demo as a ready component, which switches its animations off when it leaves the page.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The vintage radio to tune</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/fraunces@5.3.0/full.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/instrument-sans@5.3.0/wdth.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="stage">
<header class="top">
<span class="brand" data-brand>Hearth</span>
<span class="tag">Live radio, all night long</span>
</header>
<!-- the player of an invented web radio, drawn as a tabletop valve radio from the forties: a walnut cabinet with
its grain, a woven speaker cloth behind wooden fretwork, a glass dial lit from behind with the stations and a
red needle, a green magic eye, two knurled bakelite knobs and a row of bakelite keys. The grain, the knurling,
the ticks and the static are drawn by the script. No sound: the cloth beats instead -->
<section class="radio" aria-label="Vintage radio player" data-radio>
<svg class="set" viewBox="0 0 600 430" data-svg>
<defs>
<linearGradient id="walnut" x1="0" y1="0" x2="0.35" y2="1">
<stop offset="0" style="stop-color:var(--wood-light)"/>
<stop offset="0.45" style="stop-color:var(--wood)"/>
<stop offset="1" style="stop-color:var(--wood-dark)"/>
</linearGradient>
<linearGradient id="veneer" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" style="stop-color:var(--wood)"/>
<stop offset="0.5" style="stop-color:var(--wood-light)"/>
<stop offset="1" style="stop-color:var(--wood)"/>
</linearGradient>
<linearGradient id="lacquer" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.55"/>
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<radialGradient id="valves" cx="0.5" cy="0.55" r="0.6">
<stop offset="0" style="stop-color:var(--valve)" stop-opacity="1"/>
<stop offset="0.55" style="stop-color:var(--valve)" stop-opacity="0.45"/>
<stop offset="1" style="stop-color:var(--valve)" stop-opacity="0"/>
</radialGradient>
<linearGradient id="backlight" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" style="stop-color:var(--amber-light)"/>
<stop offset="1" style="stop-color:var(--amber)"/>
</linearGradient>
<linearGradient id="glassShine" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
<stop offset="0.45" stop-color="#fff" stop-opacity="0.32"/>
<stop offset="0.55" stop-color="#fff" stop-opacity="0.05"/>
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<radialGradient id="bakelite" cx="0.36" cy="0.3" r="0.8">
<stop offset="0" style="stop-color:var(--bakelite-light)"/>
<stop offset="0.6" style="stop-color:var(--bakelite)"/>
<stop offset="1" style="stop-color:var(--bakelite-dark)"/>
</radialGradient>
<linearGradient id="keyFace" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" style="stop-color:var(--bakelite-light)"/>
<stop offset="1" style="stop-color:var(--bakelite)"/>
</linearGradient>
<linearGradient id="brass" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f6dc93"/>
<stop offset="0.5" style="stop-color:var(--brass)"/>
<stop offset="1" stop-color="#8a6420"/>
</linearGradient>
<radialGradient id="eyeGlow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#d9ffd9"/>
<stop offset="0.55" style="stop-color:var(--eye)"/>
<stop offset="1" stop-color="#1f6b38"/>
</radialGradient>
<pattern id="cloth" width="6" height="6" patternUnits="userSpaceOnUse">
<rect width="6" height="6" style="fill:var(--cloth-dark)"/>
<path d="M0 1.5h6M0 4.5h6" style="stroke:var(--cloth)" stroke-width="1.6"/>
<path d="M1.5 0v6M4.5 0v6" style="stroke:var(--cloth-light)" stroke-width="1.1" stroke-opacity="0.85"/>
<path d="M0 1.5h3M3 4.5h3" style="stroke:var(--cloth-light)" stroke-width="1.6" stroke-opacity="0.5"/>
</pattern>
<clipPath id="cabClip"><path data-cab-path/></clipPath>
<clipPath id="frontClip"><rect x="46" y="46" width="508" height="328" rx="26"/></clipPath>
<clipPath id="grilleClip"><rect x="72" y="78" width="214" height="214" rx="20"/></clipPath>
<clipPath id="glassClip"><rect x="314" y="78" width="214" height="118" rx="14"/></clipPath>
</defs>
<ellipse class="shadow" cx="300" cy="418" rx="290" ry="12"/>
<rect class="foot" x="78" y="396" width="56" height="16" rx="5"/>
<rect class="foot" x="466" y="396" width="56" height="16" rx="5"/>
<!-- the cabinet: walnut with its grain, a lacquer shine on the shoulders and a bevel -->
<g data-light>
<path class="cab" data-cab-path fill="url(#walnut)"/>
<g clip-path="url(#cabClip)" data-grain></g>
<path class="bevel" data-cab-path/>
<path class="lacquer" fill="url(#lacquer)" data-shine/>
</g>
<!-- the front panel: book-matched veneer, a shade lighter -->
<rect class="front" x="46" y="46" width="508" height="328" rx="26" fill="url(#veneer)"/>
<g clip-path="url(#frontClip)" data-veneer></g>
<rect class="front-edge" x="46" y="46" width="508" height="328" rx="26"/>
<!-- the speaker: woven cloth, the valves' glow through it, the wooden fretwork over it -->
<g clip-path="url(#grilleClip)">
<rect x="72" y="78" width="214" height="214" style="fill:var(--cloth-dark)"/>
<g data-cone>
<rect x="62" y="68" width="234" height="234" fill="url(#cloth)"/>
<circle class="cone" cx="179" cy="185" r="62" data-ring/>
</g>
<ellipse cx="179" cy="205" rx="130" ry="120" fill="url(#valves)" opacity="0" class="valve-glow" data-glow/>
<rect x="72" y="78" width="214" height="214" class="cloth-shade"/>
</g>
<rect class="grille-rim" x="72" y="78" width="214" height="214" rx="20"/>
<g class="fret" data-fret></g>
<!-- the brass badge -->
<g transform="translate(144 300)">
<rect width="70" height="16" rx="3" fill="url(#brass)" stroke="#6d4c14" stroke-width="0.8"/>
<text x="35" y="11.6" class="badge" data-badge>HEARTH</text>
</g>
<!-- the dial: glass lit from behind, the scale, the stations, the needle, the static -->
<rect x="310" y="74" width="222" height="126" rx="17" class="dial-rim"/>
<g clip-path="url(#glassClip)">
<rect x="314" y="78" width="214" height="118" style="fill:var(--glass-off)"/>
<rect x="314" y="78" width="214" height="118" fill="url(#backlight)" opacity="0" data-back/>
<g data-scale></g>
<g data-stations></g>
<text class="now-station" x="421" y="176" data-now-station></text>
<text class="now-song" x="421" y="189" data-now-song></text>
<g data-noise></g>
<g data-needle>
<line class="needle" x1="0" y1="84" x2="0" y2="164"/>
<rect class="needle-foot" x="-3" y="160" width="6" height="5" rx="1"/>
</g>
<rect x="314" y="78" width="214" height="118" fill="url(#glassShine)" data-glass/>
</g>
<rect x="314" y="78" width="214" height="118" rx="14" class="glass-edge"/>
<!-- the magic eye -->
<g transform="translate(421 232)">
<circle r="15" class="eye-rim"/>
<circle r="11.5" fill="#183020" data-eye-dark/>
<circle r="11.5" fill="url(#eyeGlow)" opacity="0" data-eye-glow/>
<path fill="#0f2416" data-eye-wedge/>
<circle r="3.4" fill="#0c1a10"/>
<circle r="11.5" class="eye-glass"/>
</g>
<!-- the knobs: volume and tuning -->
<g class="knob" transform="translate(352 254)" role="slider" tabindex="0" aria-label="Volume" aria-valuemin="0" aria-valuemax="100" data-knob="vol">
<ellipse cx="2" cy="6" rx="27" ry="25" class="knob-shadow"/>
<g data-turn><circle r="26" fill="url(#bakelite)"/><g data-knurl></g><circle r="17" class="knob-cap"/><circle r="11" class="knob-ring"/><line class="knob-mark" x1="0" y1="-24" x2="0" y2="-14"/></g>
<ellipse cx="-8" cy="-9" rx="9" ry="5" class="knob-shine"/>
</g>
<g class="knob" transform="translate(490 254)" role="slider" tabindex="0" aria-label="Tuning" aria-valuemin="88" aria-valuemax="108" data-knob="tune">
<ellipse cx="2" cy="6" rx="27" ry="25" class="knob-shadow"/>
<g data-turn><circle r="26" fill="url(#bakelite)"/><g data-knurl></g><circle r="17" class="knob-cap"/><circle r="11" class="knob-ring"/><line class="knob-mark" x1="0" y1="-24" x2="0" y2="-14"/></g>
<ellipse cx="-8" cy="-9" rx="9" ry="5" class="knob-shine"/>
</g>
<text class="small" x="352" y="296">VOLUME</text>
<text class="small" x="490" y="296">TUNING</text>
<!-- the bakelite keys: power and five stations -->
<g data-keys></g>
</svg>
</section>
<p class="onair"><span class="onair__label">On air</span> <span data-caption>Press On, then a station</span></p>
<p class="sr" aria-live="polite" data-live></p>
</main>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="script.js"></script>
</body>
</html>Change values, colours and text with the controls and watch the animation respond live. Or write in the code yourself. Then download the folder with your changes.
50 values to change
With the Animorio connector, Claude Code takes it from here, guide included, and fits it into your project. Paste this request:
Take “The vintage radio to tune” from Animorio and fit it into my siteEverything comes from three values: the power (0 to 1), the needle’s place on the dial (0 to 1, from 88 to 108 MHz) and the volume. GSAP animates them and a single ticker loop draws the rest on every frame.
Every station is a bell around its place: the signal is the highest bell under the needle, the static is one minus the signal. The static decides how many white scratches show on the glass, how much the light trembles and how much the needle shivers; the signal closes the magic eye’s wedge and lights the name on the glass.
The On key does not switch on at once: a timeline gives two or three short flickers of power and then raises it slowly to 1 over two and a half seconds. The dial light, the glow behind the cloth and the magic eye follow the same number.
On a station the speaker cloth swells on every beat: an exponential that restarts on each beat, at the station’s tempo, times the signal and the volume. The news is speech: a product of slow sine waves that gives uneven pulses.
The knob turns by dragging: the pointer’s angle around its centre, measured in the SVG’s coordinates, moves the needle a turn and a half for the whole dial. A finger on the glass brings the needle where it touches, the wheel moves it a little at a time, the arrow keys by a tenth of a MHz.
| Name | File | Default | What it changes |
|---|---|---|---|
STATIONS | script.js | [['Jazz', 'Blue Hour Session', 92], …] | The stations on the dial: name, what is playing and beats per minute (0 for speech). |
BRAND | script.js | 'Hearth' | The radio’s name, in the header and on the badge. |
WARM | script.js | 2.4 | The seconds the valves take to warm up. |
SWEEP | script.js | 1.1 | The seconds the needle takes to reach a station from a key. |
STATIC | script.js | 1 | How much the dial crackles between stations. |
BEAT | script.js | 1 | How hard the speaker cloth beats on a station. |
LIGHT | script.js | 1 | How far the shine on the lacquer follows the pointer. |
For the player of a web radio or a podcast, the site of a vintage shop or a bar with live music, a “listen” page with a few stations or playlists, a café with an old-time mood.
The radio shrinks with the screen and is at most as tall as the window allows; everything is one SVG, so the details stay the same. A finger presses a key, turns the knob or slides along the glass.
No flicker or scratches on the glass and the cloth does not beat: switching on is a fade and the needle jumps to the station; the magic eye and the name on the glass still change.
GSAP 3.15.0, Fraunces (font variabile) 5.3.0, Instrument Sans (font variabile) 5.3.0.
Compressed with gzip, the three demo files weigh 11 KB; the libraries: GSAP 28 KB.
Tested on 22 October 2026
Use it in your own projects and in your clients’ projects. Do not resell it or redistribute it as is. GSAP stays under its own Standard license. Full license.
With your account’s email and password.
or with your email
You’re signed in as .
With your account, you can download the zip of every animation and every pack, for free.