Skip to content

Building Component Slots in React

Slots are an way to pass elements to a component and let them render in a specific location, like a header or footer. Let's find out how we can create a slot API in React.

Published
Updated
Reading time
9 min

About slots

Slots allow us to pass elements to a component and tell the component where these elements should be rendered. A component may support an implicit default slot (unnamed) and optionally, one or more named slots.

Here's an example using the slots API of WebComponents:

Card component with default slot, header slot and footer slot.

React doesn't have an official concept of named slots but JSX is pretty flexible and allows us to implement named slots in different ways. Let's take a look at different approaches.

Default slot (children)

The default slot in React is known as children property. Whatever you pass between the opening and closing tag of a component is passed as children property to that component and acts as the default slot.

<Card>
  {/* Default slot passed as `children` property */}
  <p>Content</p>
</Card>

Instead of accepting ReactNode as children you can define any other type when using TypeScript, limiting what a parent is allowed to pass. A FormattedDate component may only accept a date value:

<FormattedDate>{date}</FormattedDate>

Multiple slot props

The children property itself is a normal property like any other property a component accepts. Instead of passing the elements between the tags you pass the children property directly:

<Card children={<p>Content</p>} />

For the most simple implementation of named slots, we can define additional props – similar to children – that work as named slots:

<Card
  header={<h1>title</h1>}
  content={<p>Content</p>}
  footer={<a href="#">Read more</a>}
/>

This API is type-safe (when using TypeScript), easy to discover for consumers of the component and consumers don't have to care about the order of elements/props.

The drawback of this approach is that you need to pass the named slots as properties. Passing it between the opening and closing tag is not possible (except for the default slot).

Compound components

For compound components we split our component into multiple components, like Card, CardHeader and CardFooter, where the latter two acts similar to named slots:

function CardHeader(props) {
  return <header>{props.children}</header>;
}

function CardFooter(props) {
  return <footer>{props.children}</footer>;
}

function Card(props) {
  return <div className="card">{props.children}</div>;
}

The three components then build together a card:

<Card>
  <CardHeader>
    <h1>Title</h1>
  </CardHeader>
  <p>Content</p>
  <CardFooter>
    <a href="#">Read more</a>
  </CardFooter>
</Card>

We can couple the three components more tightly together by attaching the two slot components to the Card component:

function CardHeader(props) { /* ...*/ }
function CardFooter(props) { /* ... */ }
function Card(props) { /* ... */ }

Card.Header = CardHeader;
Card.Footer = CardFooter;

Now we can use Card.Header and Card.Footer which makes it even more clear that they belong to the Card component:

<Card>
  <Card.Header>...</Card.Header>
  <p>Content</p>
  <Card.Footer>...</Card.Footer>
</Card>

The order in which these three components are used matters. If we move Card.Footer to the top it will be rendered before the header. The Card component could use CSS flex or grid to reorder them, but that's not recommended for accessibility reasons. For a Card component the order is probably obvious, but there may be other use cases where it's not.

Slots by type

We can create slots based on the type of elements that are passed as children. For this approach we need to understand how JSX is compiled.

JSX is just a syntax extension for JavaScript and is compiled down to React.createElement() calls (or jsx()). When we create an element like <Card>Content</Card>, we actually get an object that looks like this (simplified):

{
  type: function Card(props) { /*...*/ },
  props: {
    children: "Content"
  }
}

The object contains the component (our Card function) and all props we pass to that component, including all children. Inspecting the type property we know what component (or HTML element) it is.

With that knowledge we can create slots based on the type. We first create two components for the header and footer that just return the children without rendering anything on their own:

function CardHeader(props) {
  return <>{props.children}</>;
}

function CardFooter(props) {
  return <>{props.children}</>;
}

Then our Card component loops through all children and checks if it's a CardHeader, CardFooter or something else. It then wraps the header and footer in a header respectively footer element and renders any other content in-between those two elements.

function Card(props) {
  let header;
  let footer;
  let content = [];

  React.Children.forEach(props.children, (child) => {
    if (!React.isValidElement(child)) return;
    if (child.type === CardHeader) {
      header = child;
    } else if (child.type === CardFooter) {
      footer = child;
    } else {
      content.push(child);
    }
  });

  return (
    <div>
      {!!header && <header>{header}</header>}
      {content}
      {!!footer && <footer>{footer}</footer>}
    </div>
  );
}

The code above is just to demonstrate the approach and is not a production-ready implementation.

The Card component can then be used this way:

<Card>
  <CardHeader>
    <h1>Title</h1>
  </CardHeader>
  <p>Content</p>
  <CardFooter>
    <a href="#">Read more</a>
  </CardFooter>
</Card>

The approach can be combined with compound components. Instead of just returning the children, the CardHeader and CardFooter could render the <header> / <footer> element directly.

The order in which the components are used doesn't matter anymore. The Card component reorders them anyway and also is in control of which elements are actually rendered. In addition, it could read the props of the child components or manipulate them to pass additional props.

It's a powerful pattern that gives the component developer a lot of flexibility. With a few utility functions we can reduce the boilerplate that makes using the pattern a breeze. We can create a generic createSlot() function that generates the CardHeader and CardFooter component. And the Children.forEach loop to split the children into slots based on their type can be extracted into a utility function that supports multiple types and HTML elements.

Generic slot component

We can adapt the slot element of WebComponents in React by creating a generic, reusable Slot component that accepts a name property:

<Card>
  <Slot name="header">
    <h1>Title</h1>
  </Slot>
  <p>Content</p>
  <Slot name="footer">
    <a href="#">Read more</a>
  </Slot>
</Card>

The Card component then loops over their children, checking for any Slot element and reading their name property.

This reduces the number of components but also worsens the developer experience because the name property is not type-safe. As a consumer of the Card component we don't know what slots exist without checking the implementation (or documentation that you surely write).

I don't think it's worth to use this approach over the previous one.

Slots with Context API

The two previous approaches have one limitation: they expect the slots to be used as a direct child. Wrapping a slot in a custom component is not possible:

<Card>
  <SomeComponent>
    {/* doesn't work, will be rendered in content */}
    <Card.Header>
      <h1>Title</h1>
    </Card.Header>
  </SomeComponent>
</Card>

Primer React, the React implementation of GitHub's Primer Design System, uses slots based on the React Context API which solves this problem. A Slots component provides a register() and deregister() function via Context API to all slots, which they use to register/deregister themselves. This works independently of whether a slot is rendered as a direct child or somewhere nested in the JSX tree.

Card component with three slots which registers themselves via Slots component.

The createSlots() function creates the root Slots component and a Slot component that has a type-safe name property, based on the provided list of slot names when calling the function. The component then uses the Slots component to get access to all rendered slot elements. Each slot component registers itself via Context API (provided by the Slots component) in a useLayoutEffect() hook.

While this API provides a flexible and type-safe API it doesn't play well with server-side rendering (SSR) because the named slots are only rendered on the client.

React Slots RFC

There is a React Slots RFC (Request for Comments) that would bring a slot API to React, eliminating the drawbacks of existing solutions. If or when this will be implemented is not clear yet.

Slots in other frameworks

Now after discovering different approaches in React, let's take a look at how other frameworks solve this problem.

Vue

Vue supports the <slot> element for both the default slot and named slots.

A component can accept slots by using the <slot> element, optionally with a name attribute to create a named slot:

<div class="card">
  <header>
    <slot name="header"></slot>
  </header>
  <div>
    <slot></slot>
  </div>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

The component can render elements in a specific slot by using the template element and the v-slot directive. All other content will be rendered in the default slot:

<Card>
  <template v-slot:header>
    <h1>Title</h1>
  </template>
  <p>Content</p>
  <template v-slot:footer>
    <a href="#">Read More</a>
  </template>
</Card>

The v-slot directive is not type-safe, using a slot that does not exist will just not render the content.

Angular

Angular supports content projection with their ng-content element and the select attribute:

<div class="card">
  <header>
    <ng-content select="[cardHeader]"></ng-content>
  </header>
  <div>
    <ng-content></ng-content>
  </div>
  <footer>
    <ng-content select="[cardFooter]"></ng-content>
  </footer>
</div>

This example has a default slot and two "named" slots that accept all elements with the cardHeader / cardFooter attribute. We now can render elements into these slots by adding one of the two attributes:

<app-card>
  <h1 cardHeader>Title</h1>
  <p>Content</p>
  <a href="#" cardFooter>Read more</a>
</app-card>

The select attribute accepts other CSS selectors like:

  • Element selectors: my-element,
  • Class selectors: .btn
  • ID selectors: #header
  • Pseudo classes: .btn:not(.btn-secondary)
  • Combinations: .btn,#btn

If you want to put an element into a specific slot that doesn't match the selector, you can use ngProjectAs:

<!--
  Button is projected into <ng-content select=".btn">
  even if it doesn't have the `btn` class
-->
<button ngProjectAs=".btn" class="custom-button">
  Click Me
</button>

How does this API compare to the approaches in React? The selector approach is very powerful and lets us control where to render elements. However, there are some drawbacks or limitations:

  • It's static. The select attribute cannot be bound to a dynamic value, it must be a static string. Likewise, matching the selector to elements is static, toggling a class on an element doesn't change if it's projected or not.
  • Rendered at least once: All projected content is rendered at least once, and then either actually added to the document (if it gets selected by the component) or thrown away if it's not matched by any ng-content element. It's better to use templates for conditional content projection.
  • Not type-safe (by default): The API is not type-safe. As a component consumer you don't know what selectors exist. Component developers can create and export empty components or directives that match the used selectors to provide some hints to the consumers of their components.
  • No nested content: Selectors cannot select nested elements. They only match direct children.

Summary

We saw different approaches of component slots in React. Now, which of them should you use? It depends (as always). All of them have pros and cons and it depends on your use case.

I hope that the RFC will be accepted and implemented in React, which will give us a better API than today's approaches.