Tutorial

Every feature of the language, one step at a time — from a single box to charts, scenes, sequence diagrams and libraries.

Sauvignon is a language for describing diagrams. You never give a coordinate; you say what the parts are and how they relate, and the engine solves the rest.

This walks the whole language, one idea per step. Each step adds a single thing to what came before, so you can read it in order and never meet a feature you have not been shown. Every diagram below is compiled from the source shown beside it, during the build of this page. If a snippet were wrong, this page would not exist.

Marks

1. Your first diagram

A document is a <diagram> holding one or more marks. A mark's tag is its shape, and its text content is its label.

<diagram>
  <rect>Hello, Sauvignon</rect>
</diagram>

The box sized itself to its text, and the page sized itself to the box. Nothing here is required: <diagram> with a mark in it is a whole document.

Two things you will reach for straight away, though.

padding is the margin left around the whole drawing — 24 unless you say otherwise, and 0 if you want none. (The same attribute on a mark means the space around its own label, which is the next step.)

width and height on the <diagram> pin the canvas. Either one on its own fixes that axis and leaves the other to size itself:

<diagram width="320" height="90" padding="12">
  <rect>Hello, Sauvignon</rect>
</diagram>

The mark does not grow to meet the page. A bigger canvas is more room, not a bigger box — the rectangle is still exactly as wide as its text, sitting in a page you made larger. (A <row> or a <column> does fill a definite canvas, because a container is a space to lay out in rather than a thing that is drawn; that is step 13.)

Ask for a canvas the drawing cannot fit into and Sauvignon says so rather than cutting it off — the point of measuring everything is that nothing is quietly lost.

Compile it from the command line:

python -m sauvignon hello.svgn -o hello.svg

2. Labels, two ways

The label can be element text or a label attribute. They mean the same thing; use whichever reads better. padding sets the space around it, and rx the corner radius — that one defaults to 6, so rx="0" is how you get a rectangle with sharp corners.

Showing four of them side by side needs something to arrange them, so this uses <row> — it lays its children out left to right, gap apart. That is the whole of it for now; combinators get a part of their own from step 9.

<diagram>
  <row gap="12">
    <rect>as text</rect>
    <rect label="as an attribute"/>
    <rect label="roomier" padding="22"/>
    <rect label="sharp corners" rx="0"/>
  </row>
</diagram>

A label can also sit beside its mark rather than in it: label-side puts it left, right, above or below, which is how a small dot carries a name longer than itself. The mark keeps its own size, and a connector (step 16) ends at the shape, not at the edge of the words.

<diagram>
  <row gap="40">
    <circle label="Bilbo" label-side="left" width="24" height="24"/>
    <circle label="Bag End" label-side="above" width="24" height="24"/>
    <circle label="Hobbiton" label-side="right" width="24" height="24"/>
  </row>
</diagram>

3. The tag is the shape

Shapes are vocabulary. Each mark owns its outline, which is also what connectors clip against — so a new shape gets correct edge-attachment for free.

<diagram>
  <row gap="12">
    <rect>rect</rect>
    <ellipse>ellipse</ellipse>
    <circle>circle</circle>
    <diamond>diamond</diamond>
    <hexagon>hexagon</hexagon>
    <parallelogram>parallelogram</parallelogram>
  </row>
</diagram>

<box> and <node> are aliases for <rect>, and <io> for <parallelogram> — the same mark under a name that suits the drawing. A shape= attribute overrides the shape independently of the tag, for when you want <node shape="ellipse">, but you rarely reach for it.

Those are the shapes with names. <polygon> is the one whose shape you describe instead: sides is what a polygon is, so it is an attribute rather than a tag per count.

<diagram>
  <row gap="12">
    <polygon sides="3">3</polygon>
    <polygon sides="5">5</polygon>
    <polygon sides="8">8</polygon>
    <polygon sides="4" skew="0.5">skew</polygon>
    <polygon sides="4" distortion="0.5">taper</polygon>
    <polygon sides="3" orientation="90">turn</polygon>
  </row>
</diagram>

Odd counts get a vertex at the top and even counts a half-step turn, so a triangle points up, a square sits flat and a six-sided one comes out as the <hexagon> above — the rule was checked against the named shapes rather than invented beside them. skew leans it, distortion widens the top and narrows the bottom, and orientation turns it in degrees.

Notice the triangle is bigger than the octagon for a shorter label. A label sits in the middle, and a regular polygon of a given size has less middle the fewer sides it has, so the room each one asks for follows from its own geometry rather than from a number picked per shape.

All four describe the outline <polygon> builds, so they are refused on the shapes that already have one: there is nothing about a <hexagon>'s sides left to say.

sides makes a regular polygon, which is most of them and not a chevron. For an outline that is not regular, give the vertices with points:

<diagram>
  <row gap="-16">
    <polygon points="0,0 3,0 4,2 3,4 0,4 1,2" width="150" height="64">Plan</polygon>
    <polygon points="0,0 3,0 4,2 3,4 0,4 1,2" width="150" height="64">Build</polygon>
    <polygon points="0,0 3,0 4,2 3,4 0,4 1,2" width="150" height="64">Ship</polygon>
  </row>
</diagram>

The numbers need no particular range — the outline is stretched to fill its box, the way every other polygon's is, so whole numbers are usually easier to read than sixths. Writing points and one of the four above is refused: both describe the outline, and which wins is not something anyone should have to look up. It is the same PolygonMark the built-ins are, so a connector clips to the outline, and it is the same one register_mark builds from Python (step 64) — one shape mechanism, reachable from either side.

4. Shapes a polygon cannot be

Almost every mark is a list of vertices in the unit box, which is why registering one is a one-liner (step 64). Two shapes are not.

<diagram>
  <row gap="14">
    <cylinder>cylinder</cylinder>
    <rect rx="99">stadium</rect>
  </row>
</diagram>

A cylinder's lid and base are arcs, so it has a mark class of its own and draws as two paths — the silhouette, then the front of the lid, which is what makes it read as a solid rather than a rounded box. <database> and <db> are aliases.

It also places its own label. The face you can write on runs from the bottom of the lid's front arc to the top of the base, which is not the middle of the box: the lid takes two of its radii off the top and the base only one off the bottom. So the label sits half a lid lower than it would on a rectangle — which means a cylinder's text is a few pixels below a neighbouring <rect>'s, and should be. A shape that knows its own outline is the only thing that can say where writing on it goes.

A stadium needs nothing at all: SVG clamps rx to half the height, so any large rx already draws one. Worth knowing before you reach for a new shape.

5. Text without a shape

<text> is a mark with no box around it. Unlike a label inside a shape, it wraps: give it a width narrower than one line and it breaks across lines and grows taller.

<diagram>
  <row gap="20">
    <text width="150">A text mark has a real min-content and max-content, so it
      breaks at spaces when the box is too narrow.</text>
    <text width="110">Supercalifragilisticexpialidocious still fits.</text>
  </row>
</diagram>

The second one has no space to break at, so it is split at character boundaries rather than allowed to overflow — CSS's overflow-wrap: break-word. A label inside a shape never wraps; the shape sizes to it instead.

The two blocks came out different heights, and the <row> centred them against each other. Lining them up by their top edges instead is a job for align, which step 11 takes properly.

6. A picture

<image> is the one mark whose content comes from outside the document. Its href is relative, and the picture is looked for beside the document that names it — a path from someone's disk would not compile anywhere else. The bytes are then embedded, so what comes out is one file you can paste into a slide with nothing to go missing.

<diagram>
  <row gap="16">
    <image href="examples/assets/vine.svg"/>
    <image href="examples/assets/vine.svg" width="64" height="64"/>
    <image href="examples/assets/vine.svg" width="64" height="64" fit="contain"/>
  </row>
</diagram>

The first takes its own size from the file. The other two are given a square to sit in, which is not the picture's shape, so something has to give: fit="cover" crops to fill it and is the default, because a letterboxed thumbnail in a row of thumbnails is the one that looks broken; fit="contain" fits the whole picture in and leaves the rest; fit="fill" stretches it.

An <image> draws no label. A caption goes under it in a <column> or over it in a <stack> — both compositions the language already has — where text in the middle of a photograph is the one answer nobody wants.

7. Giving a mark a size

width and height override the intrinsic size. A percentage resolves against the parent's definite inner size on that axis, so children can tile a row in proportion.

<diagram>
  <row gap="0" width="360">
    <rect width="50%">50%</rect>
    <rect width="30%">30%</rect>
    <rect width="20%">20%</rect>
  </row>
</diagram>

That width on the row is load-bearing: a percentage of an indefinite parent has nothing to resolve against, and falls back to the content size. This definiteness rule comes back for justify, grow and fr. So is the gap="0": the three shares are the whole row, so a gap between them has nowhere to go, and sizes that cannot fit the row they were written for are refused.

8. Aspect ratio

Give one axis and aspect derives the other.

<diagram>
  <row gap="12">
    <rect width="180" aspect="16/9">16 : 9</rect>
    <rect width="90" aspect="1">square</rect>
    <hexagon aspect="1.1547">regular</hexagon>
  </row>
</diagram>

That third one is why aspect matters beyond hero images. A polygon mark is an outline in the unit box stretched to whatever its label needs, so a <hexagon> comes out at the proportions of the text inside it — the one in step 3 is nearly twice as wide as it is tall. Its points are left and right and its flats top and bottom, so it is regular only at a width-to-height ratio of 2 / √3, which is the 1.1547 above: with it, all six sides come out the same length.

<circle> is the one mark that does this for you — it is <ellipse> with the two axes equalised, which is the whole reason both tags exist. There is no second tag for a regular hexagon, and a mark that forced its own ratio could no longer size to its label, which is the rule the next steps lean on.

Composing

9. Row and column

Combinators arrange their children. <row> runs across, <column> (alias <col>) down, and they nest freely.

<diagram>
  <column gap="10">
    <rect>top</rect>
    <row gap="10">
      <rect>left</rect>
      <rect>right</rect>
    </row>
    <rect>bottom</rect>
  </column>
</diagram>

10. Stack

<stack> superimposes its children on a shared centre — a badge over a tile, a label over a plate.

<diagram>
  <stack>
    <rect width="200" height="90"/>
    <text>on top</text>
  </stack>
</diagram>

at="x y" places a child of a stack by hand instead, from the stack's top-left corner, and the stack grows to hold everything placed in it. It is the escape hatch for a figure someone has already arranged — a hand-drawn graph, say — where the positions are the point. On a mark with its label beside it, at places the mark and the label hangs off it.

<diagram>
  <stack>
    <circle label="Gandalf" label-side="above" width="24" height="24" at="0 40"/>
    <circle label="Istar" label-side="right" width="24" height="24" at="120 0"/>
    <circle label="People" label-side="right" width="24" height="24" at="240 60"/>
  </stack>
</diagram>

step says when something appears, for a host that builds a diagram up one step at a time — a slide in Eridian, say. Sauvignon itself draws everything; the step goes into the SVG as data-step for the host to read. A container's step is everything in it, and a connector without one appears with the later of its two ends, so nothing ever points at empty space.

<diagram>
  <row gap="60">
    <circle id="a" label="first" width="60" height="60"/>
    <circle id="b" label="then" width="60" height="60" step="1"/>
    <circle id="c" label="last" width="60" height="60" step="2"/>
  </row>
  <connect from="a" to="b"/>
  <connect from="b" to="c"/>
</diagram>

11. Gap and alignment

gap spaces children (default 30); gap="row col", or row-gap and column-gap, set the axes separately. align positions children on the cross axis and defaults to center.

<diagram>
  <row>
    <column gap="6" align="start">
      <rect width="120" height="30">start</rect>
      <rect width="80" height="30"/>
    </column>
    <column gap="6" align="center">
      <rect width="120" height="30">center</rect>
      <rect width="80" height="30"/>
    </column>
    <column gap="6" align="end">
      <rect width="120" height="30">end</rect>
      <rect width="80" height="30"/>
    </column>
  </row>
</diagram>

Any child can override the container with align-self, and text-align places a label inside its own mark rather than the mark inside its row:

<diagram>
  <column gap="8">
    <rect width="240" height="34" text-align="left">a left-set label</rect>
    <rect width="240" height="34">the column centres these</rect>
    <rect width="90" height="28" align-self="end">footer</rect>
  </column>
</diagram>

A <stack> (step 10) has no main axis, so both of its axes are cross axes — and they are two axes, not one. justify places a child across it, align places it down, each on its own, and either can be overridden per child. Give a stack a size bigger than its children and there is room to put them in different places in it — two circles offset sideways is what a Venn diagram is:

<diagram>
  <stack width="230" height="130">
    <circle width="130" height="130" justify-self="start"/>
    <circle width="130" height="130" justify-self="end"/>
    <rect justify-self="end" align-self="start">badge</rect>
  </stack>
</diagram>

end on the footer means the end of the cross axis, which in a column is its right edge. The column here has no width of its own, so that edge is the one its widest child sets, and the footer lines up under the boxes above. Give the column a width larger than its children and end still means the column's edge — which is correct, and would leave the footer hanging out past them.

12. Justify needs a definite size

justify distributes children along the main axis, exactly as CSS flexbox does. It only bites when the container has a size of its own to spend: with no width, a row is as wide as its contents and there is no slack to hand out.

<diagram>
  <column gap="8" align="stretch">
    <row width="340" justify="start" gap="0">
      <rect>a</rect><rect>b</rect><rect>c</rect>
    </row>
    <row width="340" justify="center" gap="0">
      <rect>a</rect><rect>b</rect><rect>c</rect>
    </row>
    <row width="340" justify="space-between" gap="0">
      <rect>a</rect><rect>b</rect><rect>c</rect>
    </row>
    <row width="340" justify="space-evenly" gap="0">
      <rect>a</rect><rect>b</rect><rect>c</rect>
    </row>
  </column>
</diagram>

The declared gap is a floor the slack is spread on top of, not something the space-* modes replace.

13. Stretch and grow

align="stretch" makes children fill the cross axis; grow="true" gives a child the leftover main-axis space. grow needs the container to be definite on that axis — there is no leftover to share out otherwise. stretch does not: with nothing definite to fill, a child fills its line, which is as deep as the deepest thing in it.

<diagram>
  <row width="360" height="70" gap="8" align="stretch">
    <rect>fixed</rect>
    <rect grow="true">grow takes the rest</rect>
  </row>
</diagram>

14. A mark sized by its neighbour

Every mark so far sized itself to its own label. A <brace> cannot: it says these, together, so what it has to fit is the thing it is bracing — and it has no way to know how big that is. It gets the size the way anything else gets one it did not choose, from the line it is standing in.

<diagram>
  <row gap="6" align="stretch">
    <brace>48 ×</brace>
    <contains padding="14" gap="8">
      <rect>LayerNorm</rect>
      <rect>Attention</rect>
      <rect>Feed forward</rect>
    </contains>
  </row>
</diagram>

The row is as deep as the block, stretch hands the brace that depth, and the brace spans exactly what it is standing next to. Nothing measured anything.

opens names the side the arms reach toward, which is the side the braced thing is on — opens="right" is a { and is the default; left, top and bottom give the other three. The label goes on the other side, where the tip points, because that is where a reader looks for it. Centred in the box, as every other mark's label is, it would sit on top of the curve.

<diagram>
  <column gap="6" align="stretch">
    <row gap="10"><rect>x</rect><rect>y</rect><rect>z</rect></row>
    <brace opens="top">three of them</brace>
  </column>
</diagram>

15. Wrapping

wrap="true" lets a row with a definite width break onto new lines; the container grows downward to fit them.

<diagram>
  <row wrap="true" width="330" gap="8">
    <rect>alpha</rect><rect>beta</rect><rect>gamma</rect>
    <rect>delta</rect><rect>epsilon</rect><rect>zeta</rect>
  </row>
</diagram>

When the container also has a definite cross size with room to spare, align-content distributes the lines along it — the multi-line analogue of justify.

Relations

16. Connecting things

Give marks an id and relate them. A connector routes between the two shapes' boundaries, so it always meets the edge cleanly.

<diagram>
  <column>
    <rect id="start">start</rect>
    <diamond id="check">ok?</diamond>
    <rect id="done">done</rect>
  </column>
  <connect from="start" to="check"/>
  <connect from="check" to="done" label="yes"/>
</diagram>

<edge>, <arrow> and <link> are aliases of <connect>; pick the one that reads best in context.

17. Arrowheads

arrow takes end (the default), start, both, or none for a plain wire.

<diagram>
  <column gap="18">
    <row gap="90"><rect id="a1">a</rect><rect id="b1">b</rect></row>
    <row gap="90"><rect id="a2">a</rect><rect id="b2">b</rect></row>
    <row gap="90"><rect id="a3">a</rect><rect id="b3">b</rect></row>
  </column>
  <connect from="a1" to="b1" arrow="end" label="end"/>
  <connect from="a2" to="b2" arrow="both" label="both"/>
  <connect from="a3" to="b3" arrow="none" label="none"/>
</diagram>

18. Arrowheads that mean something

arrow says which ends bear a head. head and tail say which shape, at the to and from ends. In a class or ER diagram that is not decoration: a hollow triangle says "inherits from" the way no label has to.

<diagram>
  <column>
    <rect id="shape">Shape</rect>
    <row gap="40">
      <rect id="circle">Circle</rect>
      <rect id="poly">Polygon</rect>
    </row>
  </column>
  <connect from="circle" to="shape" head="triangle"/>
  <connect from="poly" to="shape" head="triangle"/>
  <connect from="poly" to="circle" head="odiamond"/>
</diagram>

Eight shapes: arrow (the default), open, triangle, diamond, odiamond for UML, and crow, circle, bar for ER cardinality, plus cross for a line that is stopped rather than aimed. Only the ones a diagram uses are defined in its SVG.

19. A label at each end

An edge label sits mid-route. A cardinality does not belong there — it belongs at the end it describes, which one label in the middle cannot say.

<diagram>
  <row gap="150">
    <rect id="customer">CUSTOMER</rect>
    <rect id="order">ORDER</rect>
  </row>
  <connect from="customer" to="order" arrow="both" head="crow" tail="bar"
           label="places" from-label="1" to-label="0..*"/>
</diagram>

from-label and to-label sit a fixed distance in from each shape, so they read the same on a long edge and a short one. Read together with the heads, that edge says "one customer places zero or more orders" without a word of prose.

20. Routing

routing chooses the shape of the line: straight (the default), orthogonal (alias elbow), or curved (alias bezier).

<diagram>
  <column>
    <row gap="150" height="120">
      <rect id="s1" align-self="start">from</rect>
      <rect id="t1" align-self="end">to</rect>
    </row>
    <row gap="150" height="120">
      <rect id="s2" align-self="start">from</rect>
      <rect id="t2" align-self="end">to</rect>
    </row>
    <row gap="150" height="120">
      <rect id="s3" align-self="start">from</rect>
      <rect id="t3" align-self="end">to</rect>
    </row>
  </column>
  <connect from="s1" to="t1" routing="straight" label="straight"/>
  <connect from="s2" to="t2" routing="orthogonal" label="orthogonal"/>
  <connect from="s3" to="t3" routing="curved" label="curved"/>
</diagram>

The drop is the point, and it is worth reading how it is made. Each <row> is given a height larger than the boxes in it, which leaves slack on the row's cross axis — vertical, for a row. align-self then pins one box to the top of that slack and the other to the bottom, so the two ends of every connector are 120 - 38 apart: the row's height less a box's own.

It has to be that way round because the three routings are only distinguishable between shapes that do not line up. Between two boxes at the same height, straight and orthogonal and curved all draw the same line. The further apart the ends, the more each one has to say — the elbow gets a vertical leg you can see, and the curve gets room to be a curve.

21. Anchors

By default a connector meets a shape where the centre-to-centre ray exits it. Pin an end with from-anchor / to-anchor — n s e w ne nw se sw center, or the long forms top bottom left right.

<diagram>
  <row gap="130">
    <rect id="l">left</rect>
    <rect id="r">right</rect>
  </row>
  <connect from="l" to="r" from-anchor="n" to-anchor="n" routing="curved"/>
  <connect from="l" to="r" from-anchor="s" to-anchor="s" routing="curved"/>
</diagram>

Pinning an anchor opts out of the automatic behaviour in the next step: an explicit anchor is an exact point, and the line between two of them is left alone — for the cases where it is meant to pass through what it crosses, like a circuit's wires.

22. What the router does for you

Four things happen without your asking. A connector routes around any shape between its ends rather than through it. Several connectors sharing a side spread along it instead of stacking. Two edges between the same pair are fanned apart so both stay visible. And an edge label sits on its line — breaking it with the halo it paints — stepping off only to clear something it would otherwise lap over.

<diagram>
  <row gap="40">
    <rect id="src">source</rect>
    <rect width="90" height="80">in the way</rect>
    <rect id="dst">target</rect>
  </row>
  <connect from="src" to="dst" label="detours"/>
</diagram>

Nothing in that source mentions the obstacle. A clear route stays perfectly straight, so this only changes the diagrams that had the problem.

23. Self-loops

The same id at both ends draws a loop, placed on whichever side of the node is least busy.

<diagram>
  <column gap="34">
    <rect id="idle">idle</rect>
    <rect id="running">running</rect>
  </column>
  <connect from="idle" to="running" label="start"/>
  <connect from="running" to="running" label="poll"/>
</diagram>

24. Grouping with contains

<contains> is a relation, not just a box: it draws a boundary around its children, can carry a styled title, and is itself connectable by id.

<diagram theme="light">
  <row gap="20">
    <contains label="Frontend" padding="12" gap="8"
              color="#2563eb" font-weight="700">
      <rect>app</rect>
      <rect>router</rect>
    </contains>
    <contains label="Backend" padding="12" gap="8"
              color="#15803d" font-weight="700">
      <rect>api</rect>
      <rect>worker</rect>
    </contains>
  </row>
</diagram>

Those two titles carry fixed colours, so the diagram pins theme="light" to match. A fixed foreground only reads against a known background: leave this one following the reader and the panel behind the titles flips while the titles do not, so one of the pair always ends up too faint. That trade is step 40.

layout= sets how the children inside are arranged — row, column (the default), stack, grid or graph. The boundary and its title do not change; only what is inside does:

<diagram>
  <contains label="Workers" layout="row" gap="8">
    <rect>ingest</rect>
    <rect>transform</rect>
    <rect>load</rect>
  </contains>
</diagram>

25. Connecting to a whole group

Because a <contains> has an id, an edge can target the group itself, and it clips to the boundary.

<diagram>
  <row gap="50">
    <rect id="client">client</rect>
    <contains id="svc" label="service" padding="12" gap="8">
      <rect>api</rect>
      <rect>db</rect>
    </contains>
  </row>
  <connect from="client" to="svc"/>
</diagram>

Constraints

26. Align

This is where Sauvignon stops being a layout tree. <align> compiles to a hard constraint, and hard constraints beat the structural placement — so you can align two elements that live in different parents, which no strict tree can express.

<diagram>
  <row gap="60">
    <column gap="12">
      <rect width="90">Alpha</rect>
      <rect id="a2" width="90">Beta</rect>
    </column>
    <column id="right" gap="12">
      <rect width="90">Gamma</rect>
      <rect width="90">Delta</rect>
    </column>
  </row>
  <align of="a2 right" edge="top"/>
  <connect from="a2" to="right"/>
</diagram>

The first id is the anchor; the rest move to it. Here Beta stays where its column put it and the right-hand column travels up to meet it — so the connector between them leaves horizontally. edge also takes left, right, bottom, center-x and center-y.

Naming a container is how you move a group intact: everything inside it comes along. Name one of its children instead and only that child moves, out of its own stacking order and possibly over a sibling — which is right when that is what you meant (two boxes in one column sharing a left edge) and a mess when it is not. The distinction is the whole of it: align what you want to move.

27. Distribute

<distribute> places ids in order with an equal edge-to-edge gap along an axis.

<diagram>
  <row>
    <rect id="d1" width="60" height="40">1</rect>
    <rect id="d2" width="110" height="40">2</rect>
    <rect id="d3" width="70" height="40">3</rect>
  </row>
  <distribute of="d1 d2 d3" gap="40"/>
</diagram>

The boxes are different widths, so an even gap is not the same as even spacing of their centres — which is exactly the distinction the constraint exists to make.

Two-dimensional layout

28. Grid tracks

<grid> brings CSS-style track sizing. columns is a track list: a fixed length, a fraction, or min-content / max-content. Cells share tracks, so columns line up across rows — the alignment a column-of-rows cannot guarantee.

<diagram>
  <grid columns="2fr 1fr" gap="10" width="380">
    <rect>main</rect>
    <rect>side</rect>
    <rect>footer left</rect>
    <rect>footer right</rect>
  </grid>
</diagram>

The width is doing real work. fr divides leftover space, and a grid with no width of its own has no leftover to divide — the tracks fall back to their content, and a column you declared 2fr can come out narrower than the 1fr beside it. Give the grid a size and the ratio appears: 246.7 and 123.3, exactly 2:1. It is the same definiteness rule as step 12.

repeat(n, …) expands a track list, so columns="repeat(3, 1fr)" is three equal columns. rows takes a track list the same way; leave it out, as here, and a row is added per wrap, each sized to its contents.

29. Named areas

areas paints a template of named regions and each child claims one with area=. Rows are separated by /, and . is an empty cell.

<diagram>
  <grid columns="110 1fr" rows="46 1fr 40" gap="8" width="380" height="230"
        areas="header header / sidebar main / footer footer">
    <rect area="header">header</rect>
    <rect area="sidebar">sidebar</rect>
    <rect area="main">main</rect>
    <rect area="footer">footer</rect>
  </grid>
</diagram>

Areas must be rectangular. The app shell falls out of four lines.

30. Line numbers and spans

A child can place itself by 1-based grid lines instead. Negatives count from the end, so 1 / -1 spans every column.

<diagram>
  <grid columns="repeat(4, 1fr)" gap="8" width="380">
    <rect grid-column="1 / -1">spans every column</rect>
    <rect grid-column="1 / 3" grid-row="2 / span 2">2 by 2</rect>
    <rect>a</rect>
    <rect>b</rect>
    <rect>c</rect>
    <rect>d</rect>
  </grid>
</diagram>

Explicitly-placed children reserve their cells and the rest auto-flow into what is left — a bento grid.

31. Placing a child inside its cell

Cells stretch-fill their track by default. justify-items / align-items on the grid, or justify-self / align-self on a child, take start, center, end or stretch.

<diagram>
  <grid columns="repeat(3, 1fr)" rows="80" width="360"
        align-items="center">
    <rect justify-self="start">start</rect>
    <rect justify-self="center">center</rect>
    <rect justify-self="end">end</rect>
  </grid>
</diagram>

justify-items sets the same thing for every cell at once, and align-content says what to do with room left over between the rows when the grid is taller than the tracks it holds:

<diagram>
  <grid columns="repeat(2, 1fr)" gap="8" width="300" height="150"
        justify-items="center" align-content="space-between">
    <rect width="90" height="26">one</rect>
    <rect width="90" height="26">two</rect>
    <rect width="90" height="26">three</rect>
    <rect width="90" height="26">four</rect>
  </grid>
</diagram>

Layout that decides for you

32. Graph

<graph> is the one container that works out placement for you. Give it marks with ids and <connect> them, and it arranges them so the edges flow downward and cross as little as it can manage.

<diagram>
  <graph gap="26" row-gap="34">
    <rect id="parse">parse</rect>
    <rect id="model">model</rect>
    <rect id="layout">layout</rect>
    <rect id="scene">scene</rect>
    <rect id="render">render</rect>
  </graph>
  <connect from="parse" to="model"/>
  <connect from="model" to="layout"/>
  <connect from="model" to="scene"/>
  <connect from="layout" to="render"/>
  <connect from="scene" to="render"/>
</diagram>

Nothing in that source says where anything goes. This is Sugiyama, Tagawa and Toda's 1981 framework — break cycles, assign layers, reduce crossings, assign coordinates — and a cycle is drawn with an edge pointing back against the flow rather than refused.

direction turns it: down (the default), right, up or left, and the codes other tools use (td, tb, lr, bt, rl) as well.

<diagram>
  <graph direction="lr" gap="22" row-gap="40">
    <rect id="parse">parse</rect>
    <rect id="model">model</rect>
    <rect id="layout">layout</rect>
    <rect id="scene">scene</rect>
    <rect id="render">render</rect>
  </graph>
  <connect from="parse" to="model"/>
  <connect from="model" to="layout"/>
  <connect from="model" to="scene"/>
  <connect from="layout" to="render"/>
  <connect from="scene" to="render"/>
</diagram>

Only the downward layout is actually computed. Flowing rightward is that same layout with every node's size transposed and the offsets transposed back, and up and left are mirrors — so the layering and crossing work is one implementation, not four that can drift apart.

Two things let you say what the connectors cannot. <rank of="a b"/> holds ids on the same row: rows come from how far a node is from a source, and two things meant as peers that no path happens to connect land on different ones and read as stages of each other. And moves="false" on a connector draws the edge without letting it order anything — for a relation that is real but is not a step, where leaving it out would lie about the system and leaving it in invents a sequence.

<diagram>
  <graph gap="26" row-gap="34">
    <rect id="ingest">ingest</rect>
    <rect id="clean">clean</rect>
    <rect id="warehouse">warehouse</rect>
    <rect id="lake">lake</rect>
    <rect id="metrics">metrics</rect>
  </graph>
  <connect from="ingest" to="clean"/>
  <connect from="clean" to="warehouse"/>
  <connect from="ingest" to="lake"/>
  <connect from="warehouse" to="metrics"/>
  <connect from="lake" to="warehouse" moves="false" dash="4 3"/>
  <rank of="warehouse lake"/>
</diagram>

Without the <rank>, lake would sit a row above warehouse; without moves="false", the backfill between them would push warehouse down again and the rank would be refused outright — an edge is an order, and a row denies one. A <rank> naming ids that do not share one <graph>, or that the connectors already put in an order, is refused rather than quietly ignored.

33. Rings instead of rows

A graph flows one way, which is right for a process and wrong for a mindmap: a mindmap has a middle, and flowing it downward buries the thing it is about in the top-left corner. <radial> is the same bargain — marks in, edges between them, positions out — in rings.

<diagram>
  <radial row-gap="46">
    <ellipse id="book">Book</ellipse>
    <rect id="res">Research</rect>
    <rect id="draft">Drafting</rect>
    <rect id="edit">Editing</rect>
    <rect id="s1">Sources</rect>
    <rect id="s2">Interviews</rect>
  </radial>
  <connect from="book" to="res" arrow="none"/>
  <connect from="book" to="draft" arrow="none"/>
  <connect from="book" to="edit" arrow="none"/>
  <connect from="res" to="s1" arrow="none"/>
  <connect from="res" to="s2" arrow="none"/>
</diagram>

The node no edge points at is the centre, and depth is the ring. Each branch takes a wedge in proportion to how many leaves it holds — not how many nodes, because leaves are what need room on the rim.

34. A web with no top and no middle

<graph> flows one way and <radial> grows out of a middle. Some things have neither. Services that call one another, a set of related ideas, anything whose shape is the relationships themselves has no source to flow from and no root to grow from — and drawn as a graph, whichever node you happened to type first becomes the top of a hierarchy nobody claimed.

<network> is the same bargain again — marks in, edges between them, positions out — with no direction at all:

<diagram>
  <network gap="34">
    <rect id="auth">auth</rect>
    <rect id="orders">orders</rect>
    <rect id="billing">billing</rect>
    <rect id="search">search</rect>
    <rect id="cache">cache</rect>
  </network>
  <connect from="auth" to="orders" arrow="none"/>
  <connect from="orders" to="billing" arrow="none"/>
  <connect from="billing" to="auth" arrow="none"/>
  <connect from="orders" to="search" arrow="none"/>
  <connect from="search" to="cache" arrow="none"/>
  <connect from="cache" to="orders" arrow="none"/>
</diagram>

It places the nodes so that the distance between every pair on the page is as close as it can get to the number of edges between them — not just for the pairs that share an edge, which is why it settles into something even rather than something merely untangled. gap is the whole of what you set: the clearance between any two nodes, and the room one edge is worth. There is no row-gap and no direction, because there are no rows and no flow; both are refused rather than taken and dropped.

Nodes with no path between them have no number of edges to honour, so each connected piece is placed on its own and the pieces are set side by side — which is also the only arrangement that says plainly that they are not connected.

35. Isometric

<isometric> arranges children in a scene rather than on the page. Each <layer> is a plane floating above the next, with its contents lying in that plane, and <link> joins an object on one plane to one or more below.

<diagram>
  <isometric>
    <layer label="edge">
      <rect id="cdn" label="cdn"/>
      <rect id="waf" label="waf"/>
    </layer>
    <layer label="app">
      <rect id="api" label="api"/>
      <rect id="worker" label="worker"/>
    </layer>
    <link from="cdn" to="api"/>
    <link from="waf" to="worker"/>
  </isometric>
</diagram>

A square tile is twice as wide once projected, so spacing is reckoned after projecting, not before, and tile is that square's side. angle changes the projection away from the 2:1 default, thickness gives each plane a visible slab, and tile-upright="true" keeps the tiles' label text unsheared — a layer can put itself back with tile-upright="false". It is tile- because a layer carries a label of its own down the gutter, and this does not touch it.

Layers stack in the order you write them — that is the whole of their ordering. What a <layer> chooses is how its contents sit on it: flow="run" lays them out as one contiguous strip, each child's width a proportional weight scaled to the layer's length, instead of the default grid. padding is the air between the contents and the plane's edge, and plane="none" drops the plane out from under them altogether.

The tile- attributes are defaults for the children, not the layer's own size: tile-width, tile-height and tile-padding are what each object gets unless it says otherwise. They say whose they are because that is not obvious — on a <row>, width is the row's.

A child's region="a b" fills that fraction of its face, with its own padding of air around it.

<diagram>
  <isometric angle="30" tile="26" thickness="6" gap="26" tile-upright="true">
    <layer label="edge" flow="run" length="200" tile-padding="8" padding="10">
      <rect id="cdn" label="cdn" width="2" region="0 0.55"/>
      <rect label="waf"/>
    </layer>
    <layer label="app" plane="none" tile-width="44" tile-height="30">
      <rect id="api" label="api"/>
      <rect label="jobs"/>
    </layer>
    <link from="cdn" to="api"/>
  </isometric>
</diagram>

Two more for when the defaults are not what you want. rows fixes how many rows a layer's grid uses instead of letting it square itself off, and shape="line" draws a <link> as a plain line rather than a prism — quieter, where there are several:

<diagram>
  <isometric>
    <layer label="cache" rows="2">
      <rect id="c1">c1</rect><rect>c2</rect>
      <rect>c3</rect><rect id="c4">c4</rect>
    </layer>
    <layer label="store"><rect id="db">db</rect></layer>
    <link from="c1" to="db"/>
    <link from="c4" to="db" shape="line"/>
  </isometric>
</diagram>

36. Lifelines and messages

Everywhere else a connector attaches to a shape. In a sequence diagram the second axis is time, and a message attaches to a point partway down a lifeline — a place no mark exists at. So <sequence> computes its geometry, the way <isometric> does.

<diagram>
  <sequence gap="70">
    <actor id="browser" label="Browser"/>
    <actor id="api" label="API"/>
    <actor id="db" label="Postgres"/>
    <message from="browser" to="api" label="GET /orders"/>
    <message from="api" to="db" label="SELECT ..."/>
    <message from="db" to="api" label="rows" dash="4 3"/>
    <message from="browser" to="db" label="health check" head="open"/>
  </sequence>
</diagram>

arrow says which ends carry a head — end (the default), start, both, or none — and head says what one looks like. They are <connect>'s two words, because a message is a connector with a lifeline at each end:

<diagram>
  <sequence>
    <actor id="ui">ui</actor>
    <actor id="worker">worker</actor>
    <message from="ui" to="worker" arrow="both">negotiate</message>
    <message from="worker" to="ui" arrow="none" head="circle">drain</message>
  </sequence>
</diagram>

Every <actor> gets a header and a dashed lifeline; every <message> takes the next row down. Look at the last one: it runs from the first actor to the third, straight through the API lifeline. That is correct here and is exactly what the obstacle-avoiding router of step 22 would have refused to do — which is why this is computed rather than routed.

37. Activation, self-calls and notes

<diagram>
  <sequence gap="72" row-height="46">
    <actor id="browser" label="Browser"/>
    <actor id="api" label="API"/>
    <message from="browser" to="api" label="GET /" activate="true"/>
    <note over="api" label="cache for 60s"/>
    <message from="api" to="api" label="serialise"/>
    <message from="api" to="browser" label="200 OK" dash="4 3" deactivate="true"/>
  </sequence>
</diagram>

row-height sets the vertical pitch between rows, as gap sets the horizontal one between lifelines. autonumber="true" on the <sequence> numbers the messages, so a step can be cited out loud. The count skips notes and runs straight through a frame — a branch is not a new diagram.

<diagram>
  <sequence autonumber="true" gap="90" row-height="34">
    <actor id="client" label="Client"/>
    <actor id="api" label="API"/>
    <message from="client" to="api" label="POST /order"/>
    <note over="api" label="validates"/>
    <message from="api" to="client" label="201 Created"/>
  </sequence>
</diagram>

activate and deactivate thicken the lifeline into a bar for the span an actor is busy, nesting if a call re-enters. A deactivate with no bar open is an error — you asked for something to end and nothing would have. Leaving one open is fine: the bar runs to the foot, which is a real thing to want and obvious when it was not. A message to its own actor loops out and back, because a horizontal line to itself has no length. And a <note> takes a row of its own — the second axis is time, so a note written between two messages belongs between them.

38. Frames: alt, opt and loop

A <frame> draws a labelled box round the rows inside it. <else/> divides one into compartments, with a dashed rule where you write it.

<diagram>
  <sequence gap="76">
    <actor id="browser" label="Browser"/>
    <actor id="api" label="API"/>
    <actor id="db" label="Postgres"/>
    <message from="browser" to="api" label="GET /order/42" activate="true"/>
    <frame kind="alt" label="in cache">
      <message from="api" to="browser" label="200 (cached)"/>
      <else label="cache miss"/>
      <message from="api" to="db" label="SELECT ..." activate="true"/>
      <message from="db" to="api" label="row" dash="4 3" head="open" deactivate="true"/>
      <message from="api" to="browser" label="200 OK" deactivate="true"/>
    </frame>
  </sequence>
</diagram>

Two things are worth pointing out.

kind is any word. alt, opt, loop, par, critical: the box is identical in all of them and only the word in the tab differs, so there is one element rather than six, and no list to be outside of. kind="retry" draws the same box with retry in the corner.

One thing to watch with activation bars. The compartments of an alt are alternatives to each other, but the bar is one shape drawn down one page, so a bar opened before the frame is closed once, on the frame's last row — not once in each compartment. Closing it twice is refused, and the message says so.

The frame covers only the lifelines its own rows touch. Above, nothing inside the frame mentions Postgres until the second compartment, but the frame as a whole does, so it reaches across all three. A frame whose messages stay between two actors stops short of the third rather than drawing a box over an actor it says nothing about.

Frames nest by nesting, and the inner one is drawn inset inside the outer:

<diagram>
  <sequence gap="80">
    <actor id="c" label="Client"/>
    <actor id="s" label="Server"/>
    <actor id="q" label="Queue"/>
    <frame kind="loop" label="every 30s">
      <message from="c" to="s" label="poll" activate="true"/>
      <frame kind="opt" label="work waiting">
        <message from="s" to="q" label="dequeue"/>
        <message from="q" to="s" label="job" dash="4 3" head="open"/>
        <message from="s" to="s" label="run it"/>
      </frame>
      <message from="s" to="c" label="result" dash="4 3" head="open" deactivate="true"/>
    </frame>
  </sequence>
</diagram>

The alternative was to name a range — from and to on the frame, pointing at message ids — which needs ids that messages do not have, and would admit two frames half-overlapping, which the picture cannot draw. XML is already a tree and the notation already is one; using the first for the second is less a design decision than the absence of a mistake.

<else/> is a row rather than a container, for the same reason: a frame's compartments are not nested inside one another. It is the dashed rule you can see, written where it is drawn — the same way the uml library's class box gets its compartments from a <rect height="1"> sitting between them in the flow (step 65).

Styling

39. Styling is CSS

Output carries semantic classes (.sv-node, .sv-edge, .sv-label, …). Add your own with class= and style them in a <style> block.

<diagram theme="light">
  <style>
    .sv-node { fill: #eef4ff; stroke: #4c78a8; }
    .danger  { fill: #ffe4e4; stroke: #c0392b; }
  </style>
  <row gap="14">
    <rect>normal</rect>
    <rect class="danger">danger</rect>
  </row>
</diagram>

Every mark also carries a class for its own tag — .sv-rect, .sv-ellipse, .sv-diamond — which is how you reach one kind of shape rather than all of them. And what you can set is not only colour. rx is a geometry property, so one rule gives every rectangle in the diagram sharp corners, without rx="0" on a single mark:

<diagram>
  <style>
    .sv-rect { rx: 0; }
  </style>
  <row gap="14">
    <rect>rect</rect>
    <ellipse>ellipse</ellipse>
  </row>
</diagram>

Reach for the tag class and not .sv-node for that one. rx on an ellipse is its horizontal radius, so .sv-node { rx: 0 } would set every ellipse's radius to nothing and they would disappear entirely — which is the kind of thing a shared class buys you and a per-shape class does not.

Every selector a diagram emits is scoped to that diagram, so two of them can share an HTML page — as they do all down this one — without restyling each other.

40. The palette, and dark mode

The defaults are a palette of custom properties. Override one and everything keyed to it moves together.

<diagram background="#faf7ff">
  <style>
    svg {
      --sv-node-fill: #f3ecff; --sv-node-stroke: #7c4dff;
      --sv-edge: #7c4dff; --sv-text: #2c1a52;
    }
  </style>
  <row gap="40">
    <rect id="p">one</rect>
    <rect id="q">two</rect>
  </row>
  <connect from="p" to="q"/>
</diagram>

background on the <diagram> paints the page behind all of it — a fixed colour, or none for a transparent SVG to drop onto something else.

Because appearance is CSS, dark mode is automatic: the default palette flips under prefers-color-scheme: dark. Pin it with theme="light" or "dark", or say theme="auto" for the default explicitly.

A variable you override no longer flips — you own it now, which is why the diagram above keeps its violet in either scheme. That is also why --sv-text is set here and not left alone: take over the background and the foreground that was chosen to suit it is no longer chosen to suit anything. Without that line the labels stay pale for dark mode and land on a pale violet fill, at a contrast of 1.04 — invisible. Whatever you take over, take its counterpart with it.

41. Per-element style

Anything not in the vocabulary becomes an inline style on that element, which wins over classes. Handy for one-offs; reach for a class when it repeats.

<diagram>
  <row gap="14">
    <rect fill="#0f766e" color="#ffffff">inline fill</rect>
    <rect stroke="#0f766e" stroke-width="3">thick stroke</rect>
    <text font-size="20" font-family="Georgia, serif"
          font-style="italic" fill="var(--sv-mark)">styled text</text>
  </row>
</diagram>

Where it lands depends on whether the mark has a shape. A <rect> has one, so fill paints the box and only color and the font-* properties cross over to its label. A <text> is nothing but its label, so everything goes there — fill on a <text> colours the glyphs.

Note that the text's fill is a palette variable, not a colour. Text sits on the page background, and the page background changes with the scheme: no one fixed colour clears the contrast bar against both white and near-black, because the first wants a dark ink and the second a light one. That is the same trap as the paragraph below, and writing this example is how I met it.

A fixed fill needs a fixed foreground to match, or a rule per scheme — the trap step 24 pinned its way around.

rotate turns a <text> about its own centre, and the layout reserves the turned box, so the label beside a chart takes the room it actually needs:

<diagram>
  <row gap="10">
    <text rotate="270" label="units shipped"/>
    <rect width="150" height="90">a panel</rect>
  </row>
</diagram>

Any mark takes it, but a painted one turns differently, and the difference is worth knowing. A <text> mark's box is its glyphs, so turning them turns the box — that is what "the layout reserves the turned box" means above. A painted mark's box is the size you asked for, so turning it turns the paint and leaves the box alone. The drawing may then lap over a neighbour, which is your business, exactly as a negative gap is:

<diagram>
  <column gap="6" align="center">
    <row gap="60" align="end">
      <rect width="110" height="42" align-self="start">Cost</rect>
      <rect width="110" height="42">Value</rect>
    </row>
    <rect rotate="-9" width="280" height="10" fill="#2f4a70" stroke="#2f4a70"/>
    <polygon sides="3" width="44" height="38"/>
  </column>
</diagram>

The canvas is told about the turned corners, so one is never clipped, and a connector meets the turned outline exactly — an arrow's direction is taken into the shape's own frame, met there, and brought back. Turning used to be refused on anything that paints for precisely that reason.

One thing to watch: <rect rotate="45"> is not a <diamond>. A diamond is inscribed in its box; a turned square sticks out of it by what turning it costs.

Components

42. Define and instantiate

<define> makes a reusable, parameterised sub-diagram. Parameters interpolate with ${...}, falling back to a default declared on the <define>.

<diagram>
  <define name="service" title="service">
    <contains label="${title}" padding="10" gap="8">
      <rect>in</rect>
      <rect>out</rect>
    </contains>
  </define>
  <row gap="20">
    <service title="API"/>
    <service title="Worker"/>
  </row>
</diagram>

Expansion is purely syntactic and happens before layout, so components cost the engine nothing — it never learns they existed.

A component is an element, not a macro, so the attributes every element takes fall through to it — width, height, class, grow, grid-column and the rest. <card width="240"/> sizes a card the way it sizes a <rect>. Anything else the template never interpolates is still refused, because a component's parameters are its own namespace.

43. Projecting children with slot

<slot/> is replaced by the instance's own children — the same idea as a Web Component's slot, or {children} in React. It lets a component be a frame around content it knows nothing about.

<diagram>
  <define name="panel" title="panel">
    <contains label="${title}" padding="10" gap="8">
      <slot/>
    </contains>
  </define>
  <row gap="20" align="start">
    <panel title="API"><rect>handler</rect></panel>
    <panel title="Worker"><rect>queue</rect><rect>retry</rect></panel>
  </row>
</diagram>

44. Ids are scoped per instance

A template contains ids, and ids must be unique because connectors resolve them globally. Instantiate a component twice and the engine scopes each copy, so you wire to them as instance.port.

<diagram>
  <define name="stage">
    <contains label="${title}" padding="10" gap="8">
      <rect id="in">in</rect>
      <rect id="out">out</rect>
    </contains>
  </define>
  <row gap="40">
    <stage id="first" title="First"/>
    <stage id="second" title="Second"/>
  </row>
  <connect from="first.out" to="second.in" routing="curved"/>
</diagram>

Data and charts

45. Data and a first plot

The same language draws statistical graphics. <data> holds <record> elements; <plot> is a coordinate frame that owns the scales, draws axes, and hosts mark layers.

<diagram>
  <data name="sales">
    <record month="Jan" units="120"/>
    <record month="Feb" units="185"/>
    <record month="Mar" units="150"/>
    <record month="Apr" units="210"/>
  </data>
  <plot data="sales" width="340" height="190">
    <bars x="month" y="units"/>
  </plot>
</diagram>

x is a band scale here because there is a <bars> layer. With a numeric field and no bars it would be linear instead.

46. The other layers

<points>, <line> and <area> bind the same channels, and layers compose in one frame.

<diagram>
  <data name="d">
    <record t="1" v="4"/><record t="2" v="9" note="the first peak"/><record t="3" v="6"/>
    <record t="4" v="12" note="the highest"/><record t="5" v="10"/>
  </data>
  <plot data="d" width="340" height="190">
    <area x="t" y="v"/>
    <line x="t" y="v"/>
    <points x="t" y="v" r="5" hover="note"/>
  </plot>
</diagram>

That is the Grammar of Graphics idea in one picture: the frame is not a chart type, it is a coordinate system hosting whatever layers you put in it.

r sets the dot radius on <points> (default 4). <points> also takes labels="<field>", naming each point from the data — which is what turns a scatter into a quadrant chart or a Wardley map, where an unnamed dot says nothing about which thing it stands for. (labels="true" writes each point's own value instead.) hover="<field>" names one too, but its text is written into each dot as an SVG <title>, which a browser shows when the pointer rests on it: hover over the two peaks above. A row may leave it out. <bars> and <chronology> take hover as well.

47. Colour as a channel

fill bound to a field builds a scale and a legend — sequential for a numeric field, ordinal for a categorical one. A literal colour is just a constant.

<diagram>
  <data name="d">
    <record k="one" v="5" g="A"/><record k="two" v="8" g="B"/>
    <record k="three" v="6" g="A"/><record k="four" v="9" g="B"/>
  </data>
  <plot data="d" width="340" height="190">
    <bars x="k" y="v" fill="g"/>
  </plot>
</diagram>

fill="index" colours each bar by position from a generated ramp, with no legend, when you want variety rather than meaning.

48. Axes, titles and reference lines

axes takes true, false, x, y, or frame for the L-shaped lines with no ticks. <rule> draws a straight reference line in data coordinates.

<diagram>
  <data name="d">
    <record t="1" v="4"/><record t="2" v="9"/><record t="3" v="6"/>
    <record t="4" v="12"/><record t="5" v="10"/>
  </data>
  <plot data="d" width="360" height="200" x-title="week" y-title="value"
        x-min="0" x-max="8" y-max="16">
    <line x="t" y="v"/>
    <rule x1="1" y1="8" x2="5" y2="8"/>
  </plot>
</diagram>

The value axis runs from zero by default, because a bar has to grow from somewhere and zero is the honest baseline. That is the wrong frame for data that lives in a narrow band well above it: a line between 95 and 100 spends ninety-five percent of its height on the part nobody is reading. y-min says where the floor goes — the twin of y-max, and the same pair x-min and x-max already make on the other axis.

<diagram>
  <data name="up">
    <record t="1" v="97.1"/><record t="2" v="98.4"/><record t="3" v="97.6"/>
    <record t="4" v="99.2"/><record t="5" v="98.8"/>
  </data>
  <row gap="18">
    <plot data="up" width="250" height="170" y-title="uptime %">
      <line x="t" y="v"/><points x="t" y="v"/>
    </plot>
    <plot data="up" width="250" height="170" y-min="96" y-max="100"
          y-title="uptime %">
      <line x="t" y="v"/><points x="t" y="v"/>
    </plot>
  </row>
</diagram>

Reach for it when zero is not a reading. A bar chart usually wants the baseline; a rate, a temperature or a score that never approaches zero usually does not.

Set axes to drop what you do not need. frame keeps the two lines and loses the ticks, which is what you want when the <rule> is the reading and the exact values are not:

<diagram>
  <data name="d">
    <record t="1" v="4"/><record t="2" v="9"/><record t="3" v="6"/>
    <record t="4" v="12"/><record t="5" v="10"/>
  </data>
  <plot data="d" width="360" height="150" axes="frame">
    <line x="t" y="v"/>
    <rule x1="1" y1="4" x2="5" y2="12"/>
  </plot>
</diagram>

49. Grouped bars

A y naming several fields makes a grouped bar chart — one sub-bar per series, ordinal-coloured, with a legend.

<diagram>
  <data name="d">
    <record q="Q1" alpha="30" beta="18"/>
    <record q="Q2" alpha="42" beta="26"/>
    <record q="Q3" alpha="35" beta="31"/>
  </data>
  <plot data="d" width="360" height="200">
    <bars x="q" y="alpha beta"/>
  </plot>
</diagram>

50. Naming a scale

<diagram>
  <data name="d">
    <record year="2021" n="900"/>
    <record year="2022" n="4200"/>
    <record year="2023" n="31000"/>
    <record year="2024" n="185000"/>
    <record year="2025" n="920000"/>
  </data>
  <plot data="d" y-scale="log" width="330" height="190"
        y-title="downloads">
    <line x="year" y="n"/>
    <points x="year" y="n"/>
  </plot>
</diagram>

Band or linear is read off the data: a categorical field gets a band, a numeric one a linear axis, and you never say so. A log axis is different — nothing in the numbers says you want one. It is a choice about what you are showing, so you name it.

What it buys is above: equal ratios take equal space, so steady multiplication reads as a straight line and you can see the rate change. On a linear axis the same data is one spike with four points squashed on the floor.

x-scale is the same choice on the other axis, and the two are independent: put it on the independent variable when that is what spans the decades. Here the input size does, and reading the cost against it is the whole question:

<diagram>
  <data name="sizes">
    <record rows="100" ms="3"/><record rows="1000" ms="21"/>
    <record rows="10000" ms="240"/><record rows="100000" ms="2600"/>
  </data>
  <plot data="sizes" x-scale="log" width="330" height="170"
        x-title="rows" y-title="ms">
    <line x="rows" y="ms"/>
    <points x="rows" y="ms"/>
  </plot>
</diagram>

A log axis has no zero and no negatives. There is no point on it for them, so they are refused rather than quietly clamped.

51. Position: side by side, stacked, or filled

<diagram>
  <data name="d">
    <record m="Jan" platform="12" growth="8"/>
    <record m="Feb" platform="18" growth="11"/>
    <record m="Mar" platform="15" growth="16"/>
  </data>
  <plot data="d" width="330" height="180">
    <bars x="m" y="platform growth" position="stack"/>
  </plot>
</diagram>

position says how several series sit relative to one another: group (the default, the step before) puts them side by side, stack sits each on the running total of the ones below. Grouped answers how do these compare?; stacked answers how big is the whole, and what makes it up?

There is a third. fill stacks the shares rather than the values, so every column is full height and the value axis reads in percentages:

<diagram>
  <data name="d">
    <record m="Jan" platform="12" growth="8"/>
    <record m="Feb" platform="18" growth="11"/>
    <record m="Mar" platform="15" growth="16"/>
  </data>
  <plot data="d" width="330" height="180">
    <bars x="m" y="platform growth" position="fill"/>
  </plot>
</diagram>

That is the third question: what is it made of? — asked without the totals getting in the way. You give up being able to compare the columns by size, which is the whole trade, and it is why fill is a separate answer rather than a better stack.

Underneath, filling is not a different drawing. Each value is divided by its own column's total and then stacked exactly as before — so what the adjustment changes is what is being drawn, not how. Which is also why it refuses what it cannot express: a negative has no share of a total, and a column of nothing but zeroes has no total to take a share of.

It is a component, not a bar option — so it is spelled the same on an <area>, where it gives a stacked area chart, and it meets any coord. Which is what makes the claim at the top of the README literally true: a pie is polar times bars times stack. The slices already sit end to end around the circle; that is what stacking means in that frame.

And group in that frame is the other one:

<diagram>
  <data name="c">
    <record cause="Disease" n="420"/><record cause="Wounds" n="90"/>
    <record cause="Cold" n="180"/><record cause="Fever" n="300"/>
  </data>
  <plot data="c" coord="polar" width="360" height="260">
    <bars x="cause" y="n" position="group"/>
  </plot>
</diagram>

A coxcomb. Side by side around a circle means each category takes an equal share of the angle and the value runs outward; end to end means each takes an equal radius and the value runs around. Two words, two meanings, one frame further on — and neither of them a chart type.

The rings are the radial axis: they stand where that axis would tick and each says its value, written up the vertical. A radar's web is labelled the same way, for the same reason — rings that say there is a scale without saying what it is get you most of the way to a decorative chart.

One caveat, stated rather than fixed: the radius is linear in the value, as every other value mapping here is, so a wedge's area grows as the square and a rose flatters its largest values. Nightingale drew hers on area to avoid that. Taking the square root without being asked would be applying a scale nobody named, which is exactly what step 50 says the language does not do.

52. Polar coordinates

coord="polar" projects a <bars> layer into a pie. A pie is not a chart type; it is bars in polar coordinates.

<diagram>
  <data name="share">
    <record k="a" v="40"/><record k="b" v="25"/>
    <record k="c" v="20"/><record k="d" v="15"/>
  </data>
  <plot data="share" width="260" height="220" coord="polar">
    <bars x="k" y="v" fill="k"/>
  </plot>
</diagram>

hole cuts the middle out, as a fraction of the radius, which turns the same pie into a ring — and a ring is what a slide puts a number in the middle of. Put it there with a <stack>: the chart and the label are two things superimposed, not a chart with a caption feature.

<diagram>
  <data name="share">
    <record k="done" v="62"/><record k="left" v="38"/>
  </data>
  <stack>
    <plot data="share" width="180" height="180" coord="polar" hole="0.62"
          legend="false">
      <bars x="k" y="v" fill="k"/>
    </plot>
    <text font-size="30" font-weight="700">62%</text>
  </stack>
</diagram>

It applies to a rose the same way, and is refused on a radar: those spokes run outward from the centre, so there is nothing in the middle to cut out.

Note the legend went with it. A legend follows a mapping — an ordinal fill, or several y fields — rather than being conjured, and that has not changed; what legend="false" says is that this chart's colours are decoded somewhere else, which for a ring is the number in the hole. Declining one that was never going to be drawn is refused, because it would be a word that does nothing. <facet> takes it too, and draws one shared key beside its grid — its panels share a scale, so a key per panel is the clutter small multiples exist to avoid.

sweep says how much of the circle the data occupies, and start-angle where that begins, clockwise from twelve o'clock. A gauge is those two and nothing else — a ring, and half a turn:

<diagram>
  <data name="cap"><record k="used" v="72"/><record k="free" v="28"/></data>
  <stack width="200" height="126" align="end">
    <plot data="cap" coord="polar" hole="0.62" sweep="180" start-angle="-90"
          legend="false" width="200" height="220" align-self="start">
      <bars x="k" y="v" fill="k" colors="#4a6fa5, #eef1f5"/>
    </plot>
    <text font-size="26" font-weight="700">72%</text>
  </stack>
</diagram>

sweep="270" start-angle="-135" is the dial on a speedometer. There is no gauge element and there does not need to be: it was already a pie in polar coordinates, and this is that pie told where to start and when to stop.

x has always had a band — a slot per category — and y now takes one too, when it names a category rather than a number. That is a grid keyed by two categories: a cohort matrix, a correlation matrix, a heat grid.

<diagram>
  <data name="m">
    <record team="Ops" q="Q1" v="3"/><record team="Ops" q="Q2" v="7"/>
    <record team="Ops" q="Q3" v="9"/>
    <record team="Eng" q="Q1" v="9"/><record team="Eng" q="Q2" v="2"/>
    <record team="Eng" q="Q3" v="6"/>
    <record team="Sales" q="Q1" v="5"/><record team="Sales" q="Q2" v="6"/>
    <record team="Sales" q="Q3" v="1"/>
  </data>
  <plot data="m" width="360" height="180">
    <points x="q" y="team" fill="v" colors="#eef1f5 #2f4a70" r="16"/>
  </plot>
</diagram>

Only <points> takes it. A bar's height is its value and a line runs between values, so a category on that axis is not a smaller version of the same idea — it is the axes the other way round, which is the next step.

53. The other frame: flipped

<diagram>
  <data name="d">
    <record team="Platform" shipped="24"/>
    <record team="Growth" shipped="17"/>
    <record team="Infrastructure" shipped="31"/>
  </data>
  <plot data="d" coord="flipped" width="330" height="170">
    <bars x="team" y="shipped"/>
  </plot>
</diagram>

flipped measures the value across the page instead of up it. Note what did not change: the layer. A horizontal bar chart is not an option on <bars> — it is flipped times bars, the same way a pie is polar times bars, and it applies to a line or a scatter just as readily.

Reach for it when the category names are long. Upright they crowd the axis; flipped they read straight, in a gutter sized to fit them.

54. Bars that start somewhere else

A bar reaches from zero to its value. y2 says where it starts instead, which makes it a span rather than a height — and a span in a flipped frame is a Gantt chart:

<diagram>
  <data name="plan">
    <record task="design"  from="0" to="3"/>
    <record task="build"   from="2" to="7"/>
    <record task="review"  from="6" to="8"/>
    <record task="ship"    from="8" to="9"/>
  </data>
  <plot data="plan" coord="flipped" width="380" height="200"
        x-title="task" y-title="week">
    <bars x="task" y="to" y2="from"/>
  </plot>
</diagram>

Nothing here says horizontal, and nothing says Gantt. It is the frame that turns it on its side and the interval that gives each bar two ends, and the two compose because neither knows about the other. Upright, the same layer draws a floating column — a temperature range, a budget band, the spread between two readings.

Only <bars> takes it: a line or an area is a path through single points, so there is no second end for them to draw between. It cannot be combined with position="stack" or "fill", because those work the base out from the series below and y2 names it outright — two ways of saying the same thing, and the language would rather you picked one.

A box is a span too. <boxes> draws one per category, from the first quartile to the third, with the median across it, whiskers out to the furthest values within one and a half box-lengths, and a dot for anything past them. It is the one layer that summarises: every row in a category goes into its box, so rows sharing a slot is the point rather than a mistake. weight says how many observations a row stands for — the way to box a column too long to write out, each distinct value once with its count:

<diagram>
  <data name="rolls">
    <record die="fair"   pips="1" times="10"/>
    <record die="fair"   pips="3" times="11"/>
    <record die="fair"   pips="6" times="9"/>
    <record die="loaded" pips="1" times="2"/>
    <record die="loaded" pips="5" times="6"/>
    <record die="loaded" pips="6" times="22"/>
  </data>
  <plot data="rolls" width="340" height="200" y-title="pips">
    <boxes x="die" y="pips" weight="times"/>
    <points x="die" y="pips" r="3"/>
  </plot>
</diagram>

The dots are the six distinct rolls; the boxes are the sixty rolls they stand for. Take weight away and each row counts once: the loaded die's box widens from 5¼–6 to 3–5½, and the 1 it threw twice stops being an outlier and becomes the end of a whisker.

55. A time axis

A column of dates says what it is, so the frame reads one off the data the same way it tells a category from a number. The gaps then mean something: a month is wider than a fortnight because it is.

<diagram>
  <data name="releases">
    <record on="2026-01-06" users="120"/>
    <record on="2026-01-20" users="180"/>
    <record on="2026-02-17" users="240"/>
    <record on="2026-06-02" users="310"/>
  </data>
  <plot data="releases" width="440" height="200" y-title="users">
    <line x="on" y="users"/>
    <points x="on" y="users"/>
  </plot>
</diagram>

Timecodes work the same way — MM:SS or H:MM:SS — and the ticks come out in whichever of the two the data is written in. A bare number does not: 90 is a number, and reading it as a minute and a half because it could be one would be guessing at what you meant. Write 01:30 if that is what you have.

<bars> keeps its band whatever the column holds, because a bar needs a slot to sit in rather than a point to sit at.

56. Small multiples

<facet> draws one plot per distinct value of by, as a grid of panels sharing a y-scale.

<diagram>
  <data name="d">
    <record g="north" k="a" v="5"/><record g="north" k="b" v="9"/>
    <record g="south" k="a" v="7"/><record g="south" k="b" v="3"/>
  </data>
  <facet data="d" by="g" width="170">
    <bars x="k" y="v"/>
  </facet>
</diagram>

The panels sit in one row. columns wraps them into a grid, which is what you want once there are more than a few:

<diagram>
  <data name="q">
    <record g="Q1" k="a" v="5"/><record g="Q1" k="b" v="9"/>
    <record g="Q2" k="a" v="7"/><record g="Q2" k="b" v="3"/>
    <record g="Q3" k="a" v="4"/><record g="Q3" k="b" v="8"/>
    <record g="Q4" k="a" v="6"/><record g="Q4" k="b" v="5"/>
  </data>
  <facet data="q" by="g" columns="2" width="150" height="110">
    <bars x="k" y="v"/>
  </facet>
</diagram>

57. Histogram

<histogram> draws a distribution as gapless bars. It takes the counts already binned — one record per bin, in order — with y naming the count field and bin-size the width of a bin along the x axis. So ten records at bin-size="20" give an axis running 0 to 200.

<diagram>
  <data name="d">
    <record n="2"/><record n="5"/><record n="11"/><record n="19"/>
    <record n="27"/><record n="24"/><record n="15"/><record n="8"/>
    <record n="3"/><record n="1"/>
  </data>
  <histogram data="d" y="n" bin-size="20" width="340" height="180"
             annotate-title="the long tail" annotate-label="most runs are short"
             x-title="words per paragraph" y-title="paragraphs"/>
</diagram>

It also takes an annotate-title / annotate-label pair, which draws a leader-line callout to the last bar — for the outlier that is the reason you drew the distribution in the first place.

58. Diverging bars

<diverging> puts two series back to back around a shared centre axis.

<diagram>
  <data name="d">
    <record label="alpha" a="30" b="18"/>
    <record label="beta" a="16" b="34"/>
    <record label="gamma" a="24" b="22"/>
  </data>
  <diverging data="d" row="label" left="a" right="b"
             left-label="before" right-label="after" width="360" height="180"/>
</diagram>

59. Treemap

A treemap's only hard constraint is that area be proportional to value, which leaves each rectangle's shape free.

<diagram>
  <data name="pkg">
    <record part="engine" loc="4100"/>
    <record part="frontend" loc="2400"/>
    <record part="foundation" loc="1200"/>
    <record part="ir" loc="700"/>
    <record part="vocabulary" loc="500"/>
  </data>
  <treemap data="pkg" by="part" value="loc" labels="true"
           width="360" height="230"/>
</diagram>

That freedom is spent on keeping cells near-square, which is what makes one readable: slicing the space naively gives slivers whose areas the eye cannot compare at all. A cell carries only what fits inside it, and takes its ink from its own colour.

60. Sankey

<diagram>
  <data name="energy">
    <record from="Coal" to="Grid" tw="42"/>
    <record from="Wind" to="Grid" tw="18"/>
    <record from="Grid" to="Homes" tw="34"/>
    <record from="Grid" to="Loss" tw="26"/>
  </data>
  <sankey data="energy" source="from" target="to" value="tw" node-width="22"
          width="420" height="230"/>
</diagram>

A Sankey is not boxes and arrows with thick lines. Its whole claim is conservation — a band's width is its value, on one scale shared by the diagram, and what arrives at a node leaves it — so the layout is computed from the values. Change a number and everything downstream re-thickens with it.

61. Timeline

<timeline> gives every distinct lane value a row, and draws each record as a duration block on a shared time axis. Timecodes parse as H:MM:SS, MM:SS, or bare seconds.

<diagram theme="light">
  <data name="d">
    <record who="Frodo" from="0:00" to="2:30"/>
    <record who="Frodo" from="4:00" to="5:30"/>
    <record who="Sam" from="1:30" to="3:00"/>
    <record who="Merry" from="3:30" to="6:00"/>
  </data>
  <timeline data="d" lane="who" start="from" end="to" fill="lane"
            tick="120" extent="7:00" sort="alpha" label-size="15"
            width="520" bar-height="34" x-start="90" top="40"/>
</diagram>

sort orders the lanes — true alphabetically, time by when each first appears — extent runs the axis past the last block, label-size sets the row labels' type size, and top is the headroom above the first row.

section adds a second level over lane: the lanes are grouped under headings, for a plan whose workstreams belong to phases. A heading takes a row of its own, outdented over the lanes it holds.

<diagram>
  <data name="p">
    <record phase="Discovery" work="Interviews" from="2026-01-05" to="2026-01-30"/>
    <record phase="Discovery" work="Data audit" from="2026-01-19" to="2026-02-20"/>
    <record phase="Build" work="Migration" from="2026-02-16" to="2026-04-03"/>
    <record phase="Build" work="Rewrite" from="2026-03-02" to="2026-05-15"/>
    <record phase="Cutover" work="Switch over" from="2026-05-18" to="2026-06-05"/>
  </data>
  <timeline data="p" lane="work" section="phase" start="from" end="to"
            fill="lane" width="560" bar-height="30" x-start="130"/>
</diagram>

A row is a lane within its heading, so the same lane name under two headings is a row under each: the same workstreams in every phase, say, or the same sources set out under every event they date.

A <period> inside the timeline sets the scene for the rows: a labelled stretch of time, such as where someone was, that is not one of the things being compared. The periods are drawn as a chronology draws its stretches, on a line above the lanes, each called out above the line or below it (side) and joined to its bar by a leader, and a dashed guide drops from each end of each through every lane, so a block far below can be read against them. period-label names the line, and fill tells one kind of period from another.

<diagram>
  <data name="b">
    <record task="Drafting" from="2026-01-05" to="2026-03-13"/>
    <record task="Revision" from="2026-03-02" to="2026-05-08"/>
    <record task="Proofs" from="2026-05-11" to="2026-06-19"/>
  </data>
  <timeline data="b" lane="task" start="from" end="to" width="560"
            bar-height="30" x-start="100" period-label="Author away">
    <period start="2026-02-09" end="2026-02-20">Conference</period>
    <period start="2026-02-20" end="2026-03-02" side="below">Holiday</period>
    <period start="2026-04-06" end="2026-04-24" fill="#b5523b">Ill</period>
  </timeline>
</diagram>

Each side's labels make one row in date order, and a crowd of them is spread about its own middle rather than pushed along to the right, so no leader crosses another or a label. Sending alternate labels below the line halves a crowd. Periods with the same label next to each other on one side, such as a place returned to, share a single label with a leader to each bar.

62. Chronology

<chronology> is the other kind of timeline: one axis of years with things pinned to it, each called out above or below the line. A record with an at is a moment, drawn as a dot; one with a start and an end is a stretch, drawn as a bar along the side of the line its callout is on, so stretches above and below can overlap; two on the same side that overlap stack outward from the line, so neither hides the other. label names the field for each callout's first line and detail the lines under it, and side the field that says above or below. align names a field that hangs a callout from its leader by its left or right edge rather than centring it, and attach one that says the year along a stretch where its leader meets the bar.

<diagram theme="light">
  <data name="h">
    <record year="1937" title="1937" note="The Hobbit" where="below"/>
    <record year="1949" title="1949" note="Busa and IBM begin a concordance" where="above"/>
    <record year="1954.5" title="1954–55" note="The Lord of the Rings" where="below"
            hang="left"/>
    <record year="1966" title="1966" note="Computers and the Humanities" where="above"/>
    <record began="1983" ended="1996" title="1983–96" note="The History of Middle-earth"
            where="below" hang="right" meets="1993"/>
  </data>
  <chronology data="h" at="year" start="began" end="ended" label="title" detail="note"
              side="where" align="hang" attach="meets" fill="where" width="560"
              label-width="150" mark-size="12"/>
</diagram>

The axis runs a round step clear of the first and last date unless from, to and tick say otherwise. label-width is where a callout wraps (its lines are balanced, so none is left holding a single word), and mark-size the diameter of a dot. The callouts are placed in date order, each centred over its mark if that is free and otherwise moved into the nearest gap, or out to another level; the marks never move, and the leaders keep each label attached to its own. A callout hung by an edge reaches out one side of its mark only, so two marks close together can send theirs apart, each leader straight. A chronology too short for the levels its labels need is refused, saying how much height they take.

63. Spine

<spine> draws a sequence of equal-width bars — sections of a book, say — partitioned into groups, such as the chapters that hold them. group-value names a field carried on every record of a group, and draws it as a grey background bar as wide as the sections it spans, with a tick and label below.

<diagram>
  <data name="d">
    <record c="I" v="6" cv="8" kind="a"/>
    <record c="I" v="9" cv="8" kind="a"/>
    <record c="II" v="4" cv="7" kind="b"/>
    <record c="II" v="11" cv="7" kind="a"/>
    <record c="II" v="7" cv="7" kind="b"/>
    <record c="III" v="5" cv="5" kind="a"/>
  </data>
  <spine data="d" group="c" value="v" group-value="cv" fill="kind" top="40"
         colors="#666699, #669966" width="360" height="180"/>
</diagram>

The foreground bars take value for height and fill for colour, so the per-section detail and the per-chapter average read against each other in one picture.

Extending the language

64. Registering a mark

Shapes are data, so a new one is a one-liner and needs no parser change. A polygon is defined by its vertices in the unit box [0,1]×[0,1], and both the rendering and the connector clipping fall out of that outline.

from sauvignon import PolygonMark, register_mark

register_mark(PolygonMark("triangle", [(0.5, 0), (1, 1), (0, 1)]))
# <triangle label="!"/> now works in any diagram

65. Libraries: the language in the language

A <define> lives in one document. A library is a document of nothing but defines, registered under a name and imported by that name — so a vocabulary can be shared without every diagram redefining it.

<diagram>
  <import name="c4"/>
  <row gap="72">
    <person id="cust" title="Customer" descr="A personal banking customer"/>
    <container id="api" title="API Application" kind="Container: Go"
               descr="Serves the mobile app"/>
  </row>
  <connect from="cust" to="api" label="Uses" head="open"/>
</diagram>

c4 ships with Sauvignon and is ordinary Sauvignon source — cat sauvignon/prelude/c4.svgn and you can read the whole thing. It defines <person>, <system>, <container> and <external> in terms of one <c4-element>, because a component may instantiate another.

An import names a library, never a path, so a document stays portable. Where that name resolves is the invocation's business:

python -m sauvignon doc.svgn --library house=house.svgn -o out.svg

A component says what it is for in a <doc> child, and that is not decoration: the grammar reference is generated from it, so <person> is listed there beside <row> and <plot>, with its parameters read straight off its <define>.

<diagram>
  <define name="aside" title="aside" body="">
    <doc>A titled card for an aside — the prose you would otherwise
         put in the caption.</doc>
    <contains label="${title}" padding="10">
      <text>${body}</text>
    </contains>
  </define>
  <aside title="On documentation" body="The doc is read, not drawn."/>
</diagram>

That is the point of the whole arrangement. An extension you cannot look up is one you have to read the source of; when it is in the reference under its own name, nothing tells a reader that <person> arrived later than <row>.

That is the honest answer to how much of Sauvignon is written in Sauvignon: vocabulary can be, geometry and algorithms cannot. A C4 box is a name, a bracketed kind and a description — composition, so it belongs in a library. A squarified treemap is not, so it lives in the engine.

Where next

  • The grammar reference is the same material organised as a lookup, element by element with every attribute — library components included.
  • The examples gallery has complete diagrams — architecture sketches, charts, timelines and layout studies — each with its source.
  • The algorithms notes explain how the engine decides: how positions are solved, how edges are routed, how the graph is laid out.