DEV Community

Cover image for Master the Art of Angular Content Projection
Khang Tran ⚡️
Khang Tran ⚡️

Posted on • Edited on

Master the Art of Angular Content Projection

Introduction

🚦 When it comes to creating customizable components in Angular, think of it as customizing your morning coffee - you've got your base (@Input(), @Output()) and your extras (*ngIf) to create the perfect brew. However, the more toppings you add to your morning coffee, the more your component gets too cozy with your underlying business logic.

Ideally, a component’s job is to enable only the user experience.

In this blog post, we're peeling back the layers to reveal its untapped potential to build Angular components that are both flexible and customizable ☕🚀.

Let's begin with Angular content projection.


1. What is Angular content projection? (<ng-content>)

Content projection is a pattern in which you insert or project, the content you want to use inside another component.

To illustrate this concept, consider the following example:

@Component({
  standalone: true,
  selector: 'app-greeny',
  template: `
    <p>Welcome to Greeny Land 🍀!</p>
    <ng-content></ng-content>
  `,
})
export class GreenyComponent {}

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, GreenyComponent],
  template: `
    <app-greeny>
      <p>I'm a Seeding 🌱, a new beginning and a fresh start.</p>
    </app-greeny>
  `,
})
export class App {}

bootstrapApplication(App);
Enter fullscreen mode Exit fullscreen mode

By adding <ng-content></ng-content> within the GreenyComponent template, you empower the dynamic inclusion and the seamless blending of content from the parent component.

Welcome to Greeny Land 🍀!
I'm a Seeding 🌱, a new beginning and a fresh start.
Enter fullscreen mode Exit fullscreen mode

2. Multi-slot content projection

Angular extends content projection beyond its basic concept by introducing multi-slot content projection, which allows content to be inserted into specific designated slots within a component, granting finer control over customization.

Let's explore this advanced feature through a practical example using the Pokémon API:

pokemon.component.ts

@Component({
  standalone: true,
  selector: 'app-pokemon',
  template: `
    <div class="header-wrapper">
      <ng-content select=".pokemon-header"></ng-content>
    </div>
    <div class="detail-wrapper">
      <ng-content select=".pokemon-detail"></ng-content>
    </div>
  `,
})
export class PokemonComponent {}
Enter fullscreen mode Exit fullscreen mode

We've defined the PokemonComponent to support multi-slot content projections by specifying a concrete select attribute (selector) for each <ng-content> slot:

  • pokemon-header
  • pokemon-detail

Angular supports selectors for any combination of tag name, attribute, CSS class, and the :not pseudo-class.

standard-pokemon.component.ts

Let's create StandardPokemonComponent to leverage the multi-slot content projection:

@Component({
  selector: 'app-standard-pokemon',
  standalone: true,
  imports: [PokemonComponent, TitleCasePipe],
  template: `
    <div class="standard">
      <app-pokemon [class]="pokemon.type">
        <div class="pokemon-header">
          <div class="number"><small>#{{pokemon.id}}</small></div>
          <img [src]="pokemon.image" [alt]="pokemon.name" />
        </div>
        <div class="pokemon-detail">
          <h3>{{pokemon.name | titlecase}}</h3>
          <small>Type: {{pokemon.type}}</small>
        </div>
      </app-pokemon>
    </div>
  `,
  styleUrls: ['./standard-pokemon.component.scss'],
})
export class StandardPokemonComponent {
  @Input() pokemon: any;
}
Enter fullscreen mode Exit fullscreen mode

To project content into the corresponding slot, you simply need to define a <div> element containing either the pokemon-header or pokemon-detail class.

The pokemon-header will include the Pokemon index and image,

<div class="pokemon-header">
  <div class="number"><small>#{{pokemon.id}}</small></div>
  <img [src]="pokemon.image" [alt]="pokemon.name" />
</div>
Enter fullscreen mode Exit fullscreen mode

while the pokemon-detail will contain the Pokemon name and type.

<div class="pokemon-detail">
  <h3>{{pokemon.name | titlecase}}</h3>
  <small>Type: {{pokemon.type}}</small>
</div>
Enter fullscreen mode Exit fullscreen mode

Additionally, we'll render the background color based on the Pokemon's type.

🔥 Now, let's bring our Pokémons to life:

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, StandardPokemonComponent],
  template: `
    <h1 class="title">Pokémon Evolution</h1>

    <div class="container">
      <ng-container *ngIf="pokemons$ | async as pokemonList">
        <ng-container *ngFor="let pokemon of pokemons">
          <app-standard-pokemon [pokemon]="pokemon"></app-standard-pokemon>
        </ng-container>
      </ng-container>
    </div>
  `,
  styles: [...]
})
export class App {
  pokemons$ = inject(PokemonService).getPokemons();
}
Enter fullscreen mode Exit fullscreen mode

Standard Pokemon (Angular Content Projection)

Link to StackBlitz

So far, we've learned how Angular's multi-slot content projection works. However, what is the practical use for it? Why don't we simply compose everything inside the PokemonComponent? Let's address these questions in the upcoming section.


3. It's time to make Content Projection shining

Baby Boss
Let's assume we've received a new requirement from our boss. Instead of just supporting standard Pokémon, he wants us to embrace the modern Pokémon style, as follows:

Modern Pokemon Design

Let's see how the ModernPokemonComponent implementation first:

@Component({
  selector: 'app-modern-pokemon',
  standalone: true,
  imports: [PokemonComponent, TitleCasePipe],
  template: `
    <div class="modern">
      <app-pokemon>
        <div class="pokemon-header">
          <div class="number"><small>{{pokemon.id}}</small></div>
          <img pokemon-image [src]="pokemon.artwork" [alt]="pokemon.name" />
        </div>
        <div class="pokemon-detail">
          <div class="headline">
            <h3 class="name">{{pokemon.name | titlecase}}</h3>
            <div class="type-wrapper">
              <small class="type">{{pokemon.type | titlecase}}</small>
              <small>{{getTypeEmoji(pokemon.type)}}</small>
            </div>
          </div>
          <div class="stat-wrapper">
            <div class="stat">
              <span class="stat-number">{{pokemon.attack}}</span>
              <span class="stat-name">Attack</span>
            </div>
            <div class="stat">
              <span class="stat-number">{{pokemon.defense}}</span>
              <span class="stat-name">Defense</span>
            </div>
            <div class="stat">
              <span class="stat-number">{{pokemon.speed}}</span>
              <span class="stat-name">Speed</span>
            </div>
          </div>
        </div>
      </app-pokemon>
    </div>
  `,
  styleUrls: ['./modern-pokemon.component.scss'],
})
export class ModernPokemonComponent {
  @Input() pokemon: any;

  getTypeEmoji(type: string): string {
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

The Pokémon header remains unchanged, while the Pokémon detail section comprises two main components: the headline and the stats.

  • headline
<div class="headline">
  <h3 class="name">{{pokemon.name | titlecase}}</h3>
  <div class="type-wrapper">
    <small class="type">{{pokemon.type | titlecase}}</small>
    <small>{{getTypeEmoji(pokemon.type)}}</small>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode
  • stats
<div class="stat-wrapper">
  <div class="stat">
    <span class="stat-number">{{pokemon.attack}}</span>
    <span class="stat-name">Attack</span>
  </div>
  <div class="stat">
    <span class="stat-number">{{pokemon.defense}}</span>
    <span class="stat-name">Defense</span>
  </div>
  <div class="stat">
    <span class="stat-number">{{pokemon.speed}}</span>
    <span class="stat-name">Speed</span>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

As you can see, the pokemon-detail is entirely different. However, we don't need to make any changes to the PokemonComponent to support it. Everything is handled separately within the ModernPokemonComponent. Therefore, we can ensure that the StandardPokemonComponent remains unchanged without any effects.

Thanks to Content Projection, we can accommodate two distinct Pokémon cards with entirely different styles.

Separation of Concerns

The UI part is separately handled by StandardPokemonComponent and ModernPokemonComponent

Open-Closed Principle

The Content Projection helps us avoid getting our hands dirty by adding ModernPokemonComponent without modifying the common PokemonComponent.

Modern Pokemon Demo

Link to StackBlitz


Final thought

Thank you for making it to the end! Content Projection is a fundamental tool that helps us create flexible components. We will continue this series by delving into another powerful Angular tool, *ngTemplateOutlet. If you found something interesting in this post or gained some value from it, please let me know by leaving a comment or reacting to this post. Your feedback and engagement are a significant source of motivation that keeps me inspired to continue publishing my next blog. ❤️


Read more:

Demystifying the Angular Structural Directives in a nutshell

Top comments (0)