Skip to main content
Version: Canary ๐Ÿšง

Customization

Every change to a theme is a patch: a positional argument of .with(..). The helpers in theme.custom build patches and check their parameter names, so a typo fails where you wrote it.

Patches and Helpersโ€‹

Import the helpers into a code block to write several of them at once. Each helper returns a one-element array, so all of them apply:

#show: invoice.with(
theme: theme.classic.with({
import theme.custom: *
colors(primary: rgb("#1d4ed8"), accent: rgb("#15803d"))
fonts(body: ("Inter", "Liberation Sans", "Libertinus Serif"))
items-table(zebra: (none, none))
totals(fill: rgb("#1d4ed8"))
}),
// sender: .., recipient: .., invoice-nr: ..
)

A helper inside a false if produces none, and none patches are ignored, so conditional patches need no extra code: if mode == "print" { stationery("pre-printed") }.

Every helper parameter defaults to auto. The values mean:

ValueEffect
autoUntouched. Only the parameters you pass are patched.
noneA real value that switches something off, such as totals(fill: none) or marks(none).
reset()Back to the schema default, for example the computed bottom margin: page(margin: (bottom: reset())).
replace(value)value replaces a dictionary as a whole instead of being merged into it.
t => ..In tokens and options: a derivation over the resolved tokens (see Tokens).

Helper Referenceโ€‹

HelperPatchesParametersTier
colorstokensprimary, on-primary, primary-text, accent, accent-text, text, text-muted, border, tint, backgroundfrozen
fontstokensbody, heading, label, numeric, number-width; regulated (0.6.x preview)frozen
sizestokensbody, small, fine, large, titlefrozen
weightstokensstrongfrozen
strokestokenshairline, thin, regular, thickfrozen
spacingtokenssmall, medium, leadingfrozen
radiitokenssmall, mediumfrozen
logo, title, line-items, items-table, totals, bank-details, page-number, continuationoptionssee Optionsprovisional
rowoptionsfill; read inside themed onlyexperimental
pagelayoutpaper, flipped, margin, body-top, body-gap, header-ascent, footer-descent, footer-clearancefrozen
stationery(value)layoutnone, "pre-printed" or (first: content, rest: content)frozen
marks(none), marks(..)layoutfold, punch, left, length, strokefrozen
envelopes(..records)layoutenvelope records; replaces the liststable
proof(value)layouttrue, false or an array of envelope namesstable
area(name, ..), area(name, none)layoutthe area fieldsfrozen
part(name, renderer), wrap(name, wrapper)parts(ctx, view) => content, (ctx, view, inner) => contentfrozen
brandtokens, optionscolor, accent, font, heading-font, logofrozen
checkschecksmin-contrast, pairsstable
reset(), replace(value)markersโ€“frozen
from-data(data, assets:)tokens, options, checks, layouta parsed brand fileexperimental

The layout helpers are described on the Layouts page and the part helpers on the Parts page.

brand()โ€‹

The quick path: one or two colors, fonts and a logo. Everything else derives from them.

KeyTypeDescription
colorcolor | autoThe brand color (colors.primary), the seed of the palette.
accentcolor | autoA second brand color (colors.accent) for rules and markers. Defaults to the brand color.
fontstr | array | autoThe body font chain (fonts.body). Label, figure and heading fonts derive from it.
heading-fontstr | array | autoThe heading font chain (fonts.heading), for the title and headings.
logocontent | autoThe logo (logo.image). Give an image an alt text; PDF/UA-1 requires it.
#let acme = theme.custom.brand(
color: rgb("#003a70"),
accent: rgb("#e2001a"),
font: ("Source Sans 3", "Liberation Sans", "Libertinus Serif"),
logo: image("logo.svg", alt: "ACME Maschinenbau GmbH"),
)

#show: invoice.with(
theme: theme.classic.with(acme),
// sender: .., recipient: .., invoice-nr: ..
)

A brand is an ordinary patch, so the same value works with every preset: theme.corporate.with(acme), theme.elegant.with(acme).

Tokensโ€‹

The tokens are the frozen design tier: 30 values that the built-in parts and presets read. A function value is a derivation t => value. It is evaluated against the resolved tokens (t.colors.primary, t.sizes.body, โ€ฆ), so a derived value follows when you change its inputs.

TokenTypeDefaultUsed for
colors.primarycolor#1f2937the brand color: fills, rails, header fills
colors.on-primarycolorblack or white, whichever contrasts moretext on primary fills
colors.primary-textcolorprimary, darkened to 4.5:1the brand color used as text (titles, kickers)
colors.accentcolorprimarythe second brand color: rules and ticks
colors.accent-textcoloraccent, darkened to 4.5:1the accent used as text
colors.textcolorblackbody text, marks
colors.text-mutedcolorluma(100)descriptions, labels, notes, footer, continuation header
colors.bordercolorblacktable rules
colors.tintcolor | nonea light tint of primarytable stripes and soft fills (#e2e8f0 for the default color)
colors.backgroundcolorwhitethe page color and the reference for contrast
fonts.bodystr | array("Liberation Sans", "Libertinus Serif")all text
fonts.headingstr | arrayfonts.bodytitle and headings
fonts.labelstr | arrayfonts.bodycolumn headers, section and reference labels
fonts.numericstr | arrayfonts.bodyamounts, quantities, IBAN, reference numbers
fonts.number-width"tabular" | "proportional""tabular"the digit width of amounts
sizes.bodylength10ptbody text
sizes.smalllength0.85emsub-labels, references, page number
sizes.finelength7ptreturn address, legal footer; absolute, at least 6 pt
sizes.largelength1.2emthe grand total
sizes.titlelength1.4emthe title
weights.strongstr | int"bold"every bold text, the table header
strokes.hairlinelength0.25ptfold and punch marks
strokes.thinlength0.5pttable rules, continuation line
strokes.regularlength1pttable header rule
strokes.thicklength2pttotals rule
spacing.smalllength0.4emcell insets
spacing.mediumlength0.6emheader insets, the gap between the cells of an area
spacing.leadinglength0.65emparagraph leading of the body and of table cells
radii.smalllength2ptfilled blocks (totals fill, payable bar)
radii.mediumlength4ptcards, the logo plate on a dark surface

fonts.regulated (default ("Liberation Sans", "Arial", "Helvetica", "Libertinus Serif")) belongs to the 0.6.x preview of reserved zones and is not frozen. Strokes are thicknesses only; the color is added where a stroke is drawn.

#show: invoice.with(
theme: theme.classic.with({
import theme.custom: *
colors(
primary: rgb("#7c2d12"),
tint: t => t.colors.primary.lighten(92%), // stripes follow the brand color
border: t => t.colors.primary,
)
sizes(body: 9.5pt, title: 1.6em)
strokes(thick: 1.2pt)
}),
// sender: .., recipient: .., invoice-nr: ..
)

A derivation must work for any valid token tree. Derivations that depend on each other in a cycle, or that do not settle, panic and name the tokens involved.

Optionsโ€‹

Options are settings of one built-in part. They are provisional: a key may still change in a 0.6.x minor release, with a changelog entry. A built-in part reads its own group; items-table and totals also read line-items. A part you replace decides for itself which options it honours; a part you wrap keeps them.

OptionTypeDefaultDescription
logo.imagenone | contentnoneThe logo. Give an image an alt text.
logo.heightlength14mmHeight of the logo.
logo.on-darkauto | none | contentautoOn a dark surface: auto puts the logo on a light plate, content replaces it, none keeps it as it is.
title.arrange"row" | "stack""row""row": subject and number, with place and date on the right. "stack": the document word above number and date.
title.show-place-datebooltrueShow the place and date next to the title.
title.colorcolorcolors.textColor of the title text.
line-items.discount-colorcolor#b22222Color of discount and prepayment amounts.
line-items.surcharge-colorcolor#333333Color of surcharge amounts.
line-items.gaplength0.7emSpace between the items table and the totals.
items-table.zebra(odd, even) of none | color | derivation, or a function(none, colors.tint)Row fills. A function (number) => fill receives the running number of the line item (1 for the first), the count that decides odd and even. The fill applies per line item, not per table row.
items-table.header-fillnone | colornoneFill of the header row.
items-table.header-textauto | colorautoHeader text color; auto picks black or white for the fill.
items-table.header-styledictionary(:)Extra set text arguments of the header, merged key by key; values may be derivations.
items-table.rulecolorcolors.borderColor of the table rules.
items-table.row-rulenone | length | color | strokenoneA rule between two entries.
items-table.row-insetlengthspacing.small * 0.75Vertical padding of every entry.
items-table.column-orderarray of str("quantity", "unit-price", "tax-rate", "total-price")Order of the value columns; a column left out is never shown. Whether a listed column shows is decided by line-items(show-column: ..).
items-table.repeat-headerbooltrueRepeat the header row on every page.
totals.widthratio | relative | length66%Width of the totals block, right-aligned.
totals.min-widthnone | lengthnoneA floor for width in narrow columns.
totals.fillnone | colornoneFill behind the totals block (corner radius radii.small).
totals.colorauto | colorautoText color on the fill; auto picks black or white.
bank-details.show-qrbooltrueShow the EPC-QR code (EUR only; never for an invalid IBAN).
bank-details.qr-sizeauto | lengthautoSize of the EPC-QR code; auto is 25 mm in classic. Sizes below 20 mm are raised to 20 mm. A size in bank-details(qr-code: ..) overrides it.
page-number.fromauto | intautoauto: every page, as soon as the invoice has more than one. An int: the first page with a number.
page-number.formatauto | functionauto(ctx, current, total) => content; auto uses the locale string document.page.
continuation.show-subjectbooltrueThe header of following pages shows "sender ยท subject" and the invoice number; false shows only the sender.
row.fillnone | colornoneExperimental: fill of the groups, items and bundles inside a themed scope; the innermost scope wins.
customdictionary(:)Free space for third-party parts, under custom.<package>; set it with a patch dictionary.
#show: invoice.with(
theme: theme.classic.with({
import theme.custom: *
items-table(
zebra: (none, none),
header-fill: rgb("#1f2937"),
row-rule: 0.5pt + luma(200),
)
totals(fill: rgb("#1f2937"), width: 60%)
title(arrange: "stack")
page-number(format: (ctx, current, total) => [#current / #total])
bank-details(qr-size: 30mm)
}),
// sender: .., recipient: .., invoice-nr: ..
)

theme.resolve(..).unread-options lists option groups you changed but that no active built-in part reads, for example totals after you replaced the totals part.

Markers: reset() and replace()โ€‹

MarkerEffectExample
reset()Restores the schema default. The only way back to a computed (auto) value.page(margin: (bottom: reset())) returns to the computed bottom margin.
replace(value)Replaces a dictionary as a whole instead of merging into it.area("footer", text: replace((size: 8pt))) drops the text color of the look; text: (size: 8pt) would keep it.

Merge Rulesโ€‹

All patches, including those of presets and packages, go through one strict merge.

PatchEffect
a dictionary onto a dictionaryMerged key by key. An unknown key panics with its path, a did-you-mean hint and the allowed keys.
a partial margin or area insetFolds: (bottom: 35mm) changes one side; x, y and rest set two or four sides.
an area anchor (left, right, top, bottom)Sets the side and clears its partner: setting left clears right.
an area's text or parMerged key by key: text: (size: 9pt) keeps the fill a look set.
an area's arrange, stroke, radius, rule, cell-alignReplaced as a whole.
arrays, scalars, content, functionsReplaced.
fields onto a group that is none (such as marks)Filled in from a template first: marks(fold: (105mm, 210mm)) on a digital layout adds marks again.
area(name, ..) for a name the layout lacksWith place: adds the area. Without: panics with a did-you-mean hint.
area(name, none)Removes the area. The removal sticks, unless a later patch with place re-creates it.

Open maps accept new keys: part names (custom ones need a prefix), options.custom, items-table.header-style, checks.pairs and area names.

Checksโ€‹

KeyTypeDefaultDescription
min-contrastnone | int | floatnoneReports every checked color pair below this contrast ratio (WCAG: 4.5) as a lint/contrast-* issue. Issues follow the validation level.
pairsdictionary(:)Extra pairs to check: name: t => (foreground, background) or a literal pair of colors. Merged by name; none removes a pair.

The core checks body and secondary text on the page, the text on primary fills, secondary text on the tint, the title and discount colors, the header text on the header fill, the totals text on the totals fill and the text of every filled area. A part that draws its own color pair should register it in pairs.

#show: invoice.with(
theme: theme.classic.with(
theme.custom.brand(color: rgb("#facc15")), // a light brand color
theme.custom.checks(
min-contrast: 4.5,
pairs: (stamp: t => (t.colors.accent-text, t.colors.background)),
),
),
// sender: .., recipient: .., invoice-nr: ..
)

Brand Files: from-dataโ€‹

warning

theme.custom.from-data is experimental.

from-data(data, assets: none) turns a parsed JSON, YAML or TOML dictionary into a patch, so agencies and pipelines can keep brands as data. There is no expression language: a file sets values, and derived values keep deriving from them.

# brand.toml
[theme.tokens.colors]
primary = "#0b3d91"
accent = "#ffb000"

[theme.tokens.fonts]
body = ["Libertinus Serif"]

[theme.options.logo]
image = "logo.svg"
height = "9mm"

[theme.options.items-table]
zebra = ["none", "none"]

[theme.layout]
margin = { bottom = "35mm" }
#let brand = toml("brand.toml")

#show: invoice.with(
theme: theme.corporate.with(theme.custom.from-data(
brand.theme,
assets: path => image(path, alt: "Nordlicht Studio"),
)),
// sender: .., recipient: .., invoice-nr: ..
)
KeyTypeDescription
datadictionaryGroups tokens, options, checks and layout. From layout only paper, margin, body-top, body-gap and footer-clearance are allowed; layouts, areas and stationery are code.
assetsnone | functionpath => content, called for options.logo.image. A package cannot open files by path, so the function must be defined in your document.

Strings are converted:

In the fileBecomes
"#0b3d91" (3, 6 or 8 hex digits)an rgb color
"10.5pt", "25mm", "2cm", "1in", "0.4em"a length
"66%"a ratio
"none"none
"auto"reset(): back to the default
any other stringa string (font names, enumeration values)

Unknown keys fail with the full path, for example theme::tokens::colors has unknown key `primry`. Did you mean `primary`?.

Color Helpersโ€‹

FunctionReturns
theme.contrast(a, b)The WCAG contrast ratio of two colors (float, 1 to 21).
theme.on-color(background)black or white, whichever contrasts more with background.
theme.legible(fg, bg, target: 4.5)fg, darkened (on a light bg) or lightened (on a dark bg) until it reaches target.

The token defaults use them: colors.on-primary is on-color(primary), colors.primary-text is legible(primary, background).

Error Messagesโ€‹

MistakeMessage
A wrong helper parameter: colors(primry: red)Typst's own error: unexpected argument: primry.
A wrong key in a patch dictionary or brand filetheme::tokens::colors has unknown key `primry`. Did you mean `primary`? Allowed keys: primary, on-primary, ..
An area the layout lacks: area("adress", ..)theme::layout::areas has no area `adress` in layout `din-5008-a`. Did you mean `address`? .. To ADD an area, give it a `place`.
A named argument other than layout:theme `classic`: unexpected named argument(s) `form`. .. `form` is a 0.5 `themes.DIN-5008` parameter; see the migration table in the theme docs.

Mistakes like these always stop the build, whatever the validation level: no output could honour them.