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
- Paste the snippet just before
</head>. - Add
data-voice-orbto an element with a height. The orb is drawn on a canvas that fills it, centred. - Add
data-autoplayto 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-autoplay | Alternate between listening and speaking by itself. |
data-state | idle, listening or speaking. Default listening. |
data-colors | Comma-separated colours for the swirls and glow. Default #8b5cf6,#ec4899,#22d3ee. |
data-size | Orb diameter as a share of the element’s smaller side. Default 0.5. |
data-caption | Show “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.
<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><div data-voice-orb data-autoplay style="height: 320px; background: #0a0618"></div>