← AI

Voice Orb

A glowing orb for a voice assistant, with coloured light swirling inside it. While it listens, soft rings ripple outwards and it swells with the incoming voice; while it speaks, its edge wobbles and pulses with every syllable. Tap it to switch between the two.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-voice-orb to an element with a height. The orb is drawn on a canvas that fills it, centred.
  3. Add data-autoplay to have it take turns listening and speaking to a pretend voice, or drive it from your own audio (see below).

Options

Add any of these attributes to the same element.

data-autoplayAlternate between listening and speaking by itself.
data-stateidle, listening or speaking. Default listening.
data-colorsComma-separated colours for the swirls and glow. Default #8b5cf6,#ec4899,#22d3ee.
data-sizeOrb diameter as a share of the element’s smaller side. Default 0.5.
data-captionShow “Listening…” or “Speaking…” under the orb.

To react to real audio, call animacious.voiceOrb(el, { state: 'speaking', level: 0.7 }) with a 0–1 level each frame (from a Web Audio AnalyserNode, say); it stops faking a voice once you feed it. The orb glows best on a dark background.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Voice Orb: a glowing orb for a voice assistant. Coloured light swirls
    // inside it; while listening, soft rings ripple outwards and it swells with
    // the incoming voice, and while speaking its edge wobbles and pulses with
    // every syllable. With data-autoplay it takes turns listening and speaking
    // to a pretend voice. Drawn on a canvas, paused when off screen.
    // Usage: <div data-voice-orb data-autoplay style="height:320px"></div>
    // Optional: data-autoplay (alternate listening and speaking by itself),
    // data-state ("idle", "listening" or "speaking"; default "listening"),
    // data-colors (comma-separated, default "#8b5cf6,#ec4899,#22d3ee"),
    // data-size (orb diameter as a fraction of the smaller side, default 0.5),
    // data-caption (show "Listening…" / "Speaking…" under the orb). Tapping
    // the orb flips between listening and speaking.
    // Script use: animacious.voiceOrb(el, { state: 'speaking', level: 0.8 })
    // switches state, or feeds your own 0–1 audio level (it then stops faking one).
    var DEFAULTS = { colors: '#8b5cf6,#ec4899,#22d3ee', size: 0.5, state: 'listening' };

    var css =
      '[data-voice-orb]{position:relative}' +
      '.vor-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none}' +
      '.vor-cap{position:absolute;left:0;right:0;bottom:8%;text-align:center;font-size:14px;font-weight:600;' +
      'letter-spacing:.01em;opacity:.75;transition:opacity .3s}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function rgb(hex) {
      var h = hex.trim().replace('#', '');
      if (h.length === 3) h = h.replace(/./g, '$&$&');
      var n = parseInt(h, 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }

    // A pretend voice: syllables of varying loudness, gaps between words and
    // longer pauses between phrases. Returns a target level each time it's asked.
    function voice() {
      var target = 0, until = 0;
      return function (t) {
        if (t > until) {
          var r = Math.random();
          if (r < 0.12) { target = 0.03; until = t + 380 + Math.random() * 500; }
          else if (r < 0.32) { target = 0.08; until = t + 90 + Math.random() * 120; }
          else { target = 0.35 + Math.random() * 0.65; until = t + 80 + Math.random() * 160; }
        }
        return target;
      };
    }

    function build(el) {
      var d = el.dataset;
      var cols = (d.colors || DEFAULTS.colors).split(',').map(rgb);
      while (cols.length < 3) cols.push(cols[cols.length - 1]);
      var size = +d.size || DEFAULTS.size;
      var canvas = document.createElement('canvas');
      canvas.className = 'vor-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var cap = null;
      if (d.caption !== undefined) {
        cap = document.createElement('div');
        cap.className = 'vor-cap';
        cap.setAttribute('aria-live', 'polite');
        el.appendChild(cap);
      }
      var ctx = canvas.getContext('2d');
      var w = 0, h = 0, dpr = 1, t = 0, last = 0, raf = 0, visible = false;
      var level = 0, swell = 0, fed = null, rings = [], nextRing = 0;
      var fake = voice();
      var st = { state: d.state || DEFAULTS.state, switchAt: 0 };

      function setState(s) {
        st.state = s;
        if (cap) cap.textContent = s === 'listening' ? 'Listening…' : s === 'speaking' ? 'Speaking…' : '';
      }
      setState(st.state);

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth; h = el.clientHeight;
        canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
        draw();
      }

      // The orb's outline: a circle nudged by a few slow waves, more when loud.
      function blob(cx, cy, r, wob) {
        ctx.beginPath();
        for (var i = 0; i <= 72; i++) {
          var a = (i / 72) * Math.PI * 2;
          var k = 1 + wob * (Math.sin(a * 3 + t * 0.0021) * 0.5 + Math.sin(a * 5 - t * 0.0033) * 0.3 + Math.sin(a * 2 + t * 0.0013) * 0.2);
          var x = cx + Math.cos(a) * r * k, y = cy + Math.sin(a) * r * k;
          if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
        }
        ctx.closePath();
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        if (!w || !h) return;
        var cx = w / 2, cy = h / 2 - (cap ? 10 : 0);
        var base = Math.min(w, h) * size / 2;
        var r = base * (1 + swell * 0.16 + Math.sin(t * 0.0016) * 0.015);
        var speaking = st.state === 'speaking';
        var wob = 0.012 + (speaking ? swell * 0.07 : swell * 0.025);

        // Ripples.
        for (var i = 0; i < rings.length; i++) {
          var g = rings[i], p = g.age / 1800;
          ctx.beginPath();
          ctx.arc(cx, cy, r * (1 + p * 0.75), 0, Math.PI * 2);
          ctx.strokeStyle = rgba(cols[i % 3], (1 - p) * 0.5 * g.power);
          ctx.lineWidth = 2 + (1 - p) * 4;
          ctx.stroke();
        }

        // Outer glow.
        var halo = ctx.createRadialGradient(cx, cy, r * 0.6, cx, cy, r * (1.9 + swell * 0.5));
        halo.addColorStop(0, rgba(cols[0], 0.45 + swell * 0.25));
        halo.addColorStop(0.45, rgba(cols[1], 0.14 + swell * 0.1));
        halo.addColorStop(1, rgba(cols[1], 0));
        ctx.fillStyle = halo;
        ctx.fillRect(0, 0, w, h);

        // The orb body: a deep base with three colour clouds orbiting inside.
        ctx.save();
        blob(cx, cy, r, wob);
        ctx.clip();
        var body = ctx.createRadialGradient(cx - r * 0.3, cy - r * 0.35, r * 0.1, cx, cy, r * 1.1);
        body.addColorStop(0, rgba(cols[0], 1));
        body.addColorStop(1, rgba([Math.round(cols[0][0] * 0.3), Math.round(cols[0][1] * 0.3), Math.round(cols[0][2] * 0.4)], 1));
        ctx.fillStyle = body;
        ctx.fillRect(cx - r * 1.3, cy - r * 1.3, r * 2.6, r * 2.6);
        ctx.globalCompositeOperation = 'lighter';
        var spin = 0.0004 + swell * 0.0012;
        for (var j = 0; j < 3; j++) {
          var a = t * spin * (j === 1 ? -1.3 : 1) + j * 2.1;
          var ox = cx + Math.cos(a) * r * 0.45, oy = cy + Math.sin(a * 1.2) * r * 0.45;
          var cloud = ctx.createRadialGradient(ox, oy, 0, ox, oy, r * 0.95);
          cloud.addColorStop(0, rgba(cols[j], 0.75));
          cloud.addColorStop(1, rgba(cols[j], 0));
          ctx.fillStyle = cloud;
          ctx.fillRect(cx - r * 1.3, cy - r * 1.3, r * 2.6, r * 2.6);
        }
        ctx.globalCompositeOperation = 'source-over';
        // A glassy highlight and a soft rim.
        var hi = ctx.createRadialGradient(cx - r * 0.35, cy - r * 0.45, 0, cx - r * 0.35, cy - r * 0.45, r * 0.8);
        hi.addColorStop(0, 'rgba(255,255,255,.55)');
        hi.addColorStop(1, 'rgba(255,255,255,0)');
        ctx.fillStyle = hi;
        ctx.fillRect(cx - r * 1.3, cy - r * 1.3, r * 2.6, r * 2.6);
        ctx.restore();
        blob(cx, cy, r, wob);
        ctx.strokeStyle = 'rgba(255,255,255,' + (0.18 + swell * 0.3) + ')';
        ctx.lineWidth = 1.5;
        ctx.stroke();
      }

      function frame(now) {
        raf = 0;
        if (!visible) return;
        var dt = last ? Math.min(50, now - last) : 16;
        last = now; t += dt;
        if (d.autoplay !== undefined && d.autoplay !== 'false' && fed === null) {
          if (!st.switchAt) st.switchAt = t + 4200;
          if (t > st.switchAt) {
            setState(st.state === 'listening' ? 'speaking' : 'listening');
            st.switchAt = t + (st.state === 'speaking' ? 3800 : 4200);
          }
        }
        var target = fed !== null ? fed : st.state === 'idle' ? 0 : fake(t) * (st.state === 'listening' ? 0.75 : 1);
        level += (target - level) * Math.min(1, dt / 70);
        swell += (level - swell) * Math.min(1, dt / 120);
        if (st.state === 'listening' && t > nextRing) {
          rings.push({ age: 0, power: 0.4 + swell });
          nextRing = t + 700 + (1 - swell) * 500;
        }
        for (var i = rings.length - 1; i >= 0; i--) {
          rings[i].age += dt;
          if (rings[i].age > 1800) rings.splice(i, 1);
        }
        draw();
        raf = requestAnimationFrame(frame);
      }

      function start() { if (!raf && !reduced()) { last = 0; raf = requestAnimationFrame(frame); } }

      el._vor = {
        set: function (o) {
          if (o.state) setState(o.state);
          if (o.level !== undefined) fed = Math.max(0, Math.min(1, +o.level));
        }
      };

      // A tap or click flips between listening and speaking (and holds off the
      // autoplay for a while).
      el.addEventListener('pointerdown', function () {
        if (fed !== null) return;
        setState(st.state === 'listening' ? 'speaking' : 'listening');
        st.switchAt = t + 9000;
      });

      new ResizeObserver(resize).observe(el);
      resize();
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) start();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-voice-orb]').forEach(function (el) {
        if (el._vor) return;
        build(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.voiceOrb = function (el, opts) {
      if (!el._vor) build(el);
      el._vor.set(opts || {});
    };

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-voice-orb data-autoplay style="height: 320px; background: #0a0618"></div>