Skip to content

Content Projection in Front-end JavaScript Frameworks

Content Projection in Front-end Frameworks

Initially, I wanted to write a nice comprehensive guide on ng-content and content projection in Angular. But then I found out that my colleague Linda already wrote a wonderful article that covers the topic very well, so I thought about it a little and realized that pretty much every front-end framework has some implementation of the concept. So I decided to write a short article about content projection in general and show how it is implemented in some of the most popular front-end frameworks.

What is Content Projection?

Some of you may also remember it by the term "transclusion", a bit of a tongue-twister from the Angular.js days. Content projection is a way to take markup (HTML, components...) and slot it right into another component. It's like the Swiss Army knife in our coding toolbox, helping us create reusable components that can fit anywhere.

Let's imagine a card component - something you see on pretty much every website these days. It can have a header, a body, maybe some image, and a footer. Now, what you put inside that card can change based on what you need. Maybe today it's a user profile, tomorrow it's a product description, and the day after, it's a blog post. The layout stays the same, but the content? That's entirely up to you, and that's the magic of content projection.

Using this approach can help us reduce redundancy in our code and keep it DRY. It also ensures a more consistent user experience. It is, however, important to remember that content projection is not a silver bullet. It can be overused, and it can make your code harder to read and maintain. So, as with everything, use it wisely.

How Does it Work

Content projection is essentially about two main players: the receiving component (where the content is projected into) and the projecting component (which provides the content to be projected). The receiving component has specific placeholders, often denoted as slots or named slots in frameworks like Vue.js or Web Components. These slots serve as 'parking spaces' for the content from the projecting component.

In general, we distinguish two types of content projection: single-slot and multi-slot. Single-slot content projection is when you have only one placeholder for the content. Multi-slot content projection is when you have multiple placeholders for the content. The placeholders can be named or unnamed. Named placeholders are used when you want to project different content into different placeholders.

When you're defining the receiving component, you usually specify these slots without defining what will go into them. It's akin to laying out an empty stage for a play, with specific spots designated for props, but not specifying what those props will be. This gives you the flexibility to later decide and alter what content will 'perform' in those spaces.

Now, when it comes to the projecting component, that's where the magic happens. Here, you'll take advantage of the slots that the receiving component provides, filling them with the specific content you want to project. For example, you might have a card component with a 'header' slot. When you use this card component elsewhere in your application, you can decide what gets projected into that 'header' slot. It could be a title for one instance of the card and an image for another.

Now, let's see the concepts different frameworks use to allow content projection, starting with the Web Components way.

The Web Components Way (Slots)

As I mentioned before, a very popular way to implement content projection is with slots. The "slot" approach is not only used in Web Components, but also in Vue.js, Svelte, or Qwik.

In Web Components, slots are defined using the <slot> tag.

Let's illustrate how slots are used in Web Components with an example of a card component with three slots: header, body, and footer. The content that will be projected into these slots will be defined when the component is used. The component will look like this:

<template id="card-template">
  <style>
    :host {
      display: block;
      border: 1px solid #ddd;
      border-radius: 4px;
      padding: 20px;
      margin-bottom: 20px;
    }
    ::slotted([slot="header"]) {
      font-weight: bold;
      margin-bottom: 10px;
    }
    ::slotted([slot="body"]) {
      margin-bottom: 10px;
    }
    ::slotted([slot="footer"]) {
      text-align: right;
      color: gray;
    }
  </style>

  <slot name="header"></slot>
  <slot name="body"></slot>
  <slot name="footer"></slot>
</template>

<script>
  class CardComponent extends HTMLElement {
    constructor() {
      super();
      const template = document.getElementById("card-template");
      const templateContent = template.content;

      this.attachShadow({ mode: "open" }).appendChild(
        templateContent.cloneNode(true)
      );
    }
  }
  customElements.define("card-component", CardComponent);
</script>

This receiving component could be used in a projecting component like this:

<card-component>
  <div slot="header">
    <h1>Card Title</h1>
  </div>
  <div slot="body">This is the main content of the card.</div>
  <div slot="footer">
    <button>Click me</button>
  </div>
</card-component>

You can check out this example on StackBlitz.

Vue.js

As mentioned above, Vue.js and other frameworks also use the slot concept. Let's see how it's implemented in Vue.js.

First, the receiving component:

<template>
  <div class="card">
    <div class="card-header">
      <slot name="header"></slot>
    </div>
    <div class="card-body">
      <slot> Default body content here </slot>
    </div>
    <div class="card-footer">
      <slot name="footer"></slot>
    </div>
  </div>
</template>

<script>
  export default {
    name: "CardComponent",
  };
</script>

<style scoped>
  .card {
    border: 1px solid #ddd;
    border-radius: 4px;
    padding: 20px;
    margin-bottom: 20px;
  }
  .card-header {
    font-weight: bold;
    margin-bottom: 10px;
  }
  .card-body {
    margin-bottom: 10px;
  }
  .card-footer {
    text-align: right;
    color: gray;
  }
</style>

And then, the projecting component:

<template>
  <card-component>
    <template v-slot:header>
      <h1>Card Title</h1>
    </template>

    This is the main content of the card.

    <template v-slot:footer>
      <button>Click me</button>
    </template>
  </card-component>
</template>

<script>
  import CardComponent from "./CardComponent.vue";

  export default {
    components: {
      CardComponent,
    },
  };
</script>

Pretty much the only difference for you as a developer is that you use the v-slot directive instead of the slot attribute. Similarly, Qwik for example uses a q:slot attribute.

You can check out the Vue.js example on StackBlitz too!

The Angular Way (ng-content)

In Angular, content projection is implemented using the <ng-content> tag. It is a tag that is used to mark the place where the content will be projected. It can be used in two ways:

  • As a tag with a select attribute, which is used to select the content that will be projected. The value of the select attribute is a CSS selector that is used to select the content that will be projected. If the select attribute is not present, all content will be projected.

  • As a tag without a select attribute, which will project all content.

Let's have look at an example of a receiving card component in Angular:

import { Component } from '@angular/core';

@Component({
  selector: 'app-card',
  standalone: true,
  template: `
    <div class="card">
      <div class="card-header">
        <ng-content select="[card-header]"></ng-content>
      </div>
      <div class="card-body">
        <ng-content select="[card-body]"></ng-content>
      </div>
      <div class="card-footer">
        <ng-content select="[card-footer]"></ng-content>
      </div>
    </div>
  `,
  styles: [
    `.card {
    border: 1px solid #ddd;
    border-radius: 4px;
    padding: 20px;
    margin-bottom: 20px;
  }`,
    `.card-header {
    font-weight: bold;
    margin-bottom: 10px;
  }`,
    `.card-body {
    margin-bottom: 10px;
  }`,
    `.card-footer {
    text-align: right;
    color: gray;
  }`,
  ],
})
export class CardComponent {}

And then, the projecting component can be used like this:

<app-card>
  <div card-header>
    <h1>Card Title</h1>
  </div>
  <div card-body>This is the main content of the card.</div>
  <div card-footer>
    <button>Click me</button>
  </div>
</app-card>

You can check out this example on StackBlitz.

As you can see, the Angular way is not very different from the Web Components way. It uses the <ng-content> tag instead of the <slot> tag and a select directive instead of the name (or q-name) attribute, but the principles are very much the same.

The React Way (children)

So far, we covered the "standard" way of content projection. In React, however, it is not so straightforward as it does not have a concept of "slots" or "content projection". We can still achieve something similar using the children prop. It is a prop that is used to pass children to a component. It can be used in two ways:

  • As a prop with a function as a value, which is used to select the content that will be projected. The function will be called for each child and it should return a React element. If the function returns null or undefined, the child will not be rendered. This way allows us to have "multiple content projection".

  • As a prop without a function as a value, which will project all content.

Let's see an example of a receiving card component in React:

import * as React from "react";

const styles = {
  card: {
    border: "1px solid #ddd",
    borderRadius: "4px",
    padding: "20px",
    marginBottom: "20px",
  },
  header: {
    fontWeight: "bold",
    marginBottom: "10px",
  },
  body: {
    marginBottom: "10px",
  },
  footer: {
    textAlign: "right",
    color: "gray",
  },
};

const CardComponent = ({ header, children, footer }) => {
  return (
    <div style={styles.card}>
      <div style={styles.header}>{header}</div>
      <div style={styles.body}>{children}</div>
      <div style={styles.footer}>{footer}</div>
    </div>
  );
};

export default CardComponent;

And then, we can project content like this:

import React from "react";
import CardComponent from "./CardComponent";

const App = () => {
  return (
    <CardComponent
      header={<h1>Card Title</h1>}
      footer={<button>Click me</button>}
    >
      This is the main content of the card.
    </CardComponent>
  );
};

export default App;

In this example, anything you pass as a header prop will be rendered in the header of the card component, and anything you pass as a footer prop will be rendered in the footer. Any children of the CardComponent will be rendered in the body.

Here's the StackBlitz to play with!

Conclusion

As we've seen, content projection is a powerful and handy concept widely adopted by front-end frameworks to enable reusability, component composition, and dynamic content management. The implementation may vary between different libraries, such as Angular's <ng-content>, Vue's slots, or React's children prop. Nevertheless, the underlying principle remains the same: providing a way to inject custom content into predefined placeholders within a component, thereby extending its flexibility and reusability.

We've taken a look at how this is achieved in Angular, Vue.js, Web Components, and even in React. But remember, just like with any tool or skill, it's important to think things through before you start implementing this in your app or library. It might be tempting to start throwing this into every component you build, but it's always worth taking a step back and considering whether it's the right tool for the job. Always aim for code that's not just flexible, but also easy to read, maintain, and efficient to run.