How to display a different background image on every page with a certain content

I’m generating a planner where I want a nice background for the Notes pages:


I want the background to be different for every page, to do this I have a JSON file with all the backgrounds I have:

{"birds": [
  { "path": "001-grypus-naevius.jpg", "name": "Grypus Naevius" },
  { "path": "002-grypus-spixi.jpg", "name": "Grypus Spixi" },
  { "path": "003-eutoxeres-aquila.jpg", "name": "Eutoxeres Aquila" },
  { "path": "004-eutoxeres-condamini.jpg", "name": "Eutoxeres Condamini" },
  { "path": "005-glaucis-hirsutus.jpg", "name": "Glaucis Hirsutus" }
]
}

I’m trying to do something like this:

#let birds = json("birds/birds.json")
#let current_bird() = {
  let bird = birds.birds.at(counter("bird").get().at(0))
  bird.path = "birds/" + bird.path
  return bird.path
}
#let next_bird() = {
  counter("bird").step()
  return current_bird()
}

But that doesn’t work.

Hi, welcome!

You said “that doesn’t work”, but I can’t know the specific problem. Did your code compile? Are there any error messages? What’s your current result? Maybe a screenshot?

In any case, you might be interested in calc.rem and the general documentation on counters. The following might work:

#set page(background: context {
  let n = calc.rem(
    counter(page).get().first() - 1, // Make it start from zero
    birds.birds.len(),
  )
  place(center + horizon, {
    image("birds/" + birds.birds.at(n).path)
  })
})
1 Like

For physical page here().page() is used, which is also simpler, if physical and logical pages are in sync.

image.fit might make it better for stretching the image. Or just width: 100%, height: 100%, haven’t tried.

The code does compile, but it always the same picture (see image in first post). As I only want the background on certain pages the suggestions unfortunately don’t fit.

I think the problem in my code, is that I don’t “consume” the step in next_bird(). Which makes Typst ignore it. However when I try to do this:

#let next_bird() = {
  let bird = birds.birds.at(counter("bird").step())
  bird.path = "birds/" + bird.path
  return bird.path
}

I get the following error:

error: expected integer, found content
  ┌─ birds.typ:8:28
  │
8 │   let bird = birds.birds.at(counter("bird").step())
  │                             ^^^^^^^^^^^^^^^^^^^^^^

I might be able to make Y.D.X’s example work by calculating at which pages I expect the background to be at.

Edit:
I’m using next_bird() like this:

#let lines_a5(title: "", alignment: left) = {
  let bird = next_bird()

  page(background: image(bird), {
    let total_lines = 26
    if title != "" {
      pad(bottom: 8pt, 
      align(alignment)[
        #heading(level: 1)[
          #text(size: 27pt, title)
        ]
      ])
      total_lines -= 2
    }
    for _ in range(0, total_lines) {
      pad(y: 3pt, line(length: 100%))
    }
  })
}

And lines_a5() is used like this:

#let day_with_notes(date) = {
  day_overview(date)
  lines_a5(title: "Notities", alignment: right)
}
#let week_with_notes(week, year) = {
  week_overview(week, year)
  lines_a5(title: "Notities", alignment: right)
  let first_day = week_to_date(week, year)
  let one_day = duration(days: 1)
  for _ in range(0, 7) {
    day_with_notes(first_day)
    first_day += one_day
  }
}
#let weeks_with_notes(first_week, last_week, year) = {
  for week in range(first_week, last_week + 1) {
    week_with_notes(week, year)
  }
}

#context [
#pagebreak(to: "even")
#align(center + horizon, heading()[#text(size: 27pt)[Weekoverzichten Januari]])

#month_with_notes(1, 2026)
#weeks_with_notes(1, 5, 2026)

#pagebreak(to: "even")
#align(center + horizon, heading()[#text(size: 27pt)[Weekoverzichten Februari]])

#month_with_notes(2, 2026)
#weeks_with_notes(6, 9, 2026)
]

The docs clearly show that counter.step returns content, not int. counter.get returns array of integers.

Yes. But then how do I consume the output if step, as I don’t want to display it?

Define “consume”. You are already getting the output of step, it’s content, the error says about it.

As I understood it, if you don’t do anything with the content (i.e. let _ = counter("bird").step()) Typst will ignore the step.

But I’ve ended up just doing this:

#let birds = json("birds/birds.json")
#set page(
  paper: "a5",
  background: context {
    // let current_page = counter(page).get().first() - 1 // Make it start from zero

    let n = calc.rem(
      counter(page).get().first() - 1, // Make it start from zero
      birds.birds.len(),
    )
    place(center + horizon, {
      image(
        "birds/" + birds.birds.at(n).path,
        fit: "contain",
        width: 80%,
        height: 80%
      )
    })
  }
)

And overriding the background on pages where I don’t want a background. There might be some duplicates in the final result, but they should be far enough apart that nobody will notice.

Yes, you must include it in your document for any update state/counter function to work.