Swinging Badge
A name badge hangs on a lanyard and swings under its own weight. Grab it and fling it about: the strap bends and whips like real fabric, and when you let go the badge swings back and slowly settles.
MotionConf 2026
AL
Ada Lovelace
Speaker
How to use it
- Paste the snippet just before
</head>. - Add
data-swinging-badgeto a container whose first child is the badge. Give the badge a width and the container a height: the lanyard hangs from the middle of its top edge.
Options
Add any of these attributes to the same element.
data-length | Length of the lanyard. Default: long enough to fill the container. |
data-color | Colour of the strap. Default #6a0dad. |
data-autoplay | Give it a little nudge whenever it has settled, after this many milliseconds. Default 3200; false lets it hang still. |
The strap is a small rope simulation drawn in SVG, and the badge stays inside its container. It runs only while on screen. With reduced motion it just hangs straight.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Swinging Badge: a name badge hangs on a lanyard from the top of its
// container and swings under gravity. Grab it and drag it round (mouse or
// finger), let go and it swings back and slowly settles. The strap is a
// little rope simulation, so it bends and whips as the badge moves.
// Usage:
// <div data-swinging-badge style="height: 420px">
// <div class="badge">…</div>
// </div>
// The container's first child is the badge; give it a width and the
// container a height.
// Optional: data-length (lanyard length, px, default: fits the container),
// data-color (strap colour, default #6a0dad), data-autoplay (give it a
// nudge every so often, ms, default 3200, while nobody is holding it).
var DEFAULTS = { color: '#6a0dad', autoplay: 3200 };
var SEGS = 12;
var css =
'.swb{position:relative;overflow:visible;touch-action:pan-y}' +
'.swb-rope{position:absolute;left:0;top:0;width:100%;height:100%;overflow:visible;pointer-events:none}' +
'.swb-badge{position:absolute!important;left:0;top:0;margin:0!important;transform-origin:50% 0;cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none;will-change:transform}' +
'.swb-badge.swb-drag{cursor:grabbing}' +
'.swb-clip{position:absolute;left:50%;top:-12px;width:16px;height:16px;margin-left:-8px;border:3px solid #9ca3af;border-bottom:0;border-radius:8px 8px 0 0;box-sizing:border-box}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var badge = el.firstElementChild;
if (!badge) return;
el.classList.add('swb');
badge.classList.add('swb-badge');
var clip = document.createElement('span');
clip.className = 'swb-clip';
clip.setAttribute('aria-hidden', 'true');
badge.appendChild(clip);
var NS = 'http://www.w3.org/2000/svg';
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'swb-rope');
svg.setAttribute('aria-hidden', 'true');
var color = d.color || DEFAULTS.color;
var strap = document.createElementNS(NS, 'path');
strap.setAttribute('fill', 'none');
strap.setAttribute('stroke', color);
strap.setAttribute('stroke-width', '12');
strap.setAttribute('stroke-linecap', 'round');
strap.setAttribute('stroke-linejoin', 'round');
var stitch = document.createElementNS(NS, 'path');
stitch.setAttribute('fill', 'none');
stitch.setAttribute('stroke', '#fff');
stitch.setAttribute('stroke-opacity', '.55');
stitch.setAttribute('stroke-width', '1.5');
stitch.setAttribute('stroke-dasharray', '5 5');
var ring = document.createElementNS(NS, 'circle');
ring.setAttribute('r', '5');
ring.setAttribute('fill', 'none');
ring.setAttribute('stroke', '#9ca3af');
ring.setAttribute('stroke-width', '3');
svg.appendChild(strap);
svg.appendChild(stitch);
svg.appendChild(ring);
el.insertBefore(svg, badge);
var s = { pts: [], seg: 10, anchor: { x: 0, y: 0 }, held: null, raf: 0, visible: false, still: 0, lastNudge: 0 };
el._swbS = s;
function measure() {
var W = el.clientWidth, H = el.clientHeight, bh = badge.offsetHeight;
var len = d.length ? +d.length : Math.max(40, H - bh - 30);
s.W = W; s.H = H; s.bw = badge.offsetWidth; s.bh = bh;
s.anchor.x = W / 2; s.anchor.y = -6;
s.seg = len / SEGS;
}
measure();
// Start hanging, pulled out to one side so it swings in.
for (var i = 0; i <= SEGS; i++) {
var k = i / SEGS;
var p = { x: s.anchor.x + (reduced() ? 0 : k * s.seg * SEGS * 0.55), y: s.anchor.y + k * s.seg * SEGS * (reduced() ? 1 : 0.84) };
p.px = p.x; p.py = p.y;
s.pts.push(p);
}
function step() {
var pts = s.pts, g = 0.55, damp = 0.985;
for (var i = 1; i < pts.length; i++) {
var p = pts[i];
if (s.held && i === pts.length - 1) continue;
var vx = (p.x - p.px) * damp, vy = (p.y - p.py) * damp;
p.px = p.x; p.py = p.y;
p.x += vx; p.y += vy + g;
}
if (s.held) {
var end = pts[pts.length - 1];
end.px = end.x; end.py = end.y;
end.x = s.held.x; end.y = s.held.y;
}
pts[0].x = s.anchor.x; pts[0].y = s.anchor.y;
for (var it = 0; it < 14; it++) {
for (var j = 0; j < pts.length - 1; j++) {
var a = pts[j], b = pts[j + 1];
var dx = b.x - a.x, dy = b.y - a.y, dist = Math.sqrt(dx * dx + dy * dy) || 0.001;
var diff = (dist - s.seg) / dist;
// Inverse masses: the anchor and a held badge don't give; the badge is heavy.
var ia = j === 0 ? 0 : 1, ib = j + 1 === pts.length - 1 ? (s.held ? 0 : 0.25) : 1;
if (ia + ib === 0) continue;
var wa = ia / (ia + ib), wb = ib / (ia + ib);
a.x += dx * diff * wa; a.y += dy * diff * wa;
b.x -= dx * diff * wb; b.y -= dy * diff * wb;
}
pts[0].x = s.anchor.x; pts[0].y = s.anchor.y;
// Keep the badge inside the box.
var e = pts[pts.length - 1];
e.x = Math.max(s.bw / 2, Math.min(s.W - s.bw / 2, e.x));
e.y = Math.min(s.H - s.bh, e.y);
}
}
function render() {
var pts = s.pts, n = pts.length;
var dpath = 'M' + pts[0].x.toFixed(1) + ' ' + pts[0].y.toFixed(1);
for (var i = 1; i < n - 1; i++) {
var mx = (pts[i].x + pts[i + 1].x) / 2, my = (pts[i].y + pts[i + 1].y) / 2;
dpath += ' Q' + pts[i].x.toFixed(1) + ' ' + pts[i].y.toFixed(1) + ' ' + mx.toFixed(1) + ' ' + my.toFixed(1);
}
var e = pts[n - 1], q = pts[n - 3];
dpath += ' L' + e.x.toFixed(1) + ' ' + (e.y - 14).toFixed(1);
strap.setAttribute('d', dpath);
stitch.setAttribute('d', dpath);
ring.setAttribute('cx', e.x.toFixed(1));
ring.setAttribute('cy', (e.y - 14).toFixed(1));
// The badge hangs along the last stretch of strap.
var ang = Math.atan2(e.x - q.x, e.y - q.y) * -180 / Math.PI;
badge.style.transform = 'translate(' + (e.x - s.bw / 2).toFixed(1) + 'px,' + e.y.toFixed(1) + 'px) rotate(' + ang.toFixed(2) + 'deg)';
}
function frame(t) {
s.raf = 0;
step();
render();
// A gentle nudge now and then, so the demo never hangs dead still.
var e = s.pts[s.pts.length - 1];
var speed = Math.abs(e.x - e.px) + Math.abs(e.y - e.py);
var every = d.autoplay === 'false' ? 0 : +d.autoplay || DEFAULTS.autoplay;
if (every && !s.held && speed < 0.4 && t - s.lastNudge > every) {
s.lastNudge = t;
e.px -= (Math.random() < 0.5 ? -1 : 1) * (6 + Math.random() * 5);
}
if (s.visible) s.raf = requestAnimationFrame(frame);
}
if (reduced()) {
for (var r = 0; r < 60; r++) step();
render();
return;
}
function local(ev) {
var rc = el.getBoundingClientRect();
return { x: ev.clientX - rc.left, y: ev.clientY - rc.top };
}
var grab = null;
badge.addEventListener('pointerdown', function (ev) {
if (ev.button > 0) return;
var p = local(ev), e = s.pts[s.pts.length - 1];
grab = { dx: p.x - e.x, dy: p.y - e.y };
s.held = { x: e.x, y: e.y };
badge.classList.add('swb-drag');
badge.setPointerCapture(ev.pointerId);
ev.preventDefault();
});
badge.addEventListener('pointermove', function (ev) {
if (!grab) return;
var p = local(ev);
var hx = p.x - grab.dx - s.anchor.x, hy = p.y - grab.dy - s.anchor.y;
// The lanyard doesn't stretch: hold the badge at most a strap's length away.
var L = s.seg * SEGS, dist = Math.sqrt(hx * hx + hy * hy);
if (dist > L) { hx *= L / dist; hy *= L / dist; }
s.held = { x: s.anchor.x + hx, y: s.anchor.y + hy };
});
function drop() {
if (!grab) return;
grab = null;
s.held = null;
s.lastNudge = performance.now() + 2000;
badge.classList.remove('swb-drag');
}
badge.addEventListener('pointerup', drop);
badge.addEventListener('pointercancel', drop);
new ResizeObserver(function () {
var oldX = s.anchor.x;
measure();
var dx = s.anchor.x - oldX;
s.pts.forEach(function (p) { p.x += dx; p.px += dx; });
}).observe(el);
new IntersectionObserver(function (en) {
s.visible = en[0].isIntersecting;
if (s.visible && !s.raf) s.raf = requestAnimationFrame(frame);
}).observe(el);
render();
}
function init() {
document.querySelectorAll('[data-swinging-badge]').forEach(function (el) {
if (el._swb) return;
el._swb = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-swinging-badge style="height: 400px">
<div class="badge" style="width: 150px">
<img src="ada.jpg" alt="">
<b>Ada Lovelace</b>
<span>Speaker</span>
</div>
</div>