This article introduces how to use two custom Remark plugins — remark-admonitions (callout boxes) and remark-button (buttons) — in a new Astro project, enabling Markdown content to support rich custom components.
Of course, I know there are plenty of ready-made plugins available online, but I chose this approach anyway.
Preview
Before installation, let’s first see what these two plugins can achieve.
Admonitions (Callout Boxes)
Five types of admonitions are suitable for different scenarios:
This is a regular note admonition, used for supplementary information.
This is a friendly tip, great for sharing small tricks.
This is an info admonition, used for providing extra information.
This is a warning admonition, used to alert users.
This is a danger warning, used to remind users to proceed with caution.
Button
Here are various button variants:
Small Primary Button
Medium Success Button
Large Danger Button
Warning Button
Custom Purple Rounded Button
Plugin Overview
These two plugins are built on remark-directive and use Markdown’s ::: directive syntax to insert custom UI components into articles.
- remark-admonitions: Renders callout boxes in Markdown, supporting five types: Note, Tip, Info, Warning, Danger
- remark-button: Renders styled buttons in Markdown, supporting preset sizes, colors, and fully custom styles
Prerequisites
Before getting started, make sure your project meets the following requirements:
- Node.js >= 22.12.0
- Astro >= 6.x
- pnpm (recommended) or npm
npm packages to install:
| Package | Purpose |
|---|---|
remark-directive | Parses the ::: directive syntax in Markdown |
unist-util-visit | Traverses and manipulates AST nodes |
hastscript | Creates hast (HTML AST) nodes declaratively |
pnpm add remark-directive unist-util-visit hastscript
Step 1: Create Plugin Files
Create a plugins/ directory in the project root and place the following two files inside.
remark-admonitions.ts
import { visit } from "unist-util-visit";
import { h } from "hastscript";
const admonitions = ["note", "tip", "info", "warning", "danger"];
export default function remarkAdmonitions() {
return tree => {
visit(tree, node => {
if (
node.type === "containerDirective" ||
node.type === "leafDirective" ||
node.type === "textDirective"
) {
if (admonitions.includes(node.name)) {
const data = node.data || (node.data = {});
const hast = h("div", node.attributes || {});
if (node.children && node.children.length > 0) {
const firstChild = node.children[0];
if (firstChild.type === "paragraph") {
const firstChildData = firstChild.data || (firstChild.data = {});
const existingClass =
(firstChildData.hProperties?.className as string[]) || [];
firstChildData.hProperties = {
...firstChildData.hProperties,
className: ["admonition-title", ...existingClass],
};
}
}
data.hName = hast.tagName;
data.hProperties = {
...hast.properties,
className: [
node.name,
...((hast.properties?.className as string[]) || []),
],
};
}
}
});
};
}
remark-button.ts
import { visit } from "unist-util-visit";
import { h } from "hastscript";
const PRESET_SIZES = ["xs", "sm", "md", "lg", "xl"];
const PRESET_COLORS = [
"primary",
"secondary",
"success",
"danger",
"warning",
"info",
"light",
"dark",
"pink",
"purple",
"indigo",
"teal",
"cyan",
"orange",
];
const PRESET_TEXT_COLORS = ["white", "black", "gray", "light", "dark"];
export default function remarkButton() {
return tree => {
visit(tree, node => {
if (
node.type === "containerDirective" ||
node.type === "leafDirective" ||
node.type === "textDirective"
) {
if (node.name === "button") {
const data = node.data || (node.data = {});
const hast = h("a", node.attributes || {});
const classNames = ["custom-button"];
const inlineStyles: string[] = [];
if (node.attributes?.size) {
if (PRESET_SIZES.includes(node.attributes.size)) {
classNames.push(`button-${node.attributes.size}`);
} else {
inlineStyles.push(`font-size: ${node.attributes.size}`);
}
}
if (node.attributes?.color) {
if (PRESET_COLORS.includes(node.attributes.color)) {
classNames.push(`button-${node.attributes.color}`);
} else {
inlineStyles.push(`background-color: ${node.attributes.color}`);
}
}
if (node.attributes?.textColor) {
if (PRESET_TEXT_COLORS.includes(node.attributes.textColor)) {
classNames.push(`button-text-${node.attributes.textColor}`);
} else {
inlineStyles.push(`color: ${node.attributes.textColor}`);
}
}
if (node.attributes?.padding) {
inlineStyles.push(`padding: ${node.attributes.padding}`);
}
if (node.attributes?.borderRadius) {
inlineStyles.push(`border-radius: ${node.attributes.borderRadius}`);
}
classNames.push(...((hast.properties?.className as string[]) || []));
const properties: Record<string, string | string[]> = {
...hast.properties,
className: classNames,
target: node.attributes?.target || "_blank",
rel: "noopener noreferrer",
};
if (inlineStyles.length > 0) {
properties.style = inlineStyles.join("; ");
}
data.hName = "a";
data.hProperties = properties;
}
}
});
};
}
Step 2: Configure Astro
Edit astro.config.ts and register these two plugins in markdown.remarkPlugins:
import { defineConfig } from "astro/config";
import remarkDirective from "remark-directive";
import remarkAdmonitions from "./plugins/remark-admonitions";
import remarkButton from "./plugins/remark-button";
export default defineConfig({
markdown: {
remarkPlugins: [
remarkDirective, // Must be registered before custom plugins
remarkAdmonitions,
remarkButton,
],
},
});
remarkDirective must come before remarkAdmonitions and remarkButton. This is because remark-directive is responsible for parsing the ::: syntax into directive nodes, and then the custom plugins convert these nodes into HTML. If the order is wrong, the custom plugins won’t receive any directive nodes, and all ::: blocks won’t be rendered.
Step 3: Add CSS Styles
Admonition Styles
In your global CSS file (e.g., src/styles/global.css), add:
/* Admonition base styles */
.prose .note,
.prose .tip,
.prose .info,
.prose .warning,
.prose .danger {
@apply my-4 rounded-lg border-l-4 p-4;
}
/* Admonition title */
.prose .note .admonition-title,
.prose .tip .admonition-title,
.prose .info .admonition-title,
.prose .warning .admonition-title,
.prose .danger .admonition-title {
@apply mb-2 text-lg font-bold;
}
/* Color schemes by type */
.prose .note {
@apply border-blue-500 bg-blue-50 text-blue-900;
}
.prose .tip {
@apply border-green-500 bg-green-50 text-green-900;
}
.prose .info {
@apply border-cyan-500 bg-cyan-50 text-cyan-900;
}
.prose .warning {
@apply border-yellow-500 bg-yellow-50 text-yellow-900;
}
.prose .danger {
@apply border-red-500 bg-red-50 text-red-900;
}
/* Dark mode adaptation */
html[data-theme="dark"] .prose .note {
@apply border-blue-400 bg-blue-950/50 text-blue-100;
}
html[data-theme="dark"] .prose .tip {
@apply border-green-400 bg-green-950/50 text-green-100;
}
html[data-theme="dark"] .prose .info {
@apply border-cyan-400 bg-cyan-950/50 text-cyan-100;
}
html[data-theme="dark"] .prose .warning {
@apply border-yellow-400 bg-yellow-950/50 text-yellow-100;
}
html[data-theme="dark"] .prose .danger {
@apply border-red-400 bg-red-950/50 text-red-100;
}
Button Styles
/* Button base styles */
.custom-button {
@apply inline-flex items-center justify-center rounded-lg bg-accent px-6 py-1 text-base font-medium text-white transition-all duration-200 hover:shadow-lg;
text-decoration: none;
}
.custom-button:hover {
transform: translateY(-2px);
}
.custom-button:active {
transform: translateY(0);
}
/* Size variants */
.button-xs {
@apply px-3 py-1 text-xs;
}
.button-sm {
@apply px-4 py-1.5 text-sm;
}
.button-md {
@apply px-6 py-2.5 text-base;
}
.button-lg {
@apply px-8 py-3 text-lg;
}
.button-xl {
@apply px-10 py-4 text-xl;
}
/* Color variants */
.button-primary {
@apply bg-blue-600 hover:bg-blue-700;
}
.button-secondary {
@apply bg-gray-600 hover:bg-gray-700;
}
.button-success {
@apply bg-green-600 hover:bg-green-700;
}
.button-danger {
@apply bg-red-600 hover:bg-red-700;
}
.button-warning {
@apply bg-yellow-500 hover:bg-yellow-600;
}
.button-info {
@apply bg-blue-500 hover:bg-blue-600;
}
.button-light {
@apply bg-gray-200 hover:bg-gray-300;
}
.button-dark {
@apply bg-gray-800 hover:bg-gray-900;
}
.button-pink {
@apply bg-pink-500 hover:bg-pink-600;
}
.button-purple {
@apply bg-purple-600 hover:bg-purple-700;
}
.button-indigo {
@apply bg-indigo-600 hover:bg-indigo-700;
}
.button-teal {
@apply bg-teal-500 hover:bg-teal-600;
}
.button-cyan {
@apply bg-cyan-500 hover:bg-cyan-600;
}
.button-orange {
@apply bg-orange-500 hover:bg-orange-600;
}
/* Text color variants */
.button-text-white {
@apply text-white;
}
.button-text-black {
@apply text-black;
}
.button-text-gray {
@apply text-gray-800;
}
.button-text-light {
@apply text-gray-100;
}
.button-text-dark {
@apply text-gray-900;
}
The above styles use Tailwind CSS’s @apply directive. If you don’t use Tailwind, simply replace @apply with equivalent CSS properties.
Step 4: Using in Markdown
Admonition Syntax
Supports five types: note, tip, info, warning, danger. The first paragraph inside an admonition is automatically rendered as a bold title.
:::note
This is a regular note admonition.
:::
:::tip
This is a friendly tip.
:::
:::info
This is an info admonition, used for providing extra information.
:::
:::warning
This is a warning admonition, used to alert users.
:::
:::danger
This is a danger warning, used to remind users to proceed with caution.
:::
Button Syntax
Buttons use the :::button{attributes...} syntax and support the following attributes:
| Attribute | Description | Preset Values | Custom Example |
|---|---|---|---|
href | Button link URL | Any URL | href="https://example.com" |
size | Button size | xs, sm, md, lg, xl | size="1.5rem" |
color | Background color | primary, secondary, success, danger, warning, info, light, dark, pink, purple, indigo, teal, cyan, orange | color="#ff6b6b" |
textColor | Text color | white, black, gray, light, dark | textColor="#2d3436" |
padding | Padding | Any CSS value | padding="1rem 2rem" |
borderRadius | Border radius | Any CSS value | borderRadius="50px" |
target | Link open mode | Default _blank | target="_self" |
When size, color, and textColor use preset values, corresponding CSS class names are generated; when using non-preset values, the plugin writes the values directly as inline CSS properties in the element’s style attribute.
Preset Sizes
:::button{href="https://example.com" size="xs"}
Extra Small Button
:::
:::button{href="https://example.com" size="sm"}
Small Button
:::
:::button{href="https://example.com" size="md"}
Medium Button
:::
:::button{href="https://example.com" size="lg"}
Large Button
:::
:::button{href="https://example.com" size="xl"}
Extra Large Button
:::
Preset Colors
:::button{href="https://example.com" color="primary"}
Primary Button
:::
:::button{href="https://example.com" color="danger"}
Danger Button
:::
:::button{href="https://example.com" color="warning" textColor="black"}
Warning Button (Dark Text)
:::
Fully Custom
:::button{href="https://example.com" size="0.75rem"}
Extra Small Font Button
:::
:::button{href="https://example.com" color="#ff6b6b"}
Custom Red Button
:::
:::button{href="https://example.com" color="#ffeaa7" textColor="#2d3436"}
Custom Yellow Button with Dark Text
:::
:::button{href="https://example.com" padding="1rem 2rem"}
Custom Padding Button
:::
:::button{href="https://example.com" borderRadius="2rem"}
Rounded Button
:::
Combined Usage
:::button{href="https://example.com" size="lg" color="success"}
Large Success Button
:::
:::button{href="https://example.com" size="sm" color="danger"}
Small Danger Button
:::
:::button{href="https://example.com" color="#fd79a8" textColor="white" padding="1.5rem 3rem" borderRadius="50px"}
Fully Custom Button
:::
Additional Notes
Plugin Registration Order
The processing flow is as follows:
remark-directiveparses the:::name{...}syntax into directive AST nodesremark-admonitions/remark-buttonconvert matching directive nodes into HTML
About TypeScript
If you use astro check for type checking, you may need to add type declarations for the plugin files. It’s recommended to create corresponding declaration files in the plugins/ directory:
// plugins/remark-admonitions.d.ts
declare module "./plugins/remark-admonitions" {
const value: () => (tree: any) => void;
export default value;
}
// plugins/remark-button.d.ts
declare module "./plugins/remark-button" {
const value: () => (tree: any) => void;
export default value;
}
CSS Framework Compatibility
- Tailwind CSS v4: The example styles use
@apply, works out of the box - Tailwind CSS v3: Requires the
@tailwindcss/typographyplugin - Vanilla CSS: Replace
@applydirectives with standard CSS properties - Other CSS solutions (UnoCSS, styled-components, etc.): Class name rules are completely independent from styles, adaptable to any solution
Known Limitations
- Buttons open in a new tab by default (
target="_blank"). To navigate in the current page, specifytarget="_self" - The button’s
relattribute is fixed to"noopener noreferrer"and cannot be customized - The first paragraph of an admonition is always rendered as a title and cannot be disabled
- Admonitions currently do not support passing custom attributes; only the five preset types are supported