Content projection means a component displays content it receives from its parent, written between its tags. It is the mechanism behind any “container” component: card, dialog, layout, panel, tab. The component defines where the content appears with <ng-content>; the parent decides what appears.
Multiple slots with select
@Component({
selector: 'app-card',
template: `
<header>
<ng-content select="[card-title]">No title</ng-content> <!-- fallback (v18+) -->
<ng-content select="[card-actions]" />
</header>
<div class="body">
<ng-content /> <!-- the default slot: the rest -->
</div>
<footer>
<ng-content select="app-card-footer" /> <!-- after the tag -->
</footer>
`,
})
export class Card {}3 products · 1,249 RON · delivery tomorrow.
Card without a title and without a footer: empty slots use the fallback or disappear.
ngProjectAsUseful when the wrapper cannot carry the selection attribute.
What can go in select
<ng-content select="app-card-footer" /> <!-- tag -->
<ng-content select="[card-title]" /> <!-- attribute -->
<ng-content select=".highlight" /> <!-- class -->
<ng-content select="h2, h3" /> <!-- list of selectors -->
<ng-content select="button:not(.secondary)" /> <!-- :not() -->
<ng-content /> <!-- everything not caught by the others -->ngProjectAs
<!-- You want to project several elements into one slot, without a wrapper in the DOM -->
<app-card>
<ng-container ngProjectAs="[card-title]">
<app-icon name="box" /> Order #1042
</ng-container>
</app-card>The important trap: projected content is created anyway
Projected components are instantiated by the parent, as part of the parent’s template. If the child puts <ng-content> inside an @if, only the DOM is hidden — the components already exist, their constructors have run, requests have started. And <ng-content> cannot be “projected” twice. The solution: receive an <ng-template> and instantiate it when you really need it.
- ExpensiveWidget #4 CREATED
// ✓ Receives a template and instantiates it only when needed
@Component({
selector: 'app-collapsible',
imports: [NgTemplateOutlet],
template: `
<button (click)="open.set(!open())">Toggle</button>
@if (open()) { <ng-container [ngTemplateOutlet]="content() ?? null" /> }
`,
})
export class Collapsible {
readonly content = contentChild(TemplateRef);
readonly open = signal(false);
}
// usage:
// <app-collapsible>
// <ng-template><app-heavy-report /></ng-template>
// </app-collapsible>// ✗ The projected content is created by the PARENT, whether or not the child displays it.
@Component({
selector: 'app-collapsible',
template: `
<button (click)="open.set(!open())">Toggle</button>
@if (open()) { <ng-content /> } <!-- hides the DOM, but the components already exist -->
`,
})
export class Collapsible {
readonly open = signal(false);
}Projection or input?
// Projection vs input — how do you choose?
// Input: the component controls HOW the data looks (fixed, consistent format)
<app-user-chip [user]="user()" />
// Projection: the parent controls WHAT appears (free markup, flexible)
<app-dialog>
<h2 dialog-title>Delete the project?</h2>
<p>This action cannot be undone.</p>
<button dialog-actions (click)="confirm()">Delete</button>
</app-dialog>- Container components: card, dialog, drawer, layout, panel.
- The parent needs free markup (links, icons, its own buttons).
- You want to avoid dozens of inputs like
titleIcon,titleColor,showTitleBadge...
- The data must always look the same (visual consistency) → inputs.
- The component must process the data (sorting, formatting) → inputs.
- The content must be repeated or created conditionally → ng-template.
How to tell whether a slot is empty
A card with a footer sometimes needs to hide the footer border too when nothing is passed in. You have two options. If the slot receives a component or a directive, contentChild() tells you whether it exists. If it is plain markup, CSS :empty on the element around the slot is enough.
@Component({
selector: 'app-card',
template: `
<div class="body"><ng-content /></div>
@if (footer()) {
<!-- the condition is on the frame; the footer content is created anyway -->
<footer><ng-content select="app-card-footer" /></footer>
}
`,
})
export class Card {
protected readonly footer = contentChild(CardFooter); // is there an <app-card-footer> in the content?
}
/* or, for plain markup: */
.actions:empty {
display: none;
}Who styles the projected content
Projected content belongs to the parent: it is created in the parent’s template, so it gets the parent’s styles, not those of the component that displays it. The component’s styles only apply to its own elements. So the component styles the “frame” (spacing, border, position), and the parent styles what it puts inside. If you still need to reach the projected content, use CSS variables on :host (see Component styles).
/* card.css — the component styles the frame */
:host {
display: block;
padding: var(--card-padding, 1rem);
border: 1px solid var(--border);
}
p {
color: red; /* ✗ does not reach the <p> received through projection */
}
/* page.css — the parent styles what it puts in the card */
app-card p {
color: var(--text-2);
}Common pitfalls
- select on elements inside the parent’s @if/@for: it works only if the element with the attribute is the “root” of the block; otherwise it ends up in the default slot. Use
ngProjectAson anng-container. - The same
<ng-content>twice: the content appears only in the last one. For repetition use templates. - Styling projected content: the component’s CSS does not reach the parent’s elements (encapsulation). Style the wrapper or use CSS variables.
Cheat sheet
<ng-content /> <!-- the default slot: everything no other select catches -->
<ng-content select="[card-title]" /> <!-- by attribute -->
<ng-content select="app-card-footer" /> <!-- by tag -->
<ng-content select="h2, h3" /> <!-- a list of selectors -->
<ng-content select="[card-title]">No title</ng-content> <!-- default content (v18+) -->
<h2 ngProjectAs="[card-title]">...</h2> <!-- looks like another element to select -->
<!-- projected content is ALWAYS created, even if the slot is hidden → on-demand creation: ng-template -->
<!-- the styles of projected content come from the parent -->