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
- Paste the snippet just before
</head>. - Add
data-molecule-modelto an empty element and give it a height. - Pick a molecule with
data-molecule, or list your own atoms withdata-atoms.
Options
Add any of these attributes to the same element.
data-molecule | A built-in molecule: water, ammonia, methane, ethanol, benzene or co2. Default ethanol. |
data-atoms | Your 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-speed | Turns per minute. Default 6. |
data-tilt | How far it leans towards you, in degrees. Default 20. |
data-size | How 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.
<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><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>