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
- Paste the snippet just before
</head>. - 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-color | Colour of the antenna, chest light and joints. Default #7c3aed. |
data-eyes | Colour of the eyes and smile. Default #67e8f9. |
data-duration | Time 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.
<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><div data-waving-robot></div>
<h2>Hi, I’m your assistant</h2>
<!-- green eyes, waving more often: -->
<div data-waving-robot data-eyes="#4ade80" data-duration="3200"></div>