diff --git a/content/echoes/arcaea.dj b/content/echoes/arcaea.dj
index 739ebbf..0f36505 100644
--- a/content/echoes/arcaea.dj
+++ b/content/echoes/arcaea.dj
@@ -10,8 +10,14 @@ A discussion on pretty much every aspect of the mobile rhythm game "Arcaea".
 
 # Why I love arcaea
 
-## What is arcaea
-- explain the base mechanics
+## Introduction
+
+- What is arcaea
+- What this article is
+- How to read this article
+
+::: toc
+:::
 
 ## What makes a good rhythm game
 - I don't need to reinvent the wheel here, I can link that one `mental checkpoint` video
diff --git a/src/html.rs b/src/html.rs
index 837bee8..2836d20 100644
--- a/src/html.rs
+++ b/src/html.rs
@@ -1,3 +1,4 @@
+use std::cmp::Ordering;
 use std::collections::HashMap;
 use std::fmt::Display;
 
@@ -311,6 +312,64 @@ impl<'s> Writer<'s> {
 						self.states.push(State::Ignore);
 					}
 					// }}}
+					// {{{ Table of contents
+					Container::Div { class: "toc" } => {
+						template!("templates/table-of-contents.html", out)?.feed_fully(
+							out,
+							|label, out| {
+								if label == "content" {
+									let mut level_stack = Vec::with_capacity(6);
+									level_stack.push(1);
+
+									for (i, heading) in self.metadata.toc.iter().enumerate() {
+										loop {
+											let level = level_stack.last().unwrap();
+											match heading.level.cmp(level) {
+												Ordering::Greater => {
+													writeln!(out, "<ol>")?;
+													level_stack.push(heading.level);
+													break;
+												}
+												Ordering::Equal => {
+													if i != 0 {
+														writeln!(out, "</li>")?;
+													}
+
+													break;
+												}
+												Ordering::Less => {
+													writeln!(out, "</li></ol>")?;
+													level_stack.pop();
+												}
+											}
+										}
+
+										write!(out, r##"<li><a href="#{}">"##, heading.id)?;
+
+										for event in &heading.events {
+											self.render_event(event, out)?;
+										}
+
+										writeln!(out, "</a>")?;
+									}
+
+									for _ in 0..level_stack.len() - 1 {
+										writeln!(out, "</li></ol>")?;
+									}
+
+									writeln!(out, "</li>")?;
+
+									Ok(true)
+								} else {
+									Ok(false)
+								}
+							},
+						)?;
+
+						// We don't care about the contents of this block
+						self.states.push(State::Ignore);
+					}
+					// }}}
 					// {{{ Div
 					Container::Div { class } => {
 						if has_role(attrs, "description") {
diff --git a/src/template.rs b/src/template.rs
index fac862a..489ddf1 100644
--- a/src/template.rs
+++ b/src/template.rs
@@ -129,6 +129,7 @@ impl<'a> TemplateRenderer<'a> {
 	}
 	// }}}
 
+	/// Automatically fill in placeholders until the provided lambda returns false.
 	pub fn feed<W: std::fmt::Write>(
 		&mut self,
 		out: &mut W,
@@ -144,6 +145,18 @@ impl<'a> TemplateRenderer<'a> {
 
 		Ok(())
 	}
+
+	/// Equivalent to running [Self::feed] and then [Self::finish].
+	pub fn feed_fully<W: std::fmt::Write>(
+		mut self,
+		out: &mut W,
+		f: impl FnMut(&str, &mut W) -> anyhow::Result<bool>,
+	) -> anyhow::Result<()> {
+		self.feed(out, f)?;
+		self.finish(out)?;
+
+		Ok(())
+	}
 }
 // }}}
 // {{{ Macro
diff --git a/src/templates/table-of-contents.html b/src/templates/table-of-contents.html
new file mode 100644
index 0000000..f27d8de
--- /dev/null
+++ b/src/templates/table-of-contents.html
@@ -0,0 +1,9 @@
+<details>
+  <summary>Toggle table of contents</summary>
+  <nav role="doc-toc" aria-labelledby="toc-title">
+    <h2 id="toc-title">Table of Contents</h2>
+    <ol>
+      {{content}}
+    </ol>
+  </nav>
+</details>