← Illustrations

Waving Robot

A friendly robot hovers gently on the spot, blinking its screen eyes while its antenna glows and its chest lights flicker. Every few seconds it lifts an arm, waves hello with a bend at the elbow and tilts its head with a happy squint.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-waving-robot> where the scene should go. It fills the element with an SVG scene.

Options

Add any of these attributes to the same element.

data-colorColour of the antenna, chest light and joints. Default #7c3aed.
data-eyesColour of the eyes and smile. Default #67e8f9.
data-durationTime from one wave to the next. Default 4400 ms.

It is 8:5 on a transparent background by default. It pauses when off screen. With reduced motion it’s a still picture.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Waving Robot: a friendly robot bobs on the spot, blinking its screen
    // eyes. Every few seconds it raises an arm, waves hello with a bend at
    // the elbow and tilts its head, then lowers it again. Drawn as SVG that
    // fills the element, and paused when off screen.
    // Usage: <div data-waving-robot></div>
    // It is 8:5 by default on a transparent background.
    // Optional: data-color (antenna, chest and joints, default #7c3aed),
    // data-eyes (colour of the eyes and smile, default #67e8f9),
    // data-duration (time between waves in ms, default 4400).
    var DEFAULTS = { color: '#7c3aed', eyes: '#67e8f9', duration: 4400 };
    var uid = 0;

    var css =
      '[data-waving-robot]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
      '.wrb-svg{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
    function ease(v) { v = clamp(v); return v < 0.5 ? 4 * v * v * v : 1 - Math.pow(-2 * v + 2, 3) / 2; }

    // An arm: upper arm and forearm, each pointing down at rotation 0.
    function arm(cls, acc) {
      return '<g class="' + cls + '"><rect x="-5" y="-4" width="10" height="27" rx="5" fill="#cbd5e1"/>' +
        '<g class="' + cls + '-fore" transform="translate(0 22)"><circle r="5.5" fill="' + acc + '"/>' +
        '<rect x="-4.5" y="0" width="9" height="22" rx="4.5" fill="#e2e8f0"/>' +
        '<path d="M-7 22A7 7 0 1 0 7 22L3.5 22A3.5 3.5 0 1 1 -3.5 22Z" fill="#94a3b8"/></g></g>';
    }

    function build(el) {
      var d = el.dataset;
      var n = ++uid;
      var acc = d.color || DEFAULTS.color;
      var eyes = d.eyes || DEFAULTS.eyes;
      var cycle = +d.duration || DEFAULTS.duration;
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A robot blinking and waving');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      var g = 'wrb' + n;
      el.innerHTML =
        '<svg class="wrb-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<defs><linearGradient id="' + g + '-metal" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#f8fafc"/><stop offset="1" stop-color="#cbd5e1"/></linearGradient>' +
        '<radialGradient id="' + g + '-glow"><stop offset="0" stop-color="' + acc + '" stop-opacity=".55"/><stop offset="1" stop-color="' + acc + '" stop-opacity="0"/></radialGradient></defs>' +
        '<ellipse class="wrb-shadow" cx="200" cy="236" rx="46" ry="6" fill="#1e1b2e" opacity=".1"/>' +
        '<g class="wrb-bot">' +
          // Legs and feet.
          '<rect x="181" y="192" width="12" height="30" rx="5" fill="#cbd5e1"/><rect x="207" y="192" width="12" height="30" rx="5" fill="#cbd5e1"/>' +
          '<rect x="173" y="220" width="26" height="11" rx="5.5" fill="#94a3b8"/><rect x="201" y="220" width="26" height="11" rx="5.5" fill="#94a3b8"/>' +
          // Arms sit behind the body.
          '<g transform="translate(168 140)">' + arm('wrb-larm', acc) + '</g>' +
          '<g transform="translate(232 140)">' + arm('wrb-rarm', acc) + '</g>' +
          // Body and chest panel.
          '<rect x="164" y="128" width="72" height="72" rx="14" fill="url(#' + g + '-metal)" stroke="#94a3b8" stroke-width="1.5"/>' +
          '<rect x="178" y="142" width="44" height="30" rx="6" fill="#1e1b2e"/>' +
          '<circle class="wrb-led" cx="189" cy="152" r="3.2" fill="#22c55e"/><circle class="wrb-led" cx="200" cy="152" r="3.2" fill="#f59e0b"/><circle class="wrb-led" cx="211" cy="152" r="3.2" fill="' + acc + '"/>' +
          '<rect x="185" y="161" width="30" height="4" rx="2" fill="#334155"/><rect class="wrb-meter" x="185" y="161" width="18" height="4" rx="2" fill="' + eyes + '"/>' +
          '<rect x="186" y="182" width="28" height="4" rx="2" fill="#94a3b8" opacity=".6"/>' +
          // Neck and head.
          '<rect x="193" y="116" width="14" height="14" rx="3" fill="#94a3b8"/>' +
          '<g class="wrb-head">' +
            '<circle class="wrb-halo" cx="200" cy="38" r="14" fill="url(#' + g + '-glow)"/>' +
            '<path d="M200 60L200 42" stroke="#94a3b8" stroke-width="3" stroke-linecap="round"/>' +
            '<circle class="wrb-bulb" cx="200" cy="38" r="5.5" fill="' + acc + '"/>' +
            '<rect x="152" y="84" width="10" height="20" rx="4" fill="' + acc + '"/><rect x="238" y="84" width="10" height="20" rx="4" fill="' + acc + '"/>' +
            '<rect x="158" y="58" width="84" height="62" rx="18" fill="url(#' + g + '-metal)" stroke="#94a3b8" stroke-width="1.5"/>' +
            '<rect x="168" y="68" width="64" height="42" rx="12" fill="#1e1b2e"/>' +
            '<path d="M174 76Q176 72 182 72L196 72" stroke="#fff" stroke-opacity=".18" stroke-width="2.5" stroke-linecap="round" fill="none"/>' +
            '<g fill="' + eyes + '"><rect class="wrb-eye" x="180" y="80" width="11" height="13" rx="5.5"/><rect class="wrb-eye" x="209" y="80" width="11" height="13" rx="5.5"/></g>' +
            '<path class="wrb-mouth" fill="none" stroke="' + eyes + '" stroke-width="2.6" stroke-linecap="round"/>' +
            '<circle cx="177" cy="100" r="3" fill="#f472b6" opacity=".45"/><circle cx="223" cy="100" r="3" fill="#f472b6" opacity=".45"/>' +
          '</g>' +
        '</g></svg>';

      var svg = el.firstChild;
      var bot = svg.querySelector('.wrb-bot');
      var head = svg.querySelector('.wrb-head');
      var larm = svg.querySelector('.wrb-larm');
      var rarm = svg.querySelector('.wrb-rarm');
      var lfore = svg.querySelector('.wrb-larm-fore');
      var rfore = svg.querySelector('.wrb-rarm-fore');
      var eyeEls = svg.querySelectorAll('.wrb-eye');
      var mouth = svg.querySelector('.wrb-mouth');
      var bulb = svg.querySelector('.wrb-bulb');
      var halo = svg.querySelector('.wrb-halo');
      var leds = svg.querySelectorAll('.wrb-led');
      var meter = svg.querySelector('.wrb-meter');
      var shadow = svg.querySelector('.wrb-shadow');

      // Blink moments within a cycle, as fractions (the second is a double blink).
      var BLINKS = [0.08, 0.62, 0.68];

      function frame(t) {
        var s = t / 1000;
        var c = (t % cycle) / cycle;
        var ms = c * cycle;
        // Raise for 500 ms, wave until 2700 ms, lower by 3200 ms.
        var up = ease(ms / 500) * (1 - ease((ms - 2700) / 500));
        var waving = clamp((ms - 450) / 200) * (1 - clamp((ms - 2600) / 200));
        var wave = Math.sin((ms - 500) / 1000 * Math.PI * 2 * 1.6) * 26 * waving;

        var bob = Math.sin(s * 2.2) * 2;
        bot.setAttribute('transform', 'translate(0 ' + bob.toFixed(2) + ')');
        shadow.setAttribute('rx', (46 - bob).toFixed(1));

        rarm.setAttribute('transform', 'rotate(' + (-10 - 118 * up).toFixed(2) + ')');
        rfore.setAttribute('transform', 'translate(0 22) rotate(' + (10 - 46 * up + wave).toFixed(2) + ')');
        larm.setAttribute('transform', 'rotate(' + (10 + 3 * Math.sin(s * 2.2 + 1)).toFixed(2) + ')');
        lfore.setAttribute('transform', 'translate(0 22) rotate(' + (-10 + 3 * Math.sin(s * 2.2 + 1.5)).toFixed(2) + ')');
        head.setAttribute('transform', 'rotate(' + (6 * up + 1.2 * Math.sin(s * 1.1)).toFixed(2) + ' 200 120)');

        // Blinks: eyes squash flat for a moment.
        var sy = 1;
        BLINKS.forEach(function (b) {
          var dt = Math.abs(ms - b * cycle);
          if (dt < 90) sy = Math.min(sy, 0.12 + 0.88 * dt / 90);
        });
        // Eyes squint happily while waving.
        sy = Math.min(sy, 1 - 0.35 * up);
        for (var i = 0; i < eyeEls.length; i++) eyeEls[i].setAttribute('transform', 'translate(0 ' + (86.5 * (1 - sy)).toFixed(2) + ') scale(1 ' + sy.toFixed(3) + ')');
        var smile = 3 + 5 * up;
        mouth.setAttribute('d', 'M190 99Q200 ' + (99 + smile).toFixed(1) + ' 210 99');

        var pulse = 0.5 + 0.5 * Math.sin(s * 3.4);
        bulb.setAttribute('opacity', (0.65 + 0.35 * pulse).toFixed(2));
        halo.setAttribute('r', (10 + 8 * pulse).toFixed(1));
        for (var k = 0; k < leds.length; k++) leds[k].setAttribute('opacity', Math.sin(s * (2.3 + k * 1.7) + k * 2) > -0.3 ? 1 : 0.25);
        meter.setAttribute('width', (8 + 22 * (0.5 + 0.5 * Math.sin(s * 1.6))).toFixed(1));
      }

      if (reduced()) { frame(1600); return; }

      var start = 0, timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () { frame(performance.now() - start); }
      });
      var pausedAt = 0;
      frame(0);
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { start = performance.now() - pausedAt; timer.play(); }
        else { pausedAt = performance.now() - start; timer.pause(); }
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-waving-robot]').forEach(function (el) {
        if (el._wrb) return;   // idempotent
        el._wrb = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-waving-robot></div>
<h2>Hi, I&rsquo;m your assistant</h2>

<!-- green eyes, waving more often: -->
<div data-waving-robot data-eyes="#4ade80" data-duration="3200"></div>