Dynamic Slots

Web Component authors already know how powerful slots are, but what if you could do even more with them? Here's an interesting technique to use (or abuse) slots in your custom elements. I've been calling the pattern dynamic slots.

Before we dive in, let's take a quick look at how slots actually work. Here's a custom button with a label and an image.

<my-button>
  Click me
  <img slot="icon" src="..." alt="...">
</my-button>

And let's imagine this is the button's template, which will be rendered in a shadow root.*

<template>
  #shadow-root
    <button type="button">
      <span class="icon">
        <slot name="icon"></slot>
      </span>

      <span class="label">
        <slot></slot>
      </span>
    </button>
</template>

If you're wondering why one slot has a name and the other doesn't, that's because there are two types of slots! By default, all content goes into the aptly named default slot — the one without a name attribute.

Elements with a slot attribute, however, will go inside of the corresponding named slot. A named slot is simply a slot with a name attribute. In this case, the <img slot="icon"> will be "slotted" into the named slot <slot name="icon">.

If you're feeling comfortable with everything up to this point, you're ready to move onto the next section. Otherwise, I'd suggest reading more about templates and slots before continuing.

Making Things Dynamic #

Since slots can be named arbitrarily, we have a lot of flexibility here. And since there's no limit as to how many can exist in a shadow root, we can do some pretty interesting things. Here's another contrived example. This time, we'll build a custom element grid that accepts an [oversimplified] attribute of columns and rows.

Under the hood, the grid will render a <table> based on the provided attributes. For brevity, we'll use Lit to build it and the markup to use it will look like this.

<my-grid cols="a,b,c,d,e" rows="1,2,3,4,5"></my-grid>

Inside the custom element, our render function will look something like this.

render() {
  const cols = this.cols?.split(',') ?? '';
  const rows = this.rows?.split(',') ?? '';

  return html`
    <table>
      <thead>
        <tr>
          <th></th>
          ${cols.map(col => html`<th>${col}</th>`)}
        </tr>
      </thead>
      <tbody>
        ${rows.map(row => html`
          <tr>
            <th>${row}</th>
            ${cols.map(col => html`
              <td>${col + row}</td>
            `)}
          </tr>
        `)}
      </tbody>
    </table>
  `;
}

Notice how the user isn't slotting in each cell declaratively. Instead, they're generated imperatively based on the columns and rows provided to the element.**

Given this API, how can we let users slot in custom content for specific cells? In a real data grid, we'd probably pass an array of objects like this to populate the grid.

grid.rows = [
  { id: 1234, content: 'Content from data store' },
  // ...
];

We could ask users to loop through the data and modify content as needed, but that seems sloppy. It would be better to offer a declarative API, so let's modify our example to make use of dynamic slots!

We'll start by wrapping each cell with a named slot using the cell:{col}{row} convention, so a slot named cell:a1 will target the first row in the first column of the grid. The custom element's template will look like this now.

render() {
  const cols = this.cols?.split(',') ?? '';
  const rows = this.rows?.split(',') ?? '';

  return html`
    <table>
      <thead>
        <tr>
          <th></th>
          ${cols.map(col => html`<th>${col}</th>`)}
        </tr>
      </thead>
      <tbody>
        ${rows.map(row => html`
          <tr>
            <th>${row}</th>
            ${cols.map(col => html`
              <td><slot name="cell:${col + row}">${col + row}</slot></td>
            `)}
          </tr>
        `)}
      </tbody>
    </table>
  `;
}

The magic behind dynamic slots exists right here.

<slot name="cell:${col + row}">

This results in a slot for every single cell that we can target in our HTML! Thus, the new markup will look like this.***

<my-grid cols="a,b,c,d,e" rows="1,2,3,4,5">
  <!-- you can slot in as many or as few cells as you like -->
  <span slot="cell:b3">foo</span>
  <span slot="cell:d4">bar</span>
  <span slot="cell:e2">baz</span>
</my-grid>

In this example, we used dynamic slots to let users customize the content of each cell. Remember that you can move the slot around, though! Perhaps you only want users to append content instead of replacing it entirely. In that case, you'd render the cell's content and then follow it with a dynamic slot. Or maybe you only want certain columns to be customizable, in which case you'd only render the slot in cells within those columns.

Perhaps a more realistic example is a date picker you want to add date-specific labels or icons to. The use cases tend to be quite specific, but dynamic slots can be really useful when you're iterating data and want to sprinkle in declarative customizations in a way regular slots just can't.

Anyways, that's the gist of dynamic slots!

Suggested Naming Convention #

If you choose to follow this pattern, I'd suggest using a colon to delimit the slot's primary name and it's positional name.

<slot name="item:1"></slot>
<slot name="item:2"></slot>
<slot name="item:3"></slot>
...

This feels cleaner than item-n and makes it a bit easier to document, especially if you use other dashes in slot names. Of course, this is by no means a requirement!

Caveats #

"Just because you can, doesn't mean you should" applies here. Here are some reasons you might want to avoid this pattern.


*The button example is a bit contrived, but try to imagine a more complex component. The spans aren't strictly necessary, but they help demonstrate the fact that slots can be rendered anywhere in a template.

**This is a common pattern for data grids and other components that render large amounts of data from a data store. Component authors could offer a declarative API that lets consumers slot in columns and rows, but this usually requires more effort from the author and the end user because authors need to do the work to watch/parse the slotted content and end users need to massage raw data into a custom, table-like API. It's not fun for anyone.

***In a real world application, you probably wouldn't use col:row for naming dynamic slots. An id is a much better way to link database records to slots.