Skip to main content

Content projection (ng-content) in Angular

Content projection (ng-content) lets a parent component pass arbitrary HTML into specific slots defined inside a child component's template.

Theory

TL;DR

  • Think of the child component as a magazine template with labeled cutouts. The parent drops content into those cutouts without touching the layout itself.
  • ng-content projects raw DOM nodes from the parent, keeping event handlers, animations, and component lifecycles intact.
  • Use select with CSS selectors to route content into named slots: [attribute], .class, or tag name.
  • Pick it for UI wrappers like cards, modals, and panels. For pure data, use @Input() instead.

Quick example

typescript
// card.component.ts @Component({ selector: 'app-card', standalone: true, template: ` <div class="card"> <header><ng-content select="[card-header]"></ng-content></header> <main><ng-content select="[card-body]"></ng-content></main> <footer><ng-content select="[card-footer]"></ng-content></footer> </div> ` }) export class CardComponent {}
html
<!-- parent usage --> <app-card> <h2 card-header>Profile</h2> <p card-body>Name: Alex</p> <button card-footer (click)="editProfile()">Edit</button> </app-card>

Angular matches each element to its slot by the select CSS selector and renders the card with the title in the header, the paragraph in the body, and the button in the footer. The button's click handler lives in the parent and fires normally after projection.

Key difference from @Input()

ng-content moves actual DOM nodes from the parent into the child's layout. Those nodes stay live: their event handlers fire, their animations run, their child components keep their lifecycle. @Input() passes data values, and the child builds its own markup from them. When you need markup from the parent inside the child's structure, not just a string or an object, ng-content is the right call.

When to use

  • Reusable wrappers (cards, panels, modals, drawers): define named slots with select so each layout zone has a clear owner.
  • Dynamic list items: a single <ng-content> without select lets the parent fully control the item template.
  • Third-party component embedding: projection preserves the embedded component's ngOnInit/ngOnDestroy, so its internal state stays clean.
  • Data-only situations: use @Input(). No DOM overhead, full type safety, and easier to test.

How Angular compiles it

During template compilation, Angular scans <ng-content> tags and records each select value as a CSS selector. At runtime, it matches nodes from the parent against those selectors and inserts the matching DOM fragments directly into the child's view, without cloning or re-rendering them. Nodes that do not match any select fall through to a default <ng-content> (one with no select attribute), if the child defines one.

Select syntax reference

html
<!-- Match by attribute (most common) --> <ng-content select="[card-header]"></ng-content> <!-- Match by CSS class --> <ng-content select=".card-footer"></ng-content> <!-- Match by tag name --> <ng-content select="h2"></ng-content> <!-- Match by component selector --> <ng-content select="app-icon"></ng-content> <!-- Default: catches everything not matched above --> <ng-content></ng-content>

Common mistakes

1. Using a bare tag selector when the element carries a named attribute

html
<!-- Wrong: does not match <h2 card-header>My Title</h2> --> <ng-content select="h2"></ng-content> <!-- Fix: match by the attribute --> <ng-content select="[card-header]"></ng-content> <!-- Or combine tag + attribute for precision --> <ng-content select="h2[card-header]"></ng-content>

The select value is a CSS selector. h2 matches any <h2> tag. [card-header] matches any element carrying that attribute. They are not the same.

2. Multiple <ng-content> tags without select

html
<!-- Wrong: all content goes into the first slot, the second is always empty --> <header><ng-content></ng-content></header> <main><ng-content></ng-content></main> <!-- Fix: each slot needs its own selector --> <header><ng-content select="[card-header]"></ng-content></header> <main><ng-content select="[card-body]"></ng-content></main>

3. Projecting content controlled by *ngIf that starts as false

html
<!-- Unreliable: if show is false on load, the node does not exist yet and there is nothing to project --> <app-card><p *ngIf="show">Conditional text</p></app-card> <!-- Fix: wrap in ng-container so Angular tracks the slot correctly --> <app-card> <ng-container *ngIf="show"><p>Conditional text</p></ng-container> </app-card>

4. Expecting child component styles to reach projected nodes

Angular's ViewEncapsulation.Emulated (the default) adds attribute selectors to the child's styles. Those attribute selectors do not apply to projected nodes because the nodes belong to the parent's view. To style projected content from the child, either move the styles to the parent or set encapsulation: ViewEncapsulation.None on the child and use targeted selectors carefully.

Accessing projected content in TypeScript

Use @ContentChild and @ContentChildren to query projected components after they land in the view:

typescript
import { Component, ContentChildren, QueryList, AfterContentInit } from '@angular/core'; import { TabComponent } from './tab.component'; @Component({ selector: 'app-tabs', standalone: true, template: `<ng-content></ng-content>` }) export class TabsComponent implements AfterContentInit { @ContentChildren(TabComponent) tabs!: QueryList<TabComponent>; ngAfterContentInit() { // projected content is available here, not in ngOnInit if (!this.tabs.some(tab => tab.active)) { this.tabs.first.active = true; } } }

Query projected components in ngAfterContentInit. Reading @ContentChildren in ngOnInit returns an empty list because projection has not happened yet.

Real-world usage

  • Angular Material: mat-card slots header, subtitle, image, and actions through named ng-content selectors like [mat-card-title] and [mat-card-content].
  • NG Bootstrap: ngb-modal projects a custom header and footer into named slots.
  • PrimeNG: p-table combines ng-content with ng-template and pTemplate directives for column and row definitions.
  • Custom design systems: most teams build card, modal, and drawer wrappers this way so product developers control markup without touching the component's internal layout.

Follow-up questions

Q: What is the difference between <ng-content> and *ngTemplateOutlet?
A: ng-content projects the parent's existing DOM nodes into a slot inside the child. *ngTemplateOutlet renders a TemplateRef and can pass a context object into it. Use ng-content when the parent owns the markup. Use ngTemplateOutlet when the child needs to render a template multiple times or inject data into it.

Q: Can projected content pass through multiple component levels?
A: Yes. Each intermediate component needs its own <ng-content> to forward content deeper. Angular tunnels the nodes through each level down to the first matching slot.

Q: Does content projection affect performance compared to @Input()?
A: Projection involves more DOM work than data binding, so it is measurably slower for large lists with many projected nodes. Adding OnPush change detection to the child component reduces unnecessary re-renders and offsets most of the cost.

Q: How does ViewEncapsulation affect styling projected content?
A: Emulated (default) scopes child styles with attribute selectors that do not reach projected nodes. ShadowDom fully isolates the child's view. None disables encapsulation entirely. For components that need to style their projected slots from the child side, None plus specific selectors is the standard approach.

Q: (Senior) Which lifecycle hook fires when projected content is ready, and why does it matter?
A: ngAfterContentInit fires after Angular finishes projecting content into the view. ngOnInit fires before projection, so any @ContentChild or @ContentChildren query there returns nothing. ngAfterContentChecked fires after each change detection pass that touches projected content.

Examples

Basic: single-slot card

typescript
@Component({ selector: 'app-card', standalone: true, template: ` <div class="card"> <ng-content></ng-content> </div> ` }) export class CardComponent {}
html
<app-card> <h2>User Profile</h2> <p>Name: Alex</p> <button (click)="editProfile()">Edit</button> </app-card>

The click handler lives in the parent and keeps firing after the button is projected. Passing name as an @Input() and rendering the button inside the child would require the child to also emit a (click) event, which is more wiring for less flexibility.

Intermediate: modal with named slots

typescript
// modal.component.ts @Component({ selector: 'app-modal', standalone: true, imports: [NgIf], template: ` <div class="modal-overlay" *ngIf="open"> <div class="modal"> <ng-content select="[modal-title]"></ng-content> <ng-content select="[modal-content]"></ng-content> <ng-content select="[modal-actions]"></ng-content> </div> </div> ` }) export class ModalComponent { @Input() open = false; }
html
<app-modal [open]="isOpen"> <h1 modal-title>Confirm Delete</h1> <p modal-content>This action cannot be undone.</p> <div modal-actions> <button (click)="confirmDelete()">Yes, delete</button> <button (click)="isOpen = false">Cancel</button> </div> </app-modal>

The modal owns layout and visibility. The parent owns text and logic. Testing the delete flow does not require mounting the modal internals at all.

Advanced: tabs with @ContentChildren

typescript
// tabs.component.ts @Component({ selector: 'app-tabs', standalone: true, template: ` <div class="tab-bar"> <button *ngFor="let tab of tabs" (click)="activate(tab)" [class.active]="tab.active"> {{ tab.title }} </button> </div> <ng-content></ng-content> ` }) export class TabsComponent implements AfterContentInit { @ContentChildren(TabComponent) tabs!: QueryList<TabComponent>; ngAfterContentInit() { if (!this.tabs.some(t => t.active)) { this.tabs.first.active = true; } } activate(selected: TabComponent) { this.tabs.forEach(tab => (tab.active = false)); selected.active = true; } }
html
<app-tabs> <app-tab title="Overview">Overview content</app-tab> <app-tab title="Settings">Settings content</app-tab> <app-tab title="Billing" [active]="true">Billing content</app-tab> </app-tabs>

QueryList updates automatically when tabs are added or removed dynamically. That is what makes this pattern work without any extra event handling on the parent side.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?