Skip to content
animorio
IT
All components

The pairing bulb

Loading, SVG, PhysicsCSS only, GSAPAdvancedCompressed weight: Demo 14 KBGSAP 28 KBFreeDownload the zip

Pairing a smart bulb in a home app, and the progress bar is the bulb itself, seen from the side: as the pairing goes on its tungsten coil warms through the real colours of a hot body, from dull red to orange to a warm white, the glass fills with light and the coil trembles a little on its wires. If the link drops the coil flickers and cools; once paired the bulb comes on in full, with a flash, and its light spreads over the panel.

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>The pairing bulb</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/archivo@5.3.0/wdth.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage">
    <!-- the pairing bulb: a smart home app pairing a new filament bulb, and the bulb is the progress. Seen from the side,
         its tungsten coil warms through the real colours of a hot body as the pairing goes on, from a dull red to
         orange to a warm white, while the glass fills with light and the coil trembles on its wires. If the link
         drops, the coil flickers and cools; once paired, the bulb comes on in full and its light spreads over the
         panel. The coil, the halo and the light are drawn by script.js -->
    <section class="pair" aria-labelledby="pair-title" data-pair>
      <div class="pair__light" aria-hidden="true" data-light></div>
      <header class="pair__head">
        <div class="pair__words">
          <h2 class="pair__title" id="pair-title"><span class="pair__swap" data-title>Living room lamp</span></h2>
          <p class="pair__meta"><span class="pair__swap" data-meta>New bulb nearby · Not paired</span></p>
        </div>
        <p class="pair__kelvin" aria-hidden="true"><span data-kelvin>Off</span></p>
      </header>

      <div class="pair__bulb" role="progressbar" aria-labelledby="pair-title" aria-valuemin="0" aria-valuemax="100"
           aria-valuenow="0" aria-valuetext="Not paired" data-bar>
        <svg class="pair__art" viewBox="10 30 180 232" aria-hidden="true" data-art>
          <defs>
            <clipPath id="pb-glass-clip"><path d="M75 188C75 166 38 144 38 98A62 62 0 0 1 162 98C162 144 125 166 125 188Z"/></clipPath>
            <radialGradient id="pb-halo-wide" gradientUnits="userSpaceOnUse" cx="100" cy="96" r="84">
              <stop offset="0" stop-color="#ffb070" stop-opacity="0" data-stop="wide0"/>
              <stop offset="0.38" stop-color="#ffb070" stop-opacity="0" data-stop="wide1"/>
              <stop offset="1" stop-color="#ffb070" stop-opacity="0" data-stop="wide2"/>
            </radialGradient>
            <radialGradient id="pb-halo-core" gradientUnits="userSpaceOnUse" cx="100" cy="96" r="26">
              <stop offset="0" stop-color="#fff4e6" stop-opacity="0" data-stop="core0"/>
              <stop offset="1" stop-color="#fff4e6" stop-opacity="0" data-stop="core1"/>
            </radialGradient>
            <radialGradient id="pb-halo-mid" gradientUnits="userSpaceOnUse" cx="100" cy="96" r="36">
              <stop offset="0" stop-color="#ffb070" stop-opacity="0" data-stop="mid0"/>
              <stop offset="1" stop-color="#ffb070" stop-opacity="0" data-stop="mid1"/>
            </radialGradient>
            <linearGradient id="pb-glass-edge" x1="0" y1="0" x2="1" y2="0">
              <stop offset="0" stop-color="#ffffff" stop-opacity="0.34"/>
              <stop offset="0.5" stop-color="#ffffff" stop-opacity="0.08"/>
              <stop offset="1" stop-color="#ffffff" stop-opacity="0.22"/>
            </linearGradient>
            <linearGradient id="pb-glass-fill" x1="0" y1="0" x2="1" y2="1">
              <stop offset="0" stop-color="#ffffff" stop-opacity="0.07"/>
              <stop offset="0.55" stop-color="#ffffff" stop-opacity="0.015"/>
              <stop offset="1" stop-color="#ffffff" stop-opacity="0.05"/>
            </linearGradient>
            <linearGradient id="pb-metal" x1="0" y1="0" x2="1" y2="0">
              <stop offset="0" style="stop-color: var(--metal-lo)"/>
              <stop offset="0.2" style="stop-color: var(--metal-mid)"/>
              <stop offset="0.36" style="stop-color: var(--metal-hi)"/>
              <stop offset="0.62" style="stop-color: var(--metal-mid)"/>
              <stop offset="1" style="stop-color: var(--metal-lo)"/>
            </linearGradient>
            <linearGradient id="pb-stem" x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stop-color="#ffffff" stop-opacity="0.2"/>
              <stop offset="1" stop-color="#ffffff" stop-opacity="0.05"/>
            </linearGradient>
            <linearGradient id="pb-base-lit" x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stop-color="#ffb070" stop-opacity="0.55" data-stop="base0"/>
              <stop offset="1" stop-color="#ffb070" stop-opacity="0" data-stop="base1"/>
            </linearGradient>
            <filter id="pb-bloom" x="-60%" y="-160%" width="220%" height="420%" color-interpolation-filters="sRGB">
              <feGaussianBlur in="SourceGraphic" stdDeviation="1.4" result="near" data-blur="near"/>
              <feGaussianBlur in="SourceGraphic" stdDeviation="5" result="far" data-blur="far"/>
              <feMerge><feMergeNode in="far"/><feMergeNode in="far"/><feMergeNode in="near"/><feMergeNode in="SourceGraphic"/></feMerge>
            </filter>
          </defs>

          <!-- the screw base: a collar, four threads, the black insulator and the contact -->
          <g class="pair__base">
            <path d="M74 186h52v8c0 3-2 5-5 5H79c-3 0-5-2-5-5z" fill="url(#pb-metal)"/>
            <rect x="78" y="198" width="44" height="40" style="fill: var(--metal-lo)"/>
            <g data-threads>
              <rect x="73" y="199" width="54" height="9" rx="4.5" fill="url(#pb-metal)" transform="rotate(-3 100 203.5)"/>
              <rect x="74" y="209" width="52" height="9" rx="4.5" fill="url(#pb-metal)" transform="rotate(-3 100 213.5)"/>
              <rect x="75" y="219" width="50" height="9" rx="4.5" fill="url(#pb-metal)" transform="rotate(-3 100 223.5)"/>
              <rect x="77" y="229" width="46" height="8" rx="4" fill="url(#pb-metal)" transform="rotate(-3 100 233)"/>
            </g>
            <path d="M80 238h40l-4 9H84z" style="fill: var(--insulator)"/>
            <path d="M90 247h20c0 5-4 8-10 8s-10-3-10-8z" fill="url(#pb-metal)"/>
            <path d="M74 186h52v14H74z" fill="url(#pb-base-lit)" style="mix-blend-mode: screen" data-base-lit/>
          </g>

          <!-- the glass and everything inside it -->
          <path d="M75 188C75 166 38 144 38 98A62 62 0 0 1 162 98C162 144 125 166 125 188Z" fill="url(#pb-glass-fill)"/>
          <g clip-path="url(#pb-glass-clip)">
            <rect x="0" y="0" width="200" height="200" fill="url(#pb-halo-wide)"/>
            <rect x="0" y="0" width="200" height="200" fill="url(#pb-halo-mid)"/>
            <rect x="0" y="0" width="200" height="200" fill="url(#pb-halo-core)"/>
          </g>
          <!-- the stem: a glass tube flaring into the neck, with the press that holds the wires -->
          <path d="M84 192C88 184 90 168 91 150Q100 145 109 150C110 168 112 184 116 192Z" fill="url(#pb-stem)"/>
          <path d="M98 150V120" style="stroke: var(--stem)" stroke-width="2.6" stroke-linecap="round" opacity="0.55"/>
          <g class="pair__wires" fill="none" stroke-linecap="round" stroke-linejoin="round" data-wires>
            <path d="M94 156L94 140L74 100L74 93" data-wire="0"/>
            <path d="M106 156L106 140L126 100L126 93" data-wire="1"/>
            <path d="M98 121L100 104" data-wire="2"/>
          </g>
          <g fill="none" stroke-linecap="round" stroke-linejoin="round">
            <path class="pair__coil pair__coil--back" data-coil="back"/>
            <g filter="url(#pb-bloom)" data-glow>
              <path class="pair__coil pair__coil--glow" data-coil="glow"/>
            </g>
            <path class="pair__coil pair__coil--front" data-coil="front"/>
            <path class="pair__coil pair__coil--core" data-coil="core"/>
          </g>
          <!-- the glass's own edge and its two highlights, over everything -->
          <path d="M75 188C75 166 38 144 38 98A62 62 0 0 1 162 98C162 144 125 166 125 188" fill="none" stroke="url(#pb-glass-edge)" stroke-width="1.2"/>
          <path d="M75 188C75 166 38 144 38 98A62 62 0 0 1 162 98C162 144 125 166 125 188" fill="none" stroke="#ffb070" stroke-width="1.3" opacity="0" data-rim/>
          <path d="M50 78C56 60 70 48 86 43" fill="none" stroke="#ffffff" stroke-width="3.2" stroke-linecap="round" opacity="0.26"/>
          <path d="M47 96C47 92 47.5 89 48.5 86" fill="none" stroke="#ffffff" stroke-width="2.4" stroke-linecap="round" opacity="0.2"/>
          <path d="M150 122C146 132 140 140 133 147" fill="none" stroke="#ffffff" stroke-width="1.6" stroke-linecap="round" opacity="0.12"/>
        </svg>
      </div>

      <footer class="pair__foot">
        <div class="pair__readout" aria-hidden="true">
          <p class="pair__pct" data-pct-row>
            <svg class="pair__tick" viewBox="0 0 24 24" data-tick><path d="M5 12.8l4.2 4.2L19 7.2" pathLength="1"/></svg>
            <span class="pair__swap" data-pct>Smart bulb</span>
          </p>
          <p class="pair__sub"><span class="pair__swap" data-sub>E27 · 806 lm · dimmable</span></p>
        </div>
        <button class="pair__btn" type="button" data-action data-autoclic><span class="pair__label" data-label>Pair</span></button>
      </footer>
      <p class="sr" role="status" aria-live="polite" data-live></p>
    </section>
  </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.

31 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 pairing bulb” from Animorio and fit it into my site

What it does

On a black background, alone in the middle, a dark panel with round corners: at the top Living room lamp and New bulb nearby · Not paired, on the right a small label with the coil’s temperature (Off); in the middle a clear glass bulb seen from the side, with its metal screw base, the glass stem, two wires and a grey tungsten coil hung between them; below, Smart bulb with E27 · 806 lm · dimmable and the pale Pair button. Pressed, the button becomes Cancel, pale and outlined, and the app looks for the bulb (Searching for the bulb…): at every search ping the coil blushes faintly and fades. When the bulb answers (Connecting to Home Wi-Fi…) the coil takes on a dull red that stays, and from there it warms with the progress: during the firmware update (Updating firmware 4.2.1, then About 3 s left) it goes from red to orange and on towards a warm white, the glass fills with a halo of its colour, the light starts to fall on the panel round it, the label at the top gives the temperature in kelvin and the coil trembles a little, with a small jolt at every piece of firmware that arrives; below, the percentage climbs. At the end the bulb comes on in full with a short flash, the coil turns almost white, the light spreads over the whole panel, a tick is drawn next to Paired, the line says Connected · Firmware 4.2.1 and below 2,700 K · Full brightness; Pair again switches the bulb off, lets it cool and starts over. One time in two, in the demo, the link drops two thirds of the way: the coil flickers a few times like a bulb with a loose contact, goes out and cools from yellow to red to grey, the line says Lost the connection to the bulb in red and the button becomes Retry. Retry brings the current back, the percentage goes back to the start of the update and the firmware starts again.

How it works

The colour of a hot body, worked out

The coil’s colour is not a gradient picked by hand. For every temperature the script sums Planck’s law, B(λ, T) = 1 ÷ (λ⁵ (e^(c₂ ÷ λT) − 1)), over the whole visible spectrum in steps of 5 nanometres, weighed by the three curves of the CIE 1931 observer (the analytic fit by Wyman, Sloan and Shirley: a few skewed gaussians per curve). Out come X, Y and Z, which a matrix turns into linear sRGB; the strongest channel is brought to 1 and the gamma curve applied. So 900 K gives a deep red, 1500 an orange, 2700 the warm white of a bulb, without any of them written down anywhere. It is worked out once at the start, a table every 10 kelvin.

Brightness on a log scale

The luminance Y of a hot body grows hugely with its temperature: at 1000 K it is a millionth of what it is at 2700. The script takes its logarithm between the first visible red (about 700 K) and the bulb’s colour, the way an eye adapts: that intensity decides how lit the coil is, how far its core goes towards white, the strength of the bloom, of the three halos in the glass and of the light on the panel. The real temperature chases the one wanted with a first order lag, quicker to heat than to cool, so a coil switched off fades from yellow to red to grey.

A coil seen at a slant, drawn again every frame

The coil is a helix along a sagging curve between the two wires: for every point θ = 2πNt, the height is the radius times sin θ and the depth cos θ, which, seen at a slant, moves the point sideways. Points with a positive depth go into the front path, the others into the back one (darker and thinner), and every run starts at the last point of the other, so the turns close without gaps. Over it lies a copy of the whole coil with an SVG bloom filter (two feGaussianBlurs merged with feMerge, widening with the intensity) and a thin core that turns white when the bulb is full. As it heats the tungsten grows longer and the coil sags a little more; the support wire in the middle follows it.

The trembling

The coil trembles like a string held at both ends: two modes up and down (half a wave and a whole one, at 6 and 11.5 swings a second) and one sideways, each a damped spring worked out in steps of 1/480 of a second, driven by noise that grows with the work. Every piece of firmware that arrives gives it a tap and a little more heat; when the link drops, every flicker of the current shakes it.

The halos and the light on the panel

Inside the glass, clipped to its shape, three radial gradients are coloured every frame: a wide one that fills the bulb, a middle one round the coil and an almost white core that lights up only near full power; the edge of the glass and the top of the base take on the colour of the light. Outside, behind the bulb, two radial gradients on the panel: a tight one round the glass and a wide one that, once the bulb is on, spreads over the whole panel on an expo.out curve while a short flash takes the coil to about 3200 K and lets it settle back to 2700.

For those who cannot see it and those who want no motion

The bulb is a role=progressbar with aria-valuenow and an aria-valuetext that names the phase, the panel has aria-busy while it works and an aria-live line says when the bulb is found, connects, loses the link and is paired. The button changes its word (Pair, Cancel, Retry, Pair again) and the pill takes the new width with a little bounce; a click from a script, the library preview’s, never cancels.

Values to change

NameFileDefaultWhat it changes
SECONDSscript.js7How long a pairing takes in the demo, in seconds.
KELVINscript.js2700The colour temperature of the bulb once it is on, in kelvin (2700 is the warm white of a filament bulb).
WARMESTscript.js2250How hot the coil gets just before the pairing ends, in kelvin.
TURNSscript.js15How many turns the tungsten coil has.
TREMBLEscript.js1How much the coil trembles on its wires while it works: 0 not at all.
FLASHscript.js1The flash when the bulb comes on in full: 0 none, 1 a clear one.
FAIL_EVERYscript.js2In the demo, one pairing in how many loses the link (0 never).
FAIL_ATscript.js0.66How far the pairing that fails gets, from 0 to 1 (always during the firmware update).
FIRMWAREscript.js'4.2.1'The firmware version the bulb receives.
NETWORKscript.js'Home Wi-Fi'The network the bulb joins.

Try them in the Playground

When to use it

For pairing or setting up a home device (a bulb, a plug, a thermostat), updating a device’s firmware, or any wait of a few seconds in a product with a warm tone, where the wait can be told as «we are switching it on». The warming colour says how far along things are even without reading the percentage.

On phones

The panel takes the screen’s width less 32 pixels (358 on a 390 screen) and stays 349 tall; the bulb is vector and stays sharp at every density, and the light on the panel centres itself on the bulb again when the window changes size. The button is a pill 38 tall: under a finger it squashes and springs back, and its words widen on the width axis only under the mouse.

With reduced motion

The coil neither trembles nor flickers, and there is no flash: when the link drops the current fades out and the coil cools; once the bulb is on the light on the panel fades in instead of spreading. The coil still warms with the progress, because its colour is the indicator; the new words fade in.

Pitfalls

  • With the brightness taken straight from Planck’s law the coil stayed black until almost 2000 K and then blew up: the luminance at 1000 K is a millionth of that at 2700. The intensity is now the log of the luminance between 700 K and the final colour, and the dull red at the start shows.
  • The glass edge filled with the colour of the light turned the bulb into a wire drawing: there is now a second, coloured edge that lights up only partly over the white one.
  • The radial gradient of the light on the panel, with only three stops, left a visible ring where it ended: with five stops falling off like an exponential the edge disappears.
  • The line with the time left changed every few tenths of a second with the blurred fade, and never read: it now slides only when the kind of line changes, and a new number in the same line just changes.
  • CSS variables written in the SVG’s stop-color and fill attributes do not work: the colours of the metal and the insulator go in the style.

Libraries

GSAP 3.15.0, Archivo (font variabile) 5.3.0.

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

Testing

Tested on 8 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 components