Skip to content
animorio
IT
Back to the library

The vintage radio to tune

Hover, SVGGSAPAdvancedCompressed weight: Demo 11 KBGSAP 28 KBFreeDownload the zip

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.

The code

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>

Playground

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

Or ask Claude Code for it

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 site

What it does

On a teal wall warmed by a lamp stands a tabletop radio in polished walnut, with its grain and a lacquer that catches the light. On the left the woven speaker cloth behind three wooden ribs and a brass badge; on the right the glass dial with the 88 to 108 MHz scale, five stations (Jazz, News, Classical, Lo-fi, Folk) and the red needle; below, the green magic eye, the knurled volume and tuning knobs and a row of six bakelite keys. The On key warms the valves: a flicker, then the glass and the cloth light up amber in two and a half seconds. A station key stays down and the needle runs there; the tuning knob turns by dragging (a turn and a half for the whole dial), the mouse wheel or a finger on the glass move the needle directly. Between two stations the glass fills with white scratches, the light trembles and the needle shivers; near a station the noise clears, the magic eye closes its dark wedge, the name and the song appear on the glass and the speaker cloth beats at the station’s tempo (the news in uneven pulses, like a voice). The volume changes how hard it beats. On its own it switches on, tries the stations, turns the knob by hand and switches off.

How it works

Three numbers

Everything 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.

Signal and static

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.

Valves warming up

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.

The cloth that beats

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.

Hands

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.

Values to change

NameFileDefaultWhat it changes
STATIONSscript.js[['Jazz', 'Blue Hour Session', 92], …]The stations on the dial: name, what is playing and beats per minute (0 for speech).
BRANDscript.js'Hearth'The radio’s name, in the header and on the badge.
WARMscript.js2.4The seconds the valves take to warm up.
SWEEPscript.js1.1The seconds the needle takes to reach a station from a key.
STATICscript.js1How much the dial crackles between stations.
BEATscript.js1How hard the speaker cloth beats on a station.
LIGHTscript.js1How far the shine on the lacquer follows the pointer.

Try them in the Playground

When to use it

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.

On phones

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.

With reduced motion

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.

Pitfalls

  • The knob’s angle must be measured in the SVG’s coordinates (getScreenCTM), not the page’s: the radio is scaled and the knob’s centre moves with the screen size.
  • Between frames the pointer’s angle can jump from plus to minus 180 degrees: the difference has to be brought back between minus and plus pi, or the needle leaps.
  • The gradient colours inside the SVG are written as style="stop-color:var(--…)" rather than as an attribute, so the Playground’s Palette recolours them.

Libraries

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.

Testing

Tested on 22 October 2026

  • Desktop: Done
  • Phone: Done
  • Reduced motion: Done
  • Safari on iPhone: To do

License

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.

All animations