diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 951e59f4560e648f0251fa8ea7850d4e763af4db..ac0bf3c92caf825df605e1f770096da79747c06d 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -17,7 +17,7 @@ pages:
   script: yarn docs:build
   artifacts:
     paths:
-      - pages
+      - public
 
 test:
   script: yarn test
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 1317c7615a17cf9fef44a1396775bff201d99be5..b2955ae134f045362d32d105a7c99f7b525e9e13 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -34,6 +34,7 @@ export default defineConfig({
             { text: "Pagination", link: "/components/pagination/" },
             { text: "Textarea", link: "/components/textarea/" },
           ] },
+			    { text: "Table of Contents", link: "/components/toc/" },
         ]
       }
 		]
diff --git a/docs/components/toc/index.md b/docs/components/toc/index.md
new file mode 100644
index 0000000000000000000000000000000000000000..884d5071d9bf85b5253315d144e43c12b3543e31
--- /dev/null
+++ b/docs/components/toc/index.md
@@ -0,0 +1,53 @@
+# Table of Contents
+## Default
+By default table of contents is based on the `<h1>` tags
+```html
+<fw-toc>
+  <h1>This is a Table of Contents</h1>
+  Content...
+
+  <h1>It automatically generates from headings</h1>
+  More content...
+</fw-toc>
+```
+<fw-toc>
+  <h1>This is a Table of Contents</h1>
+  <p>In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.</p>
+
+  <p>Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.</p>
+
+  <p>Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.</p>
+
+  <h1>It automatically generates from headings</h1>
+  <p>In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.</p>
+
+  <p>Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.</p>
+
+  <p>Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.</p>
+</fw-toc>
+
+## Custom headings
+```html
+<fw-toc heading="h2">
+  <h1>This is a Table of Contents</h1>
+  Content...
+
+  <h2>It automatically generates from headings</h2>
+  More content...
+</fw-toc>
+```
+<fw-toc heading="h2">
+  <h1>This is a Table of Contents</h1>
+  <p>In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.</p>
+
+  <p>Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.</p>
+
+  <p>Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.</p>
+
+  <h2>It automatically generates from headings</h2>
+  <p>In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.</p>
+
+  <p>Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.</p>
+
+  <p>Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.</p>
+</fw-toc>
diff --git a/package.json b/package.json
index a9809ac222df44cdeddca6b904a35bb730850450..1b6bdb50529f9a8cb391b36e3af94ba199e8bc94 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
 		"@vueuse/core": "^9.2.0",
 		"dompurify": "^2.4.0",
 		"showdown": "^2.1.0",
+		"transliteration": "^2.3.5",
 		"vue": "^3.2.38"
 	},
 	"devDependencies": {
diff --git a/src/components/index.ts b/src/components/index.ts
index 545e4da70a2defb9b543ce42a6c2302e2fdca3ee..9e9df95137658b66de6cfca66c964f2cfc92de3a 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -28,6 +28,9 @@ export { default as FwInput } from './input/Input.vue'
 // Pills
 export { default as FwPill } from './pill/Pill.vue'
 
+// Toc
+export { default as FwToc } from './toc/Toc.vue'
+
 // Utils
 export { default as FwSanitizedHtml } from './utils/SanitizedHtml.vue'
 export { default as FwMarkdown } from './utils/Markdown.vue'
diff --git a/src/components/textarea/style.scss b/src/components/textarea/style.scss
index f7a53f1230ec316be157795b76284e2f02f72c19..946b94eebf7546db197250ea2be31a9e5b2b9211 100644
--- a/src/components/textarea/style.scss
+++ b/src/components/textarea/style.scss
@@ -62,7 +62,7 @@
       font-family: monospace;
       background: transparent;
 
-      &:empty {
+      &:placeholder-shown {
         font-family: $font-main;
       }
     }
diff --git a/src/components/toc/Toc.vue b/src/components/toc/Toc.vue
new file mode 100644
index 0000000000000000000000000000000000000000..178f8bf745819d936a58bdeed34d1ffef5d44eb3
--- /dev/null
+++ b/src/components/toc/Toc.vue
@@ -0,0 +1,61 @@
+<script setup lang="ts">
+import { computed, ref, watchEffect } from 'vue'
+import { slugify } from 'transliteration'
+import { useScroll } from '@vueuse/core';
+
+interface Props {
+  heading?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
+}
+
+const props = withDefaults(defineProps<Props>(), { heading: 'h1' })
+
+const toc = ref()
+
+const headings = computed(() => toc.value?.querySelectorAll(props.heading) ?? [])
+watchEffect(() => {
+  for (const heading of headings.value) {
+    heading.id = slugify(heading.textContent)
+  }
+})
+
+const activeLink = ref()
+const { y } = useScroll(window)
+watchEffect(() => {
+  let lastActive = headings.value[0]
+  for (const heading of headings.value) {
+    if (y.value > heading.offsetTop) {
+      lastActive = heading
+    }
+  }
+
+  activeLink.value = lastActive?.id
+})
+</script>
+
+<template>
+	<div
+    ref="toc"
+    class="funkwhale toc"
+  >
+    <div class="toc-content">
+      <slot />
+    </div>
+
+    <div class="toc-toc">
+      <div class="toc-links">
+        <a
+          v-for="h of headings"
+          :key="h.id"
+          :class="{ 'is-active': activeLink === h.id }"
+          @click.prevent="h.scrollIntoView({ behavior: 'smooth' })"
+        >
+          {{ h.textContent }}
+      </a>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss">
+@import './style.scss'
+</style>
diff --git a/src/components/toc/style.scss b/src/components/toc/style.scss
new file mode 100644
index 0000000000000000000000000000000000000000..b4a0e819aae5c60002123ad087abaeb17db0fc00
--- /dev/null
+++ b/src/components/toc/style.scss
@@ -0,0 +1,42 @@
+.funkwhale {
+  &.toc {
+    display: grid;
+    grid-template-columns: 1fr 280px;
+    gap: 1rem;
+
+    > .toc-toc {
+      > .toc-links {
+        position: sticky;
+        top: 0;
+
+        .VPContent & {
+          top: 72px;
+        }
+
+        > a {
+          --fw-link-color: var(--fw-text-color);
+
+          &:hover {
+            --fw-link-color: var(--fw-text-color) !important;
+          }
+
+          padding: 4px 8px;
+          display: block;
+
+          box-shadow: inset 1px 0 0 var(--fw-grey-200);
+
+          &.is-active {
+            --fw-link-color: var(--fw-secondary);
+
+            &:hover {
+              --fw-link-color: var(--fw-secondary) !important;
+            }
+
+            box-shadow: inset 3px 0 0 var(--fw-secondary);
+            font-weight: bold
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/src/styles/colors.scss b/src/styles/colors.scss
index db891a64d51e5258f9f376ca8a3c123df896d7d3..565c794bce334235706de53877f4056be841e605 100644
--- a/src/styles/colors.scss
+++ b/src/styles/colors.scss
@@ -60,6 +60,7 @@
 
   // Override Bulma
   --fw-primary: var(--fw-blue-500);
+  --fw-secondary: #ff6600;
   --fw-destructive: var(--fw-red-500);
 }
 
diff --git a/yarn.lock b/yarn.lock
index 7e50f6f644731eaf3382b1fbb2b4f7f3f589effd..77092670229a5e829e45730a6560308fcf04fbf8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1589,7 +1589,7 @@ sourcemap-codec@^1.4.4:
   resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
   integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
 
-string-width@^4.1.0, string-width@^4.2.0:
+string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -1670,6 +1670,13 @@ tr46@^3.0.0:
   dependencies:
     punycode "^2.1.1"
 
+transliteration@^2.3.5:
+  version "2.3.5"
+  resolved "https://registry.yarnpkg.com/transliteration/-/transliteration-2.3.5.tgz#8f92309575f69e4a8a525dab4ff705ebcf961c45"
+  integrity sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==
+  dependencies:
+    yargs "^17.5.1"
+
 type-check@~0.3.2:
   version "0.3.2"
   resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"
@@ -1897,6 +1904,11 @@ yargs-parser@^20.2.2, yargs-parser@^20.2.9:
   resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee"
   integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
 
+yargs-parser@^21.0.0:
+  version "21.1.1"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
+  integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
+
 yargs@^16.2.0:
   version "16.2.0"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"
@@ -1910,6 +1922,19 @@ yargs@^16.2.0:
     y18n "^5.0.5"
     yargs-parser "^20.2.2"
 
+yargs@^17.5.1:
+  version "17.5.1"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.5.1.tgz#e109900cab6fcb7fd44b1d8249166feb0b36e58e"
+  integrity sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA==
+  dependencies:
+    cliui "^7.0.2"
+    escalade "^3.1.1"
+    get-caller-file "^2.0.5"
+    require-directory "^2.1.1"
+    string-width "^4.2.3"
+    y18n "^5.0.5"
+    yargs-parser "^21.0.0"
+
 yocto-queue@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"