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.