4  Shape Builders

A node’s style.shape holds a function, not a name:

typ.node(0, 0, label: [A], style: (
  shape: typ.shapes.ellipse,
  fill: aqua.lighten(70%),
  stroke: 0.6pt + teal,
  inset: 3pt,
))

There is no string registry to extend and no parallel validation/lookup path — a custom builder is exactly as cheap to select as a built-in one, because selecting either is just naming a function. This is also why a native Typst element like circle can’t be dropped in directly as a shape: the renderer needs the builder to also report fitted geometry for bounds, wire clipping, and ports, which circle itself doesn’t do. Use typ.shapes.circle — the package’s builder, not the element function.

4.1 Built-in builders

shapes-gallery.typ
// Generates docs/img/shapes-gallery.svg — every built-in shape builder.
//   typst compile --root . --ignore-system-fonts docs/img/shapes-gallery.typ docs/img/shapes-gallery.svg
#import "../../src/lib.typ" as typ
#set page(width: auto, height: auto, margin: 8pt)
#set text(size: 7pt)

#let swatch(name, shape) = {
  let node = typ.node(0, 0, label: [A], style: (
    shape: shape,
    fill: rgb("#eef3ff"),
    stroke: 0.6pt + navy,
    min-size: 15pt,
    inset: 3pt,
  ))
  align(center, stack(spacing: 3pt, typ.diagram(scale: 1cm, node), raw(name)))
}

#grid(
  columns: 6, column-gutter: 12pt, row-gutter: 12pt,
  swatch("circle", typ.shapes.circle),
  swatch("ellipse", typ.shapes.ellipse),
  swatch("stadium", typ.shapes.stadium),
  swatch("rect", typ.shapes.rect),
  swatch("square", typ.shapes.square),
  swatch("bare", typ.shapes.bare),
  swatch("triangle", typ.shapes.triangle),
  swatch("flat-triangle", typ.shapes.flat-triangle),
  swatch("trapezoid", typ.shapes.trapezoid),
  swatch("arrow", typ.shapes.arrow),
  swatch("diamond", typ.shapes.diamond),
  swatch("hexagon", typ.shapes.hexagon),
  swatch("regular(5)", typ.shapes.regular(vertices: 5)),
  swatch("regular(8)", typ.shapes.regular(vertices: 8, rotate: 22.5deg)),
  swatch("polygon(..)", typ.shapes.polygon(
    ((-1, -1), (1, -1), (0.35, 0), (1, 1), (-1, 1)),
    anchor: (-0.2, 0),
    clearance: (1.25, 1.1),
  )),
)

Every built-in shape builder

empty (no shape, no label — used as an invisible route point, so it isn’t pictured above) and bare (label only) are the two non-drawing builders. The rest fit an outline around the label plus inset. circle is rotation-invariant; the axis-aligned bare, ellipse, stadium, rect, and square reject a nonzero style.rotate instead of silently ignoring it — reach for a polygon-based builder when rotated geometry is required.

Shape-builder rotation follows Typst’s screen convention, where +90deg is visually clockwise. This is not the same convention as diagram-coordinate angles (edge directions, group()), where +90deg sends +x toward mathematical +y, i.e. visually up — a node whose graphic should track a group(rotate: angle) needs style: (rotate: -angle) on that node; see Fragments and Equations.

4.2 Regular polygons

typ.shapes.regular(vertices: 3, rotate: 0deg, clearance: auto)

Returns a builder for any regular polygon of three or more sides. rotate here is baked into the builder once, at factory time — a node’s own style.rotate composes on top of it afterward. With clearance: auto, the builder derives label breathing room from the circumradius/apothem ratio, so a triangle reserves more room around its label than a high-sided polygon does; pass a positive number to override that heuristic.

#let octagon = typ.shapes.regular(vertices: 8, rotate: 22.5deg)
#let stop = typ.node-type("stop", base-style: (
  shape: octagon,
  fill: red, stroke: 0.8pt + maroon,
  min-size: 18pt, inset: 3pt,
))

A regular heptagon, or any other n-gon, needs no new package code — the factory computes its unit points and trigonometry once, rather than repeating that work per node.

4.3 Arbitrary fitted polygons

typ.shapes.polygon(vertices, anchor: auto, rotate: 0deg, clearance: 1, label-offset: (0, 0))

Returns a builder from three or more unitless numeric point pairs. The template is scaled uniformly until its bounding box meets the fitted width and height, preserving aspect ratio.

  • anchor: auto uses the template’s bounding-box centre as the node origin. Pass an explicit point when that centre would land outside a concave silhouette — the node origin must be strictly inside the polygon, because wire clipping casts a ray from it and needs a well-defined first exit.
  • rotate: rotates the template before fitting; a node’s own style.rotate rotates the already-fitted result, applied after style.flip mirrors it.
  • clearance: is a positive number, or an (x, y) pair, multiplying label breathing room.
  • label-offset: is a numeric template-space vector that scales and rotates with the polygon.
#let chevron-shape = typ.shapes.polygon(
  ((-1, -1), (0.2, -1), (1, 0), (0.2, 1), (-1, 1), (-0.35, 0)),
  anchor: (-0.1, 0),
  clearance: (1.25, 1.1),
)

Concave polygons are supported; self-intersecting ones are not, and fitting does not prove that a padded label rectangle stays inside every concave silhouette — inspect the result visually and adjust clearance:.

4.4 Directional shapes and flip

triangle, flat-triangle, trapezoid, and arrow are directional: each honors style.flip, mirrored across the shape’s local y-axis before style.rotate is applied. This is the same mirror-then-rotate order used everywhere a builder in the package composes the two, so a new directional builder can’t accidentally apply them in the other order.

flip earns a place as its own concept — not just another style key — because mirroring and rotating answer different questions. Rotating an arrow by 180° and mirroring it can even look identical for a shape with no other asymmetry, but they mean different things once a label, a slant, or a tip ratio is involved: mult(..., flip: true) says “this is the same arrow, pointing the other way,” while a rotation says “this is the same arrow, at a different angle.” state/effect make the distinction concrete — they are the same flat-triangle shape, related by a mirror, not by a rotation:

flip-vs-rotate.typ
// Generates docs/img/flip-vs-rotate.svg — directional shapes and flip:.
//   typst compile --root . --ignore-system-fonts docs/img/flip-vs-rotate.typ docs/img/flip-vs-rotate.svg
#import "../../src/lib.typ" as typ
#let diagram = typ.diagram
#set page(width: auto, height: auto, margin: 8pt)
#set text(size: 8pt)

// A mirrored pair declared directly with flip: true in base-style (the
// "effect is state mirrored" pattern), and a flippable: true constructor a
// document can flip per call.
#let tri = typ.node-type("tri", base-style: (shape: typ.shapes.flat-triangle, fill: white, stroke: 0.6pt + black, min-width: 26pt, min-height: 20pt))
#let tri-mirror = typ.node-type("tri-mirror", base-style: (shape: typ.shapes.flat-triangle, flip: true, fill: white, stroke: 0.6pt + black, min-width: 26pt, min-height: 20pt))
#let pointer = typ.node-type("pointer", flippable: true, base-style: (shape: typ.shapes.arrow, fill: luma(220), stroke: 0.6pt + black, min-size: 11pt, inset: 3pt))

#table(
  columns: 4, align: center + horizon, stroke: none, column-gutter: 16pt, row-gutter: 4pt,
  [*`tri`*], [*`tri-mirror`*], [*`pointer`*], [*`pointer(flip: true)`*],
  diagram(scale: 1cm, { tri(0, 0) }),
  diagram(scale: 1cm, { tri-mirror(0, 0) }),
  diagram(scale: 1cm, { pointer(0, 0, label: $m$) }),
  diagram(scale: 1cm, { pointer(0, 0, label: $m$, flip: true) }),
  [flat-triangle,\ default orientation], [same shape,\ `flip: true` in `base-style`], [arrow points\ right], [mirrored,\ not rotated],
)

state, effect, and mult with flip: true

effect gets there by setting flip: true in its node-type base-style (see Theming); mult and map instead declare flippable: true so documents can flip them per call with a top-level flip: argument, on top of whatever the theme already set. Reach for flippable: true on any reusable kind whose shape is directional — see node-type().

4.5 Custom shape builders

A builder’s conceptual signature is:

(measured-label, resolved-inset, resolved-style) => outline
  • measured-label has absolute width/height.
  • resolved-inset has absolute left/right/top/bottom.
  • resolved-style holds every merged style value. Core geometric fields (min-width, inset, radius, stroke, …) are already scaled to absolute units; unknown custom fields pass through unchanged, since the renderer can’t infer their units or meaning. Prefer dimensionless custom ratios, or derive lengths from fit-box/the resolved core dimensions, so custom geometry still follows diagram and group() zoom.

Two helpers cover most custom geometry:

  • typ.fit-box(label, pad, style, clear-x: 1, clear-y: 1, square: false) returns a fitted (width, height) pair.
  • typ.polygon-outline(points, label-offset: (0pt, 0pt)) validates absolute-length point pairs and returns a complete polygon outline.
#let kite-shape = (label, pad, style) => {
  let (width, height) = typ.fit-box(label, pad, style, clear-x: 1.4, clear-y: 1.3)
  let (hw, hh) = (width / 2, height / 2)
  typ.polygon-outline(
    ((0pt, -hh), (hw, 0pt), (0pt, hh), (-hw * 0.65, 0pt)),
    label-offset: (-hw * 0.08, 0pt),
  )
}

#let kite = typ.node-type("kite", base-style: (
  shape: kite-shape, fill: white, stroke: 0.6pt + black, inset: 3pt,
))

A builder must return one of these outline kinds:

(kind: "empty")
(kind: "bare")
(kind: "circle", radius: <non-negative length>)
(kind: "ellipse", half-width: <length>, half-height: <length>)
(kind: "rect", half-width: <length>, half-height: <length>, radius: <length>)
(kind: "polygon", points: <absolute point array>, label-offset: <absolute pair>)

Circle, ellipse, and rectangle outlines may include an optional absolute label-offset; polygons require it. empty/bare reject one entirely. The renderer adds the standard per-side inset displacement afterward and unions the shifted label with the silhouette for diagram bounds, so an intentional offset is never cropped.

Any geometry reducible to those five kinds — closed circle, ellipse, rectangle, or polygon silhouettes, plus the two non-drawing kinds — is expressible with a custom builder. A genuinely new primitive, such as an open path or a shape with a hole, needs a new core outline kind plus matching drawing, bounds, and clipping logic; see Extending typograph.

typ.shapes.build-outline(builder, measured-label, resolved-inset, style) is the low-level validator the renderer itself calls at this boundary. Most theme authors never call it directly, but it’s the fastest way to test a custom builder in isolation, without rendering a whole diagram:

#context {
  let outline = typ.shapes.build-outline(
    kite-shape, measure[A],
    (left: 3pt, right: 3pt, top: 3pt, bottom: 3pt),
    typ.node-defaults,
  )
}

(measure needs a known layout context, hence the context wrapper — the renderer itself always calls build-outline from inside one.)

Full outline-kind field contracts and every built-in builder’s exact behavior are in the Shape Reference.