Figure with Markdown

Table of contents

With Markdown we can add images to our articles following easily, following this syntax.

![Description](./folder/image.avif "Title")
<p>
  <img src="./folder/image.avif" alt="Description" title="Title" />
</p>
The image is wrapped by a paragraph because the Markdown parser differentiates between inline and block elements. Block elements has no etra p; inline ones has.

We can’t generate custom markup. There’s no way to use the figure tag, which is very useful to add a caption to the image or video. Plus, it’s a common and an extended practice to indicate or highlight who signs the image or adding additional information the image doesn’t show.

<figure>
  <img src="./folder/image.avif" alt="Description" title="Title" />
  <figcaption>Author: Mamutlove — 2026</figcaption>
</figure>

Options

Depending on your stack, you could choose between these options:

Caption look like

Markdown processes the tags that recognizes https://www.markdownguide.org/cheat-sheet/ and figure is not one of them. Instead, you can add a text next to the image and use CSS to sytle it as it was a caption. Visually speaking, the results is good but it is not at a structural level. There’s not a relation between both elements and the accessibility tree doesn’t reflect what you are trying to express.

HTML in MDX

If you are using MDX you can embed HTML directly. This is a good solution. Expressive, idiomatic. It’s pretty straight forward but you will realise you could do it better after repeting twice the snippet.

Some content here bla, bla bla...

<figure>
  <img src="./folder/image.avif" alt="Description" title="Title" />
  <figcaption>Author: Mamutlove — 2026</figcaption>
</figure>

More content here as well...

Astro. It’s a trap!

If you’ve used Astro before, you are probably thinking that creating a reusable component is a nice solution. If you pass-in the component to Content you won’t have to import it whenever you’ll going to use it, splitting the content from the form.

---
import { render } from 'astro:content';
import Figure from '@components/Figure.astro';

const { post } = Astro.props;
const { Content } = await render(post);
---

<Content components={{ Figure }} />

But there are a couple of drawbacks. Which shape should the component have?

Astro lets you use Image or img. Image is great when you need control over the image; different sizes, crop it or get alternative formats. img is enough if you have a well define size for your image and it’s well optimise, which is our case.

The trap is that img expects a string (filepath) but Image expects an object (ImageMetadata) so it’s a great question which element should I choose for your implementation.

<figure class="root">
  <!-- srcForImage expects ImageMetadata -->
  <image src="{srcForImage}" />
  <!-- srcForImg expects string -->
  <img src="{srcForImg}" />
  <figcaption class="caption">{caption}</figcaption>
</figure>

To use Image you should import the image in the content before. This implies that you’ll be handling in a different way images that need a caption from those that it doesn’t need it, and this is weird. This rule is artificial and it’s not declared anywhere because it’s a different mental model. It binds us to rememeber a new rule: “if the image needs a caption, the pipeline is different”. And the conventions that depends on your memory break easily.

import imageWithCaption from './folder/image-with-caption.png';

# Title H1

Some content here bla, bla bla...

![Description](./folder/image.avif "Title")

More content here as well...

<Figure src={imageWithCaption} caption="Image WITH caption" />

Finally mooore content here...

The problem with img is that the prop that points to the resource lands as a raw string so the path is not processed resulting in a not existing resource and the image is not rendered.

Workarounds?

I prefer only one mechanism to add images to our content so I don’t want to import each image in the file, this is why I go with the img for the component. I like to pass to the component the image with the path already resolved and ready to be rendered, using Figure as a wrapper for a slot.

---
interface Props {
  caption: string;
}

const { caption } = Astro.props;
---

<figure>
  <slot />
  <figcaption>{caption}</figcaption>
</figure>
<Figure caption="Author: Mamutlove — 2026">
  ![Description](./folder/image.avif "Title")
</Figure>

In my opinion, this approach improves DX so you don’t have to worry about imports. Images will always be added using the pretty neat Markdown syntax. Humblebly, articles should be easy to edit. Always.

The output is <p><img .../></p> because img is an inline element, and I agree that is not the classic markup we are used to see. However, it’s not invalid neither. On the other hand, we are winning a pair of use cases we did not have in mind but are useful.

<Figure caption="Author: Mamutlove — 2026">
  Bla bla bla\
  Ble ble ble
</Figure>

<Figure caption="Author: Mamutlove — 2026">
  > Never go backwards
</Figure>

The accessibility tree is correct, so there are no cons.

Accesibility tree

Anyway, if you don’t like the markup you can unwrap it.

---
interface Props {
  caption: string;
}

const { caption } = Astro.props;

const slotted = await Astro.slots.render('default');
const media = slotted.replace(/^\s*<p>([\s\S]*?)<\/p>\s*$/, '$1').trim();
---

<figure>
  <Fragment set:html={media} />
  <figcaption>{caption}</figcaption>
</figure>

Caption Beyond its limits

What will happend next is that someone will encounter the need to add bold or cursive format in the caption but since the attribute is not parsed by Markdown we can’t do it.

A right of light enters the room. We can modify it adding a second slot for both accept the attribute or a Fragment. Tada!

---
interface Props {
  caption?: string;
}

const { caption } = Astro.props;
const hasCaption = Boolean(caption) || Astro.slots.has('caption');
---

<figure class="root">
  <slot />
  {
    hasCaption && (
      <figcaption class="caption">
        <slot name="caption">{caption}</slot>
      </figcaption>
    )
  }
</figure>
<Figure caption="Author: Mamutlove — 2026">
  ![Description](./folder/image.avif "Title")
</Figure>

<Figure>
  ![Description](./folder/image.avif "Title")

  <Fragment slot="caption">
  Author: *Mamutlove* — 2026
  </Fragment>
</Figure>

This solution is interesting because Fragment doesn’t genereate a new node. It’s true that the content of the sencond slot will be wrapped within a p but figcaption accepts any flow content, so it’s valid anyway.

Resources

comments powered by Disqus

If you find it interesting

If you have any doubt or you want to chat about this topic, as if you find interesting the content or our profiles and you think we could build something together, do not hesitate to contact us trough the email address hola@mamutlove.com