Typst to HTML: playground, tutorial, and showcase

This page, and more broadly all of my website, are (for the most part) just composed of static HTML and CSS.
What's interesting is that said HTML is fully generated via Typst's experimental HTML export feature.

In the process of building this website, I found that although experimental, the feature is already very powerful (though not fully ergonomic yet), and a bigger obstacle to my development was simply knowing what to do with the few features available. This document should make it clear that the issue with Typst's HTML output currently is not what is possible, but what is convenient.

This page is intended:

Via some dark magic, I shall embed in this document not just the output but also the source code that generates it, helping towards the goal of this being a usable showcase and tutorial.

You can find the full source code of this page here.

Getting started

Some stuff just works out of the box.

Build process

Before we get into more technical stuff, a small note on the build process, because I feel that this is one of the least well-rounded parts of using Typst for HTML currently. For the record, I use the following directory layout:

Makefile
list:page
_src/
|-- index.typ
|-- xhtml.typ
|-- css.typ
\-- ...
_assets/
|-- global.css
\-- link.svg
_highlight/
|-- highlight.min.js
|-- highlight-typst.js
\-- ...

And I have my justfile as such:

# justfile
compile-raw SRC TGT:
  typst compile --features html --format html --root . {{SRC}} {{TGT}}

watch-raw SRC TGT:
  typst watch --features html --format html --root . --no-serve {{SRC}} {{TGT}}

compile BASE:
  just compile-raw _src/{{BASE}}.typ {{BASE}}.html

watch BASE:
  just watch-raw _src/{{BASE}}.typ {{BASE}}.html

compile-all:
  cat list:page | parallel --lb just compile {}

watch-all:
  cat list:page | parallel --lb just watch {}

This builds index.html in the root directory.
To watch changes live, I open index.html in a browser and run just watch index in the background.

Non-builtins

For now Typst only provides the function elem, where to build for example a
<div class="test">inner</div>, you write
#html.elem("div", attrs: (class: "test"), { [inner] }).

I find that it is slighly more convenient to have the following in xhtml.typ:

/* _src/lib/xhtml.typ @ ll. 2-5 */
#let htmlfunc(label) = (..attrs) => {
  let keyvals = attrs.named().pairs().filter(p => p.at(1) != none).to-dict()
  html.elem(label, attrs: keyvals, ..attrs.pos())
}

and then instanciate it for each element as such:

/* _src/lib/xhtml.typ @ ll. 9-11 */
#let div = htmlfunc("div")
#let a = htmlfunc("a")
#let img = htmlfunc("img")

This means that now for the same
<div class="test">inner</div>, we can write
xhtml.div(class: "test", { [inner] })

This document imports xhtml.typ, in which more functions of the same shape are defined. Thus from now on you can assume that whenever you see a function xhtml.foo, it creates a <foo>...</foo> html element.

As a concrete example, here is an image that is also a hyperref:

/* _src/index.typ @ ll. 140-142 */
#xhtml.a(href: "https://www.example.com", {
  xhtml.img(src: "_assets/link.svg", width: "50px")
})

Prettification

Now we get to more advanced styling options. There are at least 4 standard ways of doing this in HTML:

All of these methods can be replicated in Typst.

Static global

You can import a pre-written CSS that dictates the style of headers and the color palette that you see in this document as such:

/* _src/common.typ @ ll. 4-5 */
// Concretely this expands to <link rel="stylesheet" href="_assets/global.css">
#css.include-file("_assets/global.css")
/* _assets/global.css @ ll. 32-57 */
html {
  background-color: var(--black);
}
body {
  margin: 40px auto;
  /* TODO: get something working if on mobile */
  max-width: 1200px;
  line-height: 1.5;
  font-size: 18px;
  font-weight: 350;
  color: var(--white);
  background: var(--dk-gray0);
  padding: 1cm 5mm 10cm 5mm;
}

h1,h2,h3,h4 {
  line-weight: 1.2;
  color: var(--lt-green);
}
h1 { font-size: 33px }
h2 { font-size: 31px }
h3 { font-size: 29px }
h4 { font-size: 27px }

a { color: var(--dk-aqua) }
a:visited { color: var(--lt-purple) }

(see global.css if you're curious where the colors are defined)

On-the-fly global

HTML is not limited to a single global <style> declaration, so this can be used also to set more properties after the fact. For example let us define my-style as such:

/* _src/index.typ @ ll. 175-181 */
#let my-style = (
  color: "var(--black)",
  background: "var(--dk-blue)",
  border-radius: 3pt,
  display: "inline-block",
  padding: 5pt,
)

Then we can for example bind it to the .on-the-fly class by:

/* _src/index.typ @ ll. 188-191 */
#css.elem(".on-the-fly", my-style)
#xhtml.div(class: "on-the-fly", {
  [Black on blue (on the fly)]
})
Black on blue (on the fly)

Inline style

Additionally, HTML elements accept a style parameter in which you can put CSS.

/* _src/index.typ @ ll. 200-202 */
#xhtml.div(class: "inlined", style: css.raw-style(my-style), {
  [Black on blue (inlined)]
})
Black on blue (inlined)

Dynamic

As for much more complex styling, you can always resort to calling external JS code. In this document, this is the method I use to provide syntax highlighting for code snippets through highlight.js:

/* _src/common.typ @ ll. 9-23 */
// Unpacked the archive from https://highlightjs.org/download to _highlight/
#xhtml.script(src: "_highlight/highlight.min.js")
#css.include-file("_highlight/styles/base16/gruvbox-dark-soft.css")

// Copied from https://www.npmjs.com/package/@myriaddreamin/highlighter-typst
// the "cjs, js bundled, wasm bundled" script
#xhtml.script(src: "_highlight/highlight-typst.js")

// As soon as the scripts have loaded, highlight all code blocks.
#xhtml.script("
  const run = window.$typst$parserModule.then(() => {
    hljs.registerLanguage('typst', window.hljsTypst({}))
    hljs.highlightAll();
  });
")

Document layout

Here I offer some very common functions that help set the layout. I've found that often the appearance is easy to set as just raw CSS, but the layout (centered / horizontal / vertical / grid / …) is cumbersome. Here are functions that should help! See struct.typ for the definition of these functions. The goal is that these functions should provide as close an interface to the real Typst version as possible.

/* _src/index.typ @ l. 226 */
#import struct: text, box, table, align

Text

You can control the style of text just like you would in Typst:

/* _src/index.typ @ l. 240 */
#text(fill: "var(--dk-red)", size: 50pt)[*Some text*]

Some text

(reminder: you can do color definitions like this)

Box

struct.box tries to mimic to some extent the behavior of Typst's box. It supports automatic or fixed width and height, rounded corners, background color.

/* _src/index.typ @ ll. 252-257 */
#box(width: 60%, inset: 10pt, outset: 2mm, radius: 10pt, fill: "var(--dk-purple)", {
  text(fill: "var(--black)")[
    A box \
    with round corners
  ]
})
A box
with round corners

You can even specify corners, margins, and stroke as dictionaries the same way you would do for a regular Typst box.

/* _src/index.typ @ ll. 265-274 */
#box(fill: "var(--dk-gray3)",
  radius: (bottom-left: 1cm, bottom: 3mm),
  stroke: 3pt,
)[
  #box(fill: "var(--dk-aqua)",
    inset: (x: 5mm, y: 2mm), outset: (x: 1cm, y: 2mm),
    radius: (top-left: 5mm, bottom-right: 0, rest: 2mm),
    stroke: (top: green, left: 3pt, right: (paint: red, thickness: 4pt)),
  )[Inner]
]
Inner

Table

Partially mimicking Typst's table function, we have struct.table. Note that this version does not have a stroke, it's only a grid layout.

/* _src/index.typ @ ll. 285-290 */
#let cell = box(fill: "var(--dk-gray1)", height: "100px")[A cell]
#table(
  columns: 3, gutter: "15px", {
    for elt in range(7).map(_ => cell) { elt }
  },
)
A cell
A cell
A cell
A cell
A cell
A cell
A cell

Side note: style builders

If you look at the generated HTML for the code above, you will see that the inline style for cell is repeated as many times as there are cells. Here I propose a mechanic to cut down on this repetition.

All functions defined in struct.typ offer another behavior when passed the parameter inline: false. Whereas box(...) will construct a box, box(inline: false, class: "box-name", ...) will instead return a box-builder function that will lazily declare the required CSS at most once.

/* _src/index.typ @ ll. 307-316 */
#let orange-box = box(
  inline: false, class: "orange-box",
  fill: "var(--dk-orange)", radius: 5pt, width: "fit-content", outset: 5pt,
)
#orange-box[These]
#orange-box[boxes]
#orange-box[share]
#orange-box[the]
#orange-box[same]
#orange-box[style.]
These
boxes
share
the
same
style.

and now the corresponding CSS is not duplicated, resulting in a smaller HTML output! In fact, the CSS style is included only if necessary, and at most once.

You can still overwrite on-the-fly some elements:

/* _src/index.typ @ ll. 324-325 */
#orange-box(inset: 10pt)[Add margins to existing style.]
#orange-box(radius: 0pt)[Remove the rouded corners.]
Add margins to existing style.
Remove the rouded corners.

Alignment

This mimics Typst's align function.

/* _src/index.typ @ ll. 334-353 */
#let gray-box = box(
  inline: false, class: "cell",
  fill: "var(--dk-gray1)", height: 3cm, outset: 3pt, inset: 5pt,
)
#let my-aligned-box(alignment, inner) = {
  gray-box({
    align(alignment)[#inner]
  })
}
#table(columns: 3, gutter: 3mm, {
  my-aligned-box(top + left)[Top left]
  my-aligned-box(top)[Top]
  my-aligned-box(top + right)[Top right]
  my-aligned-box(left)[Left]
  my-aligned-box(center)[Center]
  my-aligned-box(right)[Right]
  my-aligned-box(left + bottom)[Bottom left]
  my-aligned-box(bottom)[Bottom]
  my-aligned-box(right + bottom)[Bottom right]
})
Top left
Top
Top right
Left
Center
Right
Bottom left
Bottom
Bottom right

Side note: lengths

Thanks to a type-based translation from Typst to CSS, you can actually use any of Typst's length types wherever the CSS expects a length. In addition, you can also directly use whatever string is valid CSS for a length, e.g. in pixels which is not a valid Typst unit of length.

/* _src/index.typ @ ll. 365-379 */
#let as-len(l) = if type(l) == array { l.at(0) } else { l }
#let as-repr(l) = if type(l) == array { l.at(1) } else { raw(repr(l)) }
#let lengths = (
  100%, 25%, 100pt, (5cm, `5cm`), (50% + 1cm, `50% + 1cm`),
  50%, (50% - 1cm, `50% - 1cm`), 50% + 3em, (50% + 3em + 1cm, `50% + 3em + 1cm`),
  "300px", "calc(50% + 200px)",
)
#box(fill: "var(--dk-gray2)", width: 100%,
  align(left, {
    for l in lengths {
      orange-box(width: as-len(l))[#as-repr(l)]
      struct.box-linebreak // A regular linebreak wouldn't work here, unfortunately.
    }
  })
)
100%
25%
100pt
5cm
50% + 1cm
50%
50% - 1cm
50% + 3em
50% + 3em + 1cm
"300px"
"calc(50% + 200px)"

Side note: colors

In the same way, there are multiple methods to define colors

/* _src/index.typ @ ll. 394-410 */
#let as-color(c) = if type(c) == array { c.at(0) } else { c }
#let as-repr(c) = if type(c) == array { c.at(1) } else { raw(repr(c)) }
#let colors = (
  (aqua, `aqua`), (rgb(10, 50, 200), `rgb(10, 50, 200)`),
  (rgb(80%, 50%, 5%), `rgb(80%, 50%, 5%)`), rgb("#aaaaff"),
  (luma(50%), `luma(50%)`), (color.hsv(60deg, 50%, 30%), `color.hsv(60deg, 50%, 30%)`),
  (red.negate(), `red.negate()`),
  (red.darken(50%), `red.darken(50%)`), (blue.transparentize(80%), `blue.transparentize(80%)`),
  "#fa1419", "blue", "YellowGreen", "var(--dk-orange)", "rgb(200, 30, 10)",
  "rgba(200, 30, 10, 0.5)", "hsl(110, 80%, 30%)",
)
#let cbox = box(inline: false, class: "cbox", inset: 1mm, outset: 1mm, radius: 1mm)
#box(fill: "var(--black)", width: 100%,
  for c in colors {
    cbox(fill: as-color(c))[#as-repr(c)]
  }
)
aqua
rgb(10, 50, 200)
rgb(80%, 50%, 5%)
rgb("#aaaaff")
luma(50%)
color.hsv(60deg, 50%, 30%)
red.negate()
red.darken(50%)
blue.transparentize(80%)
"#fa1419"
"blue"
"YellowGreen"
"var(--dk-orange)"
"rgb(200, 30, 10)"
"rgba(200, 30, 10, 0.5)"
"hsl(110, 80%, 30%)"

Side note: hover

When you bind a style to a class, you can manually insert :hover properties:

/* _src/index.typ @ ll. 421-434 */
#let gray-box = box(inline: false, class: "highlightable",
  fill: "var(--dk-gray2)", inset: 5mm,
)
#css.elems((
  ".highlightable": (
    transition: "0.3s",
  ),
  ".highlightable:hover": struct.box-style((:),
    fill: "var(--dk-red)",
    radius: (top-left: 5mm),
  )
))

#gray-box[Hover over me]
Hover over me

Spacing

This is

a test

This is

another test.

More coming soon…

I will keep updating this page occasionally if I find an interesting trick, or simply to implement more features. I have plans for:

Don't hesitate to browse the source code in more detail, either here or on the repo.

If you have suggestions you can open an issue or pull request.