Master the Art of Angular Content Projection
Khang Tran ⚡️
Posted on October 29, 2023
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);
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.
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 {}
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;
}
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>
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>
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();
}
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
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:
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 {
...
}
}
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>
-
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>
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
.
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
Posted on October 29, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.