Plain markdown is fine for most things. Paragraphs, headings, lists, code blocks: that covers the majority of what you need for a technical post. But there's a ceiling. After a while you start noticing that everything looks the same. Every post is just a wall of text broken up by the occasional code snippet, and there's no visual hierarchy beyond h2s and h3s.
I wanted two things: a richer reading experience, and a nicer writing experience. The reading side is obvious. More visual variety keeps people engaged. The writing side is more selfish. I write better when I have tools that let me express emphasis properly. Shoving everything into a blockquote or a bold sentence feels like a hack.
So I added three custom MDX components: Callout, PullQuote, and ImageCaption.
What MDX Actually Gives You
Before getting into the components, a quick note on how this works. My blog posts are .mdx files, which is Markdown with JSX support baked in. Anywhere you'd write regular markdown, you can also drop in a React component. So a post can look like this:
## Some heading
Regular paragraph text here.
<Callout type="tip">
This is a tip rendered as a React component.
</Callout>
More paragraph text.mdxThe key part is how those components get registered. In Next.js with next-mdx-remote, you pass a components map to the MDXRemote renderer:
<MDXRemote
source={post.content}
components={{ pre: CodeBlock, Callout, PullQuote, ImageCaption }}
options={{ ... }}
/>tsxThat's it. Once a component is in that map, it's available in every post without any imports. You just use it by name. This is what makes the writing experience actually pleasant: no remembering to import anything, just write <Callout> and it works.
Callout
The most useful of the three. Callouts are highlighted boxes for surfacing context worth separating from the prose: warnings, notes, tips, background information.
This is a note callout, good for gentle caveats or extra context that doesn't belong in the main flow.
This is a tip callout. Use it for actionable suggestions.
This is a warning callout. Use it when something can go wrong.
This is an info callout with a custom title overriding the default label.
There are four variants: note, tip, warning, and info. The way this is implemented is with a config object at the top of the component file:
const config: Record<
CalloutType,
{ label: string; icon: string; bg: string; border: string; labelColor: string }
> = {
note: {
label: "Note",
icon: "✎",
bg: "var(--color-surface)",
border: "var(--color-border)",
labelColor: "var(--color-muted)",
},
tip: {
label: "Tip",
icon: "✦",
bg: "#f3faf3",
border: "#5a9e5a",
labelColor: "#3d7a3d",
},
// ...
};tsxRather than writing a big if/else or switch inside the render function, the config map keeps all the variant data in one place. Adding a new variant is just adding a new key. The component itself then just does:
const { label, icon, bg, border, labelColor } = config[type];tsxAnd uses those values inline. Clean enough.
The note variant deliberately uses --color-surface and --color-border from the site's CSS variables so it blends naturally with the warm background. The other three variants (tip, warning, info) step outside the palette slightly, into green, amber, and blue, because they need to communicate urgency or category at a glance. If all four variants used the same brown tones they'd be indistinguishable. So the rule I followed: note stays within the site palette, the other three get their own colours.
The left-side border is always 3px and the component uses borderRadius: "0 6px 6px 0", flat on the left (flush with the border) and rounded on the right. It's a small detail but it makes the shape feel intentional rather than just a box.
You can also pass a custom title prop to override the default label:
<Callout type="warning" title="Heads up">
This will override "Warning" with "Heads up".
</Callout>mdxThe title falls back to the config label if you leave it out, so title is entirely optional.
PullQuote
Pull quotes are common in editorial design. A sentence gets pulled from the article and displayed larger, giving the reader something to pause on while skimming. They work well for one or two lines you want to emphasise without interrupting the paragraph flow.
There's a ceiling to what plain markdown can express, and I kept hitting it.
The implementation is straightforward:
<figure style={{ margin: "2.5rem 0", padding: "0 1rem", textAlign: "center" }}>
<span aria-hidden="true" style={{
display: "block",
fontSize: "3rem",
lineHeight: 1,
color: "var(--color-border)",
fontFamily: "var(--font-eb-garamond), serif",
marginBottom: "-0.5rem",
}}>
“
</span>
<blockquote style={{
fontFamily: "var(--font-eb-garamond), serif",
fontSize: "1.45rem",
lineHeight: 1.55,
color: "var(--color-accent)",
fontStyle: "italic",
margin: "0", padding: "0", border: "none",
}}>
{children}
</blockquote>
</figure>tsxThe font choice matters here. The rest of the site uses Circular Std for body text: clean, geometric, modern. Post titles use EB Garamond. Using EB Garamond for the pull quote text makes it feel like it belongs to the same register as the title, elevated and slightly more considered. It signals to the reader that this particular sentence is worth slowing down for.
The opening quote glyph is set to --color-border, a light warm tan, visible but not competing with the quote text itself. Setting aria-hidden="true" on that span means screen readers skip it, since the <blockquote> element already communicates the semantic meaning.
The negative marginBottom: "-0.5rem" on the glyph pulls the quote text up slightly so there's no awkward gap between the decorative mark and the words below it.
You can also attribute a quote to someone with the optional author prop:
<PullQuote author="Paul Graham">
Write simply. Don't use a complicated word where a simple word will do.
</PullQuote>mdxWrite simply. Don't use a complicated word where a simple word will do.
The attribution renders in small-caps Circular Std, consistent with how dates and labels are styled elsewhere on the site.
ImageCaption
This one is the simplest. Standard markdown images () render fine, but they give you no way to add a caption below them. ImageCaption wraps an <img> in a <figure> with an optional <figcaption>:
<figure style={{ margin: "2rem 0", textAlign: "center" }}>
<img
src={src}
alt={alt}
style={{
display: "inline-block",
maxWidth: "100%",
borderRadius: "6px",
border: "1px solid var(--color-border)",
}}
/>
{caption && (
<figcaption style={{
marginTop: "0.6rem",
fontSize: "0.75rem",
color: "var(--color-muted)",
fontStyle: "italic",
}}>
{caption}
</figcaption>
)}
</figure>tsxOne deliberate choice here: I'm using a regular <img> tag instead of Next.js's <Image> component. Next.js Image requires explicit width and height props to do its layout work, or a fill mode that needs a sized container. In an MDX context where images can be anything (screenshots, diagrams, photos), enforcing those props would make writing more annoying. The eslint-disable comment suppresses the lint warning since this is intentional.
The image gets a 1px solid var(--color-border) border, the same border colour used on code blocks, cards, and dividers throughout the site. It gives images a framed, deliberate feel rather than just floating them raw in the prose.
Usage in a post looks like this:
<ImageCaption
src="/images/architecture-diagram.png"
alt="Three-stage data flow diagram"
caption="Figure 1: data flows left to right through ingest, transform, and serve."
/>mdxCaption is optional. If you leave it out, you get a styled image with no text below it, which is fine for decorative images.
Design Consistency
The thing that ties all three components together is CSS variables. The site's palette is defined in globals.css:
:root {
--color-bg: #fffdfa;
--color-surface: #f5ede0;
--color-border: #e2cdb0;
--color-text: #2a1a08;
--color-muted: #936a25;
--color-secondary:#674a1a;
--color-accent: #7a5018;
}cssEvery component references these variables rather than hardcoded hex values (with the exception of the tip/warning/info callout colours, which need to step outside the warm palette). This means if I ever update the palette, the components update for free.
The warm background (#fffdfa) is slightly off-white with a very faint warm cast. The surface (#f5ede0) is the next step up, used for code blocks, the note callout, and hover states. There's a natural visual hierarchy between the two that the components can lean into without any extra work.
How to Use Them
All three components are available in every post. No imports, just use them:
<Callout type="tip" title="My title">
Markdown **works** inside callouts.
</Callout>
<PullQuote author="Optional attribution">
A sentence worth pausing on.
</PullQuote>
<ImageCaption
src="/images/foo.png"
alt="description"
caption="Optional caption text."
/>mdxThat's the whole writing experience improvement. Writing a post now has a bit more range: I can draw attention to something important without bolding half a paragraph, give a key idea room to breathe with a pull quote, and caption a diagram properly. Not revolutionary, but noticeably better than what I had before.