Framework-agnostic web component for rendering ZX-calculus diagrams.
| Example basic layout | Example algebraic layout |
|---|---|
![]() |
![]() |
npm install @adnathanail/zxcc<script type="module" src="./node_modules/@adnathanail/zxcc/dist/index.bundle.js"></script>
<zx-diagram id="d"></zx-diagram>
<script type="module">
document.getElementById('d').diagram = {
nodes: [
{ id: 0, type: 'input', ioId: 0 },
{ id: 1, type: 'spider', color: 'Z', phase: 'π/2' },
{ id: 2, type: 'output', ioId: 0 },
],
edges: [
{ src: 0, tgt: 1 },
{ src: 1, tgt: 2 },
],
}
</script>diagram is a JS property, not an attribute — set it via a DOM reference so the object round-trips without JSON coercion.
Assign a new object to change the diagram; layout runs when the property's identity changes, so mutating the object already assigned paints nothing new. If you must mutate in place, call el.refresh() afterwards. Note that either way the re-layout resets the drawing: dragged nodes return to their laid-out positions and the selection is cleared.
The drawing responds to mouse, pen and touch alike: press a node or a dot to select it and drag to move it. A drawing wider than its box scrolls, and on a touch screen dragging the empty canvas is what scrolls it — so the rubber-band selection over blank canvas is a mouse and pen gesture only.
interface DiagramData {
nodes: DiagramNode[]
edges: DiagramEdge[]
boxes?: { kind: 'stack' | 'compose'; nodeIds: number[] }[]
labels?: [number, string][] // node-id → phase-label override
pauliWeb?: { src: number; tgt: number; kind: 'X' | 'Y' | 'Z' | 'I' }[]
scalar?: string // global scalar, drawn below the diagram
}
interface DiagramNode {
id: number
type: 'spider' | 'input' | 'output' | 'hadamard' | 'wire'
| 'w-input' | 'w-output' | 'z-box'
color?: 'Z' | 'X' // spider only
phase?: string // pre-formatted (e.g. "π/2", "-π/4")
ioId?: number // input/output index
col?: number // optional pre-computed column
qubit?: number // optional pre-computed qubit row
ground?: boolean // draws a ground symbol below the node
vdata?: [string, unknown][] // annotations drawn above the node
}
interface DiagramEdge {
src: number
tgt: number
kind?: 'simple' | 'hadamard' | 'w-io' | string // default 'simple'; see edgeColors
}If any node carries col, auto-layout is skipped and every node is expected to carry both col and qubit.
Otherwise a BFS from the inputs assigns rows and qubits.
An edge with src === tgt renders as a self-loop arc.
These mirror the keyword arguments of pyzx's draw_d3() and control presentation
only — graph structure always lives in diagram.
Some can be set as attributes in HTML, some must be set with JS on the element, similarly to diagram above
| Attribute | Property | Default | Meaning |
|---|---|---|---|
show-labels |
showLabels |
false |
Draw node/wire IDs |
color-scheme |
colorScheme |
original |
original / rgb / grayscale |
scale |
scale |
derived | Pixels per row/qubit |
view-mode |
viewMode |
graph |
graph / hypergraph / both-vertical / both-horizontal - see Hypergraph view. |
disable-io-blobs-in-hypergraph |
disableIOBlobsInHypergraph |
false |
Leave out the single-dot blob around each input/output in the hypergraph view. |
| — | colors |
null |
Full palette override (Record<string, string>), overrides color-scheme. |
| — | edgeColors |
null |
Define custom wire 'kinds', to display wires in custom colours, overrides both of the above for the kinds named. |
<zx-diagram show-labels color-scheme="grayscale" scale="40"></zx-diagram>The palettes are exported too, if you want to build a variant:
import { ORIGINAL_COLORS, RGB_COLORS, GRAYSCALE_COLORS, COLOR_SCHEMES } from '@adnathanail/zxcc'edgeColors maps edge kinds to colours — the kinds the diagram is written in, rather than pyzx's
edge / Hedge / Xedge palette entries. Only the kinds you name move; the rest stay on whatever
color-scheme or colors decided.
An edge's kind is only a colour: nothing in the layout or the geometry reads it. So it isn't
limited to the three built-in kinds — use any string you like and give it a colour here:
const d = document.getElementById('d')
d.diagram = {
nodes: [/* … */],
edges: [
{ src: 0, tgt: 1, kind: 'control' }, // a kind of your own
{ src: 1, tgt: 2, kind: 'hadamard' }, // a built-in, recoloured
{ src: 2, tgt: 3 }, // 'simple', left alone
],
}
d.edgeColors = { control: '#00aa55', hadamard: '#ff00aa' }A kind you don't give a colour draws like a plain wire. Both views read a wire's colour from the same lookup, so the hypergraph view's dot for a wire always comes out the colour of the wire itself.
Set view-mode="hypergraph" (or the viewMode property) to draw the diagram's hypergraph dual
instead of the diagram — the roles of wires and spiders swap. Every ZX wire becomes a node/dot;
every ZX spider becomes a hyperedge, drawn as a blob enclosing the dots of the wires incident
to it. An input/output is incident to one wire, so its blob is a circle around that one dot.
<zx-diagram id="d" view-mode="hypergraph"></zx-diagram>Only Z/X spiders, I/O, and Hadamards are supported in this view, other node types will be rejected.
Each input/output gets a blob of its own, a circle around the single dot for its leg. That circle is what tells a boundary leg apart from a self-loop: both are one dot hanging off one spider, and the rule that every dot sits in two blobs — one per end of its wire — only reads off the drawing while the boundaries have blobs too. On a diagram with many boundaries it is also a lot of outline, so it can be turned off:
<zx-diagram id="d" view-mode="hypergraph" disable-io-blobs-in-hypergraph></zx-diagram>document.getElementById('d').disableIOBlobsInHypergraph = trueThe dots stay either way — a boundary leg is still a wire. Only the circle round it goes, and with it goes anything that circle could be pressed or counted for: an input/output can no longer be selected in this view, and a dot near one no longer counts as trespassing into it.
You can also have both the graph & hypergraph views, by setting the view-mode to both-vertical or both-horizontal.
Selections will be synced across the two viewers.
The main entry defines a custom element as it loads, so it needs a DOM and won't import in Node.
To allow build-time usage of constants by Node, we have a second entry point, @adnathanail/zxcc/constants:
import { VIEW_MODES, COLOR_SCHEMES } from '@adnathanail/zxcc/constants'
if (!VIEW_MODES.includes(mode)) throw new Error(`unknown view-mode: ${mode}`)Install npm dependencies:
npm installSet up pre-commit hooks (install prek first):
prek --install
To view the Storybook example usages, and an interactive Playground story with controls:
npm install
npm run storybookYou can run tests from inside the Storybook web interface. If you want to run them via the terminal:
npm run testAnd to run with coverage:
npm run coverageThen open coverage/index.html in a browser.
npm run build # tsc → dist/, then rollup bundles to dist/index.bundle.js
npm run watch # rollup --watch (rerun tsc manually on .ts changes)The bundle is self-contained with no runtime dependencies.
npm run analyze
