diff --git a/quartz.config.ts b/quartz.config.ts
index 65539a8..3a1d433 100644
--- a/quartz.config.ts
+++ b/quartz.config.ts
@@ -1,21 +1,6 @@
 import { QuartzConfig } from "./quartz/cfg"
-import Body from "./quartz/components/Body"
-import Darkmode from "./quartz/components/Darkmode"
-import Head from "./quartz/components/Head"
-import PageTitle from "./quartz/components/PageTitle"
-import Spacer from "./quartz/components/Spacer"
-import {
-  ContentPage,
-  CreatedModifiedDate,
-  Description,
-  FrontMatter,
-  GitHubFlavoredMarkdown,
-  Katex,
-  ObsidianFlavoredMarkdown,
-  RemoveDrafts,
-  ResolveLinks,
-  SyntaxHighlighting
-} from "./quartz/plugins"
+import * as Component from "./quartz/components"
+import * as Plugin from "./quartz/plugins"
 
 const config: QuartzConfig = {
   configuration: {
@@ -54,25 +39,26 @@ const config: QuartzConfig = {
   },
   plugins: {
     transformers: [
-      new FrontMatter(),
-      new Katex(),
-      new Description(),
-      new CreatedModifiedDate({
+      new Plugin.FrontMatter(),
+      new Plugin.Description(),
+      new Plugin.TableOfContents({ showByDefault: true }),
+      new Plugin.CreatedModifiedDate({
         priority: ['frontmatter', 'filesystem'] // you can add 'git' here for last modified from Git but this makes the build slower
       }),
-      new SyntaxHighlighting(),
-      new GitHubFlavoredMarkdown(),
-      new ObsidianFlavoredMarkdown(),
-      new ResolveLinks(),
+      new Plugin.GitHubFlavoredMarkdown(),
+      new Plugin.ObsidianFlavoredMarkdown(),
+      new Plugin.ResolveLinks(),
+      new Plugin.SyntaxHighlighting(),
+      new Plugin.Katex(),
     ],
     filters: [
-      new RemoveDrafts()
+      new Plugin.RemoveDrafts()
     ],
     emitters: [
-      new ContentPage({
-        head: Head,
-        header: [PageTitle, Spacer, Darkmode],
-        body: Body
+      new Plugin.ContentPage({
+        head: Component.Head,
+        header: [Component.PageTitle, Component.Spacer, Component.Darkmode],
+        body: [Component.ArticleTitle, Component.ReadingTime, Component.TableOfContents, Component.Content]
       })
     ]
   },
diff --git a/quartz/components/ArticleTitle.tsx b/quartz/components/ArticleTitle.tsx
new file mode 100644
index 0000000..02725c6
--- /dev/null
+++ b/quartz/components/ArticleTitle.tsx
@@ -0,0 +1,11 @@
+import { QuartzComponentProps } from "./types"
+
+export default function ArticleTitle({ fileData }: QuartzComponentProps) {
+  const title = fileData.frontmatter?.title
+  const displayTitle = fileData.slug === "index" ? undefined : title
+  if (displayTitle) {
+    return <h1>{displayTitle}</h1>
+  } else {
+    return null
+  }
+}
diff --git a/quartz/components/Body.tsx b/quartz/components/Body.tsx
index 92e6682..b8ad34b 100644
--- a/quartz/components/Body.tsx
+++ b/quartz/components/Body.tsx
@@ -2,13 +2,8 @@ import clipboardScript from './scripts/clipboard.inline'
 import clipboardStyle from './styles/clipboard.scss'
 import { QuartzComponentProps } from "./types"
 
-export default function Body({ fileData, children }: QuartzComponentProps) {
-  const title = fileData.frontmatter?.title
-  const displayTitle = fileData.slug === "index" ? undefined : title
+export default function Body({ children }: QuartzComponentProps) {
   return <article>
-    <div class="top-section">
-      {displayTitle && <h1>{displayTitle}</h1>}
-    </div>
     {children}
   </article>
 }
diff --git a/quartz/components/Content.tsx b/quartz/components/Content.tsx
new file mode 100644
index 0000000..c010f2a
--- /dev/null
+++ b/quartz/components/Content.tsx
@@ -0,0 +1,9 @@
+import { QuartzComponentProps } from "./types"
+import { Fragment, jsx, jsxs } from 'preact/jsx-runtime'
+import { toJsxRuntime } from "hast-util-to-jsx-runtime"
+
+export default function Content({ tree }: QuartzComponentProps) {
+  // @ts-ignore (preact makes it angry)
+  const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
+  return content
+}
diff --git a/quartz/components/Header.tsx b/quartz/components/Header.tsx
index 8eb2d70..8b06863 100644
--- a/quartz/components/Header.tsx
+++ b/quartz/components/Header.tsx
@@ -1,4 +1,3 @@
-import style from './styles/header.scss'
 import { QuartzComponentProps } from "./types"
 
 export default function Header({ children }: QuartzComponentProps) {
@@ -7,4 +6,18 @@ export default function Header({ children }: QuartzComponentProps) {
   </header>
 }
 
-Header.css = style
+Header.css = `
+header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin: 1em 0 2em 0;
+  & > h1 {
+  }
+}
+
+header > h1 {
+  margin: 0;
+  flex: auto;
+}
+`
diff --git a/quartz/components/ReadingTime.tsx b/quartz/components/ReadingTime.tsx
new file mode 100644
index 0000000..39110f9
--- /dev/null
+++ b/quartz/components/ReadingTime.tsx
@@ -0,0 +1,20 @@
+import { QuartzComponentProps } from "./types"
+import readingTime from "reading-time"
+
+export default function ReadingTime({ fileData }: QuartzComponentProps) {
+  const text = fileData.text
+  const isHomePage = fileData.slug === "index"
+  if (text && !isHomePage) {
+    const { text: timeTaken, words } = readingTime(text)
+    return <p class="reading-time">{words} words, {timeTaken}</p>
+  } else {
+    return null
+  }
+}
+
+ReadingTime.css = `
+.reading-time {
+  margin-top: 0;
+  opacity: 0.5;
+}
+`
diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx
new file mode 100644
index 0000000..8192da4
--- /dev/null
+++ b/quartz/components/TableOfContents.tsx
@@ -0,0 +1,24 @@
+import { QuartzComponentProps } from "./types"
+import style from "./styles/toc.scss"
+
+export default function TableOfContents({ fileData, position }: QuartzComponentProps) {
+  if (!fileData.toc) {
+    return null
+  }
+
+  if (position === 'body') {
+    // TODO: animate this
+    return <details className="toc" open>
+      <summary><h3>Table of Contents</h3></summary>
+      <ul>
+        {fileData.toc.map(tocEntry => <li key={tocEntry.slug} className={`depth-${tocEntry.depth}`}>
+          <a href={`#${tocEntry.slug}`}>{tocEntry.text}</a>
+        </li>)}
+      </ul>
+    </details>
+  } else if (position === 'sidebar') {
+    // TODO
+  }
+}
+
+TableOfContents.css = style
diff --git a/quartz/components/index.ts b/quartz/components/index.ts
new file mode 100644
index 0000000..5fde7c3
--- /dev/null
+++ b/quartz/components/index.ts
@@ -0,0 +1,19 @@
+import ArticleTitle from "./ArticleTitle"
+import Content from "./Content"
+import Darkmode from "./Darkmode"
+import Head from "./Head"
+import PageTitle from "./PageTitle"
+import ReadingTime from "./ReadingTime"
+import Spacer from "./Spacer"
+import TableOfContents from "./TableOfContents"
+
+export {
+  ArticleTitle,
+  Content,
+  Darkmode,
+  Head,
+  PageTitle,
+  ReadingTime,
+  Spacer,
+  TableOfContents
+} 
diff --git a/quartz/components/styles/header.scss b/quartz/components/styles/header.scss
deleted file mode 100644
index c3ea487..0000000
--- a/quartz/components/styles/header.scss
+++ /dev/null
@@ -1,10 +0,0 @@
-header {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  margin: 1em 0 2em 0;
-  & > h1 {
-    margin: 0;
-    flex: auto;
-  }
-}
diff --git a/quartz/components/styles/toc.scss b/quartz/components/styles/toc.scss
new file mode 100644
index 0000000..33b9cca
--- /dev/null
+++ b/quartz/components/styles/toc.scss
@@ -0,0 +1,27 @@
+details.toc {
+  & summary {
+    cursor: pointer;
+
+    &::marker {
+      color: var(--dark);
+    }
+
+    & > * {
+      padding-left: 0.25rem;
+      display: inline-block;
+      margin: 0;
+    }
+  }
+    
+  & ul {
+    list-style: none;
+    margin: 0.5rem 1.25rem;
+    padding: 0;
+  }
+
+  @for $i from 1 through 6 {
+    & .depth-#{$i} {
+      padding-left: calc(1rem * #{$i});
+    }
+  }
+}
diff --git a/quartz/components/types.ts b/quartz/components/types.ts
index 8d7a79c..93f6a4b 100644
--- a/quartz/components/types.ts
+++ b/quartz/components/types.ts
@@ -2,12 +2,15 @@ import { ComponentType, JSX } from "preact"
 import { StaticResources } from "../resources"
 import { QuartzPluginData } from "../plugins/vfile"
 import { GlobalConfiguration } from "../cfg"
+import { Node } from "hast"
 
 export type QuartzComponentProps = {
   externalResources: StaticResources
   fileData: QuartzPluginData
   cfg: GlobalConfiguration
   children: QuartzComponent[] | JSX.Element[]
+  tree: Node<QuartzPluginData>
+  position?: 'sidebar' | 'header' | 'body'
 }
 
 export type QuartzComponent = ComponentType<QuartzComponentProps> & {
diff --git a/quartz/path.ts b/quartz/path.ts
index aa3870b..bece770 100644
--- a/quartz/path.ts
+++ b/quartz/path.ts
@@ -1,7 +1,7 @@
 import path from 'path'
 import SlugAnchor from 'github-slugger'
 
-const slugAnchor = new SlugAnchor()
+export const slugAnchor = new SlugAnchor()
 
 function slugSegment(s: string): string {
   return s.replace(/\s/g, '-')
diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx
index 2ab914c..d44b709 100644
--- a/quartz/plugins/emitters/contentPage.tsx
+++ b/quartz/plugins/emitters/contentPage.tsx
@@ -1,19 +1,18 @@
-import { toJsxRuntime } from "hast-util-to-jsx-runtime"
 import { StaticResources } from "../../resources"
 import { EmitCallback, QuartzEmitterPlugin } from "../types"
 import { ProcessedContent } from "../vfile"
-import { Fragment, jsx, jsxs } from 'preact/jsx-runtime'
 import { render } from "preact-render-to-string"
 import { GlobalConfiguration } from "../../cfg"
 import { QuartzComponent } from "../../components/types"
 import { resolveToRoot } from "../../path"
 import Header from "../../components/Header"
 import { QuartzComponentProps } from "../../components/types"
+import Body from "../../components/Body"
 
 interface Options {
   head: QuartzComponent
   header: QuartzComponent[],
-  body: QuartzComponent
+  body: QuartzComponent[]
 }
 
 export class ContentPage extends QuartzEmitterPlugin {
@@ -26,17 +25,14 @@ export class ContentPage extends QuartzEmitterPlugin {
   }
 
   getQuartzComponents(): QuartzComponent[] {
-    return [this.opts.head, Header, ...this.opts.header, this.opts.body]
+    return [this.opts.head, Header, ...this.opts.header, ...this.opts.body]
   }
 
   async emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emit: EmitCallback): Promise<string[]> {
     const fps: string[] = []
 
-    const { head: Head, header, body: Body } = this.opts
+    const { head: Head, header, body } = this.opts
     for (const [tree, file] of content) {
-      // @ts-ignore (preact makes it angry)
-      const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
-
       const baseDir = resolveToRoot(file.data.slug!)
       const pageResources: StaticResources = {
         css: [baseDir + "/index.css", ...resources.css],
@@ -51,7 +47,8 @@ export class ContentPage extends QuartzEmitterPlugin {
         fileData: file.data,
         externalResources: pageResources,
         cfg,
-        children: [content]
+        children: [],
+        tree
       }
 
       const doc = <html>
@@ -59,10 +56,10 @@ export class ContentPage extends QuartzEmitterPlugin {
         <body>
           <div id="quartz-root" class="page">
             <Header {...componentData} >
-              {header.map(HeaderComponent => <HeaderComponent {...componentData}/>)}
+              {header.map(HeaderComponent => <HeaderComponent {...componentData} position="header" />)}
             </Header>
             <Body {...componentData}>
-              {content}
+              {body.map(BodyComponent => <BodyComponent {...componentData } position="body" />)}
             </Body>
           </div>
         </body>
diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts
index fa59799..b24dd1c 100644
--- a/quartz/plugins/transformers/description.ts
+++ b/quartz/plugins/transformers/description.ts
@@ -15,7 +15,7 @@ export class Description extends QuartzTransformerPlugin {
   name = "Description"
   opts: Options
 
-  constructor(opts?: Options) {
+  constructor(opts?: Partial<Options>) {
     super()
     this.opts = { ...defaultOptions, ...opts }
   }
diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts
index 778faac..0baec9e 100644
--- a/quartz/plugins/transformers/frontmatter.ts
+++ b/quartz/plugins/transformers/frontmatter.ts
@@ -17,7 +17,7 @@ export class FrontMatter extends QuartzTransformerPlugin {
   name = "FrontMatter"
   opts: Options
 
-  constructor(opts?: Options) {
+  constructor(opts?: Partial<Options>) {
     super()
     this.opts = { ...defaultOptions, ...opts }
   }
diff --git a/quartz/plugins/transformers/gfm.ts b/quartz/plugins/transformers/gfm.ts
index dd6bdec..72f9870 100644
--- a/quartz/plugins/transformers/gfm.ts
+++ b/quartz/plugins/transformers/gfm.ts
@@ -19,7 +19,7 @@ export class GitHubFlavoredMarkdown extends QuartzTransformerPlugin {
   name = "GitHubFlavoredMarkdown"
   opts: Options
 
-  constructor(opts?: Options) {
+  constructor(opts?: Partial<Options>) {
     super()
     this.opts = { ...defaultOptions, ...opts }
   }
diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts
index 492a988..51aaa34 100644
--- a/quartz/plugins/transformers/index.ts
+++ b/quartz/plugins/transformers/index.ts
@@ -6,3 +6,4 @@ export { Description } from './description'
 export { ResolveLinks } from './links'
 export { ObsidianFlavoredMarkdown } from './ofm'
 export { SyntaxHighlighting } from './syntax'
+export { TableOfContents } from './toc'
diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts
index 95b5455..ef33afe 100644
--- a/quartz/plugins/transformers/lastmod.ts
+++ b/quartz/plugins/transformers/lastmod.ts
@@ -16,7 +16,7 @@ export class CreatedModifiedDate extends QuartzTransformerPlugin {
   name = "CreatedModifiedDate"
   opts: Options
 
-  constructor(opts?: Options) {
+  constructor(opts?: Partial<Options>) {
     super()
     this.opts = {
       ...defaultOptions,
diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts
index d8248a0..4bcbe82 100644
--- a/quartz/plugins/transformers/links.ts
+++ b/quartz/plugins/transformers/links.ts
@@ -21,7 +21,7 @@ export class ResolveLinks extends QuartzTransformerPlugin {
   name = "LinkProcessing"
   opts: Options
 
-  constructor(opts?: Options) {
+  constructor(opts?: Partial<Options>) {
     super()
     this.opts = { ...defaultOptions, ...opts }
   }
diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts
index 691a132..23ed37c 100644
--- a/quartz/plugins/transformers/ofm.ts
+++ b/quartz/plugins/transformers/ofm.ts
@@ -93,7 +93,7 @@ export class ObsidianFlavoredMarkdown extends QuartzTransformerPlugin {
   name = "ObsidianFlavoredMarkdown"
   opts: Options
 
-  constructor(opts?: Options) {
+  constructor(opts?: Partial<Options>) {
     super()
     this.opts = { ...defaultOptions, ...opts }
   }
diff --git a/quartz/plugins/transformers/toc.ts b/quartz/plugins/transformers/toc.ts
new file mode 100644
index 0000000..863e3a1
--- /dev/null
+++ b/quartz/plugins/transformers/toc.ts
@@ -0,0 +1,72 @@
+import { PluggableList } from "unified"
+import { QuartzTransformerPlugin } from "../types"
+import { Root } from "mdast"
+import { visit } from "unist-util-visit"
+import { toString } from "mdast-util-to-string"
+import { slugAnchor } from "../../path"
+
+export interface Options {
+  maxDepth: 1 | 2 | 3 | 4 | 5 | 6,
+  minEntries: 1,
+  showByDefault: boolean
+}
+
+const defaultOptions: Options = {
+  maxDepth: 3,
+  minEntries: 1,
+  showByDefault: true,
+}
+
+interface TocEntry {
+  depth: number,
+  text: string,
+  slug: string
+}
+
+export class TableOfContents extends QuartzTransformerPlugin {
+  name = "TableOfContents"
+  opts: Options
+
+  constructor(opts?: Partial<Options>) {
+    super()
+    this.opts = { ...defaultOptions, ...opts }
+  }
+
+  markdownPlugins(): PluggableList {
+    return [() => {
+      return async (tree: Root, file) => {
+        const display = file.data.frontmatter?.enableToc ?? this.opts.showByDefault
+        if (display) {
+          const toc: TocEntry[] = []
+          let highestDepth: number = this.opts.maxDepth
+          visit(tree, 'heading', (node) => {
+            if (node.depth <= this.opts.maxDepth) {
+              const text = toString(node)
+              highestDepth = Math.min(highestDepth, node.depth)
+              toc.push({
+                depth: node.depth,
+                text,
+                slug: slugAnchor.slug(text)
+              })
+            }
+          })
+
+          if (toc.length > this.opts.minEntries) {
+            file.data.toc = toc.map(entry => ({ ...entry, depth: entry.depth - highestDepth }))
+          }
+        }
+      }
+    }]
+  }
+
+  htmlPlugins(): PluggableList {
+    return []
+  }
+}
+
+declare module 'vfile' {
+  interface DataMap {
+    toc: TocEntry[]
+  }
+}
+