How to apply paragraph styling based on heading levels?

I know that you can style each header using show heading.where. However, is there a way to also style the paragraphs following those headers?

For example, giving paragraphs under level 1 headers a 10pt left margin and paragraphs under level 2 headers a 20pt left margin.

Currently, I create commands for each paragraph level and manually input the styles in . But as the document gets longer, this approach becomes difficult to manage. I’m looking for an alternative solution.

Typst doesn’t have a dedicated feature for this, but the feasibility of using workarounds depends on what you’re trying to do. Most involve the usage of introspection (in particular, counters).

Styling just the first paragraph after the heading

If you just want to style the first paragraph after the heading, you can try something such as this - be warned that false-positives are likely, so ensure the first thing after a heading (other than another heading) is a paragraph:

#let head-par-counter = counter("heading-par")

#show heading: it => {
  head-par-counter.update(0)
  it
  head-par-counter.update(it.level)
}

#show par: it => context {
  head-par-counter.update(0)

  // Note: context is frozen at the start of the context block,
  // so .get() below happens before the .update(0) above
  let current-level = head-par-counter.get().first()
  if current-level > 0 {
    pad(left: current-level * 2em, it)
  } else {
    it
  }
}

// Sample usage:
#set page(width: 200pt, height: auto, margin: 1cm)
Hello world, this paragraph is not indented.

= First

Hello world, this paragraph is a little indented.

== Second

Hello world, this one is even further indented.

Not affected here! (Not the first paragraph.)

=== Third

Hello world

Not affected.

output: first paragraph after a heading is indented

Styling all paragraphs after a heading

For all paragraphs following those headings, it’s harder to get a satisfying and uniform result, but you can try to adapt the above solution by not resetting the counter on each paragraph. However, to avoid unexpected results on other elements, such as tables, you may need to add some exceptions, as seen below:

#let head-par-counter = counter("heading-par")

#show heading: it => {
  head-par-counter.update(0)
  it
  head-par-counter.update(it.level)
}

// Indent paragraphs and figures (may need to add more here)
#show selector.or(par, figure): it => context {
  // No resetting
  let current-level = head-par-counter.get().first()
  if current-level > 0 {
    pad(left: current-level * 2em, it)
  } else {
    it
  }
}

// Disable this behavior inside those elements
#show selector.or(grid, table): it => {
  head-par-counter.update(0)
  it
}

// Sample usage:
#set page(width: 200pt, height: auto, margin: 1cm)
Hello world

= First

Hello world, this paragraph is indented

== Second

Hello world, this one also is

Hello world

=== Third

Hello world

Hello world

#figure(table(
  columns: 2,
  [*Name*], [*Data*],
  [ABC], [DEF]
), caption: [Caption])

output: everything is indented further after headings

Just keep in mind that, the further you go, the more you may have to add exceptions or change the targets a little bit… but maybe this is more or less what you were thinking of. Let us know if that wasn’t the case.

1 Like

Thank you so much for your long and detailed response.
I was starting to think that it might be a problem with no solution since I couldn’t find an answer, but after hearing your response, I feel much better.
It seems like understanding the selector properly is key.
Thank you once again!

The main problem here is your definition of “a paragraph under level 2 heading”. The heading and paragraph structure in Typst is similar to markdown, which is linear and does not have a tree structure to determine which heading(or section/subsection if you are using LaTeX) a paragraph should belong to. For example:

= A

Par 1

== B

Par 2

Par 3

= C

The paragraph Par 3 here can belong to heading A or B. The better way for this is to define a function to construct a tree structure manually:

#let level-counter = state("level-counter", 1)
#let section(title, body) = {
  context heading(title, level: level-counter.get())
  level-counter.update(1)
  body
  level-counter.update(1)
}
#section[
  A
][
  Par 1
  #section[
    B
  ][
    Par 2
  ]

  Par 3
]

And then style the paragraph according to the level-counter state and then there should be no ambiguity.

Thank you for clarifying my question.
Also, your help has been invaluable in understanding the structure of Typst.