← Diagrams & maps

Molecule Model

A ball-and-stick molecule turning slowly in 3D. The atoms are shaded spheres in their usual colours, joined by two-tone bonds, and the nearer ones pass in front. Drag it to fling it round or tip it over; it settles back to its steady turn when you let go.

Ethanol. Drag it to spin it the other way or tip it over.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-molecule-model to an empty element and give it a height.
  3. Pick a molecule with data-molecule, or list your own atoms with data-atoms.

Options

Add any of these attributes to the same element.

data-moleculeA built-in molecule: water, ammonia, methane, ethanol, benzene or co2. Default ethanol.
data-atomsYour own molecule: each atom’s element and x, y, z position in ångströms, separated by commas, such as O 0 0 0, H 0.76 0.59 0, H -0.76 0.59 0. Bonds are added between atoms that are close.
data-speedTurns per minute. Default 6.
data-tiltHow far it leans towards you, in degrees. Default 20.
data-sizeHow much of the box it fills, from 0 to 1. Default 0.82.

It is drawn on a canvas that fills the element, 240px tall unless you set a height. Elements it knows: H, C, N, O, S, P, F and Cl.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Molecule Model: a ball-and-stick molecule turning slowly in 3D, with
    // shaded atoms in their usual colours and sticks for the bonds. Drag it to
    // spin it the other way or tip it over; it keeps turning when let go.
    // Usage:
    //   <div data-molecule-model data-molecule="ethanol" style="height: 240px"></div>
    // Built-in molecules: water, ammonia, methane, ethanol, benzene, co2.
    // Or give your own atoms with data-atoms: element and x y z in angstroms,
    // separated by commas, e.g. data-atoms="O 0 0 0, H 0.76 0.59 0, H -0.76 0.59 0".
    // Bonds are added between atoms that sit close enough together.
    // Optional: data-molecule (default ethanol), data-atoms, data-speed
    // (turns per minute, default 6), data-tilt (degrees, default 20),
    // data-size (how much of the box it fills, default 0.82).
    var DEFAULTS = { molecule: 'ethanol', speed: 6, tilt: 20, size: 0.82 };

    var MOLECULES = {
      water: 'O 0 0 0, H 0.757 0.586 0, H -0.757 0.586 0',
      ammonia: 'N 0 -0.1 0, H 0.94 0.28 0, H -0.47 0.28 0.81, H -0.47 0.28 -0.81',
      methane: 'C 0 0 0, H 0.63 0.63 0.63, H 0.63 -0.63 -0.63, H -0.63 0.63 -0.63, H -0.63 -0.63 0.63',
      ethanol: 'C -1.17 -0.4 0, C 0.17 0.33 0, O 1.2 -0.63 0, H 2.03 -0.15 0, H -1.99 0.31 0, H -1.25 -1.03 0.88, H -1.25 -1.03 -0.88, H 0.25 0.97 0.88, H 0.25 0.97 -0.88',
      co2: 'O -1.16 0 0, C 0 0 0, O 1.16 0 0',
    };
    (function () {
      var b = [];
      for (var i = 0; i < 6; i++) {
        var a = (i * Math.PI) / 3;
        b.push('C ' + (1.39 * Math.cos(a)).toFixed(3) + ' ' + (1.39 * Math.sin(a)).toFixed(3) + ' 0');
        b.push('H ' + (2.48 * Math.cos(a)).toFixed(3) + ' ' + (2.48 * Math.sin(a)).toFixed(3) + ' 0');
      }
      MOLECULES.benzene = b.join(', ');
    })();

    // Colour, drawn radius and bonding radius for each element.
    var ELEMENTS = {
      H: ['#f8fafc', 0.3, 0.31], C: ['#3f3f46', 0.48, 0.76], N: ['#3b82f6', 0.46, 0.71], O: ['#ef4444', 0.46, 0.66],
      S: ['#facc15', 0.55, 1.05], P: ['#f97316', 0.55, 1.07], F: ['#84cc16', 0.4, 0.57], Cl: ['#22c55e', 0.5, 1.02],
    };

    var css =
      ':where(.mlm){position:relative;width:100%;height:240px}' +
      '.mlm{touch-action:pan-y;cursor:grab;user-select:none;-webkit-user-select:none}' +
      '.mlm-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function shade(hex, f) {
      var n = parseInt(hex.slice(1), 16), r = n >> 16, g = (n >> 8) & 255, b = n & 255;
      function c(v) { return Math.round(f < 0 ? v * (1 + f) : v + (255 - v) * f); }
      return 'rgb(' + c(r) + ',' + c(g) + ',' + c(b) + ')';
    }

    function parseAtoms(str) {
      var atoms = String(str).split(',').map(function (t) {
        var p = t.trim().split(/\s+/);
        var e = ELEMENTS[p[0]] || ELEMENTS.C;
        return { el: p[0], x: +p[1] || 0, y: +p[2] || 0, z: +p[3] || 0, color: e[0], r: e[1], cov: e[2] };
      }).filter(function (a) { return a.el; });
      // Centre it.
      var cx = 0, cy = 0, cz = 0;
      atoms.forEach(function (a) { cx += a.x; cy += a.y; cz += a.z; });
      atoms.forEach(function (a) { a.x -= cx / atoms.length; a.y -= cy / atoms.length; a.z -= cz / atoms.length; });
      var bonds = [];
      for (var i = 0; i < atoms.length; i++) {
        for (var j = i + 1; j < atoms.length; j++) {
          var a = atoms[i], b = atoms[j];
          var d = Math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + (a.z - b.z) * (a.z - b.z));
          if (d < a.cov + b.cov + 0.4) bonds.push({ a: a, b: b });
        }
      }
      return { atoms: atoms, bonds: bonds };
    }

    function setup(el) {
      var d = el.dataset;
      var mol = parseAtoms(d.atoms || MOLECULES[(d.molecule || DEFAULTS.molecule).toLowerCase()] || MOLECULES.ethanol);
      var reach = 0;
      mol.atoms.forEach(function (a) { reach = Math.max(reach, Math.sqrt(a.x * a.x + a.y * a.y + a.z * a.z) + a.r); });
      var s = (el._mlm = {
        mol: mol, reach: reach || 1, angle: 0.6, tilt: ((d.tilt != null ? +d.tilt : DEFAULTS.tilt) * Math.PI) / 180,
        spin: ((+d.speed || DEFAULTS.speed) * Math.PI * 2) / 60, vel: 0, visible: false, raf: 0,
      });
      s.vel = s.spin;
      el.classList.add('mlm');
      var canvas = document.createElement('canvas');
      canvas.className = 'mlm-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      s.canvas = canvas;
      s.ctx = canvas.getContext('2d');
      el.setAttribute('role', 'img');
      if (!el.getAttribute('aria-label')) {
        var counts = {};
        mol.atoms.forEach(function (a) { counts[a.el] = (counts[a.el] || 0) + 1; });
        el.setAttribute('aria-label', '3D model of ' + (d.atoms ? 'a molecule' : (d.molecule || DEFAULTS.molecule)) + ': ' + Object.keys(counts).map(function (k) { return counts[k] + ' ' + k; }).join(', '));
      }
      resize(el);
      new ResizeObserver(function () { resize(el); draw(el); }).observe(el);
      // Drag to turn it.
      el.addEventListener('pointerdown', function (e) {
        var lx = e.clientX, ly = e.clientY, lt = performance.now();
        s.dragging = true;
        el.style.cursor = 'grabbing';
        function move(ev) {
          var now = performance.now(), dt = Math.max(1, now - lt) / 1000;
          var dx = ev.clientX - lx, dy = ev.clientY - ly;
          s.angle += dx * 0.012;
          s.tilt = Math.max(-1.4, Math.min(1.4, s.tilt + dy * 0.01));
          s.vel = Math.max(-8, Math.min(8, (dx * 0.012) / dt));
          lx = ev.clientX; ly = ev.clientY; lt = now;
          if (!s.raf) draw(el);
        }
        function up() {
          s.dragging = false;
          el.style.cursor = '';
          window.removeEventListener('pointermove', move);
          window.removeEventListener('pointerup', up);
          window.removeEventListener('pointercancel', up);
        }
        window.addEventListener('pointermove', move);
        window.addEventListener('pointerup', up);
        window.addEventListener('pointercancel', up);
      });
    }

    function resize(el) {
      var s = el._mlm, dpr = Math.min(2, window.devicePixelRatio || 1);
      s.w = el.clientWidth; s.h = el.clientHeight;
      s.canvas.width = Math.round(s.w * dpr);
      s.canvas.height = Math.round(s.h * dpr);
      s.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    }

    function draw(el) {
      var s = el._mlm, ctx = s.ctx, w = s.w, h = s.h;
      if (!w || !h) return;
      ctx.clearRect(0, 0, w, h);
      var scale = (Math.min(w, h) / 2) * (+el.dataset.size || DEFAULTS.size) / s.reach;
      var ca = Math.cos(s.angle), sa = Math.sin(s.angle), ct = Math.cos(s.tilt), st = Math.sin(s.tilt);
      var cam = s.reach * 4;
      s.mol.atoms.forEach(function (a) {
        // Turn round the vertical axis, then tip towards the viewer.
        var x = a.x * ca + a.z * sa, z = -a.x * sa + a.z * ca;
        var y = a.y * ct - z * st;
        z = a.y * st + z * ct;
        var p = cam / (cam - z);
        a.sx = w / 2 + x * scale * p;
        a.sy = h / 2 + y * scale * p;
        a.sz = z;
        a.sr = a.r * scale * p;
      });
      var items = [];
      s.mol.atoms.forEach(function (a) { items.push({ z: a.sz, atom: a }); });
      s.mol.bonds.forEach(function (b) { items.push({ z: (b.a.sz + b.b.sz) / 2 - 0.2, bond: b }); });
      items.sort(function (p, q) { return p.z - q.z; });
      var bw = Math.max(3, 0.16 * scale);
      items.forEach(function (it) {
        if (it.bond) {
          var a = it.bond.a, b = it.bond.b, mx = (a.sx + b.sx) / 2, my = (a.sy + b.sy) / 2;
          ctx.lineCap = 'round';
          [[a, mx, my], [b, mx, my]].forEach(function (half) {
            var at = half[0];
            ctx.beginPath();
            ctx.moveTo(at.sx, at.sy);
            ctx.lineTo(half[1], half[2]);
            ctx.strokeStyle = shade(at.color, -0.35);
            ctx.lineWidth = bw + 2;
            ctx.stroke();
            ctx.strokeStyle = shade(at.color, at.el === 'H' ? -0.08 : 0.1);
            ctx.lineWidth = bw;
            ctx.stroke();
          });
          return;
        }
        var t = it.atom, r = t.sr;
        var g = ctx.createRadialGradient(t.sx - r * 0.35, t.sy - r * 0.4, r * 0.1, t.sx, t.sy, r);
        g.addColorStop(0, shade(t.color, 0.75));
        g.addColorStop(0.45, t.el === 'H' ? shade(t.color, -0.04) : t.color);
        g.addColorStop(1, shade(t.color, t.el === 'H' ? -0.35 : -0.55));
        ctx.beginPath();
        ctx.arc(t.sx, t.sy, r, 0, Math.PI * 2);
        ctx.fillStyle = g;
        ctx.fill();
        ctx.lineWidth = 1;
        ctx.strokeStyle = 'rgba(40,20,70,.25)';
        ctx.stroke();
      });
    }

    function frame(el, t) {
      var s = el._mlm;
      s.raf = 0;
      if (!s.visible) return;
      var dt = Math.min(0.05, (t - (s.last || t)) / 1000);
      s.last = t;
      if (!s.dragging) {
        // Ease back to the steady turn after a fling.
        s.vel += (s.spin - s.vel) * Math.min(1, dt * 1.5);
        s.angle += s.vel * dt;
      }
      draw(el);
      s.raf = requestAnimationFrame(function (tt) { frame(el, tt); });
    }

    function init() {
      var still = reduced();
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target, s = el._mlm;
          s.visible = e.isIntersecting;
          if (!s.visible) { cancelAnimationFrame(s.raf); s.raf = 0; return; }
          s.last = 0;
          if (!s.shown) {
            s.shown = true;
            s.vel = s.spin * 6;
            anime.animate(s.canvas, { opacity: [0, 1], scale: [0.7, 1], duration: 900, ease: 'out(3)' });
          }
          if (!s.raf) s.raf = requestAnimationFrame(function (t) { frame(el, t); });
        });
      }, { threshold: 0.1 });
      document.querySelectorAll('[data-molecule-model]').forEach(function (el) {
        if (el._mlm) return;
        setup(el);
        draw(el);
        if (still) return;
        anime.utils.set(el._mlm.canvas, { opacity: 0 });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-molecule-model data-molecule="benzene" style="height: 300px"></div>

<!-- or your own: -->
<div data-molecule-model data-atoms="O 0 0 0, H 0.757 0.586 0, H -0.757 0.586 0"></div>