Navigation

Because Mosaic keeps Typst headings native, the same headings that create slides can drive tables of contents, breadcrumbs, and links between sections.

Table of contents

Mosaic keeps headings native, so a table of contents is Typst’s own outline. Three of its defaults behave differently on a slide than in a document.

Depth

depth: 1 lists sections. depth: 2 adds every slide, which on most decks is the whole slide list.

Entries

A default entry ends in dotted leaders and the page its heading falls on. On slides that page is the physical frame, so on a deck using #m.pause it differs from the logical slide number in the footer.

A show outline.entry rule replaces the entry. it.body() is the title, it.element.location() is the link target, and it.prefix() is the heading number when headings are numbered.

The contents slide itself

Its own heading is outlined like any other, so it appears in its own outline unless it carries #heading(outlined: false) or a narrower target: excludes it.

#import "@preview/mosaic:0.0.1" as m

#show: m.setup
#set text(size: 22pt)

// A contents slide is a slide like any other, so its own heading would be the
// first entry in its own outline. `outlined: false` keeps it out.
#m.slide(numbered: false)[
  #heading(outlined: false, bookmarked: false)[Contents]
][
  // A default entry ends in dotted leaders and a page number. On slides that
  // number counts physical frames, not the logical slides the footer shows, so
  // this rule keeps the linked title alone.
  #show outline.entry: it => block(
    below: 0.9em,
    link(it.element.location(), it.body()),
  )

  #outline(title: none, depth: 1)
]

= Methods

== Data

Describe the data and measurements.

= Results

== Estimates

Present the main estimates.
A linked table of contents, first frame of 5 Open slideshow · 5 frames
A linked table of contents

Loading slideshow…

Page 1 of 5
Open PDF

Columns

#columns(2, outline(..)) does not spread entries across a slide. Typst fills the first column to the full height of its container before starting the second, and a slide cell is full-slide height, so a list that fits vertically stays in column one. A surrounding block does not change that.

query(heading.where(level: 1, outlined: true)) returns the section headings in document order, each with a body to print and a location() to link to. Slice them and place the chunks in a native grid.

#import "@preview/mosaic:0.0.1" as m

#show: m.setup
#set text(size: 22pt)

// `columns` fills its first column to the full height of the region before it
// starts the second, and a slide cell is full-slide height, so a list that fits
// vertically never reaches column two. Query the headings and place the chunks
// side by side instead.
#let contents(columns: 2) = context {
  let entries = query(heading.where(level: 1, outlined: true))
  let per-column = calc.ceil(entries.len() / columns)
  grid(
    columns: (1fr,) * columns,
    column-gutter: 1.5em,
    ..range(columns).map(index => {
      let start = calc.min(index * per-column, entries.len())
      let end = calc.min(start + per-column, entries.len())
      stack(
        spacing: 0.9em,
        ..entries.slice(start, end).map(entry => link(entry.location(), entry.body)),
      )
    }),
  )
}

#m.slide(numbered: false)[
  #heading(outlined: false, bookmarked: false)[Contents]
][
  #contents()
]

= Motivation

= Data

= Identification

= Results

= Robustness

= Conclusion
Contents in two columns, first frame of 7 Open slideshow · 7 frames
Contents in two columns

Loading slideshow…

Page 1 of 7
Open PDF

Lists longer than the slide

An overflowing cell is drawn past the bottom edge rather than clipped. m.fit scales a block into the space available:

#m.fit(outline(title: none, depth: 1))

Fitting scales the type with the layout, so the contents slide no longer matches the deck’s type scale.

The current section

The section layout’s toc variant lists every section with the current one marked, reading the deck’s section records rather than an outline:

#m.slide(layout: "section", variant: "toc")[Results]

It takes no outline and no query, and fits itself to the slide.

Use native contextual query with a selector ending at here() to find the active section and slide headings. Their body fields provide the labels, and location() provides a link target.

#import "@preview/mosaic:0.0.1" as m

#let active-heading(level) = {
  let headings = query(
    heading.where(level: level, outlined: true).before(here()),
  )
  if headings.len() > 0 { headings.last() }
}

#let breadcrumb() = context {
  let section = active-heading(1)
  let slide = active-heading(2)
  if section != none {
    let parts = (link(section.location(), section.body),)
    if slide != none {
      parts.push(slide.body)
    }
    parts.join([ › ])
  }
}

#show: m.setup.with(
  foreground: [
    #place(top + right)[
      #pad(top: 0.9em, right: 1.35em)[
        #text(size: 0.65em, fill: luma(35%))[#breadcrumb()]
      ]
    ]
  ],
)
#set text(size: 22pt)

= Methods

== Data

Describe the observations.

== Model

Explain the model.
Section and slide breadcrumbs, first frame of 3 Open slideshow · 3 frames
Section and slide breadcrumbs

Loading slideshow…

Page 1 of 3
Open PDF

Use query(heading.where(level: 1, outlined: true)) to collect every outlined section heading. Each result provides a label through body and a destination through location(). Compare it with the last matching heading before here() to style the active section differently.

#import "@preview/mosaic:0.0.1" as m

#let section-links() = context {
  let sections = query(heading.where(level: 1, outlined: true))
  let preceding = query(
    heading.where(level: 1, outlined: true).before(here()),
  )
  let current = if preceding.len() > 0 { preceding.last() }
  sections.map(section => {
    let active = (
      current != none
        and current.location() == section.location()
    )
    link(
      section.location(),
      box(
        inset: (x: 0.7em, y: 0.3em),
        radius: 0.3em,
        fill: if active { blue.lighten(85%) } else { luma(94%) },
        text(
          weight: if active { "bold" } else { "regular" },
          section.body,
        ),
      ),
    )
  }).join(h(0.35em))
}

#show: m.setup.with(
  foreground: [
    #place(bottom + center)[
      #pad(bottom: 0.7em)[#section-links()]
    ]
  ],
)
#set text(size: 22pt)

= Methods

= Results

= Discussion
Clickable section navigation, first frame of 3 Open slideshow · 3 frames

Loading slideshow…

Page 1 of 3
Open PDF