@molviewer/core
v0.4.4
Published
Extensible, offline, web-native 3D molecular viewer for small molecules and MD trajectories.
Maintainers
Readme
molviewer
An extensible, offline, web-native 3D molecular viewer for small molecules and MD trajectories. Built on the Mol* engine, wrapped in a custom React UI. Drag-drop a file; nothing is uploaded.
Working on the code? Read AGENTS.md instead of this file. It is the single complete reference — features, functionality, architecture, the invariants that must not be broken, and the traps already hit and fixed. This README is just the pitch and the integration snippet.
What's here
| Layer | Notes |
|---|---|
| Core — data model, parser registry, readers, inference pipeline | Framework-agnostic: no React, no Mol*, no DOM. Strictly type-checked and exercised end-to-end in Node (npm run check, npm run demo) |
| Engine seam — RenderEngine interface | Pure TypeScript, no Mol* types. Swap the renderer by implementing one interface |
| Mol* adapter — MolstarEngine | Browser-only, and together with sizeTheme.ts the only place in the repo that imports Mol* — so a version bump has two files to check, not a codebase |
| React UI — MolViewer + panels | One component, one built stylesheet, typed. Shipped as @molviewer/core |
The core imports nothing outside itself — no React, no Mol*, no DOM — and is demonstrated
end-to-end by npm run demo, which parses four example files, runs element inference + bond
perception, prints the transparency log, emits mmCIF, and asserts its way through it all.
Using it in another React app
The viewer exports a MolViewer component and a full imperative API via useImperativeHandle:
npm run build:lib # -> dist/molviewer.js, dist/style.css, dist/types/
npm pack # -> molviewer-core-0.3.0.tgz# in the host application
npm install ./molviewer-core-0.3.0.tgz react react-domBasic usage
import { MolViewer } from '@molviewer/core';
import '@molviewer/core/style.css';
export function App() {
return <div style={{ height: '100vh' }}><MolViewer /></div>;
}Controlled loading and events
import { MolViewer, type MolViewerHandle } from '@molviewer/core';
import { useRef, useState } from 'react';
export function App() {
const ref = useRef<MolViewerHandle>(null);
const [file, setFile] = useState<File>();
return (
<div style={{ display: 'flex', height: '100vh', flexDirection: 'column' }}>
<input type="file" onChange={(e) => setFile(e.target.files?.[0])} />
<button onClick={() => ref.current?.select([0, 1, 2])}>
Select first 3 atoms
</button>
<button onClick={() => ref.current?.screenshot()}>
Screenshot
</button>
<MolViewer
ref={ref}
source={file}
onLoad={(e) => console.log(`Loaded ${e.atomCount} atoms`)}
onEdit={(e) => console.log(`Edited: ${e.label}`)}
onSelectionChange={(sel) => console.log(`Selected: ${sel.atoms}`)}
onViewChange={(view) => console.log(`View changed to ${view.representation.kind}`)}
style={{ flex: 1 }}
/>
</div>
);
}Full API surface
Source types (any of these via the source prop):
File— a browserFileobject (from a drop handler or<input>'s.files[0]){ name: string; text: string }— load from a string. Twelve formats are registered by default: XYZ/extXYZ, MDL MOL/SDF, PDB, GRO, LAMMPS dump, MOL2, LAMMPS data, CIF/mmCIF, VASP POSCAR/CONTCAR, XSF, Gaussian input (.gjf.com) and Gaussian output (.log.out). Thename's extension narrows the candidates, but each reader also sniffs the content and the highest-confidence one wins — so a wrong or missing extension still resolves{ name: string; bytes: ArrayBuffer }— load from a typed buffer{ url: string; name?: string }— fetch from a URL{ system: System; name?: string }— load a pre-parsedSystemobject from@molviewer/core
Load mode — via the separate sourceMode prop (not part of source), default 'replace':
'replace'— clear and load the new structure, reset undo history and the dirty flag'update'— swap geometry of the current system, preserving view/selection/tool when atom count is stable
A third mode, 'add' (append atoms to the current structure, increments the dirty count), exists
but is reachable only through the imperative handle: ref.current.load(source, { mode: 'add' }).
Handle methods (via ref.current, 23 total — the full list lives in AGENTS.md §3):
load(source, opts?)/update(source)— imperative load, includingmode: 'add'select(indices, opts?)/selectAll()/clearSelection()— selection (firesonSelectionChange)setSettings(patch)/getSettings()— adjust view (non-undoable; firesonViewChange)setTool(tool)/setFrame(index)/play()/pause()/undo()/redo()exportText(formatId?): string— serialize the current frame to a stringexportFile(formatId?): File— serialize to aFile; does not itself trigger a download — the host wires upURL.createObjectURL+ an anchor click (seesrc/harness.tsx)screenshot(): Promise<Blob>— capture canvasresetView()/recenter()/engine()/dispatch(action)/getState()isModified(): boolean— query the dirty flaggetSystem(): System | null— access the loaded structure
Events (all optional and independent; can fire together in one dispatch):
onReady(handle)— engine and UI initialized; the handle is passed directlyonLoad(e)— structure loaded; carriesatomCount,frameCount,mode,fileNameonEdit(e)— molecule modification only ({ label, seq, system, modified }); never fires for appearance changesonModifiedChange(modified: boolean)— fires only on the clean↔dirty transitiononSelectionChange(sel)—{ atoms, bond, boxSelected }; always fires regardless of edit typeonViewChange(view: ViewerViewState)— any non-molecular change (representation, colours, labels, selection, box, per-atom sizes, …). Camera is not included: it lives in Mol*, not in the store, so orbiting the view fires nothingonFrameChange({ frame, frameCount })— trajectory frame advancedonToolChange(tool)— active pointer tool changedonLoadProgress(progress: number | null)— file fetch in progressonLoadError({ error, fileName })— load or parse failedonSave(e)— the Save button was pressed.{ text: () => string, formatId, fileName, modified, saved() }. Passing this prop is what draws the button; omit it and there is none, because a button that cannot do anything is worse than no button. The button is also disabled until there are unsaved changes.textis a thunk, so a host that only wants the file name never pays to serialise the structure. Calle.saved()once your write succeeded and the viewer marks the structure clean (button greys out,onModifiedChange(false)fires); don't call it if the write failed, and it stays unsaved. This is not Export: Export is a download the viewer performs itself, Save hands the structure to you and you decide where it goesonPolymerBuild(e)— a polymer was built (Edit › Polymer).{ text: () => string, formatId, sourceFileName, monomers, chains, tacticity, system, report }. Building is the one structure operation that does not change what is on screen — the monomer stays the monomer and the polymer is handed over as a new MOL2 file, so pressing Build again just produces another one.sourceFileNameis the monomer's name: the viewer does not name the new file, because only the host can see the folder and so only the host knows which names are taken. UnlikeonSave, omitting this does not hide the button — with no host workspace the viewer downloads the.mol2itself.report.clashes > 0means rigid placement left atoms overlapping; the geometry is never minimised. See the fuller example under UI composition below
There is also an interactions prop (onAtomClick, onBondClick, onBoxClick, onEmptyClick,
onKeyDown, onFileOpen, onContextMenuOpen — all cancelable via e.preventDefault()) for
low-level pointer/keyboard interception, and contextMenuItems to append rows to the built-in
right-click menu. See AGENTS.md §3 for the full shapes.
UI composition (all optional — keyboard shortcuts, theming and action interception are
top-level props, NOT nested under ui):
<MolViewer
ui={{ sidebar: true }} // 15 booleans + sidebarWidth + a `panels` sub-object; see AGENTS.md §3
keyboardShortcuts="global" // boolean | 'global'; default true (root-scoped)
theme={{ '--accent': '#ff6b35' }} // raw CSS custom properties
interceptAction={(action, state) => {
// Filter or modify actions before dispatch. Return the action to apply, null to skip it,
// or undefined to apply it unchanged.
if (action.type === 'DELETE_SELECTED') return null;
return undefined;
}}
/>Saving, and dirty tracking for unsaved edits
The simplest route is onSave: give the prop, get a Save button in the sidebar header, and
decide yourself where the bytes go. It sits between Open and Export, and stays disabled until
something has actually been edited.
<MolViewer
onSave={async (e) => {
// e.fileName is what it was opened as. e.text() serialises the CURRENT structure —
// only call it if you want the bytes.
try {
await daemon.write(e.fileName ?? `structure.${e.formatId}`, e.text());
e.saved(); // write succeeded -> viewer goes clean and the button greys out
} catch {
// Deliberately NOT calling e.saved(): the structure stays marked unsaved and the
// button stays live, so the user can try again.
}
}}
/>Or drive it entirely from the host, with no viewer button at all:
const [modified, setModified] = useState(false);
<MolViewer ref={ref} onModifiedChange={setModified} />
<button disabled={!modified} onClick={() => {
const file = ref.current!.exportFile('mol2');
const url = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = url; a.download = file.name; a.click();
URL.revokeObjectURL(url);
}}>
Save Changes
</button>onModifiedChange fires only on the clean↔dirty transition: true after the first molecule edit
(add/delete/modify atoms or bonds, or a mode='add' load), false again on undo back to the
loaded structure, and false on every fresh mode='replace'/'update' load.
exportFile/exportText do not clear the dirty flag — downloading a copy is not the same as
the host having stored the work, and the viewer has no way to know a download succeeded. The one
thing that does clear it is e.saved() from the onSave handler, which is the host explicitly
reporting a successful write.
UI composition and customization
ui toggles panels/chrome only (15 booleans, sidebarWidth, and a panels sub-object — see the
"UI composition" block under Full API surface above); keyboard shortcuts
(keyboardShortcuts), theming (theme) and action interception (interceptAction) are separate
top-level props on <MolViewer>, not nested inside ui.
ui.sidebar: false hides the whole <aside> (file card, scope bar, appearance, placement,
structure, polymer, box, diagnostics) — useful for an embedded/headless viewer where the host
controls loading.
The user can also fold the sidebar away at runtime with the button left of undo; it collapses to a
narrow rail carrying the button that brings it back. The structure stays centred in the canvas, so
it moves sideways as the canvas widens — the view is following the frame, not drifting.
ui.panels.trajectory: false hides the playback strip that runs across the bottom of the 3D
view. Note that the strip owns the play loop, so with it hidden handle.play() sets the playing
flag but nothing advances the frame — long-standing documented behaviour, deliberately unchanged
when the controls moved out of the sidebar.
ui.panels.polymer: false hides the polymer builder (Edit › Polymer), which is self-contained:
marking the chain ends, the build settings and the Build button all live in that one section.
onPolymerBuild fires when a polymer is built. Building deliberately does not change the
structure on screen — the monomer stays the monomer, and the polymer is handed over as a new MOL2
file for the host to write and open:
<MolViewer
source={monomerFile}
onPolymerBuild={async (e) => {
// e.sourceFileName is the MONOMER's name. The viewer does not name the new file: only the
// host can see the folder, so only the host knows which names are already taken.
const stem = e.sourceFileName?.replace(/\.[^.]+$/, '') ?? 'polymer';
await writeToWorkspace(`${stem}_${e.monomers}.mol2`, e.text());
if (e.report.clashes > 0) warn(`${e.report.clashes} close contacts — relax before simulating`);
}}
/>Omit the prop and the button still works: with no host workspace the viewer downloads the .mol2
itself, the same way Export does. (This is unlike onSave, which draws no button when absent —
"save" has no meaning without somewhere to save to, whereas a downloaded polymer is a complete
result.)
keyboardShortcuts switches shortcuts from scoped to page-wide. The default (true) listens on
the viewer's own root element — canvas and sidebar, nothing outside it. Pass 'global' if you want
Ctrl+Z/Ctrl+Y to undo/redo even when focus is elsewhere on the page, or false to disable them
entirely and avoid interfering with host page controls.
theme is a Record<string, string> of raw CSS custom properties applied inline to the root
element, e.g. { '--accent': '#ff6b35' } — there is no { accentColor } shape.
interceptAction is a powerful filter: (action: Action, state: ViewerState) => Action | null
| void. Every dispatch — including the imperative handle's dispatch() — routes through it.
Return null to drop an action, a rewritten action to apply instead, or undefined to apply it
unchanged. This is how a host enforces business logic (e.g. "no deletes on this viewer", "log
every edit").
Live example — the harness
The repository includes an interactive demo harness at harness.html (available when you run npm run dev). It exercises the full API:
npm run dev
# Open http://localhost:5173/harness.html in your browserThe harness has two instances side by side:
- Left: event testing — a full event log, dirty tracking, buttons that push new
source/sourceModevalues (replace, and'update'with both a stable and a changed atom count), a representative slice of the handle (select,setSettings,getSettings,exportText,exportFile,isModified,screenshot,engine,getState),onSave, and live checkboxes that exerciseinterceptActionand a cancelableinteractionshandler - Right: headless viewer (no sidebar) loading a
.grofile from a URL with a custom theme
Read src/harness.tsx to see how to wire up the component, handle all events, and drive it imperatively.
That is the whole integration. The component owns its own state, so it needs nothing from the host's store, and it renders into whatever box you give it — give that box a height.
React is external, deliberately. Two copies of React on one page break hooks, so the host's
copy is the only one used; react and react-dom are peerDependencies. Mol* is bundled, so
there is nothing else to install or configure — at the cost of size: dist/molviewer.js is 4.5 MB
(1.1 MB gzipped), the large majority of it Mol*. If the host app uses Mol* itself, add it to
external in vite.lib.config.ts to avoid shipping it twice.
Fonts are not bundled. The design is set in IBM Plex and falls back to system-ui without it. Vite's library mode inlines every asset a stylesheet references, which turned five faces into 912 kB of base64 in a 997 kB stylesheet; without them it is 87 kB. To get the intended typography, the host installs and imports them:
npm install @fontsource/ibm-plex-sans @fontsource/ibm-plex-monoimport '@fontsource/ibm-plex-sans/400.css'; // plus 500, 600
import '@fontsource/ibm-plex-mono/400.css'; // plus 500The framework-free core is exported from the same entry point and can be used without ever rendering the component — in Node, a worker, or a CLI:
import { parseSystem, createDefaultRegistry, sourceFromText, buildSupercell } from '@molviewer/core';
const sys = await parseSystem(sourceFromText('x.mol2', text), createDefaultRegistry());
const big = buildSupercell(sys, 2, 2, 2);Run the verifiable core
npm install # molstar + the dev toolchain (vite, typescript, tsx, sass)
npm run check # strict type-check of the core + engine interface
npm run demo # end-to-end: parse examples/*, infer, print, serialize, self-test
npm run dev # the standalone app on localhostExpected tail of npm run demo: ALL SMOKE-TEST ASSERTIONS PASSED ✅. Note what this is and is
not: a smoke script over the four files in examples/, not a test suite. There is no test runner
in the repo. npm run check type-checks everything that can be checked outside a browser; the
browser half is verified by hand and by the harness.
The one idea to take away
Adding a new file format is one new file + one line. No core change. See AGENTS.md §6. The GRO reader in this repo was added exactly that way as a live proof.
Layout
src/
core/ framework-agnostic. no React, no Mol*, no DOM. Node-checkable.
model/ data model (types), SystemBuilder, element data
parse/ FormatReader contract, FormatRegistry, readers/* <- the plugin layer
pipeline/ element inference, bond perception, PBC, orchestration
geometry/ tiny matrix/lattice helpers
serialize/ System -> mmCIF (hand-off to the engine)
engine/ rendering abstraction
RenderEngine.ts the interface the UI depends on
molstar/MolstarEngine Mol* adapter (browser-only)
ui/ React layer
MolViewer.tsx the one component: reducer + engine + tools + layout
api.ts the public surface: props, handle, event payloads, UI defaults
panels/ sidebar panels, canvas overlays, rails, dialogs, and the
trajectory strip along the bottom of the 3D view
hooks/ useLoadSource — file or url -> parse -> dispatch
state/store.ts ViewerState, actions, reducer, undo/redo
examples/ water.extxyz, water.mol, ambiguous.pdb, water.gro
scripts/demo.ts the runnable end-to-end demonstration
harness.html the API harness page (npm run dev, then /harness.html)Design commitment
Parse losslessly; never silently guess. What the file said (rawLabel) is kept separate from
what was inferred (atomicNumber + elementSource + confidence), and every inference is written
to a log you can open from the file card. Where a file leaves elements genuinely unresolved — a
LAMMPS dump carrying numeric types — the Diagnostics panel maps a type to an element once and
every atom of that type follows. This is the whole reason the tool exists — it's structural, not a
feature bolted on.
