Skip to main content

Headless viewer guide

This page is a practical guide for experimenting with the headless rendering API in @spatialdata/vis. Use it when you want deck/Viv output without SpatialCanvas sidebars, layer-order panels, or the built-in properties UI.

What "headless" means here

Headless mode is not a separate WebGL backend. It reuses the same useLayerData → deck/Viv stack as SpatialCanvas, but exposes only:

  • SpatialCanvasViewer — a measured viewport + SpatialViewer (deck/Viv)
  • useSpatialCanvasRenderer — the same loading/composition logic without UI
  • composeSpatialDeckLayers, shouldAutoFitSpatialView, shouldRenderInternalTooltip — small helpers for integrators

2D only: same limitation as SpatialCanvas — orthographic pan/zoom, no 3D orbit or volume views. Image z/c/t slice selection is supported; 3D scene navigation is not.

MDV, Vitessce, and local experiments should import these from the public @spatialdata/vis entry point, not from packages/vis/src/... paths.

Minimal controlled viewer

import { useState } from 'react';
import { readZarr } from '@spatialdata/core';
import {
SpatialCanvasViewer,
type RenderStack,
type ViewState,
} from '@spatialdata/vis';

const [spatialData, setSpatialData] = useState<Awaited<ReturnType<typeof readZarr>> | null>(null);
const [coordinateSystem, setCoordinateSystem] = useState<string | null>(null);
const [viewState, setViewState] = useState<ViewState | null>(null);

const renderStack: RenderStack = {
schemaVersion: 1,
entries: [
{
kind: 'spatial',
id: 'image',
source: { elementType: 'image', elementKey: 'my_image' },
props: { opacity: 1 },
},
{
kind: 'spatial',
id: 'shapes',
source: { elementType: 'shapes', elementKey: 'cell_shapes' },
props: {
opacity: 1,
fillColor: [100, 149, 237, 180],
fillColorByColumn: { columnName: 'cell_type', mode: 'categorical' },
tooltipFields: ['cell_type'],
},
},
],
};

// After loading:
<SpatialCanvasViewer
spatialData={spatialData}
coordinateSystem={coordinateSystem}
renderStack={renderStack}
viewState={viewState}
onViewStateChange={setViewState}
style={{ width: '100%', height: '100%' }}
/>

You own all state. Change renderStack or viewState from your app store (MobX, zustand, React state, Leva, etc.) and the viewer re-renders. Keep structural identity in entry.source; use entry.props for renderer inputs.

Hook-only composition (custom layout)

When you already have a DeckGL shell or need to split loading from the viewport:

import { useMeasure } from '@uidotdev/usehooks';
import { useSpatialCanvasRenderer } from '@spatialdata/vis';
import { SpatialViewer } from '@spatialdata/vis';

function MyViewport({ spatialData, coordinateSystem, renderStack, viewState, onViewStateChange }) {
const [ref, { width, height }] = useMeasure();
const renderer = useSpatialCanvasRenderer({
spatialData,
coordinateSystem,
renderStack,
viewState,
onViewStateChange,
width: width ?? 0,
height: height ?? 0,
hostLayerResolver: (entry) => {
if (entry.source.hostLayerId === 'deck:scatter') return myCustomScatterLayer;
},
});

return (
<div ref={ref} style={{ width: '100%', height: '100%' }}>
<SpatialViewer
width={width ?? 0}
height={height ?? 0}
viewState={viewState}
onViewStateChange={onViewStateChange}
layers={renderer.deckLayers}
layerOrder={renderer.layerOrder}
vivLayerProps={renderer.vivLayerProps}
/>
</div>
);
}

useSpatialCanvasRenderer returns deckLayers, vivLayerProps, loading flags, bounds helpers, and feature pick/tooltip resolvers — same as inside SpatialCanvasViewer.

MDV-style host overlays

Use host overlay descriptors when app-built deck layers need to interleave with SpatialData entries:

import { ScatterplotLayer } from 'deck.gl';

const mdvScatter = new ScatterplotLayer({
id: 'mdv-scatter',
data: scatterData,
getPosition: (d) => d.position,
getRadius: 2,
});

<SpatialCanvasViewer
/* ...controlled props... */
renderStack={{
schemaVersion: 1,
entries: [
{ kind: 'spatial', id: 'image', source: { elementType: 'image', elementKey: 'my_image' } },
{ kind: 'host', id: 'deck:scatter', source: { hostLayerId: 'deck:scatter' } },
{ kind: 'spatial', id: 'shapes', source: { elementType: 'shapes', elementKey: 'cell_shapes' } },
],
}}
hostLayerResolver={(entry) => {
if (entry.source.hostLayerId === 'deck:scatter') return mdvScatter;
}}
deckProps={{
controller: true,
getCursor: ({ isDragging }) => (isDragging ? 'grabbing' : 'default'),
}}
/>

The older deckLayers prop remains as a compatibility tail for simple overlays that always sit above SpatialData output. It should not be used for saved MDV layer-stack interleaving.

Tooltips: internal, external, or off

renderTooltipBehaviour
undefined (default)Built-in SpatialFeatureTooltip on hover
falseNo internal tooltip; use onFeatureHover / raw onHover
(props) => <MyTooltip {...props} />Custom renderer; optional tooltipContainer for portals
<SpatialCanvasViewer
renderTooltip={false}
onFeatureHover={(event) => {
// event.elementKind is "shapes" or "labels".
// event.spatialElement.key, event.featureId, event.rowIndex, event.tooltip, event.pickInfo, ...
}}
onFeatureClick={(event) => {
if (event.elementKind !== 'labels') return;
if (event.spatialElement.key !== 'blobs_labels') return;

// event.labelId and event.spatialElement are narrowed here.
}}
onHover={(info) => {
// raw deck PickingInfo for custom layers
}}
/>

For MDV-style outer-container tooltips, set renderTooltip={false} and route picks through your existing portal hook.

Serializable props vs runtime listeners

renderStack.entries is the saved, serializable contract. Put element identity, visibility, opacity, channel selections, tooltip field names, feature-state records, and other renderer props there. Do not put functions, MobX observables, deck.gl layer instances, DOM refs, or event handlers in entry.props.

Runtime attachments live beside the stack:

  • hostLayerResolver(entry) turns a host descriptor into one or more deck.gl layers for this running application.
  • featureColorResolver(ctx) returns precomputed per-feature RGBA for a shapes or labels layer — colour driven by data a saved config cannot carry. It lives here rather than in entry.props for exactly the reason above: a Uint8Array in the serializable contract would be a lie about what a saved config is. See Precomputed colour buffers.
  • onFeatureHover / onFeatureClick receive a typed SpatialData feature event for shapes and labels. The payload includes the stable stack layerId, elementKind for TypeScript narrowing, resolved spatialElement (use event.spatialElement.kind and event.spatialElement.key), current spatialData, featureId, optional table rowIndex, the raw pickInfo, and the same tooltip payload used by SpatialFeatureTooltip when available.
  • onShapeHover / onShapeClick remain as shape-only compatibility callbacks.
  • raw onHover / onClick remain available for host layers or lower-level deck integrations.

Planned event API

In a follow-up breaking experimental pass, onFeatureHover and onFeatureClick should be superseded by SpatialData-scoped element callbacks: onSpatialElementMove, onSpatialElementOver, onSpatialElementOut, and onSpatialElementClick.

Those events should cover SpatialData-backed images, labels, shapes, and points. Each event should carry layerId, elementKind, spatialElement, coordinateSystem, spatialData, and pickInfo. Shapes and labels should also carry feature-specific fields such as featureId, labelId, featureIndex, rowIndex, object, and tooltip when available. onSpatialElementOver and onSpatialElementOut should be synthesized from hover transitions; for shapes and labels, those transitions should be per feature rather than only per layer.

Raw deck onHover and onClick remain the escape hatch for host layers and custom deck.gl layers. This planned API does not add custom SpatialData-aware host layer events or invent feature semantics for images and points beyond what deck's pickInfo already provides.

Shapes and labels: table-driven colour and filter state

MDV should drive styling by updating the target stack entry props — not by reaching into vis internals:

entry.props = {
...entry.props,
fillColorByColumn: { columnName: 'leiden', mode: 'categorical' },
featureState: {
hiddenFeatureIds: filteredOutIds,
fadedFeatureIds: dimmedIds,
fillColorByFeatureId: selectionColors,
filteredOpacityMultiplier: 0.35,
},
};

Row alignment comes from @spatialdata/core (createFeatureTableAlignment); colour encoding from @spatialdata/layers (buildShapeFillColorByFeatureId). SpatialCanvasViewer wires these when fillColorByColumn is set.

Labels take the same API

A labels entry accepts the same fillColorByColumn and the same featureState fields, with the same meanings, resolved against the same associated table. The one omission is strokeColorByFeatureId: a label's outline is derived from its fill in the bitmask shader, so there is no per-label stroke to override.

The only thing to know is what a labels feature id is: the label's integer instance id as a string — the raster's own pixel value, and the same id the tooltip already resolves against the table.

labelsEntry.props = {
...labelsEntry.props,
fillColorByColumn: { columnName: 'cell_type', mode: 'auto' },
featureState: { hiddenFeatureIds: ['3', '17'], filteredOpacityMultiplier: 0.2 },
};

Shapes resolve per-feature colour from a texture indexed by feature index; a label has no geometry, so the analogue is a lookup table indexed by label id that the fragment shader samples. The property that matters is the same on both: a feature-state change re-uploads only the small table, never the tiles. Picking consults that table too, so a hidden label cannot be picked.

Hovering a label highlights it, the way autoHighlight highlights a shape. Nothing to configure: both canvas surfaces — SpatialCanvasViewer and the full-UI SpatialCanvas — drive it from the same pick that feeds the tooltip, through one shared resolver (resolveHoveredLabel). It therefore follows hoverTooltipMode: 'off' disables picking and with it the highlight. It is deliberately not part of the Render Stack: hover changes on every pointer move and would be meaningless in a saved view, so it never appears in an entry's props. A label the filter hides is never highlighted, for the same reason it can never be picked.

Hosts driving LabelsLayer directly get highlightedLabelId (the integer id, or -1 for none) and highlightColor — deck's own prop name, with deck's own meaning, so the tint is set the same way on labels as on shapes. Alpha is the blend weight toward the tint, not an opacity.

Choosing the colour scheme

fillColorByColumn carries the scheme, not just the column name. Every field is JSON, so it survives a saved Render Stack:

fillColorByColumn: {
columnName: 'leiden',
// 'auto' trusts the column's declared dtype; see below.
mode: 'auto',
// 'oklab' (default) or your own RGB list, which cycles.
categoricalPalette: [[220, 30, 30], [30, 120, 220]],
// Continuous endpoints, [low, high] as RGB 0-255.
numericRamp: [[0, 64, 255], [255, 220, 0]],
missingValues: {
// Sentinel STRINGS this pipeline writes. Empty by default — one dataset's
// placeholder is another's real category.
treatAsMissing: ['NA', 'unknown'],
// 'default' (keep the layer colour), 'hide', or an explicit RGBA.
render: 'hide',
},
},

:::info Behaviour change: the categorical default The default categorical palette is now 'oklab' — the unbounded golden-angle scheme already used for points colour-by-feature, whose colour is a pure function of the category index. The previous default was a six-colour list that cycled, so a column with more than six categories drew two categories the same colour — a failure that is invisible in the render. Pass categoricalPalette to pin specific colours. :::

mode: 'auto' uses the column's declared kind rather than guessing from decoded values, which is what makes integer cluster codes read as categories instead of a continuum. null and NaN are always missing and are not configurable; treatAsMissing only adds the store-specific sentinel strings. Sentinels resolve before the mode decision, the numeric extent and the category set, so one can never become a category or drag a ramp.

Precomputed colour buffers

When colour comes from data a config cannot carry — a computed column, an external annotation, a live selection — supply the bytes directly instead of a featureId → colour dictionary. featureColorResolver is a runtime prop on SpatialCanvasViewer, not part of the Render Stack:

<SpatialCanvasViewer
featureColorResolver={({ kind, elementKey, featureIds }) => {
if (elementKey !== 'cell_shapes') return undefined; // fall back to the config
// shapes: index by position in `featureIds`, which the loader decides.
// labels: index by the label's own pixel value; `featureIds` is absent.
return { colors: myRgbaBytes, count: myFeatureCount };
}}
/>

Two contracts worth reading twice:

  • What the index means differs by kind. For labels it is the raster's own pixel value, so a host can author the buffer from the table alone. For shapes it is the feature's position in the loaded geometry — the loader's decision, not the data's — so build against the featureIds ordering you are handed, never one you assume.
  • Return a stable identity when nothing changed. This is consulted on every render (a hover re-renders for the tooltip), so a fresh buffer each time means a GPU upload each time. The viewer collapses on the colors identity to make the easy mistake cheap, but returning the same object is the contract.

Alpha is a modulation, not an opacity: 0 hides the feature, anything else scales what the layer would otherwise draw. Bake hide/fade into the buffer — it wins over featureState rather than merging with it, since merging would reinstate the per-feature lookup this exists to remove.

Points: feature selection and colour

Points styling is driven the same way as shapes — by updating the stack entry props, not by reaching into vis internals. The serializable fields on a points entry:

entry.props = {
...entry.props,
// Which features are drawn. Omit for "all features".
featureNames: ['EPCAM', 'MALL'],
// Per-feature colour, keyed by feature name. Absent features keep the default
// categorical colour.
featureColorOverrides: { EPCAM: [220, 30, 30] },
// Categorical colour-by-feature. ON by default; pass false for a flat colour.
colorByFeature: true,
// Radius in the ELEMENT's own coordinate units (the layer folds in the
// element's transform scale, so the same value means the same apparent size
// across elements whose transforms differ).
pointSize: 0.1,
// Max rows retained in memory for the resident window. Raising it draws more
// points at the cost of memory and decode time.
pointsMemoryCap: 4_000_000,
};

Selections persist as names, not codes

featureNames is the durable form and the one to serialize. There is also a featureCodes: number[], which is retained for runtime use and for configs written before names existed — do not persist it.

The reason is that a points element often has no feature-code column in the file. A Xenium transcripts carries feature_name and no codes; the same is true of a merfish cell_type. For those, codes are assigned by the application as a first-seen index while building the feature catalog, so the same gene is not guaranteed the same number between the instant resident-subset catalog and the full one, between the two catalog-building paths (which are chosen by row count), or between servers that differ in HTTP range support. A persisted code can therefore come back meaning a different feature, with nothing to signal it.

Names are resolved to whatever codes the current catalog uses, at render time. Names the element does not have are dropped rather than coerced, so a config saved against one dataset can be applied to another without inventing features. featureNames takes precedence when both fields are present.

If you need to convert in either direction yourself, @spatialdata/core exports resolveFeatureSelectionCodes(selection, catalog) and featureNamesForCodes.

Reading feature state headlessly

To build your own feature UI, read the engine directly. pointsEngine and resolvePointsTarget come off the renderer-hook result; wrap a subtree in the provider and consume the hook:

import { PointsFeatureStateProvider, usePointsFeatureState } from '@spatialdata/vis';

const { pointsEngine, resolvePointsTarget } = useSpatialCanvasRenderer(/* … */);

<PointsFeatureStateProvider engine={pointsEngine} target={resolvePointsTarget(layerId)}>
<MyFeatureList config={pointsConfig} />
</PointsFeatureStateProvider>;

function MyFeatureList({ config }) {
const {
catalog, // { featureKey, entries: [{ code, name, count? }] } | null
catalogLoading, // no catalog yet, one is on its way
catalogRefining, // full scan running behind an instant preview
residentCodes, // features present in the resident window
loadedMatchingCodes, // features currently on screen via the last scan
supportsOnDemandLoad,// a whole-dataset scan can reach beyond the window
matchingLoadState, // progress of the scan for this selection
residentFeatureCounts,
requestCatalog, // idempotent; upgrades the preview to the full list
setHighlightedFeature,
} = usePointsFeatureState(config);
}

Pass the layer config (the hook resolves featureNames internally against the catalog it is already reading). An array of already-resolved codes is also accepted.

Two things worth designing around:

  • The catalog arrives in two stages. An instant preview covering just the resident window is published first, then the authoritative full-dataset list supersedes it; catalogRefining distinguishes them. Counts may lag the names — and for a dictionary-only element they may never arrive at all, because the fallback that builds the catalog cannot tally them. That is a successful settle, so nothing retries it. Treat counts as optional: sort and label from the names, and do not read a missing count as zero or as "still loading".
  • Selecting a feature whose points are outside the resident window triggers a whole-dataset scan when supportsOnDemandLoad is true. matchingLoadState reports its progress so the UI can show that points are still arriving rather than appearing to be complete.

Auto-fit and view state

  • Pass viewState={null} on first render to let the viewer compute an initial fit from visible layer bounds (autoFit defaults to true).
  • After the user pans/zooms, keep viewState controlled and pass updates through onViewStateChange.
  • Set autoFit={false} if your app restores saved view state and must never overwrite it.

Try it locally

From the repo root:

pnpm install
pnpm test:fixtures:generate:0.7.2 # once, if test-fixtures/ is missing
pnpm --filter @spatialdata/vis dev

Open http://127.0.0.1:5173/headless for the headless demo route. It loads the local blobs.zarr fixture (v0.7.2) via SpatialCanvasViewer with externally controlled layer state — no SpatialCanvas sidebars.

Open http://127.0.0.1:5173/codec for the JP2K codec fixture route. Generate it first with pnpm test:fixtures:generate:codecs.

The dev script starts the fixture server on port 38473 (override with SPATIALDATA_FIXTURE_PORT) and proxies /test-fixtures through Vite on 5173. The default Sketch UI remains at http://127.0.0.1:5173/.

Suggested experiments (matching the MDV integration roadmap):

  1. Fixed stack — hard-code renderStack.entries for one fixture URL.
  2. External controls — Leva panel for coordinate system, visibility, opacity, channel colours.
  3. Host overlay — one ScatterplotLayer interleaved by descriptor and resolver.
  4. Controlled view — save/restore viewState in sessionStorage.
  5. External tooltipsrenderTooltip={false} + log onFeatureHover payloads.

Install the published package for MDV smoke tests:

pnpm add @spatialdata/vis

Pack local builds when testing an unpublished branch:

pnpm build
pnpm --filter @spatialdata/vis pack
# install the resulting .tgz in the MDV workspace

Public API checklist

Before treating the API as stable for MDV:

  • SpatialCanvasViewer exported from @spatialdata/vis
  • useSpatialCanvasRenderer, composeSpatialDeckLayers, shouldAutoFitSpatialView, shouldRenderInternalTooltip exported
  • Controlled coordinateSystem, renderStack, viewState
  • Host overlay descriptors with hostLayerResolver; deckLayers / deckProps passthrough remains for compatibility
  • renderTooltip={false} for external tooltip ownership
  • Points feature selection persisted by NAME (featureNames), with featureColorOverrides keyed by name
  • Labels per-feature filtering and colouring through the same fillColorByColumn / featureState API as shapes
  • Host-supplied precomputed colour via featureColorResolver (runtime, not serialized)
  • demo/headless route with local blobs.zarr fixture
  • Additional demo/headless-* variants (Leva controls, custom deck layers)
  • Tooltip/pick row resolution fully on shared FeatureTableAlignment (in progress)
  • npm next prerelease published and smoke-tested in MDV

See also MDV release checklist and Feature table associations.