← SVG & icons

Logo 3D Spin

A logo flips round in 3D a couple of times, rising as it spins and slowing down, then lands and settles with a little wobble. Its back shows the logo too, the right way round, and it can have a solid edge like a coin, with a shadow that follows the turn.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-logo-3d-spin to your logo: an <img>, an inline <svg> or a styled badge.

Options

Add any of these attributes to the same element.

data-turnsFull turns per spin. Default 2.
data-durationTime for a spin. Default 2200 ms.
data-depthThickness in pixels, for a coin edge. It takes the element’s background colour, a little darker. Default 0.
data-loopSpin again every few seconds, alternating direction.
data-intervalPause between spins, when looping. Default 2600 ms.
data-hoverSpin once more when the pointer arrives.
data-shadow="false"No shadow underneath.

It starts when scrolled into view. The back is a copy of the logo, hidden from screen readers. With reduced motion the logo stays still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Logo 3D Spin: a logo flips round in 3D a couple of times, rising as it
    // spins and slowing down, then lands and settles with a little wobble. Its
    // back shows the logo too (the right way round), and with data-depth it
    // gets a solid edge, like a coin. A shadow underneath follows the turn.
    // Starts when scrolled into view.
    // Usage: <img data-logo-3d-spin src="logo.svg" alt="Animacious">
    //   Works on any element: an <img>, an <svg> or a styled <div> badge.
    // Optional: data-turns (full turns, default 2), data-duration (ms, default
    // 2200), data-depth (thickness in px for a coin edge, default 0; the edge
    // takes the element's background colour, a little darker), data-loop (spin
    // again every few seconds, alternating direction), data-interval (ms between
    // spins when looping, default 2600), data-hover (spin once more on hover),
    // data-shadow="false" (no shadow).
    var DEFAULTS = { turns: 2, duration: 2200, interval: 2600 };

    var css =
      '.lsp{position:relative;display:inline-grid;justify-items:center;perspective:700px;vertical-align:middle}' +
      '.lsp-coin{position:relative;display:grid;transform-style:preserve-3d}' +
      '.lsp-coin>*{grid-area:1/1}' +
      '.lsp-face{backface-visibility:hidden;-webkit-backface-visibility:hidden}' +
      '.lsp-edge{pointer-events:none}' +
      '.lsp-shadow{width:70%;height:12px;margin-top:10px;border-radius:50%;background:radial-gradient(closest-side,rgba(20,10,40,.28),rgba(20,10,40,0))}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function build(el) {
      var d = el.dataset;
      var depth = Math.max(0, +d.depth || 0);
      var wrap = document.createElement('span');
      wrap.className = 'lsp';
      var coin = document.createElement('span');
      coin.className = 'lsp-coin';
      el.parentNode.insertBefore(wrap, el);
      wrap.appendChild(coin);
      // The back: a copy of the logo, turned round so it reads the right way.
      var back = el.cloneNode(true);
      back.removeAttribute('data-logo-3d-spin');
      back.removeAttribute('id');
      back.querySelectorAll('[id]').forEach(function (n) { n.removeAttribute('id'); });
      back.setAttribute('aria-hidden', 'true');
      if (back.tagName === 'IMG') back.alt = '';
      el.classList.add('lsp-face');
      back.classList.add('lsp-face');
      var half = depth / 2;
      el.style.transform = 'translateZ(' + (half + 0.2) + 'px)';
      back.style.transform = 'rotateY(180deg) translateZ(' + (half + 0.2) + 'px)';
      // The edge: thin layers stacked through the thickness, a shade darker.
      if (depth) {
        var cs = getComputedStyle(el);
        var bg = cs.backgroundColor;
        if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
          var n = Math.ceil(depth / 1.5);
          for (var i = 0; i <= n; i++) {
            var layer = document.createElement('span');
            layer.className = 'lsp-edge';
            layer.setAttribute('aria-hidden', 'true');
            layer.style.cssText = 'border-radius:' + cs.borderRadius + ';background:' + bg + ';filter:brightness(.68);' +
              'transform:translateZ(' + (-half + (depth * i) / n) + 'px)';
            coin.appendChild(layer);
          }
        }
      }
      coin.appendChild(back);
      coin.appendChild(el);
      var shadow = null;
      if (d.shadow !== 'false') {
        shadow = document.createElement('span');
        shadow.className = 'lsp-shadow';
        shadow.setAttribute('aria-hidden', 'true');
        wrap.appendChild(shadow);
      }
      return { el: el, wrap: wrap, coin: coin, shadow: shadow, state: { a: 0 }, dir: 1, busy: false };
    }

    // The shadow narrows as the logo turns side-on, and shrinks as it rises.
    function render(s, lift) {
      if (!s.shadow) return;
      var side = Math.abs(Math.cos((s.state.a * Math.PI) / 180));
      var h = s.coin.offsetHeight || 1;
      var up = Math.min(1, -lift / (h * 0.25));
      s.shadow.style.transform = 'scaleX(' + (0.35 + 0.65 * side) * (1 - up * 0.3) + ')';
      s.shadow.style.opacity = 1 - up * 0.45;
    }

    function spin(s, turns, duration) {
      if (s.busy) return;
      s.busy = true;
      var dir = s.dir;
      s.dir = -s.dir;
      var h = s.coin.offsetHeight;
      var lift = { y: 0 };
      var end = s.state.a + dir * 360 * turns;
      // Fast to begin with, then an overshoot that springs back into place.
      anime.animate(s.state, {
        a: [{ to: end + dir * 14, duration: duration * 0.72, ease: 'out(3)' }, { to: end, duration: duration * 0.5, ease: 'outElastic(1, .4)' }],
        onUpdate: function () {
          s.coin.style.transform = 'translateY(' + lift.y + 'px) rotateY(' + s.state.a + 'deg)';
          render(s, lift.y);
        },
        onComplete: function () {
          s.state.a = 0;
          s.coin.style.transform = '';
          s.busy = false;
        }
      });
      anime.animate(lift, {
        y: [{ to: -h * 0.22, duration: duration * 0.35, ease: 'out(2)' }, { to: 0, duration: duration * 0.4, ease: 'outBounce' }]
      });
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      document.querySelectorAll('[data-logo-3d-spin]').forEach(function (el) {
        if (el._lsp || reduced) return;
        var s = (el._lsp = build(el));
        var d = el.dataset;
        var turns = +d.turns || DEFAULTS.turns;
        var duration = +d.duration || DEFAULTS.duration;
        var loop = d.loop !== undefined && d.loop !== 'false';
        var timer = null, seen = false;
        var next = function () {
          timer = setTimeout(function () { spin(s, turns, duration); next(); }, duration * 1.2 + (+d.interval || DEFAULTS.interval));
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (!entries[0].isIntersecting) return;
          if (!seen) { seen = true; spin(s, turns, duration); }
          if (loop) next();
        }, { threshold: 0.4 }).observe(s.wrap);
        if (d.hover !== undefined && d.hover !== 'false') {
          s.wrap.addEventListener('pointerenter', function () { spin(s, 1, duration * 0.7); });
        }
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<img data-logo-3d-spin src="/logo.svg" alt="Your brand" width="96">

<!-- a coin with an edge: -->
<div class="badge" data-logo-3d-spin data-depth="10" data-loop>…</div>