Skip to content
animorio
IT
Back to the library

The television transition

Transitions, SVGGSAP, Canvas, CSS onlyMediumCompressed weight: Demo 16 KBGSAP 28 KBFreeDownload the zip

An old valve television in its walnut cabinet, with the channel dial, the speaker and the rabbit-ear aerial. On a click the dial clunks round, the picture squeezes into a line and then into a point of light that goes out, a moment of snow rolls past with its bars and the next channel opens from the middle: weather, news, a cartoon and a quiz show.

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, viewport-fit=cover">
  <title>Change the channel</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/bricolage-grotesque@5.3.0/index.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/jetbrains-mono@5.3.0/index.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="room" data-stage>

    <div class="tv" data-tv>
      <!-- the cabinet: walnut, the bezel, the dial, the volume knob, the speaker and the rabbit ears.
           The script draws it, wide on a computer and upright on a phone (the controls go under the screen) -->
      <svg class="cabinet" data-cabinet aria-hidden="true"></svg>

      <!-- the tube: four made-up channels, each one drawn in a 400 x 300 picture -->
      <div class="screen" data-screen>
        <div class="tube" data-tube>
          <div class="roll" data-roll>

            <!-- the weather -->
            <div class="channel" data-channel="Weather">
              <svg viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice">
                <defs>
                  <linearGradient id="wx-sky" x1="0" y1="0" x2="0" y2="1">
                    <stop offset="0" stop-color="#2463a8"/>
                    <stop offset="1" stop-color="#123563"/>
                  </linearGradient>
                </defs>
                <rect width="400" height="300" fill="url(#wx-sky)"/>
                <g class="wx-sea" stroke="#ffffff" stroke-opacity="0.07" stroke-width="1" fill="none">
                  <path d="M0 60H400M0 100H400M0 140H400M0 180H400M0 220H400M60 34V262M140 34V262M220 34V262M300 34V262M380 34V262"/>
                </g>
                <path d="M58 132C70 88 140 66 196 92C236 74 300 86 312 128C326 166 292 214 240 222C196 244 128 238 104 206C66 196 46 168 58 132Z" fill="#62b06b" stroke="#3d8a4b" stroke-width="2.5"/>
                <path d="M84 140C110 120 150 128 170 112" fill="none" stroke="#4f9a5a" stroke-width="2" stroke-linecap="round"/>
                <path d="M318 214C332 196 364 198 372 216C378 234 352 248 332 240C316 234 310 224 318 214Z" fill="#62b06b" stroke="#3d8a4b" stroke-width="2.5"/>

                <!-- Port Avery: sun -->
                <g data-wx-sun>
                  <g class="wx-rays" data-spin stroke="#ffd23f" stroke-width="4" stroke-linecap="round">
                    <path d="M130 92V84M130 136V144M108 114H100M152 114H160M114.4 98.4L108.8 92.8M145.6 129.6L151.2 135.2M114.4 129.6L108.8 135.2M145.6 98.4L151.2 92.8"/>
                  </g>
                  <circle cx="130" cy="114" r="16" fill="#ffd23f" stroke="#f29e1f" stroke-width="3"/>
                </g>
                <rect x="110" y="150" width="40" height="20" rx="6" fill="#0d2342"/>
                <text class="wx-temp" x="130" y="165" text-anchor="middle">21°</text>
                <text class="wx-town" x="130" y="184" text-anchor="middle">Port Avery</text>

                <!-- Kestle: rain -->
                <g data-bob>
                  <g class="wx-rain" stroke="#9fd2ff" stroke-width="2.5" stroke-linecap="round">
                    <path data-drops d="M228 132L224 142M240 132L236 142M252 132L248 142M228 152L224 162M240 152L236 162M252 152L248 162" stroke-dasharray="6 14"/>
                  </g>
                  <path d="M214 128C204 128 200 112 214 110C214 96 234 92 240 104C246 94 266 96 266 112C278 112 278 128 266 128Z" fill="#ffffff"/>
                </g>
                <rect x="220" y="164" width="40" height="20" rx="6" fill="#0d2342"/>
                <text class="wx-temp" x="240" y="179" text-anchor="middle">16°</text>
                <text class="wx-town" x="240" y="198" text-anchor="middle">Kestle</text>

                <!-- Wynmouth: sun behind a cloud -->
                <g data-bob-2>
                  <circle cx="352" cy="190" r="12" fill="#ffd23f" stroke="#f29e1f" stroke-width="2.5"/>
                  <path d="M330 206C322 206 320 194 330 193C331 183 345 181 350 189C355 182 368 185 367 196C376 196 376 206 367 206Z" fill="#ffffff"/>
                </g>
                <rect x="324" y="244" width="40" height="18" rx="6" fill="#0d2342"/>
                <text class="wx-temp" x="344" y="258" text-anchor="middle">18°</text>

                <rect width="400" height="36" fill="#f2c230"/>
                <text class="wx-title" x="16" y="25">WEATHER AT SIX</text>
                <text class="wx-day" x="384" y="24" text-anchor="end">TOMORROW</text>
                <rect y="264" width="400" height="36" fill="#0a1b33" fill-opacity="0.9"/>
                <text class="wx-line" x="16" y="287">SUNNY SPELLS, A SHOWER BY THE COAST</text>
              </svg>
            </div>

            <!-- the news -->
            <div class="channel" data-channel="News">
              <svg viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice">
                <defs>
                  <radialGradient id="nw-bg" cx="0.7" cy="0.3" r="0.9">
                    <stop offset="0" stop-color="#2f4690"/>
                    <stop offset="1" stop-color="#0c1430"/>
                  </radialGradient>
                  <clipPath id="nw-tick">
                    <rect x="76" y="270" width="324" height="30"/>
                  </clipPath>
                </defs>
                <rect width="400" height="300" fill="url(#nw-bg)"/>
                <g fill="none" stroke="#ffffff" stroke-opacity="0.1" stroke-width="1.5">
                  <circle cx="310" cy="110" r="70"/>
                  <ellipse cx="310" cy="110" rx="34" ry="70"/>
                  <ellipse cx="310" cy="110" rx="70" ry="24"/>
                  <path d="M240 110H380M310 40V180"/>
                </g>
                <g data-globe-sweep>
                  <ellipse cx="310" cy="110" rx="10" ry="70" fill="none" stroke="#ffffff" stroke-opacity="0.22" stroke-width="1.5" vector-effect="non-scaling-stroke"/>
                </g>
                <rect x="14" y="14" width="138" height="26" fill="#c8202f"/>
                <text class="nw-logo" x="24" y="32">EVENING BULLETIN</text>
                <rect x="158" y="14" width="40" height="26" fill="#f4f1ea"/>
                <text class="nw-live" x="178" y="32" text-anchor="middle">LIVE</text>

                <!-- the presenter -->
                <g data-anchor>
                  <path d="M118 266C122 214 158 196 200 194C242 196 278 214 282 266Z" fill="#262a35"/>
                  <path d="M178 196L200 236L222 196Z" fill="#f2efe8"/>
                  <path d="M196 204H204L208 236L200 246L192 236Z" fill="#c8202f"/>
                  <path d="M178 196L194 226L186 232L170 202Z M222 196L206 226L214 232L230 202Z" fill="#323746"/>
                  <rect x="190" y="172" width="20" height="26" fill="#cf966a"/>
                  <g data-head>
                    <ellipse cx="200" cy="146" rx="30" ry="36" fill="#e0a97e"/>
                    <ellipse cx="170" cy="150" rx="5" ry="8" fill="#d59b70"/>
                    <ellipse cx="230" cy="150" rx="5" ry="8" fill="#d59b70"/>
                    <path d="M168 140C164 106 190 98 206 102C226 104 238 118 232 142C228 124 214 118 196 122C182 124 172 130 168 140Z" fill="#3a2a22"/>
                    <path d="M184 134Q190 130 196 134M204 134Q210 130 216 134" stroke="#3a2a22" stroke-width="2.4" fill="none" stroke-linecap="round"/>
                    <g data-blink>
                      <ellipse cx="190" cy="146" rx="3" ry="3.4" fill="#1d1612"/>
                      <ellipse cx="210" cy="146" rx="3" ry="3.4" fill="#1d1612"/>
                    </g>
                    <path d="M198 150Q196 160 202 160" stroke="#b97d55" stroke-width="2" fill="none" stroke-linecap="round"/>
                    <ellipse data-mouth cx="200" cy="168" rx="7" ry="3" fill="#7a3a2c"/>
                  </g>
                </g>
                <path d="M30 244H370L392 300H8Z" fill="#17244f"/>
                <path d="M30 244H370" stroke="#6f86d6" stroke-width="2"/>

                <g class="nw-lower">
                  <rect x="18" y="206" width="76" height="24" fill="#c8202f"/>
                  <text class="nw-tag" x="56" y="222" text-anchor="middle">BREAKING</text>
                </g>
                <g class="nw-lower">
                  <rect x="94" y="206" width="290" height="24" fill="#f4f1ea"/>
                <text class="nw-head" x="104" y="222">TOWN CLOCK RUNS FIVE MINUTES FAST</text>
                </g>

                <rect y="270" width="400" height="30" fill="#f4f1ea"/>
                <g clip-path="url(#nw-tick)">
                  <g data-ticker>
                    <text class="nw-tick" x="86" y="290" data-tick>FERRY BACK ON TIME / MARKETS CALM / LIBRARY OPEN LATE ON FRIDAYS /</text>
                    <text class="nw-tick" x="86" y="290" data-tick>FERRY BACK ON TIME / MARKETS CALM / LIBRARY OPEN LATE ON FRIDAYS /</text>
                  </g>
                </g>
                <rect y="270" width="76" height="30" fill="#c8202f"/>
                <text class="nw-tag" x="44" y="290" text-anchor="middle">LATEST</text>
              </svg>
            </div>

            <!-- the cartoon -->
            <div class="channel" data-channel="Cartoon">
              <svg viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice">
                <defs>
                  <linearGradient id="tn-sky" x1="0" y1="0" x2="0" y2="1">
                    <stop offset="0" stop-color="#7fcdf2"/>
                    <stop offset="1" stop-color="#d4f3ff"/>
                  </linearGradient>
                </defs>
                <rect width="400" height="300" fill="url(#tn-sky)"/>
                <g transform="translate(62 60)">
                  <g data-spin-slow stroke="#ffb627" stroke-width="5" stroke-linecap="round">
                    <path d="M0 -40V-32M0 40V32M-40 0H-32M40 0H32M-28.3 -28.3L-22.6 -22.6M28.3 28.3L22.6 22.6M-28.3 28.3L-22.6 22.6M28.3 -28.3L22.6 -22.6"/>
                  </g>
                  <circle r="24" fill="#ffd23f" stroke="#ffb627" stroke-width="3"/>
                  <path d="M-9 -4Q-6 -8 -3 -4M3 -4Q6 -8 9 -4" stroke="#7a4a12" stroke-width="2.4" fill="none" stroke-linecap="round"/>
                  <path d="M-8 6Q0 13 8 6" stroke="#7a4a12" stroke-width="2.4" fill="none" stroke-linecap="round"/>
                </g>
                <g transform="translate(270 4)">
                  <g data-cloud fill="#ffffff">
                    <path d="M40 92C30 92 28 78 40 76C40 64 58 60 64 70C70 60 88 64 86 78C98 78 98 92 86 92Z"/>
                  </g>
                </g>
                <g data-cloud-2 fill="#ffffff" fill-opacity="0.85">
                  <path d="M206 64C199 64 197 54 206 53C207 45 219 43 223 50C227 44 239 47 237 56C245 56 245 64 237 64Z"/>
                </g>
                <path d="M-10 230C60 180 140 190 210 224C260 200 330 180 410 214V300H-10Z" fill="#8bd16a"/>
                <path d="M-10 262C80 236 200 246 280 262C330 252 380 248 410 254V300H-10Z" fill="#5fb34a"/>
                <g fill="#4a9a3a">
                  <path d="M70 252l4 -10l4 10Z M300 236l4 -10l4 10Z M340 260l4 -10l4 10Z"/>
                </g>

                <!-- Pebble -->
                <g data-pebble>
                  <path d="M262 226C258 200 280 186 302 188C326 190 338 206 334 226Z" fill="#a3acb8"/>
                  <path d="M270 210C276 198 290 194 302 196" stroke="#c9d0d9" stroke-width="4" fill="none" stroke-linecap="round"/>
                  <g data-pebble-eyes>
                    <ellipse cx="290" cy="210" rx="5" ry="6" fill="#ffffff"/>
                    <ellipse cx="308" cy="210" rx="5" ry="6" fill="#ffffff"/>
                    <circle cx="291.5" cy="211" r="2.6" fill="#1d1612"/>
                    <circle cx="309.5" cy="211" r="2.6" fill="#1d1612"/>
                  </g>
                  <path d="M294 220Q299 224 304 220" stroke="#5d6672" stroke-width="2" fill="none" stroke-linecap="round"/>
                </g>

                <!-- Pip -->
                <ellipse data-pip-shadow cx="160" cy="236" rx="22" ry="4" fill="#3c7a2c" fill-opacity="0.45"/>
                <g data-pip>
                  <g data-pip-body>
                    <circle cx="160" cy="214" r="21" fill="#ffd23f" stroke="#f0a91c" stroke-width="3"/>
                    <path d="M144 216Q140 208 148 206" stroke="#f0a91c" stroke-width="3" fill="none" stroke-linecap="round"/>
                    <circle cx="156" cy="207" r="3" fill="#1d1612"/>
                    <circle cx="168" cy="207" r="3" fill="#1d1612"/>
                    <path d="M160 212L172 215L160 219Z" fill="#ff7a2e"/>
                    <path d="M156 194Q160 186 164 194" stroke="#f0a91c" stroke-width="3" fill="none" stroke-linecap="round"/>
                  </g>
                </g>

                <g data-title-wobble>
                  <text class="tn-title" x="200" y="116" text-anchor="middle">PIP &amp; PEBBLE</text>
                </g>
              </svg>
            </div>

            <!-- the quiz -->
            <div class="channel" data-channel="Quiz">
              <svg viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice">
                <rect width="400" height="300" fill="#341a66"/>
                <g transform="translate(200 46)">
                  <g data-burst fill="#4d27a0">
                    <path data-burst-path d=""/>
                  </g>
                </g>
                <rect x="104" y="18" width="192" height="44" rx="22" fill="#170b36" stroke="#ffd23f" stroke-width="3"/>
                <rect data-chase x="104" y="18" width="192" height="44" rx="22" fill="none" stroke="#ffffff" stroke-width="4" stroke-dasharray="14 14" stroke-linecap="round"/>
                <text class="qz-title" x="200" y="47" text-anchor="middle">BRAIN WAVE</text>
                <clipPath id="qz-wave">
                  <rect x="128" y="62" width="144" height="16"/>
                </clipPath>
                <g clip-path="url(#qz-wave)">
                  <path data-wave d="M128 70Q138 64 148 70T168 70T188 70T208 70T228 70T248 70T268 70T288 70T308 70T328 70" fill="none" stroke="#ffd23f" stroke-width="2.5" stroke-linecap="round"/>
                </g>

                <rect x="28" y="84" width="344" height="50" rx="12" fill="#0f0a2a" stroke="#8f6bff" stroke-width="2.5"/>
                <text class="qz-q" x="200" y="114" text-anchor="middle">WHICH PLANET HAS THE MOST KNOWN MOONS?</text>

                <g class="qz-answers">
                  <path d="M40 166L52 148H184L196 166L184 184H52Z" fill="#0f0a2a" stroke="#8f6bff" stroke-width="2"/>
                  <text class="qz-a" x="118" y="171" text-anchor="middle">Mars</text>
                  <path data-pick d="M204 166L216 148H348L360 166L348 184H216Z" fill="#0f0a2a" stroke="#8f6bff" stroke-width="2"/>
                  <path data-pick-glow d="M204 166L216 148H348L360 166L348 184H216Z" fill="#ff9d2e" stroke="#ffd23f" stroke-width="2"/>
                  <text class="qz-a" x="282" y="171" text-anchor="middle">Saturn</text>
                  <path d="M40 210L52 192H184L196 210L184 228H52Z" fill="#0f0a2a" stroke="#8f6bff" stroke-width="2"/>
                  <text class="qz-a" x="118" y="215" text-anchor="middle">Venus</text>
                  <path d="M204 210L216 192H348L360 210L348 228H216Z" fill="#0f0a2a" stroke="#8f6bff" stroke-width="2"/>
                  <text class="qz-a" x="282" y="215" text-anchor="middle">Mercury</text>
                </g>

                <rect x="28" y="248" width="96" height="36" rx="8" fill="#170b36"/>
                <text class="qz-name" x="76" y="262" text-anchor="middle">ANNA</text>
                <text class="qz-score" x="76" y="279" text-anchor="middle">300</text>
                <rect x="276" y="248" width="96" height="36" rx="8" fill="#170b36"/>
                <text class="qz-name" x="324" y="262" text-anchor="middle">RUBEN</text>
                <text class="qz-score" x="324" y="279" text-anchor="middle">250</text>
                <circle cx="200" cy="266" r="17" fill="#170b36" stroke="#4a2f8a" stroke-width="5"/>
                <circle data-clock cx="200" cy="266" r="17" fill="none" stroke="#ffd23f" stroke-width="5" stroke-linecap="round" transform="rotate(-90 200 266)" pathLength="100" stroke-dasharray="100 100"/>
                <path data-clock-hand d="M200 266V255" stroke="#ffd23f" stroke-width="2.5" stroke-linecap="round"/>
              </svg>
            </div>

          </div>
          <div class="flash" data-flash></div>
        </div>
        <canvas class="snow" data-snow width="200" height="150"></canvas>
        <div class="spot" data-spot></div>
        <p class="osd" data-osd aria-hidden="true"></p>
        <div class="scan" data-scan></div>
        <div class="glass" data-glass></div>
        <button class="screen__hit" type="button" data-hit data-autoclic aria-label="Change the channel"></button>
      </div>

      <button class="dial" type="button" data-dial data-autoclic aria-label="Turn the channel dial"></button>
    </div>

    <p class="hint" data-hint>Turn the dial, or tap the screen</p>
    <p class="sr-only" 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.

46 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 television transition” from Animorio and fit it into my site

What it does

In a dark living room, with the warm light of a lamp behind, an old television rises with a little bounce: the grained walnut cabinet, the brass frame round the tube, a cream plate with the channel dial, the volume knob and the slatted speaker, the made-up brand Lumatone, the splayed legs and the rabbit-ear aerial swaying. Round the channel dial are the channel names (WTHR, NEWS, TOON, QUIZ) and the one that is on is in red. The set comes on: a point of light, then a line opening from the middle, and the weather appears, with the channel name typed out in green at the top right. On a click on the dial, or on the screen, the dial winds back a touch and clunks into the next notch with a bounce, the cabinet jolts and the aerial trembles. The picture squashes into a horizontal line that turns white, then the line shrinks into a point that goes out, once. A moment of snow comes, with two dark bands rolling down and a bright tear running up; then the new channel opens from the middle, first wide and thin, then tall, with a flash that dies away and a little vertical jump that settles. Every channel has a life of its own: on the weather the sun turns, the rain falls and the clouds bob; on the news the presenter talks, blinks and nods, the globe behind turns and the headlines crawl along the bottom; in the cartoon Pip, a round chick, bounces and squashes on the ground while Pebble, a stone with eyes, rocks and blinks; on the quiz the rays behind turn, the wave under the title runs, the chosen answer glows on and off and the clock goes round. With a mouse the reflection on the glass follows the pointer and the dial turns a little when you pass over it.

How it works

The cabinet drawn by the script

The television is an SVG that the script writes in two versions: wide, with the controls to the right of the screen, and upright, with the controls underneath, picked with matchMedia('(max-aspect-ratio: 4/5)') and redrawn when the phone turns. The walnut grain is a feTurbulence stretched sideways (baseFrequency 0.004 0.11), turned into dark streaks with feColorMatrix and clipped to the shape of the cabinet. The screen, instead, is an HTML div laid over the drawing with its size in percent (the CSS variables --sx, --sy, --sw, --sh), so the channels stay sharp at any size.

The dial and the aerial

The dial is an SVG group turned with the transform attribute rotate(angle cx cy): an object with rot and hover, animated by GSAP, and a function that rewrites the attribute on every frame. On a click it goes back 7 degrees in 0.09 seconds, then forward 90 with back.out: that is the clunk. The angle always grows (90, 180, 270, 360...), so after the quiz it comes back to the weather turning the same way. The aerial sways with a dying sine: an object with t and w, where w (the amplitude) goes to zero while t runs.

Line, point, dark

All four channels sit in one box, the tube. To switch off, GSAP squashes it down to scaleY 0.012 with power3.in, while a white veil inside the tube comes up: the line turns to light. Then it narrows it to scaleX 0.015 and hides it; in its place is a round div with a white and green radial gradient, which swells for an instant and then shrinks and goes out in half a second. Once: nothing pulses.

The snow

The snow is a 200 by 150 pixel canvas stretched over the screen. On every frame each pixel gets a random grey (written into a Uint32Array over the ImageData, much faster than colour by colour), multiplied by two dark bands made with a sine that moves down with time; a thin strip running up is lighter and shifted sideways, like a tear. The drawing is called by the onUpdate of a GSAP tween on a number from 0 to 1, so the snow has its own length in the timeline and stops by itself.

The new channel and its life

The new channel opens the other way round: the tube starts as a line, widens in 0.2 seconds and then grows tall with back.out, the white veil dies away in steps with keyframes and the content drops from yPercent -16 with elastic.out, the vertical jump of old sets. Each channel has a six-second timeline that repeats, holding all its little movements (the turns are 45 or 60 degrees, where the drawing comes back the same): it starts from the top when the channel comes on and stops when it goes off. The channel name is typed letter by letter, then fades.

Values to change

NameFileDefaultWhat it changes
SPEEDscript.js1The speed of the whole change of channel: 2 is twice as fast, 0.5 half as fast.
COLLAPSEscript.js0.5The seconds the picture takes to squeeze into a line and then into a point.
STATICscript.js0.7The seconds of snow between two channels.
ROLLscript.js1How much the dark bands roll through the snow and the new picture jumps (0 = still).
GLOWscript.js1How bright the line and the last point of light are (0 = they just go dark).
SNAPscript.js1How hard the dial clunks: its bounce, the jolt of the cabinet and the tremble of the aerial.
SCANLINESscript.js0.35How strong the scan lines on the glass are (0 = none).

Try them in the Playground

When to use it

To move from one section of a site to another with a sixties or seventies air: a video maker's portfolio, the site of a film festival or a show, a casting page, a studio's project menu where every project is a channel. It also works as a toy or as a news carousel.

On phones

The same animation with the sizes adapted: on a tall screen the cabinet stands up and the controls go under the screen in a row (channel dial, volume, speaker), so the tube stays almost as wide as the phone. A tap on the dial or the screen changes channel. The reflection that follows the pointer is there only with a mouse.

With reduced motion

Nothing moves or squashes: the dial is already on the new notch, a still frame of snow fades in over the channel, the channel changes underneath and the snow fades away; the channel name fades in and out. The channels stay still, without their movements, and the set fades in at the start.

Pitfalls

  • The channel names to the right and left of the dial, centred on their radius, landed on top of the long ticks: at the sides the text starts (or ends) just past the ticks, with text-anchor start and end.
  • The recording of the preview video stops when the picture comes back the same: the little animations of each channel sit in a single six-second timeline that repeats, with turns of 45 or 60 degrees (where the sun and the rays look identical again) and the news strip crawling exactly one copy in six seconds.
  • The news strip has to be measured after the typeface has arrived (document.fonts.ready): measured earlier, with the fallback font, the two copies overlapped or left a gap.
  • Turns inside a group already moved with translate are done with the transform attribute rotate(angle cx cy) written by an onUpdate: that way the centre is exactly the one in the drawing, with no doubt about which coordinate system svgOrigin uses.
  • A click while the channel is changing is not lost and does not start two changes at once: it is queued and starts as soon as the first change ends.

Libraries

GSAP 3.15.0, Bricolage Grotesque (font variabile) 5.3.0, JetBrains Mono (font variabile) 5.3.0.

Compressed with gzip, the three demo files weigh 16 KB; the libraries: GSAP 28 KB.

Testing

Tested on 11 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