I’ve seen multiple topics on how to achieve different page numbering for first page, frontmatter, and main content, but these all presume that you can add the appropriate commands at the appropriate places in the document flow.
Since I am using a Typst template (via Quarto), I had to figure out how to do this. I thought I’d share it with others, in case it is helpful.
Template requirements for the footer and page numbering:
No footer on first page
From page 2 till the page with “Introduction”: Roman numerals, starting with i.
From Introduction till the end (incl. backmatter): Hindu-Arabic numerals, starting with 1.
In page.typ (since I’m using Quarto’s division of “template partials”), I added the following footer:
#set page(
footer: context {
// find page with label "introduction".
let intro-page = locate(<introduction>).page()
let current-page = here().page()
set text(10pt)
// no footer on first page
if current-page == 1 {return none}
// reset page counter on the page before the Introduction
if current-page == (intro-page -1) [
#counter(page).update(0) // this needs to be 0, not 1
]
// roman numerals for frontmatter
if current-page < intro-page [
$short-title$ #h(1fr) #counter(page).display("i")
]
else [
$short-title$ #h(1fr) #counter(page).display("1")
]
}
)
That looks neat. For page numbers to use the same corresponding styles in the outline, I think you need to insert set page(numbering: "i") (and "1") in the right places in the document, those can’t be incorporated into this page footer scheme?
I had not included the frontmatter in the ToC, so I didn’t notice the problem until you pointed it out.
This revised version (written with help from Grok) fixed the mismatch between the page numbering and the ToC by putting numbering outside of the footer function.
#let intro-start-page = state("intro-start-page", none) // store final intro page after layout
#set page(
numbering: (n) => {
// locate gives the final layout page of <introduction>, not the current page
let intro-loc = locate(<introduction>)
intro-start-page.update(intro-loc.page()) // needed because early passes are unstable
let intro-page = intro-start-page.get()
let current-page = here().page() // page we are numbering right now
if intro-page == none or current-page < intro-page { numbering("i", n) }
else { numbering("1", n) }
},
footer: context {
let current-page = here().page()
let intro-page = locate(<introduction>).page()
// update(0) so next page becomes 1 (Typst increments before display)
if current-page == intro-page - 1 { block(counter(page).update(0)) }
if current-page == 1 { return none } // no footer on title page
set text(10pt)
align(left)[ $short-title$ #h(1fr) #counter(page).display() ]
},
)
A simpler version, without using the redundant state-related code. The reason this works while the original did not is that the numbering-switching logic is now placed outside of the footer logic.
This revised version also allows for the <introduction> label to be found using a regex pattern instead of a hard-coded string.
This was coded iteratively with the help of Claude Sonnet 4.5, which I hallucinates a whole lot less with Typst code than Grok or ChatGPT 5.1.
// Page Numbering: Roman (i, ii, iii) before Introduction,
// Arabic (1, 2, 3) after
// Find the page number where Introduction heading appears
#let find-intro-page() = {
// Query all headings and extract their labels
let intro-labels = query(heading)
.map(h => h.at("label", default: none))
// Keep labels that start with "introduction"
// Matches: <introduction>, <introduction-and-context>, etc.
.filter(lbl =>
lbl != none and
str(lbl).match(regex("^introduction")) != none
)
// Return page number of first match, or none if not found
if intro-labels.len() > 0 {
locate(intro-labels.first()).page()
} else {
none
}
}
#set page(
// Switch numbering style at Introduction
numbering: (n) => {
let intro-page = find-intro-page()
let current-page = here().page()
if intro-page != none and current-page < intro-page {
numbering("i", n) // Roman before Introduction
} else {
numbering("1", n) // Arabic from Introduction onward
}
},
footer: context {
let current-page = here().page()
let intro-page = find-intro-page()
// Reset counter so Introduction becomes page 1
if intro-page != none and current-page == intro-page - 1 {
block(counter(page).update(0))
}
if current-page == 1 { return none } // No footer on title
set text(10pt, fill: rgb("#808080"))
align(left)[ $short-title$ #h(1fr) #counter(page).display() ]
},
)
// Key differences from original:
//
// ORIGINAL:
// - locate(<introduction>) requires exact label match
// - Footer handled numbering format using conditionals
// - ToC showed wrong page numbers (didn't match footer)
//
// NEW:
// - query(heading) + regex for flexible label matching
// - set page(numbering: ...) switches format globally
// - This ensures ToC page numbers match footer page numbers
// - Helper function eliminates code duplication
//
// Requirements:
// - Label must START with "introduction"
// - $short-title$ variable must be defined