Pages
A column that does not fit continues on the next page, breaking between its children. Everything else moves whole: a row, a padding, a background, a text.
The rules
- Only a
columnor atablesplits, and only between children or rows. - A
pageBreakends the page where it stands. - A child that does not fit moves to the top of the next page.
keepis this rule applied to a wrapper. - A table's header repeats on every page it runs onto and is never left alone at the bottom of one.
- A child that fits nowhere is placed on its own page and clipped. Nothing ever throws for layout reasons.
- A split column with padding, background, or margin carries the full box on every page fragment.
keep and pageBreak
column([
heading,
...rows,
keep(column([ text("Total due"), text("€280.00") ])), // never straddles a page
pageBreak(),
text("Terms and conditions"), // always starts a fresh page
])keep also exists as an option on column and table: { keep: true }.
Box options
Wrapping a block in padding, then background, then padding again for space above, then keep, is four nested calls for one idea. Containers carry those as options instead:
column([
row([text("Opening balance"), text("€1200.00")], { justify: "between" }),
row([text("Closing balance"), text("€1585.00")], { justify: "between" }),
], { gap: 6, padding: 12, background: "#f2f2f2", margin: { top: 24 }, keep: true })paddingis space inside,backgroundcovers the padded box,marginis space outside it. Applied in that order, inside to out, the way CSS does.- These are the same words as the standalone
padding,background, andkeep. Use the words when you need a different order, for instance a background that does not include the padding. row,column, andtabletake them.tabletakesbackground,margin, andkeepbut notpadding, which on a table isrowPadding. Leaves liketextandimagedo not; wrap those.
Alignment
row and column take align for the cross axis and justify for the main axis, with the same words and values as CSS align-items and justify-content.
row(children, { align: "start" | "center" | "end" | "stretch" }) // vertical, in a row
row(children, { justify: "start" | "center" | "end" | "between" }) // horizontal, in a rowFor a column the axes swap. justify is ignored when any child is a fill or spacer, since they already decide the distribution.
Left and right
// Two texts at the far edges: split in half, right-align the second.
row([ fill(text("ACME Ltd")), fill(text("Invoice #1042", { align: "right" })) ])
// Both hug their content, a spacer pushes them apart. Better for long text.
row([ text("Date: 14 Sep 2026"), spacer(), text("Due: 14 Oct 2026") ])
// Logo left, address right, bottom edges lined up.
row([
image("./logo.png", { width: 120 }),
spacer(),
column([ text("ACME Ltd"), text("1 Example Street") ], { align: "end" }),
], { align: "end" })How fill works
A row asks each child how wide it wants to be and places them left to right. fill changes the question for that child: instead of asking, the row tells it to take a share of whatever is left after the other children are placed.
The share is a weight, not a size. Only the ratio matters: shares of 1, 1, 2 give the same layout as 5, 5, 10. A share of 0 keeps the child in the row but gives it no width.
Fill is also what gives text a width to work in. Text inside a fill wraps at the fill's edge and can be aligned within it; text sitting directly in a row hugs its words on one line.
fill and spacer only work as a direct child of a row or column. Wrapped in anything else they do nothing. Put the wrapper inside the fill, not around it: fill(padding(x, 8)), not padding(fill(x), 8).
Text inside a fill takes the fill's width and wraps; text as a direct child of a row hugs its content on one line.