Eiko Wagenknecht
Software Developer, Freelancer & Founder

How to add Obsidian-style callouts to Astro

Eiko Wagenknecht

This is how callouts appear in Obsidian. If you aim to replicate this style and functionality on your Astro site, this article is for you.

Table of Contents

Callout Syntaxes for Markdown

Callouts are defined in GitHub Flavored Markdown and Obsidian Flavored Markdown. However, they are not compatible with each other, meaning you cannot interchange the syntax between the two formats.

This articles focuses on the Obsidian callouts. For those interested in GitHub callouts (or “alerts”), please refer to my guide: Use GitHub Alerts (Admonitions, Callouts) in Astro.

Obsidian callouts are - like GitHub alerts - defined by starting a blockquote with > [!callouttype]. This can optionally be followed by a - or + sign to make the callout foldable and, also optionally, a custom title. A full callout looks like this:

> [!hint]+ Did you know?
> You can fold this by clicking on it.

The type can be one of the following:

All other types will default to note.

While searching for an Obsidian callout parser for remark, I wanted support for:

There are multiple options available, but none supports all the features:

All options have some limitations. Therefore, I chose the one that best fits the required features (@r4ai/remark-callout). I added its second GitHub star and initiated a conversation with the maintainer to improve it.

Over the past few days, we collaboratively added many of the missing features with v0.5.0, including:

There are still some minor issues, but the plugin is in a good state, and I’m going to use it on this site. Some of the remaining issues include minor bugs and edge cases that need to be addressed.

To add the plugin to Astro and configure it to behave like Obsidian, you need to apply some custom configuration. This involves modifying the Astro settings to support the specific features of Obsidian callouts.

Installation and Configuration

Install the plugin with your favorite package manager, e.g. pnpm add @r4ai/remark-callout.

Add the plugin to your astro.config.mjs file:

import { remarkCallout } from "@r4ai/remark-callout";

function getCalloutIcon(type) {
  switch (type) {
    case "abstract":
    case "summary":
    case "tldr":
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-clipboard-list"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>';
    case "info":
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-info"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path></svg>';
    case "todo":
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-check-circle-2"><circle cx="12" cy="12" r="10"></circle><path d="m9 12 2 2 4-4"></path></svg>';
    case "tip":
    case "hint":
    case "important":
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-flame"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg>';
    case "success":
    case "check":
    case "done":
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-check"><path d="M20 6 9 17l-5-5"></path></svg>';
    case "question":
    case "help":
    case "faq":
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><path d="M12 17h.01"></path></svg>';
    case "warning":
    case "caution":
    case "attention":
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-alert-triangle"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>';
    case "failure":
    case "fail":
    case "missing":
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-x"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>';
    case "danger":
    case "error":
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-zap"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>';
    case "bug":
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-bug"><path d="m8 2 1.88 1.88"></path><path d="M14.12 3.88 16 2"></path><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"></path><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"></path><path d="M12 20v-9"></path><path d="M6.53 9C4.6 8.8 3 7.1 3 5"></path><path d="M6 13H2"></path><path d="M3 21c0-2.1 1.7-3.9 3.8-4"></path><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"></path><path d="M22 13h-4"></path><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"></path></svg>';
    case "example":
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-list"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>';
    case "quote":
    case "cite":
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-quote"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>';
    case "note":
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-pencil"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path><path d="m15 5 4 4"></path></svg>';
    default: // Note icon as fallback as well
      return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-pencil"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path><path d="m15 5 4 4"></path></svg>';
  }
}

export default defineConfig({
  markdown: {
    remarkPlugins: [
      [
        remarkCallout,
        {
          root: (callout) => ({
            tagName: "div",
            properties: {
              dataCalloutFold: callout.isFoldable
                ? callout.defaultFolded
                  ? "-"
                  : "+"
                : false,
              dataCallout: callout.type,
              dataCalloutDefaultFolded:
                callout.defaultFolded == null
                  ? undefined
                  : String(callout.defaultFolded),
              class: callout.isFoldable
                ? callout.defaultFolded
                  ? "callout is-collapsible is-collapsed"
                  : "callout is-collapsible"
                : "callout",
            },
          }),
          title: (callout) => ({
            tagName: "div",
            properties: {
              class: "callout-title",
            },
          }),
          body: () => ({
            tagName: "div",
            properties: {
              dataCalloutTitle: true,
              class: "callout-content",
            },
          }),
          icon: (callout) => ({
            tagName: "div",
            properties: {
              className: "callout-icon",
            },
            children: getCalloutIcon(callout.type),
          }),
          foldIcon: (callout) =>
            callout.isFoldable
              ? {
                  tagName: "div",
                  properties: {
                    className: "callout-fold",
                  },
                  children:
                    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"></path></svg>',
                }
              : undefined,
        },
      ],
    ],
  },
});

Add CSS to style it as you like (I use Tailwind):

.callout {
  @apply border;
  @apply rounded-md;
  @apply px-0;
  @apply py-4;
  @apply bg-zinc-800;
}

.callout-title {
  @apply flex;
  @apply flex-row;
  @apply gap-2;
  @apply items-center;
  @apply font-semibold;
  @apply mb-2;
}

/* Partially undo Tailwind prose */
.callout-content p:last-of-type {
  @apply font-normal;
  @apply mb-0;
}

.callout-icon svg {
  @apply w-5;
  @apply h-5;
}

.callout.is-collapsed .callout-fold svg {
  @apply -rotate-90;
}

.callout.is-collapsed .callout-content {
  @apply hidden;
}

/* Default callout, also applies to "note" */
.callout[data-callout],
.callout[data-callout="note" i],
.callout[data-callout="info" i],
.callout[data-callout="todo" i] {
  @apply text-text-inverted;
  @apply border-s-blue-500;
  @apply border-s-8;
  @apply my-6;
  @apply ps-4;
}

.callout[data-callout] > .callout-title,
.callout[data-callout="note" i] > .callout-title,
.callout[data-callout="info" i] > .callout-title,
.callout[data-callout="todo" i] > .callout-title {
  @apply text-blue-400;
}

.callout[data-callout="abstract" i],
.callout[data-callout="summary" i],
.callout[data-callout="tldr" i],
.callout[data-callout="tip" i],
.callout[data-callout="hint" i],
.callout[data-callout="important" i] {
  @apply border-s-teal-500;
}

.callout[data-callout="abstract" i] > .callout-title,
.callout[data-callout="summary" i] > .callout-title,
.callout[data-callout="tldr" i] > .callout-title,
.callout[data-callout="tip" i] > .callout-title,
.callout[data-callout="hint" i] > .callout-title,
.callout[data-callout="important" i] > .callout-title {
  @apply text-teal-400;
}

.callout[data-callout="success" i],
.callout[data-callout="check" i],
.callout[data-callout="done" i] {
  @apply border-s-green-500;
}

.callout[data-callout="success" i] > .callout-title,
.callout[data-callout="check" i] > .callout-title,
.callout[data-callout="done" i] > .callout-title {
  @apply text-green-400;
}

.callout[data-callout="question" i],
.callout[data-callout="help" i],
.callout[data-callout="faq" i],
.callout[data-callout="warning" i],
.callout[data-callout="caution" i],
.callout[data-callout="attention" i] {
  @apply border-s-yellow-500;
}

.callout[data-callout="question" i] > .callout-title,
.callout[data-callout="help" i] > .callout-title,
.callout[data-callout="faq" i] > .callout-title,
.callout[data-callout="warning" i] > .callout-title,
.callout[data-callout="caution" i] > .callout-title,
.callout[data-callout="attention" i] > .callout-title {
  @apply text-yellow-400;
}

.callout[data-callout="failure" i],
.callout[data-callout="fail" i],
.callout[data-callout="missing" i],
.callout[data-callout="danger" i],
.callout[data-callout="error" i],
.callout[data-callout="bug" i] {
  @apply border-s-red-500;
}

.callout[data-callout="failure" i] > .callout-title,
.callout[data-callout="fail" i] > .callout-title,
.callout[data-callout="missing" i] > .callout-title,
.callout[data-callout="danger" i] > .callout-title,
.callout[data-callout="error" i] > .callout-title,
.callout[data-callout="bug" i] > .callout-title {
  @apply text-red-400;
}

.callout[data-callout="example" i] {
  @apply border-s-purple-500;
}

.callout[data-callout="example" i] > .callout-title {
  @apply text-purple-400;
}

.callout[data-callout="quote" i],
.callout[data-callout="cite" i] {
  @apply border-s-zinc-500;
}

.callout[data-callout="quote" i] > .callout-title,
.callout[data-callout="cite" i] > .callout-title {
  @apply text-zinc-400;
}

Add custom JavaScript to your base layout to make the callouts foldable:

<!-- Collapsible element toggle -->
<script>
  let collapsibleElements = Array.from(
    document.querySelectorAll(".is-collapsible"),
  );

  collapsibleElements.forEach((element) => {
    element.addEventListener("click", () => {
      element.classList.toggle("is-collapsed");
    });
  });
</script>

Callouts should look like this now:

Did you know?

You can fold this by clicking on it.

I deliberately tweaked the CSS to suit this site’s design preferences, making it look slightly different from Obsidian. The “Original” look can be achieved with a few adjustments to the CSS.

No Comments? No Problem.

This blog doesn't support comments, but your thoughts and questions are always welcome. Reach out through the contact details in the footer below.