Embedding 3D structure viewers
Put a protein structure in a teaching page, turned to the region you need, with the residue you are teaching marked and labelled. Mol* suits most pages. Use the PDBe Mol* tag if you would rather not write code, and 3Dmol.js when the page must stay small.
Which viewer to use
| Feature | Mol* | PDBe Mol* | 3Dmol.js | NGL |
|---|---|---|---|---|
| What it is | The viewer used by the RCSB PDB and PDBe websites, as one script | PDBe’s packaging of Mol*, with a <pdbe-molstar> tag |
A small viewer with a JavaScript API and a no-code mode | An older viewer, one of the two that Mol* grew from |
| Version tested | 5.12.0 | 3.12.0, which contains Mol* 5.8.0 | 2.5.5 | 2.5.0 |
| Download | 5.2 MB (1.5 MB compressed) | 6.1 MB (1.6 MB compressed) | 0.5 MB (155 kB compressed) | 1.3 MB (360 kB compressed) |
| Best for | Most teaching pages: hover labels, a sequence panel, scenes, scripted views and AlphaFold confidence | A viewer from one tag, and choosing residues by their UniProt number | Small pages, several viewers on one page, and labels without code | Pages that already use it |
| Limits | Large, and its options change between monthly releases | Larger still, with an older Mol* inside; selections need code | Few built-in tools for students | No Greek letters in labels; its AlphaFold shortcut asks for deleted files |
| Licence | MIT | Apache 2.0 | BSD 3-clause | MIT |
The examples below all show human haemoglobin and the residue changed in sickle-cell disease, glutamate 6 of the β chains, so that you can compare the viewers on the same view. The structure is PDB entry 4HHB, deoxyhaemoglobin at 1.74 Å resolution [1]. All the examples were tested on 15 October 2026 with the versions in the table. The Mol* and PDBe Mol* examples wait for a click before they load, because each downloads about 1.5 MB and opens its own WebGL context, the browser’s connection to the graphics card.
Start with a basic viewer
A stylesheet, a script and one call to molstar.Viewer.create give you a working viewer [2]. loadPdb then fetches an entry from the Protein Data Bank.
This example downloads Mol* (1.5 MB compressed) when it starts.
Code molstar-basic.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mol*: a 3D viewer open at haemoglobin</title>
<!-- Mol* 5.12.0 from jsDelivr: one stylesheet and one script. The integrity hashes stop changed files from running. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/molstar@5.12.0/build/viewer/molstar.css"
integrity="sha384-RIontCdJN53gEl2fmiHN+4bscIBvaUaOiCeeGktXqmFqdEBF+COnSdt9O4IKFSvq"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/molstar@5.12.0/build/viewer/molstar.js"
integrity="sha384-Mx9hwknXam3/nCj5WsEpyvr6nqiUp8vVLEh0ZG9OXpFepTFhVKTdEOJ4JG0XjMLf"
crossorigin="anonymous"></script>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 8px; }
/* Mol* fills its container, so the container needs a height and a position */
#viewer { position: relative; height: 400px; }
#status { margin: 6px 0 0; }
</style>
</head>
<body>
<h1>Human haemoglobin (PDB 4HHB)</h1>
<div id="viewer" role="region" aria-label="Interactive 3D model of human deoxyhaemoglobin, PDB entry 4HHB"></div>
<p id="status" role="status" aria-live="polite"></p>
<script>
const say = text => { document.getElementById("status").textContent = text; };
molstar.Viewer.create("viewer", {
layoutIsExpanded: false, // stay inside the page rather than filling the window
layoutShowControls: false, // no side panels or sequence
viewportShowAnimation: false,
pdbProvider: "pdbe" // where loadPdb fetches entries: "pdbe" (the default), "rcsb" or "pdbj"
}).then(async viewer => {
await viewer.loadPdb("4hhb"); // fetches https://www.ebi.ac.uk/pdbe/entry-files/download/4hhb.bcif
// Mol* can finish without a structure when the download fails, so check
if (viewer.plugin.managers.structure.hierarchy.current.structures.length === 0) {
throw new Error("the file did not arrive");
}
}).catch(err => {
console.error(err);
say("The structure could not be loaded (" + ((err && err.message) || err) + "). Check your connection, or try again later.");
});
</script>
</body>
</html>How it works:
- The container needs a height and
position: relative, because Mol* fills it. Label it withrole="region"and anaria-labelthat says what is shown;role="img"would hide Mol*’s own buttons from screen readers. - Options switch parts of the interface on and off.
layoutIsExpanded: falsekeeps Mol* inside the page, andlayoutShowControls: falsehides the side panels and the sequence. The expand button in the corner still lets students fill the screen. loadPdb("4hhb")fetches the entry as BinaryCIF, a compact binary form of the PDB’s mmCIF format. By default it comes from PDBe, atwww.ebi.ac.uk/pdbe/entry-files/download/4hhb.bcif: 344 kB, or 86 kB compressed. The viewer optionpdbProvider: "rcsb"fetches the RCSB PDB’s copy instead (553 kB, or 126 kB compressed).- Failures. When the download fails, Mol* can finish without a structure and without an error, so the example checks that a structure arrived and says so if not.
loadStructureFromUrlloads any file, andloadAlphaFoldDban AlphaFold model. - Hovering over a residue names it in the corner. For glutamate 6 of chain B, Mol* shows
B | GLU 6andUNP P68871 7 E: the same residue is number 7 in UniProt. The numbering section explains why.
Mark and label a residue
For teaching, colour the molecule to make the point, mark and label the residue you are teaching, and add buttons that take students to the right place. Mol* handles the first two with a MolViewSpec scene, a description of what to load and how to draw it [3], and the third with structureInteractivity.
This example downloads Mol* (1.5 MB compressed) when it starts.
Code molstar-hbs.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mol*: the sickle-cell residue in haemoglobin, marked and labelled</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/molstar@5.12.0/build/viewer/molstar.css"
integrity="sha384-RIontCdJN53gEl2fmiHN+4bscIBvaUaOiCeeGktXqmFqdEBF+COnSdt9O4IKFSvq"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/molstar@5.12.0/build/viewer/molstar.js"
integrity="sha384-Mx9hwknXam3/nCj5WsEpyvr6nqiUp8vVLEh0ZG9OXpFepTFhVKTdEOJ4JG0XjMLf"
crossorigin="anonymous"></script>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 6px; }
p { margin: 0 0 6px; }
.controls { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 8px; }
button { font: inherit; font-size: 14px; padding: 4px 9px; }
/* Mol* fills its container, so the container needs a height and a position */
#viewer { position: relative; height: 420px; }
#status { min-height: 1.5em; margin-top: 6px; }
</style>
</head>
<body>
<h1>Glutamate 6 of β-globin, the residue changed in sickle-cell haemoglobin</h1>
<p>Deoxyhaemoglobin (PDB 4HHB): α chains grey, β chains blue, haem orange, and Glu6 of each β chain red.</p>
<div class="controls" role="group" aria-label="Views of the structure">
<button type="button" id="focus">Zoom to Glu6 of chain B</button>
<button type="button" id="select">Select both Glu6</button>
<button type="button" id="highlight">Highlight chain B</button>
<button type="button" id="reset">Whole molecule</button>
</div>
<div id="viewer" role="region" aria-label="3D model of haemoglobin with glutamate 6 of each beta chain in red and labelled"></div>
<p id="status" role="status" aria-live="polite">Loading…</p>
<script>
const say = text => { document.getElementById("status").textContent = text; };
// Residues are named by chain and author number (auth_seq_id), the numbers printed in the PDB file.
// In 4HHB the sickle-cell residue is Glu6. UniProt P68871 and AlphaFold count the first
// methionine, which is removed from the finished protein, so they call it residue 7 (p.Glu7Val).
const GLU6_B = { auth_asym_id: "B", auth_seq_id: 6 };
const GLU6_D = { auth_asym_id: "D", auth_seq_id: 6 };
async function start() {
const viewer = await molstar.Viewer.create("viewer", {
layoutIsExpanded: false,
layoutShowControls: false,
viewportShowAnimation: false,
viewportShowSelectionMode: false
});
// A MolViewSpec (MVS) scene: a description of what to load and how to show it
const mvs = molstar.PluginExtensions.mvs;
const scene = mvs.MVSData.createBuilder();
const structure = scene
.download({ url: "https://models.rcsb.org/4hhb.bcif" }) // BinaryCIF from RCSB PDB
.parse({ format: "bcif" })
.modelStructure();
const protein = structure.component({ selector: "polymer" });
protein.representation({ type: "cartoon" })
.color({ color: "#b8b8b8" }) // α chains grey
.color({ selector: [{ auth_asym_id: "B" }, { auth_asym_id: "D" }], color: "#4a7fc1" });
// Look at the molecule from the side where Glu6 of chain B faces outwards
// (the direction from that residue to the centre of the molecule, worked out from the coordinates)
protein.focus({ direction: [-0.49, 0.817, 0.304] });
structure.component({ selector: "ligand" }) // the four haem groups
.representation({ type: "ball_and_stick" })
.color({ color: "#e0a526" });
structure.component({ selector: [GLU6_B, GLU6_D] })
.representation({ type: "spacefill" })
.color({ color: "#d7263d" });
structure.component({ selector: GLU6_B })
.tooltip({ text: "Glutamate 6 of the β chain: valine in sickle-cell haemoglobin (HbS)" });
// A label with a white background and a line to the residue. Its size is in ångströms.
structure.primitives({ label_background_color: "#ffffff", label_show_tether: true, label_tether_length: 4, label_attachment: "bottom-center" })
.label({ position: GLU6_B, text: "β Glu6 (Val in HbS)", label_size: 3, label_color: "#000000" });
await mvs.loadMVS(viewer.plugin, scene.getState());
viewer.plugin.managers.snapshot.clear(); // hide the one-item list of scenes that MVS adds
// loadMVS finishes even when the download fails, so check that a structure arrived
if (viewer.plugin.managers.structure.hierarchy.current.structures.length === 0) {
throw new Error("the file did not arrive from the RCSB PDB");
}
// Focus, select and highlight from your own buttons (Mol* 5.5 and later)
document.getElementById("focus").onclick = () => {
viewer.structureInteractivity({ elements: GLU6_B, action: "focus", focusOptions: { extraRadius: 14 } });
say("Zoomed to glutamate 6 of chain B.");
};
document.getElementById("select").onclick = () => {
viewer.structureInteractivity({ elements: { items: [GLU6_B, GLU6_D] }, action: "select" });
say("Glutamate 6 of both β chains selected (outlined in green). Each haemoglobin has two.");
};
document.getElementById("highlight").onclick = () => {
// label_asym_id "B" is the protein chain alone; auth_asym_id "B" would also take in its haem and water
viewer.structureInteractivity({ elements: { label_asym_id: "B" }, action: "highlight" });
say("Chain B, one of the two β chains, highlighted. The highlight goes when the pointer moves over the molecule.");
};
document.getElementById("reset").onclick = () => {
viewer.structureInteractivity({ action: ["select", "highlight"] }); // no target: clear both
viewer.plugin.managers.camera.reset();
say("Showing the whole molecule.");
};
say("Drag to turn the molecule, scroll or pinch to zoom, and hover over a residue to name it.");
}
start().catch(err => {
console.error(err);
say("The structure could not be loaded (" + ((err && err.message) || err) + "). Check your connection, or try again later.");
});
</script>
</body>
</html>How the code works:
- The scene is built with
MVSData.createBuilder(): download a file, parse it and make a structure, then add components ("polymer","ligand"or a list of residues), each with a representation (cartoon,ball_and_stick,spacefill) and colours.loadMVSdraws it. A scene also adds a one-item list of scenes to the corner of the viewer, which the example removes withmanagers.snapshot.clear(). - Residues are named by chain and number, as in
{ auth_asym_id: "B", auth_seq_id: 6 }. These are the author’s chain letter and residue number, the ones printed in the PDB file. - The label is a MolViewSpec primitive with a background and a line to the residue. Its size is in ångströms (
label_size: 3), so it grows as students zoom in. - The starting view. A label can hide behind nearer parts of the molecule, so the scene’s
focushas adirection: the camera looks from Glu6 of chain B towards the centre of the molecule, along a vector worked out once from the coordinates, so that Glu6 faces the reader when the page opens. - Buttons call the viewer’s
structureInteractivitywith anaction."focus"moves the camera,"select"outlines residues in green until they are cleared, and"highlight"shows them as if the pointer were over them, until the pointer next moves over the molecule. Called without a target, it clears both the selection and the highlight. It needs Mol* 5.5 or later. - A chain letter is more than the chain.
auth_asym_id: "B"also matches chain B’s haem, a phosphate ion and 57 water molecules, because they carry the same letter.label_asym_id: "B", the file’s own name for the chain, is the protein alone, which is why the highlight button uses it. - Saving a scene.
MVSData.toMVSJ()turns a scene’s state into a small JSON file, and the viewer’sloadMvsFromUrl()loads one. A colleague can then change the colours or a label in the file without touching the code.
Match the PDB and UniProt numbering
The same glutamate has two numbers, and both are correct. The gene’s first codon codes for a methionine, which is removed from the finished protein. The PDB entry numbers the finished chain, so in 4HHB each β chain starts at valine 1 and the sickle-cell residue is Glu6. UniProt and AlphaFold number the sequence the gene codes for, so the same residue is 7. Variant names follow the gene: ClinVar records the sickle-cell change as NM_000518.5(HBB):c.20A>T (p.Glu7Val), while older papers and textbooks call it Glu6Val.
| Where | The sickle-cell residue | Counts from |
|---|---|---|
| PDB entry 4HHB, chains B and D | Glu6 (auth_seq_id and label_seq_id are both 6) |
The valine that starts the finished protein |
| UniProt P68871 | 7 | The methionine coded by the start codon |
| AlphaFold model AF-P68871-F1 | Glu7 | The UniProt sequence |
| ClinVar and other HGVS variant names | p.Glu7Val | The start codon |
| Older papers and textbooks | Glu6Val, β6 | The finished protein |
So say which numbering a page uses, and test every selection on the file you load. Two more traps are rarer in teaching entries: in some entries auth_seq_id and label_seq_id differ, and some number extra residues with insertion codes, such as 52A. PDBe Mol* can select residues by their UniProt number, as shown further down.
Turn the viewer into an activity
Mol* tells your page what students click, in the 3D view and in the sequence panel above it. With that, a page can check each step of a task and answer straight away, as the genome browser activity does with igv.js.
This example downloads Mol* (1.5 MB compressed) when it starts.
Code molstar-find-glu6.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mol* activity: find the sickle-cell residue in haemoglobin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/molstar@5.12.0/build/viewer/molstar.css"
integrity="sha384-RIontCdJN53gEl2fmiHN+4bscIBvaUaOiCeeGktXqmFqdEBF+COnSdt9O4IKFSvq"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/molstar@5.12.0/build/viewer/molstar.js"
integrity="sha384-Mx9hwknXam3/nCj5WsEpyvr6nqiUp8vVLEh0ZG9OXpFepTFhVKTdEOJ4JG0XjMLf"
crossorigin="anonymous"></script>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 6px; }
p { margin: 0 0 6px; }
/* Steps on the left and the viewer on the right; one column on narrow screens */
.layout { display: grid; grid-template-columns: minmax(240px, 350px) minmax(0, 1fr); gap: 14px; align-items: start; }
@media (max-width: 640px) { .layout { grid-template-columns: minmax(0, 1fr); } }
ol { margin: 0 0 8px; padding-left: 1.3rem; }
li { margin: 0 0 8px; }
li.done { color: #0b6b50; }
li.done::after { content: " ✓ done"; font-weight: 700; }
.answers { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
button, input, select { font: inherit; font-size: 14px; padding: 2px 8px; }
input { width: 4.5em; }
form { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 8px; font-size: 14px; }
#feedback { min-height: 1.5em; font-weight: 600; }
/* Mol* fills its container, so the container needs a height and a position */
#viewer { position: relative; height: 580px; }
</style>
</head>
<body>
<h1>Activity: find the sickle-cell residue</h1>
<p>In sickle-cell disease, glutamate 6 of β-globin becomes valine. Find it in human haemoglobin (PDB entry 4HHB).</p>
<div class="layout">
<div>
<ol>
<li id="t1">Hover over the four chains to read their names, then click a β chain (<i>Hemoglobin subunit beta</i>).</li>
<li id="t2">Find residue 6 of a β chain and click it, in 3D or in the sequence at the top (choose <b>2: Hemoglobin subunit beta</b> there first).</li>
<li id="t3">Is glutamate 6 on the surface, or buried inside the molecule?
<span class="answers">
<button type="button" data-step="t3" data-right>On the surface</button>
<button type="button" data-step="t3">Buried inside</button>
</span></li>
<li id="t4">UniProt and AlphaFold number this glutamate 7. Why?
<span class="answers">
<button type="button" data-step="t4" data-wrong="pdb">The PDB file is wrong</button>
<button type="button" data-step="t4" data-right>They count the first methionine</button>
<button type="button" data-step="t4" data-wrong="shape">AlphaFold predicts another shape</button>
</span></li>
</ol>
<!-- The same checks without a mouse: choose a chain and a residue number -->
<form id="pick">
<label for="chain">Or go to chain</label>
<select id="chain"><option>A</option><option>B</option><option>C</option><option>D</option></select>
<label for="number">residue</label>
<input id="number" type="number" min="1" max="146" value="1">
<button type="submit">Go</button>
</form>
<p id="feedback" role="status" aria-live="polite">Loading the structure…</p>
</div>
<div id="viewer" role="region" aria-label="3D model of haemoglobin, PDB 4HHB, with its four chains in different colours"></div>
</div>
<script>
const feedback = document.getElementById("feedback");
const say = text => { feedback.textContent = text; };
const done = id => document.getElementById(id).classList.add("done");
const isDone = id => document.getElementById(id).classList.contains("done");
const BETA_CHAINS = ["B", "D"];
// View directions (camera to target) that look at Glu6 of each β chain from outside the molecule
const OUTSIDE = { B: [-0.49, 0.817, 0.304], D: [0.717, 0.53, -0.453] };
// Build the scene. Once the residue is found, the scene is loaded again with Glu6 shown and labelled.
function scene(foundChain) {
const builder = molstar.PluginExtensions.mvs.MVSData.createBuilder();
const structure = builder
.download({ url: "https://models.rcsb.org/4hhb.bcif" })
.parse({ format: "bcif" })
.modelStructure();
const protein = structure.component({ selector: "polymer" });
protein.representation({ type: "cartoon" })
.color({ selector: { auth_asym_id: "A" }, color: "#56b4e9" }) // four colours that do not give
.color({ selector: { auth_asym_id: "B" }, color: "#009e73" }) // away which chains are α and β
.color({ selector: { auth_asym_id: "C" }, color: "#cc79a7" })
.color({ selector: { auth_asym_id: "D" }, color: "#0072b2" });
structure.component({ selector: "ligand" })
.representation({ type: "ball_and_stick" })
.color({ color: "#e69f00" });
if (!foundChain) {
protein.focus({ direction: OUTSIDE.B });
} else {
const glu6 = { auth_asym_id: foundChain, auth_seq_id: 6 };
structure.component({ selector: [{ auth_asym_id: "B", auth_seq_id: 6 }, { auth_asym_id: "D", auth_seq_id: 6 }] })
.representation({ type: "spacefill" })
.color({ color: "#d7263d" });
structure.primitives({ label_background_color: "#ffffff", label_show_tether: true, label_tether_length: 4, label_attachment: "bottom-center" })
.label({ position: glu6, text: "β Glu6 (Val in HbS)", label_size: 3, label_color: "#000000" });
// Turn the camera to face the residue that was found, with 15 Å of its surroundings
structure.component({ selector: glu6 }).focus({ direction: OUTSIDE[foundChain], radius_extent: 15 });
}
return builder.getState();
}
async function start() {
const viewer = await molstar.Viewer.create("viewer", {
layoutIsExpanded: false,
layoutShowControls: true, // needed for the sequence panel above the structure
layoutShowLeftPanel: false, // but not the side panels or the log
collapseRightPanel: true,
layoutShowLog: false,
layoutShowRemoteState: false,
viewportShowAnimation: false,
viewportShowSelectionMode: false
});
const mvs = molstar.PluginExtensions.mvs;
await mvs.loadMVS(viewer.plugin, scene(null));
// loadMVS finishes even when the download fails, so check that a structure arrived
if (viewer.plugin.managers.structure.hierarchy.current.structures.length === 0) {
throw new Error("the file did not arrive from the RCSB PDB");
}
viewer.plugin.managers.snapshot.clear();
// molstar.lib gives access to Mol*'s structure model, so the page can name what was clicked
const { StructureElement, StructureProperties: P } = molstar.lib.structure;
function describe(loci) {
if (!StructureElement.Loci.is(loci) || StructureElement.Loci.isEmpty(loci)) return null;
const at = StructureElement.Loci.getFirstLocation(loci);
return {
chain: P.chain.auth_asym_id(at),
number: P.residue.auth_seq_id(at), // the number printed in the PDB file
name: P.atom.label_comp_id(at), // three-letter code, such as GLU
molecule: P.entity.pdbx_description(at).join(", "),
type: P.entity.type(at) // "polymer", "non-polymer" or "water"
};
}
async function check(r) {
if (!r) return;
if (r.type === "water") { say("That is a water molecule. Click the protein itself."); return; }
if (r.type !== "polymer") {
say(r.name === "HEM" ? "That is a haem group, which holds the iron that binds oxygen. Click a protein chain." : "That is " + r.name + ", not part of a protein chain.");
return;
}
const what = r.name + " " + r.number + " of chain " + r.chain + " (" + r.molecule + ")";
if (!BETA_CHAINS.includes(r.chain)) { say("That is " + what + ", an α chain. Try another chain."); return; }
done("t1");
if (r.number !== 6) {
say("That is " + what + ", a β chain. " + (isDone("t2") ? "" : "Now find residue 6 of a β chain."));
return;
}
if (!isDone("t2")) {
done("t2");
await mvs.loadMVS(viewer.plugin, scene(r.chain)); // replaces the first scene
viewer.plugin.managers.snapshot.clear();
}
say("Found it: " + what + ". Both β chains have one, now shown in red.");
}
// Clicks in the 3D view and in the sequence panel both arrive here
viewer.subscribe(viewer.plugin.behaviors.interaction.click, event => check(describe(event.current.loci)));
// The form: find the residue, move the camera to it, and check it in the same way
document.getElementById("pick").addEventListener("submit", event => {
event.preventDefault();
const target = { auth_asym_id: document.getElementById("chain").value, auth_seq_id: Number(document.getElementById("number").value) };
const structure = viewer.plugin.managers.structure.hierarchy.current.structures[0].cell.obj.data;
const found = describe(StructureElement.Schema.toLoci(structure, target));
// Haem and water carry chain letters and numbers too, so accept only amino acids
if (!found || found.type !== "polymer") {
say("Chain " + target.auth_asym_id + " has no amino acid numbered " + target.auth_seq_id + ". α chains have 141, β chains 146.");
return;
}
viewer.structureInteractivity({ elements: target, action: ["select", "focus"], focusOptions: { extraRadius: 10 } });
check(found);
});
// The two questions
const replies = {
t3: { right: "Yes: it sits on the outside, 21 Å from the nearest haem. In HbS, the valine here sticks to a neighbouring haemoglobin molecule when oxygen is low, and the molecules join into fibres.",
wrong: "Not quite. Turn the molecule to look at Glu6 from the side. Is anything covering it?" },
t4: { right: "Yes. The gene starts with a methionine that is removed from the finished protein. The PDB file numbers the finished protein; UniProt, AlphaFold and the variant name p.Glu7Val count the methionine.",
pdb: "No. Both numbers are correct, but they count from different starting points. Look at the first residue of a β chain in the sequence.",
shape: "No. The shape is not the question: the two count from different starting points. Look at the first residue of a β chain in the sequence." }
};
document.querySelectorAll("button[data-step]").forEach(button => button.addEventListener("click", () => {
const step = button.dataset.step;
if (button.hasAttribute("data-right")) { done(step); say(replies[step].right); }
else say(replies[step][button.dataset.wrong || "wrong"]);
}));
say("Ready. Drag to turn the molecule and scroll or pinch to zoom.");
}
start().catch(err => {
console.error(err);
say("The structure could not be loaded (" + ((err && err.message) || err) + "). Check your connection, or try again later.");
});
</script>
</body>
</html>To build your own version, reuse these parts:
- Listening for clicks.
viewer.subscribe()on the plugin’sbehaviors.interaction.clickevent runs your function on every click in the 3D view. A click on a letter in the sequence panel sends the same event. - Naming what was clicked. The event carries a “loci”, Mol*’s description of a set of atoms.
molstar.lib, which the 5.12.0 bundle exposes, reads it:Loci.getFirstLocationfromStructureElementgives one atom, andStructurePropertiesgives its chain, residue number, residue name and molecule name. - The sequence panel needs
layoutShowControls: true. Turning off the other panels (layoutShowLeftPanel: false,collapseRightPanel: true,layoutShowLog: false) leaves only the sequence above the structure. - Changing the scene. When a student finds the residue, the page loads the scene again with Glu6 drawn and labelled, and turns the camera to face it.
loadMVSreplaces the old scene; pass{ keepCamera: true }if you would rather keep the student’s view. - Without a mouse. The form finds a residue by chain and number with
Schema.toLocifromStructureElement, moves the camera to it withstructureInteractivity, and runs the same check, so every step works from the keyboard. Haem and water carry chain letters and numbers too, so the form accepts only amino acids. - Feedback goes into an element with
aria-live="polite", so screen readers announce it.
To use the activity with another protein, change the entry, the chain letters, the residue number, the view direction and the wording.
Colour an AlphaFold model by confidence
AlphaFold DB holds predicted structures for hundreds of millions of proteins [4]. Ask its API which model files are current rather than writing a file address into your page: file names carry a version (_v6 today), and AlphaFold DB has deleted the older versions, so AF-P68871-F1-model_v5.bcif now returns an error.
This example downloads Mol* (1.5 MB compressed) and a model from AlphaFold DB when it starts.
Code molstar-alphafold.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mol*: an AlphaFold model coloured by confidence, with its predicted aligned error</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/molstar@5.12.0/build/viewer/molstar.css"
integrity="sha384-RIontCdJN53gEl2fmiHN+4bscIBvaUaOiCeeGktXqmFqdEBF+COnSdt9O4IKFSvq"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/molstar@5.12.0/build/viewer/molstar.js"
integrity="sha384-Mx9hwknXam3/nCj5WsEpyvr6nqiUp8vVLEh0ZG9OXpFepTFhVKTdEOJ4JG0XjMLf"
crossorigin="anonymous"></script>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 6px; }
p { margin: 0 0 6px; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; }
button, select { font: inherit; font-size: 14px; padding: 3px 8px; }
#meta { font-size: 14px; color: #3b4659; }
.legend { list-style: none; padding: 0; margin: 0 0 8px; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap: 0 16px; font-size: 14px; }
.swatch { display: inline-block; width: 0.9em; height: 0.9em; margin-right: 0.35em; vertical-align: -0.1em; border: 1px solid #0003; }
.panes { display: flex; flex-wrap: wrap; gap: 12px; }
/* Mol* fills its container, so the container needs a height and a position */
#viewer { position: relative; flex: 1 1 320px; height: 360px; }
figure { margin: 0; flex: 0 1 260px; font-size: 13px; color: #3b4659; }
figure img { display: block; width: 100%; height: auto; max-width: 260px; aspect-ratio: 1; border: 1px solid #c3cbd8; image-rendering: pixelated; }
.key { display: flex; align-items: center; gap: 6px; margin: 4px 0; max-width: 260px; }
.key .bar { flex: 1; height: 10px; background: linear-gradient(to right, #00471c, #f7fcf5); border: 1px solid #c3cbd8; }
#status { min-height: 1.5em; margin-top: 6px; }
</style>
</head>
<body>
<h1>How confident is AlphaFold about this protein?</h1>
<div class="controls">
<label for="protein">Protein</label>
<select id="protein">
<option value="P04637">p53, UniProt P04637</option>
<option value="P0DP23">Calmodulin-1, UniProt P0DP23</option>
<option value="P68871">β-globin, UniProt P68871</option>
</select>
<button type="button" id="low">Select residues with pLDDT below 50</button>
<button type="button" id="clear">Clear</button>
</div>
<p id="meta">Asking AlphaFold DB for the model…</p>
<!-- AlphaFold DB's confidence bands and colours. The text after each band is the text version of the 3D colours. -->
<ul class="legend" id="legend"></ul>
<div class="panes">
<div id="viewer" role="region" aria-label="3D AlphaFold model coloured by per-residue confidence (pLDDT)"></div>
<figure>
<img id="pae" alt="">
<div class="key" aria-hidden="true"><span>0 Å</span><span class="bar"></span><span>30 Å</span></div>
<figcaption>Predicted aligned error (PAE). Both axes run from the first residue (top left) to the last. Dark green: AlphaFold is confident about where two residues sit relative to each other. White: it is not.</figcaption>
</figure>
</div>
<p id="status" role="status" aria-live="polite"></p>
<script>
const BANDS = [
{ code: "H", name: "Very high", range: "90 and above", colour: "#0053d6", fraction: "fractionPlddtVeryHigh" },
{ code: "M", name: "Confident", range: "70 to 90", colour: "#65cbf3", fraction: "fractionPlddtConfident" },
{ code: "L", name: "Low", range: "50 to 70", colour: "#ffdb13", fraction: "fractionPlddtLow" },
{ code: "D", name: "Very low", range: "below 50", colour: "#ff7d45", fraction: "fractionPlddtVeryLow" }
];
const $ = id => document.getElementById(id);
const say = text => { $("status").textContent = text; };
// The longest unbroken stretch of residues in each band, from the per-residue confidence file
function longestStretches(conf) {
const best = {};
for (let start = 0, i = 1; i <= conf.residueNumber.length; i++) {
if (i === conf.residueNumber.length || conf.confidenceCategory[i] !== conf.confidenceCategory[start]) {
const band = conf.confidenceCategory[start];
if (!best[band] || i - start > best[band].length)
best[band] = { from: conf.residueNumber[start], to: conf.residueNumber[i - 1], length: i - start };
start = i;
}
}
return best;
}
async function show(viewer, accession) {
say("");
// 1. Ask the AlphaFold DB API which files are current. File names carry a version (v6 today),
// and old versions are deleted, so never hard-code a file address.
const response = await fetch("https://alphafold.ebi.ac.uk/api/prediction/" + accession);
if (!response.ok) throw new Error("AlphaFold DB answered " + response.status);
const entry = (await response.json())[0];
$("meta").textContent = entry.uniprotDescription + " (" + entry.gene + ", " + entry.organismScientificName + "). " +
"Model " + entry.modelEntityId + ", version " + entry.latestVersion + ", made " + entry.modelCreatedDate.slice(0, 10) +
". Mean pLDDT " + entry.globalMetricValue.toFixed(1) + ".";
// 2. The per-residue scores give a text version of the colours
const conf = await (await fetch(entry.plddtDocUrl)).json();
const longest = longestStretches(conf);
$("legend").innerHTML = BANDS.map(b => {
const run = longest[b.code];
return '<li><span class="swatch" style="background:' + b.colour + '"></span><b>' + b.name + "</b> (" + b.range + "): " +
Math.round(entry[b.fraction] * 100) + "% of residues" +
(run ? "; longest stretch: " + (run.length > 1 ? "residues " + run.from + "–" + run.to : "residue " + run.from) : "") + "</li>";
}).join("");
// 3. The model itself, coloured by pLDDT (one-line alternative: viewer.loadAlphaFoldDb(accession))
await viewer.plugin.clear();
await viewer.loadStructureFromUrl(entry.bcifUrl, "mmcif", true, {
representationParams: { theme: { globalName: "plddt-confidence" } },
label: entry.modelEntityId
});
// 4. The predicted aligned error, as AlphaFold DB draws it
$("pae").src = entry.paeImageUrl;
$("pae").alt = "Predicted aligned error plot for " + entry.modelEntityId + ", " + entry.uniprotDescription;
}
async function start() {
const viewer = await molstar.Viewer.create("viewer", {
layoutIsExpanded: false, layoutShowControls: false,
viewportShowAnimation: false, viewportShowSelectionMode: false
});
const load = () => show(viewer, $("protein").value).catch(err => {
console.error(err);
$("meta").textContent = "Could not load the AlphaFold model: " + err.message;
});
$("protein").addEventListener("change", load);
// AlphaFold files keep pLDDT in the B-factor column, so a query on B_iso_or_equiv finds low-confidence residues
$("low").onclick = () => {
viewer.structureInteractivity({
expression: Q => Q.struct.generator.atomGroups({
"residue-test": Q.core.rel.lt([Q.struct.atomProperty.macromolecular.B_iso_or_equiv(), 50])
}),
action: "select"
});
const stats = viewer.plugin.managers.structure.selection.stats;
say(stats.elementCount ? "Selected (in green): " + stats.label.replace(/<[^>]+>/g, "") + "." : "No residues have a pLDDT below 50.");
};
$("clear").onclick = () => {
viewer.structureInteractivity({ action: ["select", "highlight"] });
viewer.plugin.managers.camera.reset();
say("");
};
await load();
}
start().catch(err => {
console.error(err);
$("meta").textContent = "The viewer could not start: " + err.message;
});
</script>
</body>
</html>What the code does:
- The API. A request for
/api/prediction/P04637onalphafold.ebi.ac.ukreturns a list with one record for each model. A record gives the model files (bcifUrl,cifUrl,pdbUrl), the scores for each residue (plddtDocUrl), the predicted aligned error as a picture (paeImageUrl) and as numbers (paeDocUrl), the version (latestVersion), the mean pLDDT (globalMetricValue) and the share of residues in each band (fractionPlddtVeryHighand three more). The API and the files allow use from other websites. - pLDDT is AlphaFold’s confidence in the local structure around each residue, on a scale of 0 to 100. AlphaFold DB colours it in four bands: very high (90 and above), confident (70 to 90), low (50 to 70) and very low (below 50) [5].
theme: { globalName: "plddt-confidence" }gives the same colours in Mol*. The viewer’sloadAlphaFoldDb("P04637")does the lookup and the loading in one line, if you do not need the record. - Finding low-confidence residues. AlphaFold files keep pLDDT in the B-factor column [5], so a query on
B_iso_or_equivbelow 50 selects the very low residues: 117 of p53’s 393. - A text version. The API gives each band’s share of the residues, and the file of scores its longest unbroken stretch, so the colours are not the only way to read the model.
- The predicted aligned error (PAE) is the error, in ångströms, that AlphaFold expects in the position of one residue when the model and the true structure are lined up on another. Low values between two domains mean that AlphaFold is confident about their positions relative to each other [5]. AlphaFold DB’s picture of it is a bare 320-pixel square, so the page adds a key and a caption. Values stop at 31.75 Å [5].
- Three proteins, three lessons. p53’s DNA-binding domain (residues 94 to 292) is mostly very high, while most of its first 90 residues and much of its far end are very low. Calmodulin’s two lobes are each confident, but the PAE between them is high: from the PAE file, about 4 Å within each lobe and 22 Å between them. β-globin is very high almost everywhere, yet the model is one chain without its haem.
Put a viewer in a page without code
Two of the viewers can be set up in HTML alone. PDBe Mol* gives you the whole of Mol* as one tag. 3Dmol.js turns a <div> with the right attributes into a viewer, at a tenth of the size [6].
This example downloads PDBe Mol* (1.6 MB compressed) when it starts.
Code pdbe-molstar-tag.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PDBe Mol*: a structure viewer from one HTML tag</title>
<!-- PDBe Mol* 3.12.0: a stylesheet and the script that defines the <pdbe-molstar> tag -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pdbe-molstar@3.12.0/build/pdbe-molstar-light.css"
integrity="sha384-kARlbziDLc0Hj8qk+KXR1P27YYVjD71TknAF2jm8h4pCtySxFh7ivRH8IXXJNC/B"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/pdbe-molstar@3.12.0/build/pdbe-molstar-component.js"
integrity="sha384-Lvs9mZ6oGGQHH9AFrQb4n+M7GdH8Mp3adRaCEJuEgNOeME7JSuvcakm6pxJ/Yn3L"
crossorigin="anonymous"></script>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 8px; }
/* The tag needs a size and a positioned box */
pdbe-molstar { display: block; position: relative; height: 380px; }
.controls { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; }
button { font: inherit; font-size: 14px; padding: 4px 9px; }
#status { min-height: 1.5em; margin: 6px 0 0; }
</style>
</head>
<body>
<h1>Haemoglobin (PDB 4HHB) from one tag</h1>
<!-- The whole viewer. Attributes choose the entry, the background and which panels to show. -->
<pdbe-molstar id="hb" molecule-id="4hhb" hide-controls="true" pdbe-link="false"
bg-color-r="255" bg-color-g="255" bg-color-b="255"></pdbe-molstar>
<!-- Optional: buttons that script the same viewer. Delete from here to </script> and the viewer still works. -->
<div class="controls" role="group" aria-label="Select the sickle-cell residue">
<button type="button" id="uniprot">Select UniProt P68871 residue 7</button>
<button type="button" id="pdb">Select PDB residue 6 of chains B and D</button>
<button type="button" id="reset">Reset</button>
</div>
<p id="status" role="status" aria-live="polite"></p>
<script>
const viewer = document.getElementById("hb").viewerInstance; // the plugin inside the tag
const say = text => { document.getElementById("status").textContent = text; };
// loadComplete reports true or false once the tag has tried to load its entry
viewer.events.loadComplete.subscribe(ok => {
if (!ok) say("The structure could not be loaded from PDBe. Check your connection, or try again later.");
});
// PDBe's files map every residue to UniProt, so residues can be chosen by either numbering
document.getElementById("uniprot").onclick = async () => {
await viewer.visual.select({
data: [{ uniprot_accession: "P68871", uniprot_residue_number: 7, color: "#d7263d", sideChain: true }],
nonSelectedColor: "#cccccc"
});
say("UniProt residue 7 of β-globin is glutamate 6 of chains B and D in the PDB file. Hover over it to see both numbers.");
};
document.getElementById("pdb").onclick = async () => {
await viewer.visual.select({
data: [
{ auth_asym_id: "B", auth_seq_id: 6, color: "#d7263d", sideChain: true },
{ auth_asym_id: "D", auth_seq_id: 6, color: "#d7263d", sideChain: true }
],
nonSelectedColor: "#cccccc"
});
say("The same two residues, chosen by the numbers in the PDB file.");
};
document.getElementById("reset").onclick = async () => {
await viewer.visual.clearSelection();
await viewer.visual.reset({ camera: true });
say("");
};
</script>
</body>
</html>Points to note:
- Attributes set everything:
molecule-id(a PDB ID, fetched from PDBe),custom-data-urlandcustom-data-formatfor other files,alphafold-viewfor pLDDT colours,hide-controls,sequence-panel,pdbe-link, and the background asbg-color-r,bg-color-gandbg-color-b. An empty value counts as true, andfalse,offor0as false. - Scripting. The tag’s
viewerInstanceis the PDBe Mol* plugin.visual.selectcolours residues, shows their side chains and greys out the rest in one call;visual.clearSelectionandvisual.resetundo it. - UniProt numbers. PDBe’s files map the protein’s residues to UniProt, so
uniprot_accessionanduniprot_residue_numberwork as selectors. Residue 7 of P68871 selects Glu6 of chains B and D, and the hover label showsB | GLU 6withUNP P68871 7 E. This works only with PDBe’s own files. - An older Mol*. PDBe Mol* 3.12.0, from April 2026, contains Mol* 5.8.0, and it writes its settings to the browser’s console. Its wiki still shows older versions and older names, such as
struct_asym_idfor what is nowlabel_asym_id.
Code 3dmol-no-code.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>3Dmol.js: haemoglobin with the sickle-cell residue labelled, without writing JavaScript</title>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 8px; }
p { margin: 6px 0 0; font-size: 14px; color: #3b4659; }
/* 3Dmol.js needs a positioned container with a size */
.viewer_3Dmoljs { position: relative; height: 400px; }
</style>
</head>
<body>
<h1>Haemoglobin (PDB 4HHB), with no JavaScript written</h1>
<!-- Every element with the class viewer_3Dmoljs becomes a viewer, set up by its data- attributes.
data-selectN chooses atoms and data-styleN says how to draw them, applied in order.
A style replaces the earlier style of those atoms, so style 2 repeats the cartoon.
data-labelresN labels each chosen residue with its name and number. -->
<div class="viewer_3Dmoljs" role="img"
aria-label="Haemoglobin with alpha chains in grey, beta chains in blue, haem groups in orange, and glutamate 6 of each beta chain as labelled red spheres"
data-pdb="4HHB"
data-backgroundcolor="0xffffff"
data-style="cartoon:color=#b8b8b8"
data-select1="chain:B,D" data-style1="cartoon:color=#4a7fc1"
data-select2="chain:B,D;resi:6" data-style2="cartoon:color=#4a7fc1;sphere:color=#d7263d"
data-labelres2="fontSize:14;fontColor:black;backgroundColor:white;backgroundOpacity:0.85;inFront:true"
data-select3="resn:HEM" data-style3="stick:colorscheme=orangeCarbon"></div>
<p>α chains grey, β chains blue, haem orange. The red spheres are glutamate 6 of each β chain.</p>
<p id="status" role="status" aria-live="polite"></p>
<!-- Optional: 3Dmol.js says nothing when it cannot download the file, so this does. -->
<script>
window.addEventListener("unhandledrejection", () => {
document.getElementById("status").textContent =
"The structure could not be downloaded from the RCSB PDB. Check your connection, or try again later.";
});
</script>
<!-- 3Dmol.js 2.5.5. Loaded after the viewer element; it finds the element and draws it. -->
<script src="https://cdn.jsdelivr.net/npm/3dmol@2.5.5/build/3Dmol-min.js"
integrity="sha384-OsczYbldvrHgslr9fFp/i4GiLSeuw9l+QIlv99ITw8soOwXcoGeflFMLg+CU/X1d"
crossorigin="anonymous"></script>
</body>
</html>A few points about the code:
- Any element with the class
viewer_3Dmoljsbecomes a viewer when the script loads.data-pdbfetches a PDB-format file from the RCSB PDB, anddata-hrefloads any address, taking the format from the file name. The largest entries have no PDB-format file, so usedata-hrefwith an mmCIF file for those. - Selections and styles come in numbered pairs, applied in order:
data-select2chooses atoms anddata-style2says how to draw them. A style replaces the earlier style of those atoms, so style 2 repeats the cartoon. - Labels.
data-labelres2labels each chosen residue with its name and number. Options inside options need the attribute written as JSON. - More control needs JavaScript, and the API is small:
$3Dmol.createViewer,addModel,setStyle,addLabelandsetClickable, whose callback receives the clicked atom with itschain,resiandresn.data-callbackcan run code after loading, but througheval, which a strict content security policy blocks.
Draw the same view with NGL
NGL [7] is one of the two viewers that Mol* grew from [2], and it still works. Its selection language is short and readable, but version 2.5.0 came nearly two years after 2.4.0, and two problems matter for teaching: labels cannot show Greek letters, and its built-in AlphaFold shortcut asks for files that no longer exist.
Code ngl-basic.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NGL: haemoglobin with the sickle-cell residue labelled</title>
<script src="https://cdn.jsdelivr.net/npm/ngl@2.5.0/dist/ngl.js"
integrity="sha384-qOi8a5spSPm4TRNARfg3E5ksGPddyrJ4+cwWLRXFKthmvVZK5HgLmh2/gxptXYPl"
crossorigin="anonymous"></script>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 8px; }
#viewport { height: 400px; }
#picked { min-height: 1.5em; margin: 6px 0 0; }
</style>
</head>
<body>
<h1>Haemoglobin (PDB 4HHB) in NGL. Click an atom to name its residue.</h1>
<div id="viewport" role="img" aria-label="Haemoglobin with alpha chains in grey, beta chains in blue and glutamate 6 of each beta chain as labelled red spheres"></div>
<p id="picked" role="status" aria-live="polite"></p>
<script>
const picked = document.getElementById("picked");
// NGL leaves an empty box when the browser has no WebGL, so check first and say so
const canvas = document.createElement("canvas");
if (!(canvas.getContext("webgl2") || canvas.getContext("webgl"))) {
picked.textContent = "This browser cannot show 3D graphics (WebGL is turned off or not supported).";
} else {
const stage = new NGL.Stage("viewport", { backgroundColor: "white" });
window.addEventListener("resize", () => stage.handleResize()); // NGL does not resize by itself
stage.loadFile("rcsb://4hhb.cif").then(hb => { // https://files.rcsb.org/download/4hhb.cif
// NGL's selection language: ":B" is chain B, "6:B" is residue 6 of chain B
hb.addRepresentation("cartoon", { sele: ":A or :C", color: "#b8b8b8" });
hb.addRepresentation("cartoon", { sele: ":B or :D", color: "#4a7fc1" });
hb.addRepresentation("ball+stick", { sele: "HEM", color: "#e0a526" });
hb.addRepresentation("spacefill", { sele: "6:B or 6:D", color: "#d7263d" });
// Label text is looked up by atom index. NGL's font has no Greek letters, so write "beta", not β.
const ca = hb.structure.getAtomIndices(new NGL.Selection("6:B and .CA"))[0];
hb.addRepresentation("label", {
sele: "6:B and .CA", labelType: "text", labelText: { [ca]: "beta Glu6 (Val in HbS)" },
color: "black", radiusType: "size", radiusSize: 1.6,
fixedSize: true, // the same size on screen at any zoom; otherwise the size is in ångströms
zOffset: 6 // drawn 6 Å nearer the viewer, in front of the spheres
});
hb.autoView();
}).catch(err => {
console.error(err);
picked.textContent = "The structure could not be loaded (" + ((err && err.message) || err) + "). Check your connection, or try again later.";
});
// Report the residue under the pointer when it is clicked
stage.signals.clicked.add(pick => {
if (pick && pick.atom) {
const a = pick.atom;
picked.textContent = "Chain " + a.chainname + ", " + a.resname + " " + a.resno + " (atom " + a.atomname + ")";
}
});
}
</script>
</body>
</html>- Selections are short strings:
":B"is chain B,"6:B"is residue 6 of chain B, and"HEM"the haem groups. - Labels are looked up by atom index. The font has no Greek letters (β shows as a question mark in a diamond), so the example writes “beta”.
fixedSize: truekeeps the text one size on screen. - Resizing. Call
stage.handleResize()when the window changes size. - AlphaFold.
alphafold://P68871asks for version 4 files, which now return an error. Load the address from the API instead, as the Mol* example does. - No WebGL. NGL draws nothing and says nothing, so the example checks first.
Things that catch people out
- No WebGL. All four viewers draw with WebGL, which some locked-down or old computers lack. Mol* then shows its own message (“WebGL does not seem to be available”) and NGL shows nothing. We could not test 3Dmol.js without WebGL, because Chromium fell back to drawing in software. Check with
canvas.getContext("webgl2") || canvas.getContext("webgl"), as the NGL example does, and give a picture and a sentence of text instead. - Computers without a graphics card draw in software. In our tests Mol* then took 3.5 to 7 seconds to show a structure, camera moves took a few seconds, and a molecular surface in NGL took more than 30 seconds. So let students switch surfaces on, rather than showing them by default.
- Several viewers on one page. Each viewer uses its own WebGL context, and browsers allow only a limited number at once (about 16 in Chrome). Load viewers when they are needed, as this page does.
- Phones and tablets. The examples worked without sideways scrolling in Chrome’s emulation of a Pixel 7 phone and at 350 pixels wide, but we have not tested real phones or touch gestures. Mol*’s expand button gives the viewer the whole screen, and Mol* runs on iPads, which many students use instead of laptops [8].
- Screen readers and keyboards. The 3D view is a picture drawn on a canvas, which screen readers cannot read, and turning it needs a drag. Give every task another route: buttons, a form, a text list of the key residues, and alt text that states the structural point [9]. Turning by dragging needs an alternative that does not drag (WCAG 2.2, success criterion 2.5.7), and a molecule that spins by itself needs a pause button (2.2.2). See accessibility.
- Numbering. PDB author numbers, PDB label numbers, UniProt numbers and HGVS names can all differ. Say which you use, and test each selection on the file you load. The haemoglobin table shows one case.
- AlphaFold versions. Never write a file address with a version in it, such as
…model_v4.pdb. Ask the API for the current address instead. - Versions. Mol* releases about once a month (5.10.0 in June 2026, 5.13.0 in October), and its options change between releases. We tested version 5.12.0. Pin the version, and read the release notes before you update.
Licences and credits
Mol* and NGL are under the MIT licence, PDBe Mol* under Apache 2.0 and 3Dmol.js under BSD 3-clause. If you publish teaching material built on them, cite their papers (below). PDB data are in the public domain (CC0) under the wwPDB usage policy, which encourages you to credit the authors of each entry; for 4HHB, that is Fermi and colleagues [1]. AlphaFold DB files are under a CC BY 4.0 licence, which requires credit: cite AlphaFold DB [4, 5] and AlphaFold [10].
References
- Fermi G, Perutz MF, Shaanan B, Fourme R (1984). The crystal structure of human deoxyhaemoglobin at 1.74 Å resolution. Journal of Molecular Biology 175(2): 159–174. doi:10.1016/0022-2836(84)90472-8
- Sehnal D, Bittrich S, Deshpande M, et al. (2021). Mol* Viewer: modern web app for 3D visualization and analysis of large biomolecular structures. Nucleic Acids Research 49(W1): W431–W437. doi:10.1093/nar/gkab314
- Midlik A, Bittrich S, Fleming JR, et al. (2025). MolViewSpec: a Mol* extension for describing and sharing molecular visualizations. Nucleic Acids Research 53(W1): W408–W414. doi:10.1093/nar/gkaf370
- Bertoni D, Tsenkov M, Magana P, et al. (2026). AlphaFold Protein Structure Database 2025: a redesigned interface and updated structural coverage. Nucleic Acids Research 54(D1): D358–D362. doi:10.1093/nar/gkaf1226
- Varadi M, Anyango S, Deshpande M, et al. (2022). AlphaFold Protein Structure Database: massively expanding the structural coverage of protein-sequence space with high-accuracy models. Nucleic Acids Research 50(D1): D439–D444. doi:10.1093/nar/gkab1061
- Rego N, Koes D (2015). 3Dmol.js: molecular visualization with WebGL. Bioinformatics 31(8): 1322–1324. doi:10.1093/bioinformatics/btu829
- Rose AS, Bradley AR, Valasatava Y, et al. (2018). NGL viewer: web-based molecular graphics for large complexes. Bioinformatics 34(21): 3755–3758. doi:10.1093/bioinformatics/bty419
- Berndsen CE, Shenk KE, Young A, et al. (2026). Breaking barriers in large classes: using cloud computing and computational biology to implement research-inspired curricula in Biochemistry I. Biochemistry and Molecular Biology Education 54(5): 442–449. doi:10.1002/bmb.70069
- Smits TC, Weru L, Gehlenborg N, L’Yi S (2025). Ten simple rules for making biomedical data resources accessible. PLOS Computational Biology 21(11): e1013657. doi:10.1371/journal.pcbi.1013657
- Jumper J, Evans R, Pritzel A, et al. (2021). Highly accurate protein structure prediction with AlphaFold. Nature 596(7873): 583–589. doi:10.1038/s41586-021-03819-2