3 Drawing Nodes
A “node” is anything placed at a diagram coordinate that can be an edge endpoint: a labelled box, a logic gate with ports, an invisible route point, or a semantic kind a theme gives its own appearance. All of them are built on the same generic constructors — a theme’s named constructors, like typograph-zx’s z/x/mult, are a thin layer on top, not a separate mechanism.
3.1 Generic constructors
typ.node(x, y, label: none, name: none, style: (:), kind: "node", base-style: (:))The fully general constructor. With neutral defaults it has shapes.empty — no outline, no label box — so it’s useful as an invisible route point for an edge to pass through. Give it a builder in style.shape for one-off visible geometry that doesn’t deserve its own reusable kind.
typ.box(x, y, label: none, fill: auto, stroke: auto, inset: auto, radius: auto, name: none, style: (:))A generic rectangle. fill, stroke, inset, and radius are exposed as direct arguments (the call-site style layer); anything else, including min-size, goes in style:.
The label is named: typ.box(0, 0, label: [A]). In contrast, gate() takes its label positionally, including none for an unlabeled gate.
typ.gate(x, y, label, legs: (left: 1, right: 1), max-legs-per-side: none, port-spacing: auto, size: auto, inset: auto, name: none, style: (:), kind: "gate")A labelled, port-capable node. legs holds non-negative integer counts for left, right, top, and bottom; when an axis has multiple ports, its automatic minimum grows from the exact center spacing plus a gate margin: (count - 1) * spacing + 12pt horizontally and (count - 1) * spacing + 9pt vertically, with every length following the diagram scale. A lone port adds no size floor. port-spacing: auto inherits the diagram/config spacing, while max-legs-per-side optionally rejects an oversubscribed side. Supply size: (width, height) to override the automatic minimum directly, or one length for a square size. Unlabeled gates do not inherit the generic gate’s label padding, so a compact theme min-size can shrink all the way to the requested value without also setting inset: 0pt. kind: controls which theme preset applies — a gate with kind: "measurement" is independently themeable and still port-capable, since port behavior travels as node capability metadata, not as the "gate" kind specifically. Its shape can be a circle, ellipse, rectangle, or polygon; port candidates are projected onto whichever outline is resolved.
size: replaces the minimum, not the final dimensions: a larger label or explicit inset can still enlarge the gate. Only the positional none disables implicit label padding; an explicit empty label [] still counts as a label and selects shape-labelled when provided.
typ.port(node, side, index: 0)References one port on a port-capable node. side is "left", "right", "top", or "bottom"; index can also be passed positionally. The exact point is deferred until the node’s measured outline is known, so it doesn’t matter that a gate’s size depends on its label — and a port carries its node with it, so edge(port(g, "left"), ..) draws g even if nothing else references it directly.
Indices start at zero. Left/right sides run from bottom to top; top/bottom sides run from left to right. Omitted sides have zero ports. Spacing is applied to candidates on the outline’s bounding rectangle before projection: it remains exact along a sufficiently large straight side, but circular, rounded, or other projected boundaries can bring the final ports closer together. An explicitly small size: does not override this projection.
gates-ports.typ
// Generates docs/img/gates-ports.svg — a box and a gate with labeled ports.
// typst compile --root . --ignore-system-fonts docs/img/gates-ports.typ docs/img/gates-ports.svg
#import "../../src/lib.typ" as typ
#let diagram = typ.diagram
#set page(width: auto, height: auto, margin: 8pt)
#set text(size: 9pt)
#diagram(scale: 1cm, {
import typ: *
box(0, 0, label: [box])
let g = gate(2.4, 0, $U$, legs: (left: 2, right: 1, top: 1))
for i in range(2) { edge(port(g, "left", i), rel(-0.8, 0)) }
edge(port(g, "right"), rel(0.8, 0))
edge(port(g, "top"), rel(0, 0.8))
})rel(dx, dy) (an offset from the preceding resolved waypoint) is what makes those leads dead straight regardless of the gate’s label or scale — see Deferred endpoints for why that offset has to be deferred instead of computed up front.
3.2 Relative positioning
Node constructors accept either x, y or one point. A point can be a coordinate pair, another node (its center), a port(...), a named ref(...), or an offset(...) result. The same forms work with place() and the single-node constructors in both companion themes.
#typ.diagram({
let g = typ.gate((0, 0), [U])
let p = typ.port(g, "right")
let a = typ.box(typ.offset(p, 1.4, 0), label: [A])
let b = typ.box(p.x, -1.2, label: [B])
typ.edge(p, a)
typ.edge(p, b)
typ.place(p.x, -1.8, [same port x])
})typ.box(p, label: [A]) places the box’s center exactly on the port. Use offset(p, dx, dy) to move that center away from it; this is not an automatic boundary-to-boundary gap or collision-avoidance rule. Offsets are signed numbers in diagram units, not lengths such as 4pt. They follow scale, scale-edges, and group scaling/rotation. Successive calls compose into one displacement, so repeatedly calling offset() does not build an ever-deeper expression. Even a zero offset remains an explicit point: an edge to offset(node, 0, 0) ends at the node’s center without silhouette clipping.
Ports, named references, and offsets expose .x and .y. These are deferred coordinate values, not numbers: p.x + 1 is unsupported; write typ.offset(p, 1, 0).x. Either axis can be absolute or relative, and they can come from different points:
typ.box(p.x, -1, label: [one relative axis])
typ.box(p.x, typ.ref("other").y, label: [two references])
typ.place(typ.offset(p, 0, -0.5), [caption])Labels keep their existing calling convention: gate(p, [U]) takes a positional label; box(p, label: [U]) takes a named label; place(p, [text]) takes a positional body. Numeric calls such as box(1, 2) remain valid.
relative-positioning.typ
// Same relative layout with two differently sized gate labels.
#import "../../src/lib.typ" as typ
#set page(width: auto, height: auto, margin: 8pt)
#set text(size: 9pt)
#let example(label) = typ.diagram(inset: 0.15, {
let g = typ.gate((0, 0), label)
let p = typ.port(g, "right")
let a = typ.box(typ.offset(p, 1.4, 0), label: [A], fill: rgb("e8efff"))
let b = typ.box(p.x, -1.2, label: [B], fill: rgb("e4f5ec"))
typ.edge(p, a)
typ.edge(p, b, stroke: (paint: gray, thickness: 0.6pt, dash: "dashed"))
typ.node(p, style: (shape: typ.shapes.circle, min-size: 3pt, inset: 0pt, fill: black, stroke: none))
typ.place(typ.offset(p, 0.7, 0.4), [both axes])
typ.place(p.x, -1.65, [same port x])
})
#stack(dir: ttb, spacing: 12pt, example([U]), example([a wider gate label]))3.2.1 References, capture, and dependencies
Direct node and port values carry their source nodes into the diagram, including through offsets and coordinate projections. Reusing the same source value draws it once. Named references only look up nodes that the diagram otherwise emits; they may refer forward:
#typ.diagram({
let p = typ.port(typ.ref("later"), "right")
typ.box(typ.offset(p, 1, 0), label: [A])
typ.gate(0, 0, [U], name: "later")
})All node outlines are measured before coordinate dependencies are resolved, so port alignment follows labels, theme overrides, shape geometry, and port spacing. A missing name, a nonexistent port, or a true coordinate cycle produces an error. Dependencies are checked per axis: a.x depending on b.x while b.y depends on a.y is valid if the remaining axes are fixed. No general constraint equations are solved.
Within a group(), projections and offsets follow the fragment’s local axes. Rotation still leaves node graphics and text upright; ports project onto those actual transformed-node outlines, exactly as edge endpoints do. Names remain diagram-global, even when a referring fragment is transformed. Prefer direct node values for self-contained reusable fragments.
rel(dx, dy) still means an offset from the preceding edge waypoint; it is not a node position. Bézier controls, group pivots, and diagram configuration coordinates still require numbers.
3.3 Circuit constructors (companion package)
For a focused quantum-circuit style, import the companion package:
#import "@preview/typograph-circuit:0.1.0": measurement, state, effect, discard, unitary, phase, control, target, control_dot, open_control, swap, ancilla, cnot, cz, cswap, barrier, displacement, squeezer, beamsplitter, homodyne, circuitThen call with the helper wrapper:
#circuit(levels => {
let a = measurement(0, levels.qubit, label: [M])
let b = state(1, levels.wire, label: [\|0⟩])
let c = effect(2, levels.register)
typ.edge(a, b)
typ.edge(b, c)
})The companion package also exports diagram, so existing typ.diagram-style layouts can keep their structure and just swap in a themed import.
The optional circuit(...) helper in that package is a thin wrapper around typ.diagram(...) that keeps defaults stable. Pass a function body to receive a levels dictionary with semantic y-level names:
qubit(default0)wire(default0)register(default-1)label(default1)
Diagram-item arrays or none are also accepted when semantic levels are not needed.
Circuit constructors are still ordinary generic nodes under the hood:
measurement(...)is a white gate whose semicircle and arrow paint behind the box while retaining independent body/mark strokes.state(...)points left and accepts any number of ports only on its flat right side;effect(...)points right and accepts ports only on its flat left side. Tip-side ports are rejected.discard(...)preserves the hollow circle-with-cross terminal marker.control(...)/target(...)create mirrored gate forms for CNOT-style control wiring.control_dot(...)(aka control-dot) is a compact filled control marker.open_control(...)is the hollow control-on-zero variant.swap(...)is a plain diagonal cross marker; connected wires run to its center.ancilla(...)is a small cross-marked symbol for scratch ancilla lines.cnot(control-x, control-y, target-x, target-y, ...)composes both plus the connecting control wire in one call.unitary(...)is a generic labelled one- or multi-wire gate box;phase(...)provides the compact circular phase form.cz(...)/controlled_z(...)andcswap(...)/fredkin(...)provide standard controlled-Z and controlled-SWAP compositions.barrier(...)draws a local dashed circuit barrier;input_label(...)andoutput_label(...)place endpoint labels without introducing special nodes.
Filled controls, open controls, and control_dot(...) default to 5pt; the target defaults to 12pt. Their size: auto default preserves ordinary theme, diagram, and instance min-size precedence, including values below 5pt. Use size: 3pt for a direct per-instance override, or style: (min-size: 3pt) when composing style dictionaries. An explicit size wins over the styled minimum. cnot(...) exposes the same choices as control-size: and target-size:.
This focused vocabulary follows the recurring elements in the Quill circuit package, IBM circuit visualizations, and Microsoft’s circuit notation guide.
The circuit theme also exposes wire presets you can pass through preset::
quantumclassicaldotted/dotted-delaymeasurement-output
Convenience constructors for the same presets are exported as: quantum, classical, dotted_delay, and measurement_output.
State/effect flat sides have no maximum port count. Their port centers use the diagram’s exact 7pt default spacing, so labels cannot make corresponding wires diverge. Override with port-spacing: on one gate, on diagram(...), or document-wide through #show: typ.config.with(port-spacing: 8pt). Other gate-style constructors retain max-legs-per-side where applicable:
state(0, 0, legs: (right: 8), port-spacing: 6pt)
effect(2, 0, legs: (left: 8)) // inherits diagram/config spacing
cnot(0, 0, 1, 0, control-legs: (right: 2), target-legs: (left: 2), max-legs-per-side: 2)3.3.1 Continuous-variable gates
The theme includes photonic/CV constructors for displacement, squeezer, phase_shift, beamsplitter, two_mode_squeezer, cv_controlled_addition, cv_controlled_phase, cubic_phase, kerr, cross_kerr, homodyne, heterodyne, photon_number_measurement, vacuum, and fock_state. Labels remain overridable content, and two-mode constructors default to two ports on each side.
This selection follows the official PennyLane CV operator reference and Strawberry Fields photonic circuit examples.
typ.edge(measurement(0, 0), state(1, 0), preset: "quantum")
typ.edge(state(1, 0), effect(2, 0), preset: "classical")
typ.edge(measurement(2, 0), effect(3, 0), preset: "dotted-delay")
typ.edge(ancilla(4, 1), state(5, 1), preset: "measurement-output")3.4 Reusable node types: node-type()
Most named nodes — z, x, a project’s own process or pentagon — are not written with typ.node directly. They’re declared once with node-type() and then called like any other constructor:
typ.node-type(kind, base-style: (:), flippable: false)This returns a constructor with signature (x, y, label: none, name: none, style: (:)) — or, if flippable: true, that signature gains a top-level flip: auto argument that overwrites style.flip whenever it’s explicitly given. kind must be a string and becomes the node’s semantic kind, which is exactly what a theme’s node-presets dictionary is keyed by. base-style is a factory-level default that sits below the theme preset in the precedence chain (see Core Concepts), so a document can still restyle a reusable kind without touching its constructor:
#let decision = typ.node-type("decision", base-style: (
shape: typ.shapes.diamond,
fill: white,
stroke: 0.6pt + black,
min-size: 14pt,
inset: 3pt,
))flippable: true is worth reaching for whenever a kind’s shape is directional — see Shape Builders for what “directional” means and why flip: earns a dedicated constructor argument instead of always being written into style: by hand.
For constructors that need extra named metadata beyond the ordinary node fields, typ.make-node(kind, x, y, label:, name:, style:, base-style:, size-scale:, ..extra) is the low-level escape hatch node-type itself is built on. Prefer node-type for ordinary semantic kinds; reach for make-node only when a constructor genuinely needs to carry something node-type doesn’t expose.
3.5 Node style keys
The neutral core defaults (typ.node-defaults) are:
(
shape: typ.shapes.empty,
shape-labelled: auto,
fill: none,
stroke: none,
min-width: 0pt,
min-height: 0pt,
inset: 0pt,
radius: 0pt,
rotate: 0deg,
flip: false,
slant: 0.55,
tip: 0.32,
font-size: auto,
)shape— a builder function; see Shape Builders.shape-labelled— an alternate builder used only when a label exists, orautoto always useshape. Opt-in: most kinds keep one builder whether labelled or not, but the bundled spiders switch from a circle to a stadium once they carry a label, so a long label doesn’t get cramped into a fixed circle.fill,stroke— passed straight to the underlying Typst element.min-width,min-height,min-size—min-sizeis shorthand that expands to both axes; within one style dictionary an explicitmin-width/min-heightwins on its axis regardless of write order, while across dictionaries normal precedence applies (a latermin-sizeoverrides both axes from an earlier layer).inset— one length/number or a side dictionary with any ofleft/right/top/bottom/x/y/rest(unknown keys are rejected, not silently treated as zero). A larger inset on one side visually pushes the label toward the opposite side within the fitted outline.radius— uniform corner radius, consumed byrect/square; accepts a length, percentage, or relative length like20% + 1pt, clamped to the maximum sensible rounded radius. Per-corner radii aren’t part of the core outline protocol.rotate,flip,slant,tip— consumed by the builders that need them; see Shape Builders.font-size— per-node override of the label’s type size.
The fields above are type-checked after style resolution, but a node style dictionary otherwise stays open: a custom shape builder can read any extra key it wants out of the resolved style (see Custom shape builders). This is the one deliberate asymmetry with edge styles, which are closed — there’s no per-edge rendering extension point to justify tolerating unknown keys there.
Full precedence, defaults, and every key’s exact contract live in the Node Reference.