Toolarity

Typst Heading Numbering: Per-Level Formats and the none Trap

Fix Typst Published October 8, 2026

If you search for how to give different heading levels different numbering in Typst, you usually find this answer: pass a function to numbering and return none for the levels you don’t want numbered. It compiles, and at a glance it looks right.

I compiled every variant below with the official Typst CLI, 0.15.1 (current release, 2026-07-17) and 0.14.2, and measured where the text lands in the PDF. Both versions behaved the same in every case. The none trick has two side effects that no error message tells you about.

Short answer (Typst 0.15.1 and 0.14.2, tested 2026-09-25):

You wantUseWatch out for
Stop numbering below level 2#show heading.where(level: 3): set heading(numbering: none)Returning none from the numbering function instead leaves a 0.3em gap before the heading text and lets @references compile to “Section .”
Limit the depth with a pattern like "1.1"Doesn’t work that way"1.1" gives level 3 headings 1.1.1. The last counting symbol repeats
Different formats per level (“TOP 1:” then “1.1”)A numbering function that checks nums.pos().len()Whatever the function returns also appears in references and the outline, including a trailing colon
Only the last number with a per-level symbol (I. / A. / 1.)numbering(("I.", "A.", "1.").at(n.len() - 1), n.last())The pattern "I.A.1." gives the full chain I.A.1. instead
Appendices restarting at A#counter(heading).update(0) then #set heading(numbering: "A.1", supplement: [Appendix])Nothing, this worked as expected

Test 1: returning none leaves a gap

The widely shared version looks like this:

#set heading(numbering: (..n) => if n.pos().len() <= 2 { numbering("1.1", ..n) })
= Intro
== Scope
=== Detail

Level 3 “Detail” has no number, as intended. But it is shifted to the right. I drew a red line on the left margin to make the difference visible:

Two Typst 0.15.1 renders side by side, each with a red line on the left margin. Left (A): the numbering function returns none for level 3, and the heading "Detail" starts slightly to the right of the line, while "1 Intro", "1.1 Scope" and "Body." start on it. Right (B): a show-set rule sets numbering to none for level 3, and "Detail" starts on the line.

Measured from the PDF (text x position, in points, with an 11pt body and a 1cm page margin). The absolute numbers depend on your margin; compare the difference, not the values:

Variant“Detail” starts atBody text starts at
No numbering at all28.3528.35
Function returns none for level 331.6528.35
Function returns "" for level 331.6528.35
show heading.where(level: 3): set heading(numbering: none)28.3528.35

The gap is 3.30pt, which is exactly 0.3em at 11pt. The reason is in the source (crates/typst-layout/src/rules.rs, tag v0.15.1): if a heading’s numbering is set to anything, Typst renders the numbering, adds SPACING_TO_NUMBERING (Em::new(0.3)), then the body. It doesn’t check whether your function returned anything. An empty string behaves the same way.

The show-set rule sets numbering itself to none for that level, so Typst skips the numbering and the spacing.

Test 2: references to those headings go quiet

This is the one that can reach a finished PDF. With the same function, I added a label and referenced it:

=== Detail <det>
See @det.

Typst 0.15.1 output. The outline lists "1 Intro", "1.1 Scope" and an unnumbered "Detail". Below the headings, the body text reads "See Section ." with nothing between "Section" and the period.

It compiled without an error or a warning and printed “See Section .”. Typst considers the heading numbered, because numbering is set, so the reference is allowed and shows whatever the function returned for it: nothing.

With the show-set rule, the same reference fails the build:

error: cannot reference heading without numbering
  = hint: you can enable heading numbering with `#set heading(numbering: "1.")`

A build error is the better outcome here. You find the broken reference now, not after the PDF is sent.

Test 3: “1.1” doesn’t limit the depth

A common assumption is that numbering: "1.1" means “number two levels”. It doesn’t. My level 3 heading came out as 1.1.1 Detail. The numbering docs (read 2026-09-25) explain it: “If numbering is a pattern and more numbers than counting symbols are given, the last counting symbol with its prefix is repeated.” To stop at a depth, use the show-set rule from Test 1.

Test 4: different formats per level

A forum question asks for level 1 as “TOP 1: First Name” and level 2 as “1.1 first subsection”. The direct answer:

#set heading(numbering: (..n) => {
  let n = n.pos()
  if n.len() == 1 { "TOP " + str(n.first()) + ":" } else { numbering("1.1", ..n) }
})

The headings look right. The outline and references don’t:

Typst 0.15.1 output. Headings read "TOP 1: First Name", "1.1 first subsection", "1.2 second subsection", "TOP 2: Second name". The outline shows "TOP 1:" and "TOP 2:" with colons, and the body text reads "See Section TOP 1: and Section 1.2."

“See Section TOP 1: and Section 1.2.” The colon and the word “TOP” are part of the number, so every reference gets them, plus the default “Section” supplement.

What I ended up with: keep the number itself clean, turn off the supplement for level 1, and add the colon only where the heading is drawn.

#set heading(numbering: (..n) => {
  let n = n.pos()
  if n.len() == 1 { "TOP " + str(n.first()) } else { numbering("1.1", ..n) }
})
#show heading.where(level: 1): set heading(supplement: none)
#show heading.where(level: 1): it => if it.numbering == none { it } else {
  block(counter(heading).display(it.numbering) + [: ] + it.body)
}

Typst 0.15.1 output with the fix. The outline shows "TOP 1  First Name" without a colon, headings read "TOP 1: First Name" and "TOP 2: Second name", and the body text reads "See TOP 1 and Section 1.2."

Now the reference reads “See TOP 1 and Section 1.2.”, and the outline has no stray colons.

The if it.numbering == none { it } guard is needed. My first version didn’t have it and failed on both versions with error: expected string, function, or auto, found none. The outline’s own “Contents” title is a level 1 heading with no numbering, and the show rule applies to it too.

Test 5: only the last number (I. / A. / 1.)

For an outline-style “I. / A. / 1.” where each level shows only its own number, the pattern "I.A.1." isn’t enough. It gave I., I.A., I.A.1.. Choosing the symbol by depth and passing only the last number works:

#set heading(numbering: (..n) => {
  let n = n.pos()
  numbering(("I.", "A.", "1.").at(n.len() - 1), n.last())
})

Typst 0.15.1 output: "I. Top level heading", "A. First subheading", "1. Second subheading", "B. Another subheading".

The counter still counts the full hierarchy, so the second level 2 heading correctly becomes “B.”. If you nest deeper than three levels, .at() fails with an index error, so add a fourth symbol or a fallback.

Test 6: appendices

#set heading(numbering: "1.1")
= Intro
= Method
== Setup <setup>
#counter(heading).update(0)
#set heading(numbering: "A.1", supplement: [Appendix])
= Raw data <raw>
== Tables
See @setup and @raw.

Output: 1 Intro, 2 Method, 2.1 Setup, A Raw data, A.1 Tables, and “See Section 2.1 and Appendix A.” The references before the switch keep “Section”, and the ones after get “Appendix”. No surprises.

What I’d do

  • Don’t return none or "" from a numbering function to hide numbers. Use a show-set rule per level. You avoid the gap, and a reference to an unnumbered heading fails the build instead of printing “Section .”.
  • Keep decoration out of the numbering. Anything the function returns ends up in references and the outline. Put colons and words like “Chapter” in a show rule or the supplement.
  • Guard custom heading show rules with it.numbering == none, or your outline title can break the build.

What this test does not cover

  • Only the official macOS (Apple Silicon) CLI builds of Typst 0.15.1 and 0.14.2, tested 2026-09-25. I didn’t test the web app.
  • Default page and font settings, left-aligned headings. Centered headings and a custom hanging-indent weren’t tested.
  • PDF output only. HTML export handles headings differently (see the heading docs).