An A0 poster in one self-contained Typst file. No typst.toml, no packages, no
network — it compiles offline on a clean machine with Typst 0.13+.
Fork this repo, then:
typst watch poster.typ poster.pdfEdit two regions of poster.typ and nothing else — the CONFIG block at the top
and the CONTENT section at the bottom. Everything between them is machinery.
Drop your figure exports in figures/, keeping the shipped filenames or changing
the paths in the config.
poster.typ the file you edit
figures/ your figure exports — dummies + a QR placeholder shipped
logos/ KERMIT and Ghent University marks, ready to use
demo/ four worked posters, from minimal to kitchen sink
Before printing, set paper: "a4" and print that: a proportionally identical
proof you can hold, which is a better check than any on-screen preview.
| File | What it shows |
|---|---|
demo/minimal-poster.typ |
Start here. Two columns, quiet palette, no frames, conclusion first. |
demo/figures-first-poster.typ |
A hero figure, four short claims, almost no prose. |
demo/boxed-poster.typ |
Every panel framed, bleeding key message, references from a .bib. |
demo/demo-poster.typ |
The kitchen sink — every component at once. A test, not a design to copy. |
All five posters share one body of machinery, byte-identical between them; only the config block and the content section differ. See Four temperaments. Build them all with:
for f in demo/*.typ; do typst compile "$f"; doneThe demos keep their own copies of the logos in demo/assets/, because Typst
resolves relative paths against the directory of the file it is compiling and
demo/*.typ cannot reach ../logos/ without a --root flag. poster.typ at
the root has no such problem.
logos/ holds kermit-warm.svg (the default), kermit-cold.svg (quieter, for
saturated bands), ugent.png and ugent-white.png (knockout, for a dark
brand). The UGent files are cropped from the official exports to their ink plus
8 % clear space — the originals carry ~45 % transparent padding, which made a
height: cap produce a logo half the intended size.
Logos appear once, in the footer. Repeating them in the header spends the
second-most valuable space on the page telling the reader something they already
know. The header's right-hand slot is art: instead — a teaser figure, a
graphical abstract, or a portrait of whoever is standing next to the poster,
which is the one thing that genuinely helps a passer-by find you:
art: "figures/header-art.svg", // or "assets/portrait.svg", or none, or contentBoth slots take a path or (src: .., scale: ..), because two institutional
marks almost never carry the same optical weight at one height:
footer-logos: (
"logos/kermit-warm.svg",
(src: "logos/ugent.png", scale: 1.2),
),art: is capped at 130 mm, which fills the band without forcing it taller or
pushing a two-line title onto a third line.
figures/ ships dummies at 16:9, 5:2, 4:3 and 1:1 (the header slot) plus a
placeholder QR, so a fresh fork compiles with figures in place. See figures/README.md for the export
settings that survive poster magnification.
Changing columns changes how much fits. At two columns the measure roughly
doubles, so two columns wants ~34 pt body. Larger type and wider figures both
cost height, so a two-column poster carries materially less content than a
three-column one. The skeleton fits on one page at 2, 3 and 4 columns, but the
headroom is ~7 mm at 2, ~80 mm at 3 and ~225 mm at 4. A figure keeps its aspect
ratio, so its height scales with its column width: prefer wide figures, since
a square one costs 230 mm of page at three columns and 360 mm at two.
Type has a floor and spacing does not. Everything a reader is expected to
read clears 24 pt. scale-factor shrinks spacing only — see below.
A0 posters are read from about 1.5 m. legibility-floor (default 24pt,
page-scaled) is the smallest type on the poster, and it is enforced rather than
documented:
| Step | pt at scale-factor: 1.0 |
at 0.7 |
|---|---|---|
| title | 130 | 91 |
| section heading | 46 | 32.2 |
| body | 30 | 24 — held |
| caption, chip | 24 | 24 — held |
| references, funding, code | 24 | 24 — held |
scale-factor multiplies every spacing value and clamps every type size at
the floor. At 0.7 the block gap drops from 24 mm to 16.8 mm and no step goes
below 24 pt. That is deliberate: recommending a lower scale-factor to make a
poster fit would otherwise trade legibility for fit without saying so. Set
legibility-floor: 0pt to opt out — for a poster meant to be read at arm's
length, say.
Two things the floor does not and cannot cover. Superscripts and subscripts —
affiliation markers, the exponent in
The page has zero margin; each band applies the 45 mm inset itself. That is what lets the header and footer bleed to the trim with no white hairline. The body is a vertical stack:
#header-band(cfg)
#band[ ... ] // cfg.columns columns
#keymessage[ ... ] // full width, works at top level or nested
#band(cols: 2)[ ... ]
#v(1fr) // pushes the footer to the bottom edge
#footer-band(cfg)A band takes the block-level items in its body, measures each one, and packs them into columns so the tallest column is as short as possible. Order is preserved: items fill column 1, then 2, then 3. Consequences worth knowing:
- Typst's own
columns()is not used. It only balances inside a container of known height, and a band's height is unknown until its content is laid out — which is why a naivecolumns()band dumps everything into column one. - A band never splits across pages, and no block is ever clipped.
- A child of a band is treated as a block-level packing unit unless it is a
known inline element. Custom components are safe, including ones that return a
bare
contextelement — which is what any component that callsmeasure()returns. footer-bandemits its own non-collapsible top spacer, because thev(1fr)above it would otherwise absorb the automatic block spacing and let the last block come to rest flush against the footer on a poster that exactly fills the page.
| Call | Notes |
|---|---|
panel(title: none, frame: none, body) |
The generic unit. frame is none / "rule" / "box". |
keymessage(body) |
The takeaway. Once or twice per poster, never more. |
fig(src, caption:, width:, ratio:, label:) |
Unnumbered caption below. src: none → placeholder. |
callout(kind: "note", body) |
Filled box; kind: "definition" uses accent. |
chip(body) |
Inline pill for a tag or short label. |
codeblk(lang: none, highlight: false, body) |
Mono, tight leading, faint fill. Highlighting off — see Colour. |
tbl(..) |
No vertical rules, one rule under the header row. Wrap header cells in thead[..][..]. |
refs(items) |
Compact numbered list from a hand-written array. |
bibrefs(path, style:, full:) |
The same, from a .bib file. full: false prints only @key-cited entries. |
funding(body) |
One-line acknowledgement. |
sec(title) |
The section heading on its own. = Heading markup maps to it. |
free(x:, y:, width:, body) |
Overlay layer — see below. |
hand(body) |
The decorative font slot. A tagline or one annotation, never body text. |
Every block-level component is legible with its frame off. Nothing depends on a border to define its extent.
Three dummies are shipped, so a fresh fork compiles with figures in place. Replace a file keeping its name and nothing in the config changes.
To lay a poster out before an export exists, pass src: none and the box is
drawn without the image:
#fig(none, ratio: 16/9, label: "figures/fig-method.svg",
caption: [What the reader should take from it.])The dashed box reports the path it is waiting for and its own rendered size in
mm, so you know what to export. When the export exists, pass it as the first
argument and delete label.
Set ratio to the aspect of the export you intend to produce, so nothing moves
when it lands. Export with large type: a 6.4 in wide figure scaled into a 230 mm
column is magnified 1.4×, so 17 pt in the export arrives at ~24 pt on the poster.
demo/assets/make-figures.py shows the matplotlib settings; the same reasoning
applies to Makie.
Do not let hue be the only channel. A reader with a red–green deficiency (~8 % of men), a greyscale printout, or a photocopy all collapse a colour-only encoding. The demo figures carry a second channel: the bars are hatched, the two series have distinct markers and dash patterns, and the class rows are labelled in words. This is the one accessibility problem the template cannot check for you.
Every length is written at A0 and multiplied by the page width relative to A0, so
paper: changes the size and nothing else:
paper: |
Size | Compile |
|---|---|---|
"a0" |
841 × 1189 mm | 0.15 s |
"a1" |
594 × 841 mm | 0.07 s |
"a2" |
420 × 594 mm | 0.07 s |
"a4" |
210 × 297 mm | 0.07 s — the proof |
"us-36x48" |
914 × 1219 mm | 0.08 s — aspect differs from ISO |
paper: "a4" gives a proportionally identical poster you can print and hold. It
is a better check than squinting at a PNG, and 15 % of A0 is roughly what you see
from 1.5 m if you would rather do that:
typst compile poster.typ preview.png --ppi 24draft: true adds a page-foreground overlay: hairlines every 0.1 of the page in
both axes with their fraction values labelled, the column bands tinted, the
margin box dashed, a DRAFT marker so a proof never reaches a printer, and a
palette check in the bottom-left corner listing each role's luminance and its
WCAG contrast ratio against ink, flagging anything under 4.5:1 and reporting the
brand↔accent luminance gap. draft: false removes all of it.
Separately, placeholder-figs: true stubs out every figure at once, for laying
a poster out before any export exists.
Independent of both, a poster that spills onto a second page gets an unmissable red OVERFLOW banner. That is always on: silently becoming two pages is the failure mode that survives a quick glance.
For the things the grid cannot express — an arrow across two columns, a floating annotation, a logo in a corner:
#free(x: 0.105, y: 0.7775, width: 0.16)[#hand[flat from here on →]]x, y and width are fractions of the full page, bleed to bleed; (0, 0)
is the top-left page corner and y sets the block's top edge. Turn on draft and
read the coordinates off the grid. Overlay content draws on top of band content.
Overlay items are drawn on page 1 only. If a poster overflows, page 2 gets the OVERFLOW banner and no overlay — repeating the annotations there would disguise the overflow rather than expose it.
Everything below the END CONFIG fence reads from cfg and is not edited per
poster.
| Knob | Default | Effect |
|---|---|---|
title, subtitle |
— | Title is 130 pt; keep it under ~42 characters or it takes a third line. |
authors |
— | (name:, affil: (1, 2), presenter: true). Presenter is bolded; superscripts are omitted when there is one affiliation. |
affiliations |
— | One per line, muted. |
affiliations-in-footer |
false |
Moves them off the header. Worth ~12 mm per line of the most expensive space on the page — measured at 37 mm on boxed. |
bib |
none |
Path to a .bib file, for bibrefs(). |
art |
a dummy | The header's right-hand slot: a teaser figure, graphical abstract or portrait. A path, (src:, scale:), or content. none lets the title run the full width. |
footer-logos |
KERMIT + UGent | The only logo slot. Each entry is a path or (src:, scale:); capped at a max height so a wide logo cannot blow up the band. |
qr, qr-caption |
figures/qr.png |
QR arrives as an image file; there is no in-Typst QR generation. The shipped one is not scannable. |
funding |
— | One line, in the footer. |
paper |
"a0" |
See Paper size. |
columns |
3 |
Default column count for band(). A content-budget knob — see the top. |
scale-factor |
1.0 |
Spacing density. Type is clamped at the legibility floor. |
legibility-floor |
24pt |
Smallest type on the poster. 0pt to opt out. |
draft |
false |
Grid overlay, DRAFT marker, palette check. |
placeholder-figs |
false |
Force every fig to a placeholder. |
heading-mark |
"none" |
"none" / "bar" / "tick" / "text" / "full". |
heading-mark-role |
"support" |
Which role colours the mark, when there is one. |
frame-role |
"accent" |
Which role strokes frame: "box" and frame: "rule". |
keymessage-bleed |
false |
Whether a top-level key message runs to the trim. |
flavour |
"frappe" |
latte / frappe / macchiato / mocha. |
roles |
(:) |
Overrides, e.g. (accent: rgb("#c04a3f"), support: "mauve"). A string is looked up as a hue name in the active flavour. |
fonts |
see file | Fallback chains for sans, mono, hand. |
Roles, never hues, in component code. Four rules:
- One hue, one role. A colour that means "methods" here does not also mean "emphasis".
- At most three accents in play:
brand+accent+ one ofsupport/note. - Accents are fills, never text colour. Coloured body text is prohibited. The one sanctioned exception is a figure-pointer label echoing the colour of the thing it points at.
- The header band and the key-message fill must differ in luminance by about
0.12. They are the two largest colour areas on the page; matched in lightness
the poster reads flat, and it collapses entirely in greyscale. Draft mode
reports this gap. Role fills that a reader never has to tell apart —
noteversusbrand, in different structural positions — do not need separating; what genuinely does is any figure encoding a category by hue alone.
A caution on rule 4 if you tighten it: Catppuccin's accents are deliberately
clustered in a narrow lightness band, which is what makes them pastel. Of the 364
three-hue combinations in Frappé, only 17 have all three pairs at least 0.12
apart, and every one of those includes rosewater. Demanding mutual separation
across three roles effectively picks your palette for you — which is why the rule
is scoped to the one pair that carries the page.
bg, ink, muted and panel are deliberately not Catppuccin values — the
poster is dark ink on warm paper whichever flavour is active.
Code blocks are not syntax highlighted. Typst's highlighter sets an explicit
fill on every span, which both imports hues from outside the role map and breaks
rule 2 — and a show rule cannot undo it, so codeblk drops the language
instead. Pass highlight: true to opt back in.
Nothing but the config block and the content section changes between these.
minimal |
figures-first |
boxed |
demo |
|
|---|---|---|---|---|
| columns | 2 throughout | 1 · 3 · 4 | 3, plus two 2-col bands | 3, plus a 2-col band |
| ordering | takeaway first, then problem, figures, upshot | hero figure, then takeaway, then detail | question → method → takeaway → evidence | question → method → takeaway → evidence |
| frames | none | none | every panel | one boxed panel |
| key message | inset, under the header | inset, below the hero | bleeds to the trim | inset, mid-page |
| heading mark | none | "tick" in support |
"bar" in brand |
none |
| palette | five muted greys | sky / blue / peach | mauve / flamingo / yellow | Frappé defaults |
| references | refs() array |
none | bibrefs() from .bib |
refs() array |
| affiliations | header | header | footer | header |
scale-factor |
1.0 | 1.0 | 0.94 | 0.98 |
| overlay | none | two hand-lettered notes | none | one hand-lettered note |
Two techniques worth stealing:
#v(1fr)between bands, not just before the footer. Onev(1fr)pools all the leftover height above the footer, which reads as a mistake. Several share it out between the bands, which reads as air.minimalandfigures-firstdo this.- Reordering is one moved call.
keymessageadapts to its container, so conclusion-first is a matter of putting that block above the firstband.
- Header ≈ 235 mm, footer ≈ 105 mm (auto height, tuned against the render).
235 mm is 20 % of the page, and a three-line title pushes it to ~295 mm, which
is a quarter of the sheet. The 130 pt title earns its space; the affiliations
underneath it mostly do not, which is what
affiliations-in-footeris for — it broughtboxeddown to 198 mm. The footer's height is pinned by a fixed grid row so it stays a band whatever the slots contain. - Section headings carry no rule by default. A coloured line under every
heading spends the accent colour dozens of times before the reader reaches the
key message, which is the one place it should land. Bold weight at 46 pt already
separates a heading from 30 pt body at A0, which is how the reference posters do
it. Four marked alternatives remain, defaulting to
supportrather thanaccentfor the same reason. A wrapping heading with"text"gets a full-width rule automatically, since the text already reaches the edge. - The key message respects the margin. With a 10 mm corner radius it reads as
a card, and cards should not bleed.
keymessage-bleed: trueif you disagree. Its inset adapts to its container, so the same call works at the top of the document, inside a full-width band, and nested in one column. - Radii and insets: 8 mm on boxed panels and callouts, 10 mm on the key message, 18 mm box inset, 30 mm key-message inset (14 mm nested).
- Missing files cannot be detected. Typst has no way to test whether a path
exists —
image()on a missing path is a hard compile error with no way to catch it. Placeholders are requested explicitly instead:src: nonefor one figure,placeholder-figs: truefor all of them. - Two to four font warnings on every compile are expected. They name the
fallback families not installed on this machine (
inter,libertinus sans, andjetbrains mono/fira codeonce acodeblkis used). The first available family in each chain wins; here that resolves to Helvetica Neue for text, DejaVu Sans Mono for code and Excalifont forhand. Trim the chains to silence them, at the cost of portability. bibliography()may appear once per document, sobibrefscan be called once.full: falseneeds at least one@keycitation elsewhere on the poster.- The logos are real; everything else in
demo/assets/andfigures/is a stand-in. The KERMIT and UGent marks are the actual files. The QR has correct QR geometry with random data modules and is not scannable; the figures, the.biband all the demo content are invented. Replace them. - No licence file is included — that is your call. The poster machinery is
yours to relicense; the institutional logos in
logos/are trademarks and are not covered by whatever licence you put on the code.
