npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@molviewer/core

v0.4.4

Published

Extensible, offline, web-native 3D molecular viewer for small molecules and MD trajectories.

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 seamRenderEngine interface | Pure TypeScript, no Mol* types. Swap the renderer by implementing one interface | | Mol* adapterMolstarEngine | 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 UIMolViewer + 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-dom

Basic 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 browser File object (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). The name'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-parsed System object 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, including mode: 'add'
  • select(indices, opts?) / selectAll() / clearSelection() — selection (fires onSelectionChange)
  • setSettings(patch) / getSettings() — adjust view (non-undoable; fires onViewChange)
  • setTool(tool) / setFrame(index) / play() / pause() / undo() / redo()
  • exportText(formatId?): string — serialize the current frame to a string
  • exportFile(formatId?): File — serialize to a File; does not itself trigger a download — the host wires up URL.createObjectURL + an anchor click (see src/harness.tsx)
  • screenshot(): Promise<Blob> — capture canvas
  • resetView() / recenter() / engine() / dispatch(action) / getState()
  • isModified(): boolean — query the dirty flag
  • getSystem(): 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 directly
  • onLoad(e) — structure loaded; carries atomCount, frameCount, mode, fileName
  • onEdit(e) — molecule modification only ({ label, seq, system, modified }); never fires for appearance changes
  • onModifiedChange(modified: boolean) — fires only on the clean↔dirty transition
  • onSelectionChange(sel){ atoms, bond, boxSelected }; always fires regardless of edit type
  • onViewChange(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 nothing
  • onFrameChange({ frame, frameCount }) — trajectory frame advanced
  • onToolChange(tool) — active pointer tool changed
  • onLoadProgress(progress: number | null) — file fetch in progress
  • onLoadError({ error, fileName }) — load or parse failed
  • onSave(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. text is a thunk, so a host that only wants the file name never pays to serialise the structure. Call e.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 goes
  • onPolymerBuild(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. sourceFileName is 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. Unlike onSave, omitting this does not hide the button — with no host workspace the viewer downloads the .mol2 itself. report.clashes > 0 means 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 browser

The harness has two instances side by side:

  • Left: event testing — a full event log, dirty tracking, buttons that push new source/ sourceMode values (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 exercise interceptAction and a cancelable interactions handler
  • Right: headless viewer (no sidebar) loading a .gro file 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-mono
import '@fontsource/ibm-plex-sans/400.css';   // plus 500, 600
import '@fontsource/ibm-plex-mono/400.css';   // plus 500

The 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 localhost

Expected 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.