Score Viewer
@viritura/score-viewer turns a container element into a scrollable,
zoomable score. It doesn't depend on any UI framework, and it loads the engine,
WebAssembly and fonts on first use.
Embed a score
Install it from npm, or use score-viewer.js from the
release archive as below:
npm install @viritura/score-viewer
<div id="score" style="height: 80vh"></div>
<script type="module">
import { mountScore } from "./score-engine/score-viewer.js";
const mnx = await fetch("./my-score.mnx").then((response) => response.text());
const viewer = mountScore(document.getElementById("score"), mnx, {
zoom: "fit-width",
viewMode: "page",
});
</script>
Give the container a height. The viewer fills it and scrolls inside it. MNX
can be a JSON string, a parsed object, or null for an empty viewer.
Always call viewer.destroy() when you remove the container. It disconnects
the resize observer, stops scheduled paints and terminates any layout worker.
Update and reconfigure
viewer.update(nextMnx); // new document, same options
viewer.setOptions({ viewMode: "spread", zoom: 1.25 });
viewer.zoomTo("fit-page");
setOptions merges with the current options. Changes to presentation, such as
zoom, page gap, colours or the playhead, only repaint. Changes that affect
engraving, such as page size, margins, staff size, score selection or switching
into or out of horizon, run layout again.
Use it from a framework
The viewer manages its own DOM inside the container, so any framework can host
it: mount once, call update when the document changes, and destroy on
unmount. In React:
import { useEffect, useRef } from "react";
import { mountScore, type ScoreViewerHandle } from "@viritura/score-viewer";
export function Score({ mnx }: { mnx: string }) {
const container = useRef<HTMLDivElement>(null);
const viewer = useRef<ScoreViewerHandle | null>(null);
useEffect(() => {
viewer.current = mountScore(container.current!, null, { zoom: "fit-width", assetBaseUrl: "/score-engine/" });
return () => viewer.current?.destroy();
}, []);
useEffect(() => viewer.current?.update(mnx), [mnx]);
return <div ref={container} style={{ height: "80vh" }} />;
}
Vue (onMounted / onBeforeUnmount), Svelte (onMount) and web components
(connectedCallback / disconnectedCallback) follow the same pattern.
For React, Score Viewer React does this for you and adds components, hooks, a control bar, and playhead and page-overlay slots.
View modes
viewMode |
Result |
|---|---|
page |
Pages stacked vertically (default) |
horizontal |
Pages in one row |
spread |
Facing pages, stacked vertically |
spread-horizontal |
Facing pages in one row |
horizon |
One continuous, unpaged system, painted from cached tiles |
spreadFirstPage controls whether the first page of a spread stands alone
(single, like a right-hand title page) or is paired (paired).
pagesPerRow sets a fixed number of pages per row in page mode.
Only visible pages get a canvas, and horizon paints only the visible tiles, so
very long scores stay responsive.
Options
| Option | Default | Notes |
|---|---|---|
viewMode |
page |
See View modes |
zoom |
1 |
A number, fit-width or fit-page. At 1, one layout unit is one CSS pixel |
pageWidth |
800 |
Page width in layout units; ignored in horizon |
pageHeight |
A4 ratio | Page height in layout units |
pageMargins |
15 mm | { top, right, bottom, left } in layout units; also applied in horizon when set |
spatium |
7 |
Staff-space height; this sets the staff size |
scoreIndex |
0 |
Which entry of the MNX scores array to render |
pageGap |
16 |
CSS pixels between pages |
pageBackground |
#fff |
Paper colour |
contentAlign |
"start" |
"center" centres a score smaller than the viewport; larger axes still scroll |
ink |
none | Replaces default black ink, for dark themes; explicit colours are kept |
background |
none | Viewport colour behind the pages |
playhead |
none | See Playhead |
useWorker |
false |
Run layout in a worker so large documents don't block the page |
engine |
none | Share an already loaded engine |
assetBaseUrl |
module dir | Base URL containing wasm/, fonts/ and score-engine.worker.js |
textFont |
true |
false to skip Libertinus Serif and use the page's serif font |
Callbacks
| Callback | Called when |
|---|---|
onLoading |
Engine loading or a layout starts |
onReady |
The first layout is ready: { engine, displayList } |
onLayout |
Any layout finishes: (measurements, arrangement) |
onPaint |
Visible pages were painted |
onError |
The engine failed to load, or the document could not be read or laid out |
A failed engine load can be retried by mounting again or by calling update.
Playhead
viewer.setOptions({ playhead: { beat: 12.5, follow: true } });
viewer.setOptions({ playhead: { measureIndex: 4, beat: 1, partId: "flute" } });
viewer.setOptions({ playhead: null }); // hide
A position is either a global quarter-note beat or a measure index and a beat
within that measure. partId picks the staff the cursor aligns to. With
follow: true, the viewer scrolls to keep the playhead in view.
viewer.playheadGeometry() returns the current cursor rectangle if you want to
draw your own. viewer.scrollToPosition(position) scrolls to a position
without showing a playhead.
Handle properties
The handle returned by mountScore also exposes:
| Property | Contents |
|---|---|
engine |
The loaded engine, or null while loading |
displayList |
The current layout, for engine queries |
measurements |
Page sizes and parts |
arrangement |
Where each page sits on the scrolling surface, in CSS pixels |
viewport |
The scrolling element |
surface |
The element that holds the pages; place your own overlays here |
zoom |
The resolved zoom factor, even when you asked for fit-width |
To place an overlay, position it inside surface. Page positions in
arrangement.positions already include zoom. Engine geometry is page-local and
unzoomed, so convert it like this:
const { engine, displayList, arrangement, zoom } = viewer;
for (const measure of engine.measures(displayList)) {
const page = arrangement.positions[measure.page];
const left = page.x + measure.x * zoom;
const top = page.y + measure.y * zoom;
// place an element at (left, top) inside viewer.surface
}
Dark themes
viewer.setOptions({ pageBackground: "#1b1f27", ink: "#e8e8e8", background: "#101318" });
ink only replaces the default black ink. Anything the document colours
explicitly keeps its colour.