Cookbook
The recipes below solve common integration tasks with plain JavaScript and CSS; none needs a framework or a build step. Each one states which export options it needs.
Highlight every atom of an element on hover
Problem: hovering any oxygen should visually pick out all the oxygens in the structure.
Requires: a static SVG export (chemistry metadata is always embedded; data-mk-element is the attribute used).
<style> /* CSS-only variant: hovering one O dims everything that is not an O */ .mol:has(.atom-group[data-mk-element="O"]:hover) .atom-group:not([data-mk-element="O"]), .mol:has(.atom-group[data-mk-element="O"]:hover) .bond-group { opacity: 0.25; transition: opacity 0.15s; }
/* Used by the JS variant below */ .atom-group.is-highlighted { filter: drop-shadow(0 0 3px #fab005); }</style>
<div class="mol"><!-- inline SVG export here --></div>
<script> // JS variant: works in any browser and for any element, not just O document.querySelectorAll('.mol .atom-group[data-mk-element]').forEach(atom => { const symbol = atom.dataset.mkElement; const peers = () => document.querySelectorAll(`.mol .atom-group[data-mk-element="${symbol}"]`); atom.addEventListener('pointerenter', () => peers().forEach(a => a.classList.add('is-highlighted'))); atom.addEventListener('pointerleave', () => peers().forEach(a => a.classList.remove('is-highlighted'))); });</script>How it works: every atom is a g.atom-group carrying data-mk-element, so a single attribute selector matches all atoms of one element. The CSS variant dims non-matches instead of recoloring matches because theme-aware exports set element colors with !important, which a plain fill override would lose to; opacity and filter sidestep that entirely. The CSS variant needs :has() support, so the JS variant doubles as the fallback.
Tooltip from atom metadata
Problem: show hybridization, geometry, and formal charge in a tooltip that follows the cursor.
Requires: a static SVG export (chemistry metadata is always embedded).
<style> .atom-tip { position: fixed; z-index: 10; pointer-events: none; background: #222; color: #fff; padding: 4px 8px; border-radius: 4px; font: 13px/1.4 system-ui; white-space: pre-line; }</style>
<script> const tip = document.createElement('div'); tip.className = 'atom-tip'; tip.hidden = true; document.body.appendChild(tip);
const svg = document.querySelector('.mol svg');
svg.addEventListener('pointerover', e => { const atom = e.target.closest('[data-mk-element]'); if (!atom) return; const d = atom.dataset; const lines = [d.mkElement]; if (d.mkHybridization) lines.push('Hybridization: ' + d.mkHybridization); if (d.mkGeometry) lines.push('Geometry: ' + d.mkGeometry); if (d.mkFormalCharge && d.mkFormalCharge !== '0') lines.push('Formal charge: ' + d.mkFormalCharge); tip.textContent = lines.join('\n'); tip.hidden = false; });
svg.addEventListener('pointermove', e => { tip.style.left = e.clientX + 14 + 'px'; tip.style.top = e.clientY + 14 + 'px'; });
svg.addEventListener('pointerout', e => { if (e.target.closest('[data-mk-element]')) tip.hidden = true; });</script>How it works: a single delegated pointerover listener on the SVG catches hovers on any atom via closest(), so it keeps working if you swap the molecule out. Hybridization and geometry are conditional attributes (Molkit omits them when the model has nothing to say), so each line is guarded before it is added.
Build your own structure inspector
Problem: you want a small teaching panel where clicking an atom or bond shows its chemistry.
Molkit exports carry enough chemistry to drive small interactive teaching tools without any server or chemistry library: the per-atom and per-bond data-mk-* attributes are the whole data layer.
Requires: a static SVG export (chemistry metadata is always embedded).
<div class="mol-row" style="display: flex; gap: 1rem;"> <div id="mol"><!-- inline SVG export with metadata here --></div> <aside id="inspector" style="min-width: 14rem;">Click an atom or bond.</aside></div>
<script> const panel = document.getElementById('inspector');
function row(label, value) { return value == null ? '' : `<dt>${label}</dt><dd>${value}</dd>`; }
function renderAtom(d) { panel.innerHTML = `<h3>${d.mkElement} atom</h3><dl>` + row('Formal charge', d.mkFormalCharge) + row('Lone pairs', d.mkLonePairs) + row('Hybridization', d.mkHybridization) + row('Geometry', d.mkGeometry) + row('Octet', d.mkOctet) + '</dl>'; }
function renderBond(d) { const names = { 1: 'single', 2: 'double', 3: 'triple' }; const polarity = d.mkPolarity ? `${d.mkPolarity} (toward ${d.mkPolarToward})` : null; panel.innerHTML = '<h3>Bond</h3><dl>' + row('Order', names[d.mkOrder] ?? d.mkOrder) + row('Stereo', d.mkStereo) + row('Polarity', polarity) + '</dl>'; }
document.querySelectorAll('#mol .atom-group[data-mk-element]').forEach(a => a.addEventListener('click', () => renderAtom(a.dataset)));
document.querySelectorAll('#mol .bond-group[data-mk-order]').forEach(b => b.addEventListener('click', () => renderBond(b.dataset)));</script>How it works: atoms are queried by data-mk-element and bonds by data-mk-order, the two attributes present on every metadata-enabled group; everything else is conditional, which is why row() drops null values instead of printing “undefined”. The octet value is satisfied, deficient, or expanded, and is absent where the octet is undefined (generic atoms, bridging hydrogens, bare metal ions); polarity only appears on meaningfully polar bonds. Generic placeholder atoms carry only data-mk-element="*", data-mk-generic, and their label, so the panel shows only the heading for them. From here it is a short step to quizzes (“predict the hybridization, then click to check”) or step-through Lewis-structure reasoning.
Static-to-animated toggle
Problem: show the static structure by default and play its animated companion on demand.
Requires: both exports inlined on the page, for example reaction.svg and reaction-animation.svg.
<div id="reaction"> <span class="static"><!-- inline reaction.svg --></span> <span class="animated" hidden><!-- inline reaction-animation.svg --></span></div><button id="play">Play animation</button>
<script> const staticView = document.querySelector('#reaction .static'); const animView = document.querySelector('#reaction .animated'); const btn = document.getElementById('play'); let playing = false;
btn.addEventListener('click', () => { playing = !playing; staticView.hidden = playing; animView.hidden = !playing; btn.textContent = playing ? 'Show static' : 'Play animation'; if (playing) { const svg = animView.querySelector('svg'); svg.setCurrentTime(0); // restart from the first state svg.unpauseAnimations(); } });</script>How it works: animated exports run on a single SMIL timeline, so setCurrentTime(0) plus unpauseAnimations() is a full restart; without the rewind, re-opening the animation would resume mid-timeline. The static sibling stays in the DOM so toggling back is instant. See Animations for the full control surface.
Site theme toggle wiring
Problem: your site has its own light/dark toggle, and embedded SVGs should follow it instead of the OS preference.
Requires: theme-aware exports from a current Molkit build (older theme-aware exports only respond to the OS media query).
const wrapper = document.querySelector('main');
function syncMolkitTheme(dark) { wrapper.classList.toggle('mk-dark', dark); wrapper.classList.toggle('mk-light', !dark);}
// Call from your existing toggle; for example:syncMolkitTheme(document.documentElement.dataset.theme === 'dark');How it works: every theme-aware export ships its dark rules under two hooks: the prefers-color-scheme media query, and an mk-dark class on the SVG or any ancestor that forces dark unconditionally. The mk-light class is the opposite hook, suppressing the media query so a forced-light site never gets OS-dark embeds. Putting both classes on one shared wrapper themes every embed on the page at once; Theming explains the generated CSS.
Feed the structure to RDKit.js
Problem: you want real cheminformatics (canonical SMILES, descriptors, substructure search) on the embedded structure, in the browser.
Requires: a static SVG export of a single species (the MOL block is written only for one species). Loading RDKit.js from a CDN is a network dependency of roughly 9 MB of WebAssembly, so lazy-load it.
<script src="https://unpkg.com/@rdkit/rdkit/dist/RDKit_minimal.js"></script><script> function molBlockFrom(svg) { const meta = svg.querySelector('metadata'); if (!meta) return null; for (const block of meta.children) { if (block.localName !== 'chemistry') continue; for (const child of block.children) { if (child.localName === 'mol-block') return child.textContent; } } return null; }
initRDKitModule().then(RDKit => { const molBlock = molBlockFrom(document.querySelector('.mol svg')); if (!molBlock) return; const mol = RDKit.get_mol(molBlock); console.log(mol.get_smiles()); // canonical SMILES console.log(mol.get_descriptors()); // computed properties (JSON) mol.delete(); // RDKit objects are manual-free });</script>How it works: metadata-enabled exports embed a standard V2000 MOL block inside the SVG’s metadata element, in a namespaced mk:chemistry block. The lookup matches on localName rather than tag name because namespaced selectors behave differently across browsers. Once you have the MOL block as text, anything that reads V2000 will take it; RDKit.js is the most convenient in-browser consumer.
Keyboard step-through presenter
Problem: present an animated mechanism one state at a time with the arrow keys instead of letting it play through.
Requires: an animated export, inlined.
<div class="anim"><!-- inline reaction-animation.svg --></div><p id="anim-status" aria-live="polite"></p>
<script> const svg = document.querySelector('.anim svg'); const status = document.getElementById('anim-status'); const breakpoints = svg.dataset.breakpoints.split(',').map(Number); let state = 0;
function seek(i) { state = Math.max(0, Math.min(i, breakpoints.length - 1)); svg.setCurrentTime(breakpoints[state]); status.textContent = `State ${state + 1} of ${breakpoints.length}`; }
svg.pauseAnimations(); seek(0);
document.addEventListener('keydown', e => { if (e.key === 'ArrowRight') seek(state + 1); if (e.key === 'ArrowLeft') seek(state - 1); });</script>How it works: animated exports stamp data-breakpoints on the root, a comma-separated list of timeline seconds at which each state has fully settled, starting with 0 for the initial state. Pausing the timeline and calling setCurrentTime with a breakpoint renders that state as a still frame, so arrow keys become discrete state navigation. The companion data-duration and data-transitions attributes support richer players; see Animations.
Show a figure’s potential map in place of the drawing
Problem: a page should open on a molecule’s electrostatic potential map rather than its 2D drawing, at the angle the author chose.
Requires: an SVG exported with a map (see Electrostatic Potential Maps); the structure widget, with molkit-widget-esp.js in the same folder as molkit-widget.js. This recipe uses the widget rather than an inline SVG.
// Separate .esp files that are not beside their SVGs live here (looked up by file name).MolkitWidget.espBase = '/files/esp/';
MolkitWidget.mount(document.getElementById('water-map'), { src: '/img/water.svg', espAuto: true,});How it works: espAuto opens the widget on its 3D tab and turns the map on as soon as the 3D view is ready. When the author exported with the map showing in Molkit’s 3D viewer, the SVG stores that camera angle in mk:esp-display, and the widget starts there once; after that the reader turns the model freely. The map code and data download only for a figure opened this way or when a reader presses the map button.
See also
- Overview for the three-layer picture of what an export contains
- Embedding for inlining, sizing, and multiple-copy pitfalls
- Theming for the
mk-*class reference and the dark-mode CSS - Metadata for the full
data-mk-*schema these recipes read - Animations for the SMIL timeline and player patterns
- Structure widget for a ready-made interactive viewer over the same metadata