~9 min de lecture
ng-content : 6 patterns de projection Angular qui séparent le composant réutilisable du jetable
Tu as écrit une Card. Tu l'exposes à trois équipes. Une semaine plus tard tu reçois trois tickets. La première équipe se plaint que le header est vide quand elle ne le remplit pas et ça casse le layout. La deuxième te demande pourquoi son groupe d'éléments enveloppé dans un <ng-container> n'apparaît nulle part. La troisième t'écrit "on veut afficher Aucune donnée quand personne ne passe de contenu, comment on détecte ça ?".
Le point commun de ces trois tickets : <ng-content> n'est pas un trou magique dans lequel on jette du contenu. C'est une API avec des règles précises, des ordres de résolution, une gestion des cas vides, et des mécanismes d'override côté consumer que 80% des devs ne connaissent pas.
Voici les 6 patterns qui font qu'un composant projette proprement, sans obliger tes consumers à connaître ton HTML interne.
Valide Angular 18+ pour le fallback
<ng-content>, Angular 17.2+ pourcontentChild()en preview, 19+ en stable.
TL;DR
| Pattern | Problème | Solution |
|---|---|---|
| 1. Fallback content | Slot vide casse le layout | <ng-content>Default</ng-content> |
2. Multi-slot select |
Croyances fausses sur l'ordre | Wildcard = fallback positionnel-agnostique, gaffe aux sélecteurs qui se chevauchent |
3. ngProjectAs |
<ng-container> invisible sur un slot à sélecteur de tag |
ngProjectAs="tag" sur le wrapper |
| 4. Détection slot vide | :empty CSS ne marche pas avec les commentaires ng |
contentChild() signal + hasActions |
| 5. Content queries typées | @ContentChild sans type explicite |
contentChild.required<Header>(Header) |
6. Portails via ng-template |
Contenu qui a besoin du contexte | TemplateRef projeté + *ngTemplateOutlet |
La règle d'or : un slot sans fallback est une bombe visuelle, un slot sans détection d'état vide est un bug latent.
Le composant qui casse partout
Voici la Card de base, celle qu'on retrouve dans 100% des design systems maison :
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<header class="card-header">
<ng-content select="[card-title]" />
</header>
<div class="card-body">
<ng-content />
</div>
<footer class="card-footer">
<ng-content select="[card-actions]" />
</footer>
`,
styles: `
.card-header, .card-footer { padding: 1rem; border-bottom: 1px solid #eee; }
.card-body { padding: 1rem; }
`,
})
export class Card {}
Le consumer l'utilise :
<app-card>
<h2 card-title>Facture #4212</h2>
<p>Montant : 240 EUR</p>
</app-card>
Trois problèmes immédiats :
- Le
<footer>s'affiche vide et crée une bordure inutile. - Si le consumer wrap son titre dans
@if (isReady()) { <h2 card-title>...</h2> }, plus rien ne s'affiche. - Impossible côté composant de savoir si le slot
card-actionsa reçu du contenu ou pas.
On corrige ça un pattern à la fois.
Pattern 1 : le fallback content, stable depuis Angular 18
Avant Angular 18, le contenu entre les balises <ng-content> était ignoré. Depuis, il devient le contenu par défaut affiché quand rien n'est projeté. C'est le premier pattern à appliquer partout où tu as un slot optionnel.
@Component({
selector: 'app-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<header class="card-header">
<ng-content select="[card-title]">
<h2>Sans titre</h2>
</ng-content>
</header>
<div class="card-body">
<ng-content>
<p class="text-muted">Aucun contenu.</p>
</ng-content>
</div>
<footer class="card-footer">
<ng-content select="[card-actions]" />
</footer>
`,
})
export class Card {}
Le fallback est rendu uniquement si aucun élément ne matche le sélecteur. Dès qu'un consumer projette quelque chose, ton contenu par défaut disparaît. Zéro logique en TypeScript, zéro @if dans le template. C'est purement structurel, résolu au compile-time.
Petite subtilité : le fallback est stateful. S'il contient des bindings à des signals du composant Card, ils réagissent normalement. Tu peux mettre <h2>{{ defaultTitle() }}</h2> dans le fallback, ça se met à jour comme n'importe quel binding.
Pattern 2 : multi-slot select, ce que l'ordre de déclaration contrôle vraiment
Le multi-slot est plein de croyances fausses. La plus répandue : « le <ng-content /> sans select avale tout ce qui est déclaré après lui ». C'est faux, et le corriger t'évite d'écrire du code défensif inutile.
Le wildcard <ng-content /> (sans select) est un fallback traité indépendamment de sa position. Il ne capture que ce qu'aucun select explicite n'a matché, où qu'il soit déclaré. Ce template fonctionne parfaitement, même avec le wildcard en premier :
@Component({
selector: 'app-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="body"><ng-content /></div>
<footer><ng-content select="[card-actions]" /></footer>
`,
})
export class Card {}
Un <button card-actions> projeté va bien dans le <footer>, pas dans le body. Le wildcard ne récupère que le reste. Retiens ça : tu n'as pas besoin de placer le catch-all "en dernier logique" pour la résolution.
Alors qu'est-ce que l'ordre de déclaration contrôle vraiment ? Deux choses concrètes.
1. L'ordre de rendu. Les <ng-content> sont rendus dans l'ordre du template. Si tu déclares <footer> avant <div class="body">, le footer sort au-dessus du body, quel que soit l'ordre côté consumer.
2. Le tie-break des sélecteurs qui se chevauchent. Un nœud qui matche deux select explicites va au premier déclaré. Exemple vérifié : avec ces deux slots,
template: `
<div class="actions"><ng-content select="[card-actions]" /></div>
<div class="btn"><ng-content select="button" /></div>
`,
un <button card-actions> matche les deux. Il atterrit dans [card-actions] (déclaré en premier) et le slot button reste vide. C'est ça, le vrai piège de l'ordre : pas le wildcard, mais les sélecteurs qui se recouvrent.
Reste la question de l'ordre visuel. Comme l'ordre de déclaration = ordre de rendu, tu ne peux pas mettre un catch-all "en dernier déclaré mais au milieu visuellement". Deux options propres :
Option A — un select explicite sur chaque slot, pas de catch-all. Tu gardes ton ordre visuel intact, le consumer marque chaque bloc :
@Component({
selector: 'app-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<header>
<ng-content select="[card-title]" />
</header>
<div class="body">
<ng-content select="[card-body]" />
</div>
<footer>
<ng-content select="[card-actions]" />
</footer>
`,
})
export class Card {}
Consumer :
<app-card>
<h2 card-title>Facture</h2>
<p card-body>Montant : 240 EUR</p>
<button card-actions>Payer</button>
</app-card>
Option B — un catch-all, mais alors il est le dernier déclaré ET le dernier rendu. Utile quand le "body" arrive naturellement à la fin du composant :
@Component({
selector: 'app-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<header>
<ng-content select="[card-title]" />
</header>
<footer>
<ng-content select="[card-actions]" />
</footer>
<div class="body">
<ng-content />
</div>
`,
})
export class Card {}
Le rendu produit header, puis footer, puis body. Ordre visuel discutable pour une Card, parfait pour un layout où le contenu par défaut est effectivement en bas (footer-first sticky, ou tabs où le body vient sous les onglets).
Règle mémo : dès que tu veux garder un ordre visuel non-trivial (body au milieu), passe en Option A et renonce au catch-all. Le catch-all n'est jamais un raccourci "pour ne pas typer chaque slot", c'est un choix structurel qui te coûte l'ordre visuel.
Autre pattern à préférer dans les deux options : les attribute selectors ([card-title]) plutôt que les sélecteurs de type (h2, button). Un attribute selector laisse ton consumer choisir n'importe quel élément (<h2 card-title>, <div card-title>, <my-heading card-title>) sans que tu imposes le tag.
Pattern 3 : ngProjectAs, quand un <ng-container> doit matcher un sélecteur qu'il n'a pas
D'abord, tuons un mythe tenace : non, un @if côté consumer ne casse pas la projection dans un slot nommé. Ce code marche, le <h2> atterrit bien dans le header :
<app-card>
@if (invoice(); as inv) {
<h2 card-title>{{ inv.title }}</h2>
}
</app-card>
Le control flow moderne (@if, @for) est conscient de la projection. La croyance inverse vient de l'ère *ngIf, où la lore disait qu'il fallait ngProjectAs. Avec des attribute selectors, tu n'en as pas besoin. Ne colle pas de ngProjectAs partout par réflexe.
Le vrai cas où ngProjectAs est indispensable est plus précis : projeter un <ng-container> dans un slot qui utilise un sélecteur de tag (ou de composant), pas d'attribut.
Un <ng-container> ne rend aucun DOM et n'a pas d'identité de tag. Si ton composant expose un slot ciblé par tag :
@Component({
selector: 'app-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<header><ng-content select="card-title" /></header>`,
})
export class Card {}
alors ce code du consumer ne projette rien (header vide), parce que le <ng-container> ne matche pas le tag card-title :
<app-card>
<ng-container>
<b>{{ first }}</b> <b>{{ last }}</b>
</ng-container>
</app-card>
ngProjectAs règle exactement ça. Il dit au moteur de projection "considère ce nœud comme s'il matchait ce sélecteur", sans rendre quoi que ce soit :
<app-card>
<ng-container ngProjectAs="card-title">
<b>{{ first }}</b> <b>{{ last }}</b>
</ng-container>
</app-card>
Le header reçoit maintenant les deux <b>, groupés, sans wrapper DOM parasite.
Note au passage : si ton slot est un attribute selector (select="[card-title]"), tu n'as même pas besoin de ngProjectAs, un simple attribut sur le <ng-container> suffit (<ng-container card-title>). C'est le cas le plus courant, et le plus propre. Garde ngProjectAs pour les slots à sélecteur de tag, où l'attribut ne peut pas t'aider.
Pattern 4 : détecter un slot vide avec contentChild()
Le fallback content résout le cas "slot vide → affiche un contenu par défaut". Il ne résout pas le cas "slot vide → cache complètement le wrapper".
Reviens à la Card. Tu veux que si personne ne projette dans card-actions, tout le <footer> disparaisse (pas juste son contenu, la bordure aussi).
Solution naïve avec du CSS : .card-footer:empty { display: none; }. Ça ne marche pas. Angular projette même quand il n'y a rien, en insérant des nœuds de commentaire <!--container--> qui rendent le pseudo :empty faux.
La bonne solution combine contentChild() en signal query avec une directive-ancre attachée au même attribute selector que celui utilisé pour la projection. Un seul token côté consumer, cohérence garantie entre "ce qui est projeté" et "ce qui est détecté".
// card-actions.ts
import { Directive } from '@angular/core';
@Directive({
selector: '[card-actions]',
})
export class CardActions {}
// card.ts
import { ChangeDetectionStrategy, Component, contentChild } from '@angular/core';
import { CardActions } from './card-actions';
@Component({
selector: 'app-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<header>
<ng-content select="[card-title]" />
</header>
<div class="body">
<ng-content />
</div>
@if (hasActions()) {
<footer>
<ng-content select="[card-actions]" />
</footer>
}
`,
})
export class Card {
protected readonly hasActions = contentChild(CardActions);
}
Côté consumer, un seul attribut :
<app-card>
<h2 card-title>Facture</h2>
<p>Contenu</p>
<button card-actions>Payer</button>
</app-card>
Le bouton card-actions est projeté dans le slot <ng-content select="[card-actions]"> et détecté par contentChild(CardActions). Le signal renvoie l'instance de directive si présente, undefined sinon. Le @if cache complètement le <footer> et sa bordure quand rien n'est projeté.
Pourquoi pas juste une template reference variable ? Parce que #cardActions sur un élément ne le fait pas matcher [card-actions] dans le select. Ce sont deux mécanismes déconnectés : la query trouverait l'élément, mais la projection ne le routerait nulle part. La directive-ancre unifie les deux via un seul attribut source de vérité.
Cas multi-éléments : si le consumer veut projeter plusieurs boutons, il colle card-actions sur un <ng-container> :
<app-card>
<ng-container card-actions>
<button>Payer</button>
<button>Annuler</button>
</ng-container>
</app-card>
Alternative sans query : exposer un input() booléen showActions que le consumer positionne à la main. Fonctionne, mais duplique l'état (le consumer doit dire "j'ai projeté ET je te confirme que je l'ai fait"), c'est fragile.
Pattern 5 : content queries typées avec contentChild.required
Quand ton slot attend un composant précis, ne te contente pas de "un truc est là". Type-le. Signal query API + required te force à ce que le consumer projette exactement ce que tu attends, sous peine d'erreur claire au runtime.
import { ChangeDetectionStrategy, Component, contentChild } from '@angular/core';
import { CardHeader } from './card-header';
@Component({
selector: 'app-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [],
template: `
<ng-content select="app-card-header" />
<div class="body">
<ng-content />
</div>
`,
})
export class Card {
private readonly header = contentChild.required(CardHeader);
syncTitle(title: string): void {
this.header().setTitle(title);
}
}
Si le consumer oublie <app-card-header>, contentChild.required throw un NG0951 clair au premier accès. Bien meilleur qu'un null pointer obscur quinze frames plus loin.
Pour les collections, contentChildren() renvoie un signal de tableau, tu peux le computed() pour dériver un état :
private readonly tabs = contentChildren(Tab);
protected readonly activeTab = computed(() => this.tabs().find(t => t.active()));
Pattern 6 : TemplateRef projeté quand tu as besoin du contexte
Le dernier pattern, souvent oublié : parfois tu ne veux pas projeter du HTML statique, tu veux projeter un template que le composant va instancier avec ses propres données.
Cas typique : un composant DataTable qui laisse le consumer customiser le rendu de chaque cellule tout en recevant la ligne courante en paramètre.
import { ChangeDetectionStrategy, Component, contentChild, input, TemplateRef } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
@Component({
selector: 'app-data-table',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgTemplateOutlet],
template: `
<table>
@for (row of rows(); track row.id) {
<tr>
<ng-container
[ngTemplateOutlet]="rowTemplate()"
[ngTemplateOutletContext]="{ $implicit: row }"
/>
</tr>
}
</table>
`,
})
export class DataTable<T extends { id: string }> {
readonly rows = input.required<T[]>();
protected readonly rowTemplate = contentChild.required<TemplateRef<{ $implicit: T }>>('row');
}
Consumer :
<app-data-table [rows]="invoices()">
<ng-template #row let-invoice>
<td>{{ invoice.number }}</td>
<td>{{ invoice.amount | currency }}</td>
<td>
<button (click)="pay(invoice)">Payer</button>
</td>
</ng-template>
</app-data-table>
Le pattern combine contentChild.required pour récupérer le TemplateRef, et NgTemplateOutlet pour l'instancier avec le contexte. Résultat : le consumer garde tout son typage (invoice est bien typé T), et ton composant reste totalement agnostique du rendu.
C'est le pattern qu'utilise le CDK pour toutes ses primitives (cdk-table, cdk-tree, cdk-portal). Une fois que tu l'as en tête, tu ne repasses plus jamais par un <ng-content> bête pour ce genre de cas.
Récap actionnable
Ce qui tombe dans ton kit ng-content :
- Toujours un fallback content sur les slots optionnels. Zéro exception.
selectavec attribute selectors. Le wildcard<ng-content />est un fallback indépendant de sa position. L'ordre de déclaration ne joue que sur le rendu et le tie-break des sélecteurs qui se chevauchent.ngProjectAsseulement pour projeter un<ng-container>dans un slot à sélecteur de tag. Le control flow@ifne casse pas la projection : pas dengProjectAspar réflexe.- Slot conditionnel qui doit cacher son wrapper ?
contentChild()+@if. Jamais du:emptyCSS. - Slot qui attend un composant précis ?
contentChild.required(MonComposant)pour un fail-fast propre. - Slot qui a besoin du contexte du composant hôte ?
TemplateRefprojeté +NgTemplateOutlet. C'est le pattern CDK.
Un composant qui applique ces 6 règles, c'est un composant que tes consumers peuvent utiliser sans lire le code source. C'est aussi celui qui ne génère pas de tickets support à répétition. Ce qui est, spoiler, la vraie mesure de la réutilisabilité.