Angular 69 🅰️ Content Projection and ng-content
Content projection is how Angular lets a parent component pass markup into a child component, and the child decides where that markup appears in its own template. It is the mechanism behind every reusable card, modal, tab, and layout component you have ever used. Without it, a component like <app-card> would be a black box with hardcoded internals. With it, the same component becomes a shell that accepts a header, a body, a footer, and an action bar, each written by the caller and placed by the child.
The concept sounds simple — “put this HTML over there” — but Angular’s implementation has real depth. There are single-slot and multi-slot projections, fallback content when nothing is provided, conditional projection based on whether content exists, and a subtle but critical rule about where the projected content’s change detection actually runs. This chapter covers all of it, plus the modern ng-content behavior changes that landed in recent Angular versions.
Key point: <ng-content> is not a component and not a DOM element. It is a special placeholder that the Angular compiler processes at build time. You cannot insert, remove, or modify <ng-content> at runtime, and you cannot add directives, styles, or arbitrary attributes to it . The <ng-content> element itself never appears in the rendered DOM — it is replaced by the projected content.
Why content projection exists
Static templates and input bindings handle most data flow in Angular applications. A parent passes data to a child via @Input, and the child renders it. But this model breaks down when the parent needs to pass markup, not just data.
The layout problem. A card component needs to arrange its content in a specific visual structure: header at top, body in the middle, footer at the bottom. The parent knows what content to show, but the card knows where each piece goes. Content projection bridges this gap — the parent supplies the pieces, the child arranges them.
The reusable container problem. A modal dialog needs to be usable for confirmation prompts, information displays, and form containers. Each use case has different content but the same chrome: backdrop, close button, animation. Content projection lets the modal define the chrome and the caller define the content.
The flexibility problem. Without projection, a component would need dozens of @Input properties to cover every possible configuration — title, subtitle, bodyText, footerText, showCloseButton, iconName. This becomes unmanageable. Projection replaces all of those inputs with markup slots.
The dynamic content problem. Sometimes the content itself contains directives, bindings, and event handlers. @Input can pass data but not markup with behavior. Projection preserves the full Angular template context of the parent’s content.
The trade-off. Content projection adds indirection. The child component’s template defines slots, but the actual content lives in the parent. This means the child cannot easily inspect or manipulate the content without ContentChild queries, and debugging can be harder because the rendered DOM doesn’t match either component’s template in isolation. Use projection when the structure is fixed but the content varies. Use inputs when the content is simple data.
a. Single-Slot Projection: The Default Case
The simplest form of content projection uses a bare <ng-content> element with no attributes. Whatever markup the parent places between the child’s opening and closing tags gets rendered at that spot. The <ng-content> element itself never appears in the DOM — it is a placeholder that Angular replaces with the projected nodes .
@Component({
selector: 'app-card',
template: `
<div class="card">
<ng-content></ng-content>
</div>
`,
})
export class CardComponent {}
A parent uses it like this:
@Component({
selector: 'app-root',
template: `
<app-card>
<h2>Title</h2>
<p>Body text goes here.</p>
</app-card>
`,
})
export class AppComponent {}
The <h2> and <p> are authored in AppComponent, but they render inside CardComponent‘s .card div. This distinction matters: the projected content belongs to the parent’s template, so its bindings, event handlers, and change detection all run in the parent’s context. The child only controls where it appears, not what it does. This is the single most important thing to internalize about content projection — it is not a transfer of ownership, only a transfer of placement.
The <ng-content> element supports Angular-specific functionality similar to the native <slot> element, but with important differences. Unlike <slot>, <ng-content> is processed entirely at compile time. You cannot use it dynamically, cannot apply conditional logic to it, and cannot attach directives to it . It is a static marker.
A critical limitation: you should not conditionally include <ng-content> with @if, @for, or @switch. Angular always instantiates and creates DOM nodes for content rendered to a <ng-content> placeholder, even if that placeholder is hidden . If you need conditional rendering of component content, use <ng-template> with ngTemplateOutlet instead. The template approach defers content initialization until the template is explicitly rendered, while ng-content initializes content regardless of whether the slot is visible .
b. Multi-Slot Projection: select and ngProjectAs
Real components usually need more than one insertion point. A card might need a header, a body, and a footer, each styled differently. Multi-slot projection handles this with the select attribute on <ng-content>. Angular matches projected nodes against these selectors and routes each one to the correct slot .
@Component({
selector: 'app-card',
template: `
<div class="card">
<div class="card-header">
<ng-content select="[card-header]"></ng-content>
</div>
<div class="card-body">
<ng-content></ng-content>
</div>
<div class="card-footer">
<ng-content select="[card-footer]"></ng-content>
</div>
</div>
`,
})
export class CardComponent {}
Usage:
<app-card>
<h2 card-header>Title</h2>
<p>Body text.</p>
<button card-footer>Save</button>
</app-card>
The select attribute accepts any valid CSS selector: element names, class names, attribute selectors, and combinations. Angular supports selectors for any combination of tag name, attribute, CSS class, and the :not pseudo-class . The header and footer are routed to their slots by attribute selector, while the <p> with no matching attribute falls into the catch-all <ng-content> with no select.
That catch-all is important — any projected node that does not match a selective slot ends up there. If you include one or more <ng-content> placeholders with a select attribute and one without, the latter captures all elements that did not match a select attribute . If you omit the catch-all, unmatched content is silently discarded and does not render into the DOM . This is the first thing to check when projected content mysteriously disappears.
There is a limitation worth knowing: select cannot use Angular component selectors directly. If you want to project a specific Angular component into a named slot, you wrap it or use the ngProjectAs attribute.
<app-card>
<app-header-component ngProjectAs="[card-header]"></app-header-component>
<p>Body text.</p>
</app-card>
ngProjectAs tells Angular “treat this element as if it matched this selector” for projection purposes . It is the standard workaround when the element you want to project is a component whose tag name you cannot use in a select. ngProjectAs supports only static values and cannot be bound to dynamic expressions . For multi-layer projection scenarios — where content projected into one component needs to be further projected into another — ngProjectAs is the key mechanism that tells Angular to route transcluded content further down the tree .
c. Fallback Content, Conditional Projection, and Change Detection
Angular lets you provide default content inside <ng-content> that renders only when the parent projects nothing into that slot.
@Component({
selector: 'app-card',
template: `
<div class="card">
<div class="card-header">
<ng-content select="[card-header]">
<h2>Default Title</h2>
</ng-content>
</div>
<ng-content></ng-content>
</div>
`,
})
export class CardComponent {}
If the parent provides a [card-header] element, the default <h2> disappears. If not, it renders. This is fallback content, and it works per-slot . It is the cleanest way to give a component a sensible default appearance without forcing the caller to supply everything.
More advanced patterns need to know whether content was projected at all — to conditionally render a wrapper, apply a class, or hide a border. Angular exposes this through the ContentChild and ContentChildren decorators, which query projected content. Combined with a template reference variable and @ContentChild, you can detect presence and adjust the surrounding markup.
@Component({
selector: 'app-card',
template: `
<div class="card" [class.has-header]="header">
<ng-content select="[card-header]"></ng-content>
<ng-content></ng-content>
</div>
`,
})
export class CardComponent {
@ContentChild('header') header?: TemplateRef<unknown>;
}
The has-header class only applies when a #header reference exists in the projected content, letting you style the card differently when a header is present versus absent.
Now the change detection rule, which is the part most developers get wrong. Projected content is owned by the parent. Its bindings are checked as part of the parent’s change detection cycle, not the child’s . This means if the child component uses OnPush and the parent does not, the projected content still updates on every tick because the parent is what drives it. Conversely, if the parent uses OnPush and the projected content depends on parent state that changes without a new reference, the projected content will not update even though the child re-renders.
The technical mechanism is in Angular’s change detection implementation. When a component with projected views is checked, Angular explicitly checks projected views as part of the parent’s cycle. The CheckAndUpdateProjectedViews and CheckProjectedViews actions in the change detection logic ensure that views created from projected templates are checked when their owning component is checked . This is why a component with OnPush can still have its projected content updated — the update happens during the parent’s check, not the component’s own.
There is also a behavior change in modern Angular worth flagging. Angular 18 introduced a change where ng-content no longer creates a Comment node in the DOM as a placeholder. Older code that relied on that comment node for positioning or queries may behave differently. The same release line also tightened the rules around projecting the same content into multiple slots — a node can only be projected into one slot, and Angular now warns or errors on attempts that previously slipped through silently.
Complete Example Session
This session builds a reusable modal component with header, body, footer, and fallback behavior, then verifies change detection.
// ============================================
// PART 1: THE MODAL WITH THREE SLOTS
// ============================================
import { Component, ContentChild, TemplateRef, input, output } from '@angular/core';
@Component({
selector: 'app-modal',
template: `
<div class="modal-backdrop" [class.open]="open()">
<div class="modal">
<div class="modal-header">
<ng-content select="[modal-title]">
<h3>Untitled</h3>
</ng-content>
<button class="close" (click)="close.emit()">×</button>
</div>
<div class="modal-body">
<ng-content></ng-content>
</div>
<div class="modal-footer">
<ng-content select="[modal-actions]"></ng-content>
</div>
</div>
</div>
`,
})
export class ModalComponent {
open = input(false);
close = output<void>();
@ContentChild('body') body?: TemplateRef<unknown>;
}
// ============================================
// PART 2: THE PARENT USAGE
// ============================================
@Component({
selector: 'app-root',
template: `
<app-modal [open]="isOpen" (close)="isOpen = false">
<h2 modal-title>Delete item?</h2>
<p>This action cannot be undone.</p>
<div modal-actions>
<button (click)="isOpen = false">Cancel</button>
<button (click)="confirmDelete()">Delete</button>
</div>
</app-modal>
`,
})
export class AppComponent {
isOpen = false;
confirmDelete() { /* ... */ }
}
// ============================================
// PART 3: THE CHILD COMPONENT WITH ngProjectAs
// ============================================
@Component({
selector: 'app-icon-button',
template: `<button><i class="icon"></i> {{ label() }}</button>`,
})
export class IconButtonComponent {
label = input.required<string>();
}
@Component({
selector: 'app-toolbar',
template: `
<div class="toolbar">
<ng-content select="[toolbar-start]"></ng-content>
<span class="spacer"></span>
<ng-content select="[toolbar-end]"></ng-content>
</div>
`,
})
export class ToolbarComponent {}
// Parent uses ngProjectAs to route the component into a slot
@Component({
selector: 'app-page',
template: `
<app-toolbar>
<app-icon-button ngProjectAs="[toolbar-start]" label="Menu" />
<app-icon-button ngProjectAs="[toolbar-end]" label="Settings" />
</app-toolbar>
`,
})
export class PageComponent {}
// ============================================
// PART 4: THE FALLBACK CONTENT
// ============================================
@Component({
selector: 'app-card',
template: `
<div class="card">
<div class="card-header">
<ng-content select="[card-header]">
<h2>Default Title</h2>
</ng-content>
</div>
<ng-content></ng-content>
</div>
`,
})
export class FallbackCardComponent {}
// Usage with no header: fallback renders
<app-card>
<p>Just body content.</p>
</app-card>
// Usage with header: fallback replaced
<app-card>
<h2 card-header>Custom Title</h2>
<p>Body content.</p>
</app-card>
// ============================================
// PART 5: THE CHANGE DETECTION VERIFICATION
// ============================================
@Component({
selector: 'app-parent',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-modal>
<p>{{ counter() }}</p>
</app-modal>
`,
})
export class ParentComponent {
counter = signal(0);
}
// Because the projected <p> belongs to ParentComponent,
// and ParentComponent uses OnPush, the paragraph updates
// when counter changes only if counter is a signal
// or the parent is otherwise marked for check.
// ============================================
// PART 6: THE WRONG CONDITIONAL PROJECTION
// ============================================
// ❌ Don't conditionally wrap ng-content
@Component({
selector: 'app-bad-modal',
template: `
@if (showHeader) {
<ng-content select="[title]"></ng-content>
}
`,
})
export class BadModalComponent {}
// The projected content is still initialized even when hidden.
// Use ng-template instead for conditional content.
// ============================================
// PART 7: THE CORRECT CONDITIONAL APPROACH
// ============================================
@Component({
selector: 'app-good-modal',
template: `
@if (showHeader) {
<ng-container [ngTemplateOutlet]="headerTemplate()" />
}
`,
})
export class GoodModalComponent {
headerTemplate = contentChild.required(TemplateRef);
}
// Usage:
// <app-good-modal>
// <ng-template #headerTemplate>
// <h2>Conditional Title</h2>
// </ng-template>
// </app-good-modal>
// ============================================
// PART 8: THE CONTENTCHILD PRESENCE CHECK
// ============================================
@Component({
selector: 'app-card-with-check',
template: `
<div class="card" [class.has-header]="header">
<ng-content select="[card-header]"></ng-content>
<ng-content></ng-content>
</div>
`,
})
export class CardWithCheckComponent {
@ContentChild('header') header?: TemplateRef<unknown>;
}
// ============================================
// PART 9: THE MULTI-LAYER PROJECTION
// ============================================
// Layer 1: parent -> my-component
// Layer 2: my-component -> my-header-component
@Component({
selector: 'my-component',
template: `
<my-header-component>
<ng-content select="[slot=header]" ngProjectAs="[slot=nav]"></ng-content>
</my-header-component>
<main class="main">
<ng-content select="[slot=main]"></ng-content>
</main>
`,
})
export class MyComponent {}
// The ngProjectAs on the inner ng-content tells Angular
// to project the transcluded content further down the tree.
// ============================================
// PART 10: THE CATCH-ALL REMOVAL TRAP
// ============================================
// ❌ No catch-all: unmatched content is discarded
@Component({
selector: 'app-discarding-card',
template: `
<ng-content select="[title]"></ng-content>
<ng-content select="[body]"></ng-content>
`,
})
export class DiscardingCardComponent {}
// <app-discarding-card>
// <p>This paragraph is discarded.</p> ← gone
// </app-discarding-card>
// ✅ Add catch-all to preserve unmatched content
@Component({
selector: 'app-safe-card',
template: `
<ng-content select="[title]"></ng-content>
<ng-content select="[body]"></ng-content>
<ng-content></ng-content> <!-- catch-all -->
`,
})
export class SafeCardComponent {}
The ten parts cover the modal with three slots, the parent usage, the ngProjectAs routing, the fallback content, the change detection verification, the wrong conditional projection, the correct conditional approach, the ContentChild presence check, the multi-layer projection, and the catch-all removal trap.
Quick Reference
The Projection Features
| Feature | Purpose | Syntax |
|---|---|---|
| Default slot | Single insertion point | <ng-content></ng-content> |
| Named slot | Targeted insertion | <ng-content select="[attr]"></ng-content> |
| Fallback | Default content | <ng-content>...</ng-content> |
ngProjectAs | Route component to slot | <app-x ngProjectAs="[slot]"></app-x> |
ContentChild | Query projected node | @ContentChild('ref') ref?: TemplateRef<unknown> |
The Selector Types
| Selector Type | Example | Matches |
|---|---|---|
| Attribute | select="[card-header]" | Elements with that attribute |
| Class | select=".title" | Elements with that class |
| Element | select="h2" | <h2> elements |
| Compound | select="button[primary]" | <button primary> |
:not | select=":not([skip])" | Elements without the attribute |
The Change Detection Ownership
| Scenario | Who Checks Projected Content |
|---|---|
| Parent default, child OnPush | Parent |
| Parent OnPush, child default | Parent (but only when parent marked) |
| Both OnPush | Parent (but only when parent marked) |
The Conditional Projection Options
| Approach | When to Use | How |
|---|---|---|
ng-content | Content always initialized | Default |
ng-template + ngTemplateOutlet | Content initialized conditionally | Pass template, render manually |
@if around wrapper | Wrapper visibility only | Content still initialized |
Best Practices
✅ Do This:
// Always include a catch-all <ng-content>
<ng-content></ng-content> // ✅
// Use ngProjectAs when projecting components
<app-header ngProjectAs="[card-header]"></app-header> // ✅
// Provide fallback content for optional slots
<ng-content select="[card-header]">
<h2>Default Title</h2>
</ng-content> // ✅
// Debug projection issues in the parent's change detection
// The projected content belongs to the parent. // ✅
// Use ng-template for conditional content
<ng-template #headerTemplate>...</ng-template> // ✅
// Use ContentChild to detect projected content
@ContentChild('header') header?: TemplateRef<unknown>; // ✅
❌ Don’t Do This:
// Don't conditionally wrap ng-content with @if
@if (show) { <ng-content></ng-content> } // ❌
// Don't forget the catch-all and discard content
<ng-content select="[title]"></ng-content> // ❌
<ng-content select="[body]"></ng-content>
// Don't add directives or styles to ng-content
<ng-content class="my-class"></ng-content> // ❌
// Don't rely on ng-content for conditional rendering
// Use ng-template instead. // ❌
// Don't project the same node into multiple slots
<ng-content select="[a]"></ng-content> // ❌
<ng-content select="[a]"></ng-content>
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Content not appearing | No matching slot or no catch-all | Add catch-all <ng-content> or fix selector |
| Component won’t project into named slot | select cannot match component tag | Use ngProjectAs on the component element |
| Projected content not updating | Parent uses OnPush with mutated property | Use signals or immutable updates in parent |
| Same node projected twice | Angular 18+ disallows multi-slot projection | Ensure each node matches exactly one slot |
| Content initialized when hidden | ng-content always initializes | Use ng-template + ngTemplateOutlet |
ngProjectAs not working | Bound to dynamic expression | Use static value only |
Real-World Examples
1. Card Component with Header and Footer
@Component({
selector: 'app-card',
template: `
<div class="card">
<div class="card-header">
<ng-content select="[card-header]">
<h2>Default</h2>
</ng-content>
</div>
<div class="card-body"><ng-content></ng-content></div>
<div class="card-footer">
<ng-content select="[card-footer]"></ng-content>
</div>
</div>
`,
})
export class CardComponent {}
2. Modal with Action Buttons
<app-modal>
<h2 modal-title>Confirm</h2>
<p>Are you sure?</p>
<div modal-actions>
<button>Cancel</button>
<button>OK</button>
</div>
</app-modal>
3. Toolbar with ngProjectAs
<app-toolbar>
<app-icon-button ngProjectAs="[toolbar-start]" label="Menu" />
<app-icon-button ngProjectAs="[toolbar-end]" label="Settings" />
</app-toolbar>
4. Fallback Content
<ng-content select="[card-header]">
<h2>Untitled</h2>
</ng-content>
5. Conditional Content with ng-template
@if (showHeader) {
<ng-container [ngTemplateOutlet]="headerTemplate()" />
}
6. ContentChild Presence Check
@ContentChild('header') header?: TemplateRef<unknown>;
7. Multi-Layer Projection with ngProjectAs
<ng-content select="[slot=header]" ngProjectAs="[slot=nav]"></ng-content>
8. Catch-All for Unmatched Content
<ng-content select="[title]"></ng-content>
<ng-content></ng-content> <!-- catch-all -->
9. Attribute Selector with Multiple Values
<ng-content select="input, textarea"></ng-content>
10. Change Detection with OnPush Parent
// Projected content is checked when parent is checked,
// not when the child component is checked.
Visual: Single vs Multi-Slot Projection
┌──────────────────────────────────────────────┐
│ SINGLE SLOT │
│ │
│ <app-card> │
│ <p>Content</p> │
│ </app-card> │
│ │
│ Card template: │
│ <div class="card"> │
│ <ng-content></ng-content> ← all content │
│ </div> │
│ │
├──────────────────────────────────────────────┤
│ MULTI-SLOT │
│ │
│ <app-card> │
│ <h2 card-header>Title</h2> │
│ <p>Body</p> │
│ <button card-footer>Save</button> │
│ </app-card> │
│ │
│ Card template: │
│ <ng-content select="[card-header]"></ng-content> │
│ <ng-content></ng-content> ← catch-all │
│ <ng-content select="[card-footer]"></ng-content> │
│ │
└──────────────────────────────────────────────┘
Visual: Ownership and Change Detection
┌──────────────────────────────────────────────┐
│ OWNERSHIP MODEL │
│ │
│ Parent template Child template │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ <app-card> │ │ <ng-content> │ │
│ │ <p>{{x}}</p>────────>│ │ │
│ └──────────────┘ └──────────────┘ │
│ │ │
│ │ │
│ └─ {{x}} is checked by PARENT's │
│ change detection, not child's. │
│ │
│ Even if child is OnPush, projected content │
│ updates when parent is checked. │
│ │
└──────────────────────────────────────────────┘
Visual: ngProjectAs Routing
┌──────────────────────────────────────────────┐
│ ngProjectAs ROUTING │
│ │
│ Parent: │
│ <app-toolbar> │
│ <app-button ngProjectAs="[start]" /> │
│ <app-button ngProjectAs="[end]" /> │
│ </app-toolbar> │
│ │
│ Toolbar template: │
│ <ng-content select="[start]"></ng-content> │
│ <ng-content select="[end]"></ng-content> │
│ │
│ Angular sees ngProjectAs and routes the │
│ component into the matching slot even │
│ though the component tag doesn't match. │
│ │
└──────────────────────────────────────────────┘
Visual: Conditional Projection Trap
┌──────────────────────────────────────────────┐
│ ng-content ALWAYS INITIALIZES │
│ │
│ ❌ @if (show) { │
│ <ng-content></ng-content> │
│ } │
│ │
│ Even when show is false, the projected │
│ content is created. The wrapper is hidden │
│ but the content exists in memory. │
│ │
│ ✅ Use ng-template: │
│ @if (show) { │
│ <ng-container [ngTemplateOutlet]="tpl" />│
│ } │
│ │
│ Content is only created when the template │
│ is explicitly rendered. │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Single slot | <ng-content></ng-content> |
| Multi-slot | <ng-content select="[attr]"></ng-content> |
| Catch-all | <ng-content></ng-content> (no select) |
| Fallback | Content inside <ng-content> tags |
| Alias | ngProjectAs="[slot]" |
| Conditional | Use ng-template + ngTemplateOutlet |
| Query | @ContentChild / @ContentChildren |
| Change detection | Owned by parent, not child |
| Processed | Build-time only, not runtime |
Key takeaways:
<ng-content>is a compile-time placeholder, not a DOM element. It marks where projected content appears, never renders itself, and cannot be inserted, removed, or modified at runtime .- Single-slot projection uses a bare
<ng-content>. All content placed between the component’s tags renders at that spot. The content belongs to the parent’s template context. - Multi-slot projection uses
selectwith CSS selectors. Attributes, classes, element names, and combinations route content to specific slots. The catch-all<ng-content>withoutselectreceives unmatched content. - Without a catch-all, unmatched content is discarded. This is the first thing to check when projected content disappears .
ngProjectAsroutes components into named slots. Sinceselectcannot match component tag names,ngProjectAsprovides the selector Angular uses for matching .- Fallback content renders when nothing is projected. Content placed inside
<ng-content>tags serves as the default for that slot. - Projected content is owned by the parent. Its bindings and change detection run in the parent’s context, not the child’s .
- Don’t conditionally wrap
<ng-content>. Content is always initialized. Useng-templatewithngTemplateOutletfor conditional rendering .
Remember: Content projection turns components from fixed black boxes into flexible shells. <ng-content> marks where caller markup appears, select routes it to named slots, fallback content keeps components usable when callers omit optional pieces, and ngProjectAs bridges the gap between CSS selectors and component tags. The one rule to remember above all others: projected content is owned by the parent, so its bindings and change detection live in the parent’s context. When projection misbehaves, the answer is almost always in the parent, not the child.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!