← Cards & images

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.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-swinging-badge to 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-lengthLength of the lanyard. Default: long enough to fill the container.
data-colorColour of the strap. Default #6a0dad.
data-autoplayGive 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.

1 · Snippet
<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>
2 · Your HTML
<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>