#Content projection with ng-content
Content projection lets a component accept markup from its parent and decide where it appears. The child's template has a <ng-content /> placeholder; whatever the parent writes between the child's opening and closing tags is moved into that spot. It's how you build wrappers — cards, dialogs, panels, layout shells — without the wrapper knowing what goes inside. React calls it children, Web Components call it slots.
The mental model that prevents most bugs: the parent builds the content, the child only places it. The projected nodes are created by the parent's template, bound to the parent's data, checked as part of the parent's view, and destroyed with the parent. <ng-content> doesn't create anything — it's a hole in the child's template that the parent's already-built nodes are slotted into. It doesn't even exist in the DOM.
@Component({
selector: 'app-card',
template: `
<div class="card">
<ng-content />
</div>
`,
})
export class Card {}
@Component({
selector: 'app-card-demo',
imports: [Card],
template: `
<app-card>
<h2>Ada Lovelace</h2>
<p>First programmer.</p>
</app-card>
`,
})
export class CardDemo {}<app-card><div class="card"><h2>Ada Lovelace</h2><p>First programmer.</p></div></app-card>CardDemo's template is compiled with<h2>and<p>as its own nodes. When it's created, Angular creates both elements — they belong toCardDemo, not toCard.Card's template has a<div class="card">with<ng-content />inside. At creation time Angular movesCardDemo's two nodes into that position.- The rendered DOM has no
<ng-content>element — it's purely a compile-time placeholder. The<h2>and<p>sit directly inside thediv. - If
CardDemobound{{ name }}inside the<h2>, that expression would readCardDemo'sname, and it would update whenCardDemo's view is checked.Cardcan't see or change it — it only owns the frame.
Heavy constructed
rendered, closed: <app-collapsible><button>toggle</button></app-collapsible>
open: <app-collapsible><button>toggle</button><app-heavy>heavy!</app-heavy></app-collapsible>
closed again: <app-collapsible><button>toggle</button></app-collapsible>
open again
Heavy destroyed
fixture destroyedCollapsibleDemois created, and its creation pass constructsHeavystraight away — before anyone has looked atopen(). The first log line isHeavy constructed, while the collapsible is closed and<app-heavy>is nowhere in the DOM.- Clicking toggle sets
opentotrue. The@ifblock is created and<ng-content />inside it attaches the existingHeavyinstance. No second constructor call. - Closing again removes the
@ifblock, which detaches<app-heavy>from the DOM — but doesn't destroy it. There's noHeavy destroyedline, and opening again re-attaches the same instance. Heavyis only destroyed when its owner,CollapsibleDemo, is destroyed (fixture.destroy()).- So
@ifaround<ng-content>controls visibility, not existence. If the content is expensive (makes HTTP calls inngOnInit, renders a big chart) and should only be created when shown, the child must take a template instead and create it on demand — see ng-template and Template-driven components: putting it together.
A related rule: each projected node can be in one place only. You can't print the same content twice by writing <ng-content /> twice.
@Component({
selector: 'app-twice',
template: `<section>A: <ng-content /></section><section>B: <ng-content /></section>`,
})
export class Twice {}
// parent: <app-twice><b>hello</b></app-twice>ng-content wins<app-twice><section>A: </section><section>B: <b>hello</b></section></app-twice>- The parent created exactly one
<b>node. A DOM node can only have one parent, so Angular can't put it in both sections. - With two catch-all
<ng-content />s, the content went to the last one (B) in this Angular 22 test, andAstayed empty. Angular doesn't warn about this. - To render the same thing several times (in a list, for example), the child needs a template it can stamp out repeatedly — that's ngTemplateOutlet.
ng-content