A browser-native, WebGPU rendering engine that ports the visualization model of napari (the Python multi-dimensional image viewer) to TypeScript.
Status: published to npm —
npm install napari-js(latest: v0.13.0). napari-js implements the POC called for in jit-ui#102 — a browser-based napari shipped as a JS library. The renderer (NJ-0…NJ-5+) is complete and browser-verified, and napari-js now ships in production as the WebGPU backend ofsci-image-visualizer— the engine behind the JAX Image Tools viewer. See the CHANGELOG.
- WebGPU rendering, 100% client-side — no Python, Pyodide, WASM, or server.
- Image layers: single- and multi-channel,
uint8/uint16/float32, with live colormap (LUT), contrast limits, gamma, invert, opacity, and blend modes (opaque/translucent/additive/minimum). - Tiled & pyramidal large images with level-of-detail + an LRU GPU-tile cache, and
z-stacks — fed by a pluggable
TextureSource(typed arrays orImageBitmaptiles). - Points (instanced SDF markers) and Labels (
uint8/uint16/uint32ids, cyclic palette). - 3D points with per-point colormapped values plus per-point alpha and size — enough to mute
a cloud and highlight a subset within a single layer and a single draw — and a
dataVersionso recolouring mutates the layer instead of replacing it. - 3D volume raymarching — MIP, translucent, and iso-surface, with an orbit camera; a plane
depiction (napari's
depiction: 'plane') that shows one slice of the volume in 3D, and clipping planes on volumes and surfaces. - Surface — a 3D triangular mesh (napari's
Surfacelayer) with per-vertex colormapping, one colour or a colour per vertex, depth-tested flat shading and in-placesetData, plus aheightFieldhelper that turns a 2D image into a surface plot. Surface picking (viewer.pickSurface,canvasRay) finds the mesh and triangle under the cursor. - Shapes — closed polygon rings in 2D (napari's
Shapeslayer, rings only) from a flatcoords+offsetspair, drawn as 1-pixel boundaries or filled interiors and coloured by one scalar per shape through a colormap — sized for the 10⁵–10⁶ cell outlines a segmentation produces, with pureringsToOutline/ringsToFanexpanders. - Readback: displayed-pixel readout, PNG screenshot, and per-channel histograms.
- Host-friendly: device-loss recovery,
ResizeObserverauto-resize, andcanvasToWorld/worldToCanvas/visibleWorldRectfor overlays and picking. - 3D projection & picking:
viewer.projectPoints()(or the pureprojectPoint/projectPoints) maps world coordinates to canvas CSS pixels under the orbit camera, honouring the near and far planes as well as the eye.nearestProjectedIndexhit-tests the result front-most first, optionally per-point radius and pickability, so a tooltip agrees with what the depth buffer actually drew;ScreenIndexbuckets the projection so a multi-million-point cloud picks in microseconds rather than milliseconds. A host no longer reimplements the perspective divide. - Explicit camera framing:
fit3d: 'always' | 'once' | 'never'on the viewer (or per add), plusfitToLayers()over the union of every 3D layer andresetFit3D()— so building a scene from several layers does not make the view jump. The distance is derived from both half-angles of the frustum, so a portrait viewport frames the scene rather than cropping it.
npm install napari-jsimport { Viewer } from 'napari-js';
const viewer = new Viewer({ canvas: document.querySelector('canvas')! });
await viewer.ready; // WebGPU device acquired
// one layer per channel; composited additively on the GPU
viewer.addImage(channel0, { colormap: 'green', blending: 'additive', contrastLimits: [0, 4095] });
const dapi = viewer.addImage(channel1, { colormap: 'blue', blending: 'additive' });
dapi.gamma = 0.8; // live — updates a uniform, no texture re-upload
viewer.addPoints(points, { size: 12, faceColor: [1, 1, 0, 1] });
viewer.addLabels(labelImage, width, height, { opacity: 0.5 });A layer's data is any TextureSource input: an ImageBitmap, a typed-array descriptor
({ kind: 'typed', width, height, channels, dtype, data }), or a pyramidal
{ kind: 'tiled', …, fetchTile }. Full API in docs/02.
Unit tests (Vitest, Jest) have no WebGPU. napari-js/testing is the whole public API with Viewer
bound to HeadlessViewer: the real ViewerModel, LayerList, cameras and layers, the real
add*, fitToLayers and worldToCanvas, with no device. Nothing is drawn, ready is already
resolved, and readback returns a blank frame.
import { HeadlessViewer } from 'napari-js/testing';
const viewer = new HeadlessViewer({ canvasRect: { left: 100, top: 50, width: 800, height: 600 } });
const cloud = viewer.addPoints3D(positions); // a real Points3DLayer, validated as usual
viewer.fitToLayers(); // frames exactly as Viewer does
viewer.worldToCanvas(x, y); // includes the canvas offsetTo run code that does new Viewer({ canvas }) unchanged, point the test runner's napari-js at
it: Jest moduleNameMapper: { '^napari-js$': '<rootDir>/node_modules/napari-js/dist/testing.js' }
(and let Jest transform napari-js, which is ESM), or a Vitest resolve.alias. Layers it builds
are instances of the same classes napari-js exports.
DOM chrome over the canvas, as napari's viewer overlays: a physical scale bar (2D zoom, or the 3D
camera at its target's depth), crisp 3D axis labels, an OpenSeadragon-style 2D navigator, and
SlicePlanesWidget: three orthogonal slice planes through a volume with grips that drag them along
their axes (demo 9). Each
takes (host, viewer, opts) and has dispose(); the main bundle does not include them.
import { ScaleBarOverlay, NavigatorOverlay, scaleBarFor } from 'napari-js/overlays';
const bar = new ScaleBarOverlay(host, viewer, { unitPerWorld: 0.25, unit: 'µm' });
const nav = new NavigatorOverlay(host, viewer, { worldWidth: w, worldHeight: h, image: thumb });
scaleBarFor(pxPerWorld, umPerWorld, 120); // the pure maths, for a bar drawn elsewhereAll 83 matplotlib colormaps as exact 256-entry RGB tables, one tree-shakeable Uint8Array(768) per
map, so a bundle keeps only the maps it imports (about 1 KB each). The main entry's VIRIDIS,
MAGMA and INFERNO already use the exact tables.
import { TURBO_LUT, lutColormap, matplotlibColormap } from 'napari-js/colormaps';
viewer.addImage(img, { colormap: lutColormap('turbo', TURBO_LUT) });
matplotlibColormap('RdBu'); // by name, via the COLORMAP_LUTS registry (keeps every map)Framework-free raster/vector geometry, the operations behind napari's Labels tools: scanline
rasterizePolygon (with holes), pointInRing/pointInPolygonWithHoles, labelComponents (4/8),
traceContours (outer rings + holes along pixel edges, so a mask round-trips exactly), floodFill
and autoContrastLimits (saturation-based auto contrast, also in the main entry), and meshes from
voxel masks: surfaceNets, labelsToMeshes and smoothMesh (Laplacian or Taubin). Rings are flat
[x0, y0, x1, y1, …]; rasters are row-major { data, width, height }.
import { traceContours, rasterizePolygon, floodFill } from 'napari-js/geometry';
const mask = floodFill({ data, width, height }, seedX, seedY, { tolerance: 12 });
const [blob] = traceContours({ data: mask, width, height }, { minSize: 4, minHoleSize: 4 });
const back = rasterizePolygon(blob.outer, blob.holes); // the same pixels, as a bbox masknpm install
npm run dev # serve the playground (8 demos in the dropdown — see below)
npm test # GPU-free unit tests (Vitest)
npm run test:gpu # real-GPU tests in headless Chromium (Vitest browser mode; see below)
npm run test:coverage
npm run typecheck && npm run lint && npm run format:check
npm run build # library bundle + types → dist/npm run test:gpu runs test/gpu on the machine's GPU through Playwright's Chromium (run
npx playwright install chromium once). NAPARI_GPU_SWIFTSHADER=1 uses Chromium's software
adapter, as CI does; without an adapter the tests skip unless NAPARI_GPU_REQUIRED=1. Shader math
keeps its CPU reference and GPU-free unit test; a GPU test checks the shader against that reference
within a few levels of 255.
npm run dev serves index.html → playground/main.ts. Pick a demo from the dropdown (or press
its number key) to verify each render path; if WebGPU is unavailable the page shows the reason
instead of crashing. The status line along the bottom reports what is on screen — vertex counts,
slice index, active mode — and the per-demo keys.
napari-js is a standalone, framework-agnostic library. It renders large
multi-dimensional / multi-channel scientific images in the browser on the GPU, with
napari's model: a Viewer holding a list of Layers (Image, later Points / Labels /
Volume), each with its own colormap (LUT), contrast limits, gamma, opacity, and blending.
It is not a port of napari's Python code or its Qt GUI. It is a faithful port of
napari's rendering concepts — the layer→visual model, per-layer GPU colormapping,
serializable transforms and camera — onto WebGPU and WGSL. See
docs/07-napari-concept-mapping.md.
WebGPU now ships in all major browsers (late 2025). napari's strengths — GPU multi-channel fluorescence compositing, live scalar colormapping, and volume rendering — are exactly the things current browser image viewers do poorly or on the CPU. napari-js brings those strengths to the web as a reusable npm package.
~/git/napari Reference: the Python renderer being ported (napari/_vispy, layers, components)
~/git/napari-js THIS repo: the standalone TS + WebGPU port, published to npm
~/git/sci-image-visualizer Main consumer: wraps napari-js as its WebGPU IVisualizer backend
napari-js is built and published independently. Its main downstream consumer is
@jax-data-science/sci-image-visualizer, the
Angular visualization library behind JAX Image Tools, which registers napari-js as its
WebGPU IVisualizer backend alongside OpenSeadragon and Plotly. See Used in
production below; the original design of that seam
is in docs/06-jit-ui-integration.md.
napari-js is the WebGPU rendering backend of @jax-data-science/sci-image-visualizer
(v0.2.2) — The Jackson Laboratory's Angular 17 library for interactive scientific image
visualization, and the engine behind the JAX Image Tools viewer shown below.
sci-image-visualizer is a ports-and-adapters library: every renderer implements one
IVisualizer contract, and a router (RoutingVisualizerService) picks one per plot type.
napari-js sits alongside two other backends and is the production default for 3D:
| What's rendered | Backend |
|---|---|
| 3D Volume, Isosurface, Surface | napari-js (WebGPU) — production default, Plotly fallback |
| napari 2D image & scatter modes | napari-js (WebGPU), with OpenSeadragon / Plotly fallback |
| Gigapixel whole-slide · other 2D plots | OpenSeadragon / Plotly (napari-js 2D image opt-in via VizConfig.useNapariRenderer) |
- Dependency: a plain npm dependency —
"napari-js": "^0.11.1"— bundled, not a peer dep. - Adapter:
NapariVisualizerService(@Injectable,implements IVisualizer) constructs oneViewer, awaitsviewer.ready, and dispatches by plot type. - DI wiring:
provideVisualization()registers all three backends and binds theVISUALIZERtoken to the router.
Condensed from NapariVisualizerService (the volume / isosurface path). One Viewer drives
every 3D mode — the only thing that differs between the screenshots below is the rendering
flag and how many channels are pushed in:
import { Viewer, MultiChannelVolumeView, tintColormap, type VolumeChannel } from 'napari-js';
// created once, when the host mounts the visualizer
const viewer = new Viewer({ canvas, background: { r: 0.07, g: 0.07, b: 0.09, a: 1 } });
await viewer.ready;
// Volume & Isosurface both go through a MultiChannelVolumeView:
// · one additive, tinted channel per fluorescence channel → multi-channel volume
// · a single colormapped channel → grayscale / CT volume
const view = new MultiChannelVolumeView(viewer);
const channels: VolumeChannel[] = state.channels.map((ch) => ({
data: ch.volume, // Uint8Array, length width*height*depth, x-fastest
width,
height,
depth,
colormap: tintColormap(ch.color), // black → channel colour
contrastLimits: [ch.min, ch.max],
gamma: ch.gamma,
visible: ch.visible,
}));
// rendering: 'mip' for the Volume mode, 'iso' for Isosurface
const rendering = isIsosurface ? 'iso' : 'mip';
view.render(channels.length > 1 ? 'multichannel' : 'grayscale', channels, { rendering });
// the toolbar's live "Iso" slider maps straight onto the volume layer's setters
// (updates a GPU uniform — no data re-upload):
volumeLayer.rendering = 'iso';
volumeLayer.contrastLimits = [isoMin, isoMax];
volumeLayer.isoThreshold = 0.5;2D images use the sibling MultiChannelImageView (render('multichannel' | 'grayscale' | 'rgb', views, { interpolation })); the region-centroid scatter, 3D point cloud, height-field
surface, and axes gizmo call viewer.addPoints, viewer.addPoints3D, heightField +
viewer.addSurface, and viewer.addAxes directly.
Each shot is a different napari-js render mode, chosen from the toolbar's visualizer dropdown:
| Doc | Contents |
|---|---|
| 00 — Feasibility | Why this is feasible: napari architecture findings, what ports cleanly, what's hard |
| 01 — Architecture | Engine module layout, render loop, design principles |
| 02 — Public API | The Viewer / Layer / Colormap / TextureSource API surface |
| 03 — RenderState IR | The serializable intermediate representation between model and GPU |
| 04 — WGSL rendering plan | Shader pipelines: image+colormap, multi-channel compositing, future raycasting |
| 05 — Roadmap | Milestones NJ-0 … NJ-5+ |
| 06 — jit-ui integration | The IVisualizer adapter design — now shipped in sci-image-visualizer |
| 07 — napari concept mapping | How each napari concept maps to napari-js |
| 08 — Landscape & related work | Does a browser napari exist? CZI/roadmap WIP, Viv/vizarr/ndv, and how napari-js differs |
napari-js is an independent TypeScript reimplementation of the visualization model of napari — the Python n-dimensional image viewer (github.com/napari/napari, BSD-3-Clause). The layer model, naming, and rendering semantics follow napari's; all credit for that design goes to the napari core developers and its community. This project is not affiliated with or endorsed by the napari project.
MIT — see LICENSE.












