Skip to content

Set up a Mastodon-based comment system

Recently did I ask in the Material for MkDocs repository1 about how I could implement a Mastodon-based comment system (#5728).
After looking a bit myself did I manage to setup a working version using some blog post on how to do it.

The post giving the code snippet and explaining it was made for Hugo, so some adjustments were required to make it work.
And since there is no post addressing this for MkDocs and the Material for MkDocs theme am I making this post now.

I already posted a Paste2 with some basic instructions, but I feel like a more in-depth explanation was useful to have, so here we are.



To make this comment system work you need...

  • A Mastodon account
  • The Material for MkDocs theme
  • Knowledge in how to extend the theme (Override files)

Limitations and issues

Compared to Giscuss does this setup have one main drawback: It requires a Mastodon Post to already exist.

Since the system relies on a Post ID will you either need to create this blog post, publish it, make a Mastodon post and then edit your blog post, or make a Mastodon post and then create a blog post with the ID.

Other Notes

During this post we will assume the following setups:

  • The custom_dir setting of theme in your mkdocs.yml is set to theme
  • The Mastodon instance is and the user is Example

Adjust those things to your personal need.

Step 1: Creating the files

If you haven't already, you need to extend the Material for MkDocs theme3 and create a new file in the theme extension folder.
The file we need is comments.html which is located in partials/ so the full path - relative to the mkdocs.yml file - would be theme/partials/comments.html

In addition should you make a CSS file in the assets directory (i.e. docs/assets/stylesheets/comments.css) and add it as an entry to the extra_css setting of your mkdocs.yml to get it loaded as CSS for your pages.

Now open the files in a text editor of your choice...

Step 2: Adding content

You need to add some content to the comments.html file and the comments.css file.

{% if page.meta and page.meta.comments %}
  <h2 id="__comments">{{ lang.t("meta.comments") }}</h2>
  {% if page.meta and page.meta.comment_id %}
      <div class="admonition danger">
        <p class="admonition-title">
          Please enable Javascript to see comments from Mastodon.

    <div class="admonition quote">
      <p>Comment system powered by <a href="" target="_blank" rel="nofollow noreferrer noopener"><span class="twemoji">{% include ".icons/simple/mastodon.svg" %}</span> Mastodon</a>.  <br>
      <a href="https://{{ }}/@{{ config.extra.mastodon.user }}/{{ page.meta.comment_id }}" target="_blank" rel="nofollow noreferrer noopener">Leave a comment</a> using Mastodon or   another Fediverse-compatible account.</p>

    <p id="mastodon-comments-list"></p>

  <script src="" integrity="sha512-W5fT2qIB5mnnYGQpzMLesMO7UmqtR7o712igk1FUXP+ftlu94UYDAngTS83l+0s3MwRmtqGDyWncZfiUjsCNHw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script type="text/javascript">
      var host = '{{ }}'; // (1)
      var user = '{{ config.extra.mastodon.user }}'; // (2)
      var id = '{{ page.meta.comment_id }}' // (3)

      function escapeHtml(unsafe) {
        return unsafe
          .replace(/&/g, "&amp;")
          .replace(/</g, "&lt;")
          .replace(/>/g, "&gt;")
          .replace(/"/g, "&quot;")
          .replace(/'/g, "&#039;");

      var commentsLoaded = false;

      function toot_active(toot, what) {
        var count = toot[what+'_count'];
        return count > 0 ? 'active' : '';

      function toot_count(toot, what) {
        var count = toot[what+'_count'];
        return count > 0 ? count : '';

      function user_account(account) {
        var result =`@${account.acct}`;
        if (account.acct.indexOf('@') === -1) {
          var domain = new URL(account.url)
          result += `@${domain.hostname}`
        return result;

      function render_toots(toots, in_reply_to, depth) {
        var tootsToRender = toots
          .filter(toot => toot.in_reply_to_id === in_reply_to)
          .sort((a, b) => a.created_at.localeCompare(b.created_at));
        tootsToRender.forEach(toot => render_toot(toots, toot, depth));

      function render_toot(toots, toot, depth) {
        toot.account.display_name = escapeHtml(toot.account.display_name);
        toot.account.emojis.forEach(emoji => {
          toot.account.display_name = toot.account.display_name.replace(`:${emoji.shortcode}:`, `<img src="${escapeHtml(emoji.url)}" alt="Emoji ${emoji.shortcode}" title=":${emoji.shortcode}:height="20"         width="20" />`);
        toot.emojis.forEach(emoji => {
          toot.content = toot.content.replace(`:${emoji.shortcode}:`, `<img src="${escapeHtml(emoji.url)}" alt="Emoji ${emoji.shortcode}" title=":${emoji.shortcode}:" height="20" width="20" />`);
        status_date = `${toot.created_at.substr(0, 10)} ${toot.created_at.substr(11, 8)}`
        if (toot.edited_at) {
          status_date += `<abbr title="Edited ${toot.edited_at.substr(0, 10)} ${toot.edited_at.substr(11, 8)}">*</abbr>`
        mastodonComment =
          `<div class="mastodon-comment" style="margin-left: calc(var(--mastodon-comment-indent) * ${depth})">
            <div class="author">
              <div class="avatar">
                <img src="${escapeHtml(toot.account.avatar_static)}" height=60 width=60 alt="${escapeHtml(toot.account.display_name)}" draggable=false loading="lazy" />
              <div class="details">
                <a class="name" href="${toot.account.url}" target="_blank" rel="nofollow">${toot.account.display_name}</a>
                <a class="user" href="${toot.account.url}" target="_blank" rel="nofollow">${user_account(toot.account)}</a>
              <a class="date" href="${toot.url}" target="_blank" rel="nofollow">${status_date}</a>
            <div class="content">${toot.content}</div>
            <div class="attachments">
              ${ => {
                if (attachment.type === 'image') {
                  return `<a href="${attachment.url}" target="_blank" rel="nofollow"><img src="${attachment.preview_url}" alt="${escapeHtml(attachment.description)}" draggable=false loading="lazy" /></a>`;
                } else if (attachment.type === 'video') {
                  return `<video controls><source src="${attachment.url}" type="${attachment.mime_type}"></video>`;
                } else if (attachment.type === 'gifv') {
                  return `<video autoplay loop muted playsinline><source src="${attachment.url}" type="${attachment.mime_type}"></video>`;
                } else if (attachment.type === 'audio') {
                  return `<audio controls><source src="${attachment.url}" type="${attachment.mime_type}"></audio>`;
                } else {
                  return `<a href="${attachment.url}" target="_blank" rel="nofollow">${attachment.type}</a>`;
            <div class="status">
              <div class="replies ${toot_active(toot, 'replies')}">
                <a href="${toot.url}" rel="nofollow">
                  <span class="twemoji">{% include ".icons/fontawesome/solid/reply.svg" %}</span>
                  ${toot_count(toot, 'replies')}
              <div class="reblogs ${toot_active(toot, 'reblogs')}">
                <a href="${toot.url}" rel="nofollow">
                  <span class="twemoji">{% include ".icons/fontawesome/solid/retweet.svg" %}</span>
                  ${toot_count(toot, 'reblogs')}
              <div class="favourites ${toot_active(toot, 'favourites')}">
                <a href="${toot.url}" rel="nofollow">
                  <span class="twemoji">{% include ".icons/fontawesome/solid/star.svg" %}</span>
                  ${toot_count(toot, 'favourites')}
        document.getElementById('mastodon-comments-list').appendChild(DOMPurify.sanitize(mastodonComment, {'RETURN_DOM_FRAGMENT': true, ADD_ATTR: ['target']}));

        render_toots(toots,, depth + 1)

      function loadComments() {
        if (commentsLoaded) return;

        document.getElementById("mastodon-comments-list").innerHTML = "Loading comments from the Fediverse...";

        fetch('https://' + host + '/api/v1/statuses/' + id + '/context')
          .then(function(response) {
            return response.json();
          .then(function(data) {
            if(data['descendants'] && Array.isArray(data['descendants']) && data['descendants'].length > 0) {
                document.getElementById('mastodon-comments-list').innerHTML = "";
                render_toots(data['descendants'], id, 0)
            } else {
              document.getElementById('mastodon-comments-list').innerHTML = 
              `<div class="admonition info">
                <p class="admonition-title">
                  No comments found. <a href="https://{{ }}/@{{ config.extra.mastodon.user }}/{{ page.meta.comment_id }}">Be the first!</a>

            commentsLoaded = true;

      function respondToVisibility(element, callback) {
        var options = {
          root: null,

        var observer = new IntersectionObserver((entries, observer) => {
          entries.forEach(entry => {
            if (entry.intersectionRatio > 0) {
        }, options);


      var comments = document.getElementById("mastodon-comments-list");
      respondToVisibility(comments, loadComments);
  {% else %}
    <div class="admonition warning">
      <p class="admonition-title">
        No Mastodon post configured for this page. Contact {{ config.site_author | default('the post author', true) }} if you want to comment here.
  {% endif %}
{% endif %}
  1. This gets the value from the mkdocs.yml file's extra section:
        user: example
  2. This gets the value from the mkdocs.yml file's extra section:
        user: example
  3. This gets the value from the YAML frontmatter of the page that gets rendered:
    title: Some title
    description: Some description
    comment_id: 123456789
    # Page title
  --mastodon-comment-indent: 40px;
  --mastodon-comment-border-radius: .2rem;

  --mastodon-comment-bg-color: #313543;
  --mastodon-comment-border-color: #393f4f;

  --mastodon-comment-username-color: #fff;
  --mastodon-comment-usertag-color: #d9e1e8;

  --mastodon-comment-date-color: #606984;
  --mastodon-comment-link-color: #8c8dff;
  --mastodon-comment-hashtag-color: #d9e1e8;

  --mastodon-comment-status-inactive: #606984;
  --mastodon-comment-status-inactive__hover: #707b97;
  --mastodon-comment-status-inactive-bg__hover: rgba(96,105,132,.15);

  --mastodon-comment-status-replies--active: #448aff;
  --mastodon-comment-status-favourite--active: #ca8f04;
  --mastodon-comment-status-reblog--active: #8c8dff;

:root [data-md-color-scheme="default"] {
  --mastodon-comment-bg-color: #fff;
  --mastodon-comment-border-color: #c0cdd9;

  --mastodon-comment-username-color: #000;
  --mastodon-comment-usertag-color: #282c37;

  --mastodon-comment-date-color: #444b5d;
  --mastodon-comment-link-color: #3a3bff;
  --mastodon-comment-hashtag-color: #3a3bff;

  --mastodon-comment-status-inactive__hover: #51596f;

@media only screen and (max-width: 1024px){
    --mastodon-comment-indent: 20px;

@media only screen and (max-width: 640px){
    --mastodon-comment-indent: 0px;

  background-color: var(--mastodon-comment-bg-color);
  border-radius: var(--mastodon-comment-border-radius);
  border: 1px var(--mastodon-comment-border-color) solid;
  padding: 20px;
  margin-bottom: 1.5rem;
  display: flex;
  flex-direction: column;

.mastodon-comment p{
  margin-bottom: 0px;

.mastodon-comment .content{
  margin: 15px 20px;

.mastodon-comment .content p:first-child{
  margin-top: 0;
  margin-bottom: 0;

.mastodon-comment .content a {
  color: var(--mastodon-comment-link-color);

.mastodon-comment .attachments{
  max-width: 0px 10px;

.mastodon-comment .attachments > *{
  max-width: 0px 10px;

.mastodon-comment .author{
  padding-top: 0;
  display: flex;

.mastodon-comment .author a{
  text-decoration: none;

.mastodon-comment .author .avatar img{
  margin-right: 1rem;
  min-width: 60px;
  border-radius: 5px;

.mastodon-comment .author .details{
  display: flex;
  flex-direction: column;

.mastodon-comment .author .details .name{
  font-weight: bold;
  color: var(--mastodon-comment-username-color);
} {
  text-decoration: underline;

a.mention.hashtag {
  color: var(--mastodon-comment-hashtag-color);

.mastodon-comment .author .details .user{
  color: var(--mastodon-comment-usertag-color);

.mastodon-comment .author .date{
  margin-left: auto;
  font-size: small;
  color: var(--mastodon-comment-date-color);

.mastodon-comment .status > div{
  display: inline-block;
  margin-right: 15px;

.mastodon-comment .status {
  margin-left: 15px;

.mastodon-comment .status a{
  color: var(--mastodon-comment-status-inactive);
  text-decoration: none;

  padding: .2rem;
  border-radius: .2rem;

.mastodon-comment .status a:hover {
  color: var(--mastodon-comment-status-inactive__hover);
  background-color: var(--mastodon-comment-status-inactive-bg__hover);
  transition: all .2s ease-out;

.mastodon-comment .status *{
  color: var(--mastodon-comment-status-replies--active);

.mastodon-comment .status a{
  color: var(--mastodon-comment-status-reblog--active);

.mastodon-comment .status a{
  color: var(--mastodon-comment-status-favourite--active);

.mastodon-comment .status svg{
  margin: 0 0.2rem;
  vertical-align: middle;

Final steps

What is left to do is add the necessary data to the mkdocs.yml file and the pages you want comments for.

In the mkdocs.yml you need to add the following:

    user: example     # Replace with your account name
    host: # Replace with the instance you use

Why not include this in the comments.html?

I've chosen this setup as it would allow a relatively easy adjustment for the case of you switching instances.
Although, old comments would no longer work for that matter.

On the pages you want to have comments enabled, you need to add these frontmatter options:

  • comments: true to enable the comments feature. Only needed if you're not enabling it by default (i.e. by not including the if check in the partials file or by using the meta plugin to enable it for a folder)
  • comment_id: <id> with <id> being an id to a valid post on the Mastodon instance and from the user you defined in your mkdocs.yml file.

This should be everything and once you publish your pages (or check them in the live preview using mkdocs serve) should the comment system load your posts or otherwise show admonition boxes with further info.


Date is in the format


  • Made comments look more like Mastodon comments in terms of style.
  • Updated CSS to include a white-theme variant to use with Material for MkDocs default theme.


  • Added rendering of custom Emojis within a Comment's content itself.
  • Replaced the "Comment on this blog post" message with a "Note" details block to have more info without unnecessary screen height increase.


  • Replaced details about commenting with a simple quote admonition box.
  • Added target="_blank" and some rel attributes to some links for the comments (i.e. attachment urls).
  • Added Indicator for when a post was edited, similar to how Mastodon displays it (Asterisk with Abbreviation).
  • Updated Dompurify version shown.


  • Improved javascript of comment.html.
  • Added missing target="_blank" to display name and mention link.
  • Added draggable=false and loading="lazy" to avatar and attachment images and made alt of avatar contain display name.
  • Updated DOMPurify to keep target="_blank" tags instead of filtering them out.
  • Updated DOMPurify version.


Last update: 28. May 2024 ()


Comment system powered by Mastodon.
Leave a comment using Mastodon or another Fediverse-compatible account.