Modify Slot Children in Astro

Grab Astro <slot /> HTML, parse it, modify it, and render it

If you’ve spent some time in Astro, you’re probably familiar with the <slot /> feature which you can use to render child components inside a wrapper component. In this post, we’ll explore a way to modify Astro slot children from inside the wrapper component.

Astro Slot Basics Link to this heading

You can learn all about Astro slots in the Astro docs , but for this post, we’ll start with a basic example which we’ll build upon later.

Let’s say we want to build an FAQ section with some custom styles and behavior using the HTML <details> element. We might want one component for the FAQ section and one component for the individual FAQ items that go inside the section.

First, we’ve got an Astro component for the FAQ section where we’ll insert our frequently asked questions via the <slot />.

FAQSection.astro
<section>
<h2>Frequently Asked Questions</h2>
<slot />
</section>

Next, we’ve got an Astro component for each individual FAQ item. This component uses the HTML <details> and <summary> elements together to enable each answer to expand and collapse.

FAQItem.astro
---
const { question, answer } = Astro.props
---
<details>
<summary>{question}</summary>
{answer}
</details>

Putting those two components together on our Astro page, we insert the FAQItem components between the opening and closing tags of our FAQSection.

strawberries.astro
---
import FAQSection from '@components/FAQSection.astro'
import FAQItem from '@compoents/FAQItem.astro'
---
<FAQSection>
<FAQItem
question="How many strawberries do you have?"
answer="I have five strawberries :)"
/>
<FAQItem
question="Would you give me a strawberry, please?"
answer="Yes, you may have three :)"
/>
</FAQSection>

In the final rendered HTML shown below, the <slot /> placeholder inside the FAQSection component gets replaced with the rendered HTML of our FAQItem children components:

<section>
<h2>Frequently Asked Questions</h2>
<details>
<summary>How many strawberries do you have?</summary>
I have five strawberries :)
</details>
<details>
<summary>Would you give me a strawberry, please?</summary>
Yes, you may have three :)
</details>
</section>

This would display on the page as something like this:

Frequently Asked Questions

How many strawberries do you have?
I have five strawberries :)
Would you give me a strawberry, please?
Yes, you may have three :)

(I’ve added some additional styles, not shown.)

Astro slots are very useful!

Limitations of Astro Slots Link to this heading

This is all pretty standard stuff for Astro and other web frameworks like React, Svelte, etc., but one of the limitations of Astro slots as compared to React children is there is no built-in way to modify children passed in via the <slot />. Suppose we want to set an attribute on certain children, or style odd-numbered items a little differently. By default, we can’t do that because Astro doesn’t give us access to pre-rendered slots content.

Modify Astro Slot Contents Link to this heading

Even though we don’t get a nice children array in Astro like we do in other frameworks, Astro does have a slots property on the Astro global with a render function that allows us to grab the rendered HTML of the <slot /> contents. Combined with Astro’s set:html template directive on an Astro <Fragment />, and an HTML parser library, we should have enough tools to achieve our goals.

Here are the steps we’ll take:

  1. Grab the slot HTML contents using Astro.slots.render()
  2. Parse the HTML into a DOM tree using a parser like linkedom
  3. Iterate over our slot children via the DOM tree and process however we want
  4. Render the modified slot children using <Fragment set:html={modifiedSlotHTML}/>

Grab The Astro Slot Contents Link to this heading

To get the HTML of the slot contents, we’ll use Astro.slots.render('default'). We’ll await the render function and assign it to a variable in the component script 1 part of our Astro component.

FAQSection.astro
---
const html = await Astro.slots.render('default')
---
<section>
<h2>Frequently Asked Questions</h2>
<slot />
</section>

Parse HTML Link to this heading

To parse HTML, we’ll need to install an HTML parser. I found linkedom to work pretty well for this, but there are several other options available if you don’t want to write your own HTML parser.

npm install -D linkedom

As shown below:

  1. First, import parseHTML from the linkedom package
  2. Then we destructure the parsed HTML document into document from parseHTML(html)
  3. Finally, assign the HTMLCollection document.children to a children variable
FAQSection.astro
---
import { parseHTML } from 'linkedom'
const html = await Astro.slots.render('default')
const { document } = parseHTML(html)
const children = document.children
---
<section>
<h2>Frequently Asked Questions</h2>
<slot />
</section>

Modify Slot HTML Link to this heading

Now we have have an array of children HTML elements that were passed into the <slot /> of the FAQSection component. We can do almost anything we’d normally do to HTML elements.

There are a few important things to get clear:

  • children consists of HTML elements, not Astro components. This means you cannot pass props to the children and access them via the child component Astro.props. Unfortunate!
  • DOM manipulation on children is implemented by our HTML parser (linkedom in this case), not the browser. Remember, everything between the code fences ( --- ) in Astro runs on the server or build process, not in the browser. HTML parsers like linkedom may or may not implement all the DOM manipulation methods you’re used to using in the browser.

So how should we modify our FAQ section slot children? One common pattern for FAQs is to have the first one expanded by default. Since we’re expecting our children to be FAQItem components 2 which render a <details> element, we can add the open attribute to the first slot child.

FAQSection.astro
---
import { parseHTML } from 'linkedom'
const html = await Astro.slots.render('default')
const { document } = parseHTML(html)
const children = document.children
children[0].setAttribute('open', '')
---
<section>
<h2>Frequently Asked Questions</h2>
<slot />
</section>

Render Modified Slot Contents Link to this heading

Finally, we need to replace the <slot /> placeholder with a <Fragment> and set the HTML of the fragment to our modified document using Astro’s set:html client directive.

FAQSection.astro
---
import { parseHTML } from 'linkedom'
const html = await Astro.slots.render('default')
const { document } = parseHTML(html)
const children = document.children
children[0].setAttribute('open', '')
---
<section>
<h2>Frequently Asked Questions</h2>
<slot />
<Fragment set:html={document} />
</section>

And here’s the result! Notice the first FAQ is open by default, which is exactly what we wanted.

Frequently Asked Questions

How many strawberries do you have?
I have five strawberries :)
Would you give me a strawberry, please?
Yes, you may have three :)

Let’s Wrap Up Link to this heading

Thanks for reading! In summary, you can modify astro slot contents, but it comes with some pretty big caveats:

  1. You need to use a third-party HTML parser (or implement your own 😎).
  2. You can’t pass props to slot children which are Astro components. I know, bummer! 3

If you have any feedback, questions, or ideas about this post, I’d love to hear from you. Eventually I’d like to implement some form of comments on this blog, but for now you can email me here: cassidy@cassidysmith.dev