Skip to content

March 3, 2026 • 659 words • 4 min read

Custom Blog Emotes

Astro supports unicode emojis out-of-the-box, say 👍😊😍🎉. In my opinion, however, the default emojis are simply not expressive enough in some cases. Since I use Discord a lot, I wanted to add support for inline emotes by simply using :emote_name: in the Markdown file for a blog post.

Why bother?

Because I’m looking to extend my website with features that I like from other platforms, even if they are not exactly useful. In Discord, you can upload any image you want on a server, assign a name to it, and use it as a pseudo-emoji, commonly called emotes.

I wanted that same ability on my blog - upload an image as a static asset, map it to a name, and use it anywhere on this website.

Implementation

Astro uses remark under the hood to process Markdown files. This processor works by parsing Markdown into a so-called Abstract Syntax Tree (AST) which is basically a structured representation of the document. This tree can be further transformed by remark plugins before finally being rendered as HTML.

So for my use case, I created a new remark plugin called remarkEmotes.mjs and placed it under src/plugins/:

import { visit } from "unist-util-visit";

// Matches :emote_name: pattern
const EMOTE_REGEX = /:([a-zA-Z0-9_]+):/g;

export function remarkEmotes(emoteMap) {
  return (tree) => {
    // Walk all text nodes in the Markdown AST
    visit(tree, "text", (node, index, parent) => {
      const matches = [...node.value.matchAll(EMOTE_REGEX)];
      if (!matches.length) return;

      const children = [];
      let lastIndex = 0;

      for (const match of matches) {
        const [full, name] = match;
        const emote = emoteMap[name];

        // Preserve text before the emote
        if (match.index > lastIndex) {
          children.push({
            type: "text",
            value: node.value.slice(lastIndex, match.index),
          });
        }

        // Replace supported emotes with associated image, otherwise keep as text
        if (emote) {
          children.push({
            type: "html",
            value: `<span class="emote-container">
              <img src="${emote}" alt="${name}" class="emote" />
              <span class="emote-tooltip">:${name}:</span>
            </span>`,
          });
        } else {
          children.push({ type: "text", value: full });
        }

        // Update lastIndex to the end of the current match
        lastIndex = match.index + full.length;
      }

      // Preserve text after the last emote
      if (lastIndex < node.value.length) {
        children.push({ type: "text", value: node.value.slice(lastIndex) });
      }

      // Replace the original text nodes with the new children
      parent.children.splice(index, 1, ...children);
    });
  };
}

The next step for me was to add the emotes as static images to the public/ folder. To keep the emote name to image path mapping isolated and make it easy to find, I created a new file under src/config/ called emotes.mjs:

export const emoteMap = {
  joemad: "/emotes/joemad.webp",
  kekw: "/emotes/kekw.webp",
};

I then imported this mapping in astro.config.mjs so it can be passed to the new plugin when it is added to remark:

...
import { emoteMap } from "./src/config/emotes.mjs";
import { remarkEmotes } from "./src/plugins/remarkEmotes.mjs";

export default defineConfig({
  ...
  markdown: {
    remarkPlugins: [[remarkEmotes, emoteMap]],
  },
  ...
});

And, of course, some CSS styling to make it look good:

.emote-container {
  position: relative;
  display: inline-block;
}

.emote {
  width: 24px;
  height: 24px;
  display: inline-block;
  vertical-align: middle;
  margin: 0;
}

.emote-tooltip {
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  margin-bottom: 0.25rem;
  padding: 0.25rem 0.5rem;
  font-size: 0.75rem;
  font-weight: 600;
  border-radius: 0.25rem;
  white-space: nowrap;
  opacity: 0;
  transition: opacity 0.15s;
  background-color: var(--color-card);
  border-width: 1px;
  border-color: var(--color-border);
}

.emote-container:hover .emote-tooltip {
  opacity: 1;
}

Result

With the above custom plugin, I can now simply type :kekw: or :joemad: in the post Markdown file which Astro renders as kekw :kekw: and joemad :joemad: respectively. The number of emotes can simply be extended by adding more images to the /public/emotes/ folder and mapping them to a name in src/config/emotes.mjs. I’m especially pleased with the tooltip which shows the exact name of the emote and can provide context on the meaning of the emote.

To wrap this up, I also added :aaaaaa:, :bruh:, :clueless:, :pog:, and :sotrue: - which render as aaaaaa :aaaaaa: bruh :bruh: clueless :clueless: pog :pog: sotrue :sotrue: respectively. Admittedly, I will probably never use these again to avoid turning this blog into a Twitch chat.