Tables: How can I style the borders of cells in each row that is below a header row?

I have a long table that takes multiple pages. The table has a header that repeats on every page (I have set repeatable: true for the header).

For the sake of simplicity, let’s assume that the table has only one column.

Now I would like to style the borders of each cell that comes immediately after a (repeated) header cell in a special way. In other words, at the top of each page, the table header (in this case, one cell) is repeated. For the cell that follows the table header cell, I would like to style the cell borders another way than for the other cells.

I have already put a significant amount of time into trying to achieve this, but failed so far. The problem is that I obviously need a counter or a state to solve the problem, and this in turn requires using context. This is where the problems begin, because context is opaque, and its return value cannot be compared to anything, regardless of which data type it returns.

My best idea (so far) is based on the observation that the y coordinate of cells in a table header row is always 0: In a show or set rule, we could check for y == 0 and update a state if this is the case. In the same or in another rule, we could check that state, style the borders accordingly, and then reset the state.

For example:

#let after-header = state("after-header", 0)

#show table.cell: it => context {
  if (it.y == 0) {
    after-header.update(1)
  }
  else {
    if (1 == after-header.get()) {
      /* Apply styles (stroke) here - NOT POSSIBLE */
    }
    after-header.update(0)
  }
  it
}

The code above shows the idea, but actually cannot be used. We have two problems here; one eventually could be solved, the other apparently can’t be solved in a show rule.

The problem that could be solved eventually:

The documentation says that the update of the state only happens when the state variable actually becomes part of the document’s content. That is, we would need something like [#after-header.get()] in the show rule, which of course would place content in the table that we don’t want to see there. We perhaps could use a text size of 0pt before that piece of code; I didn’t try that because of the second problem.

The problem that apparently can’t be solved in a show rule:

In a show rule, it seems to be impossible to style the borders of the cell that is currently processed by the rule.

First, it and its fields can’t be modified in such rules, because they are read-only; notably, we can’t modify it.stroke.

Second, saying something like set table.cell(stroke: 10pt) in that rule does not effect anything (probably the cell itself is already laid out when the rule processes it, so that the rule can only change the cell’s content or that content’s styling, not the cell itself or its styling).

With set rules, the situation is not better:

/* DOES NOT WORK */
#set table(stroke: (x, y) => context {
  /* Do something here */
})

Again, we need context in order to read the state. Now, regardless of the code in the function body and regardless of the function’s return value, context always returns content. This leads to an error because the value of stroke must be of type length, dictionary or one of the other types that the documentation mentions.

Using context at another place in the function’s body does not work either. context is opaque, its return values cannot be used outside of it, and its return values cannot be compared.

For example:

#set table(stroke: (x, y) => {
  /* This is syntactically correct, but does not work, because the condition
     always evaluates to false. context returns a sort of content that can't
     be compared to anything else, and that even does not carry children,
     body or other values that can be compared to anything else. */
  if ((context after-header.get()) == 1) {
    return (bottom: 10pt)
  }
})

or:

#set table(stroke: (x, y) => {
  context {
    /* Here, the comparison works, but ... */
    if (after-header.get() == 1) {
      /* ... it is not the intended dictionary that gets returned; instead, a
         sort of content is returned which is not suitable as a value for
         stroke. Therefore, this approach fails, too. */
      return(bottom: 10pt)
    }
  }
})


Can somebody solve this dilemma?

Thank you very much again in advance!

I think you can avoid state and context completely here.

If the main goal is to give the cell immediately after the repeated header a different top border , you can make that border part of the repeatable header itself by putting a table.hline inside table.header .

For example:

#set page(
  width: 12cm,
  height: 8cm,
  margin: 1cm,
)

#table(
  columns: 1,
  stroke: 0.5pt,

  table.header(
    repeat: true,

    [*Header*],

    // This line is part of the header,
    // so it gets repeated on every page.
    table.hline(stroke: 2pt + red),
  ),

  ..range(1, 31).map(i => [Row #i]),
)

I also compiled this example to check the behavior. The header repeats on every page, and the red line is repeated directly below it each time.

So visually, instead of detecting “the first body cell after a repeated header”, you can treat that special border as the bottom border of the repeated header .

This only solves the case where the special styling is the border between the header and the first body row. If you need to change the left/right/bottom borders of that first body cell as well, then it becomes a different problem because Typst’s stroke: (x, y) => ... callback only knows the logical table coordinates, not whether a row happens to be the first row on a new page.

1 Like

Thank you very much for the reply and for the example!

Yes, I already had noticed that the problem is easy to solve if I’d like to style only the top border of such cells (as you stated, I could then style the header cell’s bottom border instead). Unfortunately, I’d like to style the other borders as well.

I can understand why typst requires #context when accessing states, counters and so on. But IMHO, it was a fatal design decision to make it absolutely impossible to pass values from the context to the outside.

The main advantage of typst compared to LaTeX could have been typst’s scripting. LaTeX (despite its age) has problems at many places (especially after page breaks), like nearly other typesetting system I know of. Many of such problem could be solved by a well designed scripting system, and I really hoped that typst would be the game changer that many of us are waiting so long for.

But as long as even most basic things are impossible due to state and counter values not being usable in any logical or mathematical expressions outside the respective #context, typst (for me!) fails exactly at the same point where LaTeX fails (especially, the inability to treat certain elements differently if they come immediately after a page break).

I believe (but I am not sure) that custom elements (if they can be associated with their own show and set rules) could solve a part of the problems. As far as I have understood from the roadmap, this is not a priority, though.

Personally, I have put more than one complete week into trying to learn typst, and I am still excited about it and grateful for it. It is a totally fresh approach to typesetting which hopefully will drive further competition. However, I had to notice that it doesn’t solve the problems that I have in LaTeX and other typesetting systems.

Therefore, I’ll leave it for now, return to my previous workflow and from time to time read the roadmap and the release notes on the typst website. I’ll revisit typst as soon as we can use the return value of #context outside the context, or custom elements are implemented.

Happy typsting, and a good time for everybody!

I tried another workaround that seems to work for this case. The idea is inspired by how Meander handles layout-dependent content: let Typst lay things out first, inspect the resulting physical positions, then use that information on the next layout iteration.

Instead of trying to pass a contextual value outside “context”, I put the whole table inside a “context” block and add invisible “metadata” markers to the body rows. Then I query those markers, group them by physical page, find the topmost body row on each page, and use those row indices directly in the table’s “stroke” callback.

Very roughly:

#set page(width: 10cm, height: 8cm, margin: 1cm)
#set text(size: 9pt)

#let rows = range(1, 31)
#let normal = 0.5pt + gray
#let special = 2pt + red

#context {
  let hits = query(selector(<body-row-marker>).within(here()))
  let first = (:)

  for hit in hits {
    let pos = hit.location().position()
    let p = str(pos.page)
    let row = hit.value

    if p not in first or pos.y < first.at(p).y {
      first.insert(p, (row: row, y: pos.y))
    }
  }

  let first-rows = first.values().map(it => it.row)

  table(
    columns: 1,
    inset: 5pt,
    stroke: (x, y) => {
      let is-special = y in first-rows
      let follows-special = (y - 1) in first-rows

      (
        top: if is-special or follows-special { special } else { normal },
        right: if is-special { special } else { normal },
        bottom: if is-special { special } else { normal },
        left: if is-special { special } else { normal },
      )
    },

    table.header(
      repeat: true,
      [*Repeated header*],
      table.hline(stroke: special),
    ),

    ..rows.map(i => [
      #metadata(i) <body-row-marker>
      Row #i — enough text to make the row visible.
    ]),
  )
}

I compiled this with Typst 0.15.1 and it worked as expected: the first body row after the repeated header gets all four special borders on each page.

So this is not really a way of “getting a value out of “context””. The trick is to keep the table construction inside “context”, where the queried values are normal values and can be used freely.

The approach is very much inspired by Meander’s general strategy of using layout introspection and repeated layout passes to make decisions based on where content actually ended up.

Of course, this kind of feedback loop can become unstable if the styling itself changes pagination. For something like border thickness, though, it seems to converge fine in my test.

2 Likes

Update 2026-08-22:
The following statements are partly wrong, and this post should be deleted. Unfortunately, later posts in this thread refer to it. Therefore, in order to enable later readers to follow the full discussion, I have decided to leave it as-is.
End of update


Original post:

I confirm that the approach you have shown solves the problem. Thank you very much for taking the effort! That’s a really good solution.

One minor thing, though:

The code you have posted contains a few little glitches. I was able to resolve the resulting issues and can post my changes of course.

However, from your screenshot I have noticed that you already have seen and resolved these issues, too. Since I believe that your code is of better quality than mine, I would like to ask if you could post your final version that leads to the result which is shown in the right part of your screenshot.

Have a nice weekend, and thanks again.

1 Like

Can you send a screenshot of the said glitches? I tested the above code again and it produced the same result. Can you also specify Typst version you used to the compile the code?

1 Like

I am very sorry. My previous post resulted from a mistake on my side:

It seems that the very first version of your code was different than the version that is currently published. Unfortunately, I had used your very first version for my test, and I did not notice that you had changed your code in the meantime.

Your code that is currently published (2026-08-22 07:25 UTC) works as intended and does not cause any issues.

For reference, this is the first version of your code that I had used for my first test (which my previous post was based on):

#context {
  let hits = query(
    selector(<body-row-marker>).within(here())
  )

  let first = (:)

  for hit in hits {
    let pos = hit.location().position()
    let page = str(pos.page)

    if page not in first or pos.y < first.at(page).y {
      first.insert(page, (
        row: hit.value,
        y: pos.y,
      ))
    }
  }

  let first-rows = first.values().map(it => it.row)

  table(
    columns: 1,

    stroke: (x, y) => {
      if y in first-rows {
        (
          top: 2pt + red,
          right: 2pt + red,
          bottom: 2pt + red,
          left: 2pt + red,
        )
      } else {
        0.5pt
      }
    },

    table.header(
      repeat: true,
      [*Header*],
    ),

    ..range(1, 31).map(i => [
      #metadata(i) <body-row-marker>
      Row #i
    ]),
  )
}

This shows the general approach (that works like a charm), but is quite different from your current code and leads to minor issues. For example, with that first version, the thickness of the top border of the first body cell is 1pt (instead of the intended 2pt), and the same is true for the bottom border. In other words, the red top and bottom border are 1pt instead of 2pt.

I was able to correct that and then wrote my previous post. When writing my previous post, I unfortunately had missed that you had changed your code in the mean time, too, and that you already had fixed these issues.

Again, thank you very much for the great help! I have learned a lot from your code.

P.S. I am using typst 0.15.1 (the most recent version at the time of writing).

1 Like

No worries at all, and thanks for checking it again.

You are right that I changed the example after noticing the shared-border issue. The first version was mainly meant to demonstrate the introspection approach, but with tables the border between two cells is a shared edge, so simply returning a thicker top /bottom stroke for one cell is not always enough to get the expected visual thickness.

The later version handles those shared edges explicitly, which is why it behaves correctly.

I’m glad the general approach was useful. The main trick is really just this: keep the whole table inside context , add invisible markers to the body rows, query their physical positions after layout, and then use the resulting row indices normally while constructing the table.

1 Like