Content projection lets a component display markup that its caller wrote. A card with a title slot, a dialog with an actions slot, a tab group where you put the tabs inside. It’s how you build flexible UI components in Angular without passing HTML strings around.
The concept is simple. The part interviewers probe is ownership: who creates the projected content, who can it see, and when is it destroyed? Get that wrong and you end up with charts rendering into collapsed panels, or expensive components initialising on pages where nobody ever opens them. This note works through a collapsible panel, rendered with Angular 20.3’s TestBed under Node. The log lines are from that run; Angular 19 behaves the same.
A panel with three slots
@Component({
selector: 'app-panel',
template: `
<header><ng-content select="[panel-title]" /></header>
@if (open()) {
<section><ng-content /></section>
}
<footer><ng-content select="app-actions, [panel-actions]" /></footer>
`,
})
export class Panel {
open = signal(false);
}A page uses it like this:
<app-panel>
<h2 panel-title>Revenue</h2>
<app-chart />
<button panel-actions>Export</button>
<p>stray</p>
</app-panel>Each <ng-content> with a select attribute takes the direct children that match its CSS selector. The h2 has the panel-title attribute, so it goes to the header. The button matches [panel-actions], so it goes to the footer. The select list can contain several selectors separated by commas, like a CSS selector list. Everything that matched nothing (app-chart and the p) goes to the one <ng-content> without select. If there were no default slot, unmatched content would simply not appear anywhere.
Matching happens once, at compile time, against the top level of what you projected. Wrapping the button in a <div> would break the match, because the div is what Angular sees. If you need a wrapper, <ng-container ngProjectAs="[panel-actions]"> tells Angular to treat the container as if it matched that selector.
The parent owns projected content
Rendering the panel closed gives this HTML (comments removed):
<header><h2 panel-title="">Revenue</h2></header>
<footer><button panel-actions="">Export</button></footer>No section, as expected. But the log from the same render starts with:
Chart constructedapp-chart was created even though the slot it would appear in doesn’t exist yet. Opening the panel shows the chart; closing it again does not destroy it. The chart was destroyed only when the whole page was destroyed.
This surprises people because the @if looks like it controls the chart. It doesn’t. The chart is written in the page’s template, so the page creates it, along with every other element in that template, when the page renders. The panel’s <ng-content> decides only where existing nodes are attached in the DOM. @if around an <ng-content> toggles whether the slot is in the DOM, not whether the content exists.
Ownership has other consequences that come up in interviews:
- Bindings in projected content use the parent’s context.
{{ total }}inside the projected markup reads the page’stotal, not the panel’s. - Change detection follows declaration. Projected content is checked as part of the page’s view. With an OnPush host that nothing marked dirty, a projected
{{ count }}still updated when the page changedcount, in a quick test. - DI follows the element tree. A projected component can inject services from the panel’s
providers(but not itsviewProviders). The note on hierarchical providers shows this with real instance ids. - You can’t project the same content twice. Two
<ng-content>slots with the sameselect, or a slot inside@for, won’t duplicate anything. A DOM node can only be in one place. In a test with two default<ng-content />slots, Angular 20 put the content in the second one and left the first empty.
Making projection lazy with ng-template
If the panel should create its body only when opened (because the body is a heavy chart, or makes HTTP calls when it initialises), the caller has to hand over a template rather than ready-made content. Templates are instructions, not instances, and the panel decides when to stamp them out.
@Directive({ selector: 'ng-template[panelBody]' })
export class PanelBody {}
@Component({
selector: 'app-lazy-panel',
imports: [NgTemplateOutlet],
template: `
@if (open()) {
<section><ng-container [ngTemplateOutlet]="body()" /></section>
}
`,
})
export class LazyPanel {
open = signal(false);
body = contentChild(PanelBody, { read: TemplateRef });
}<app-lazy-panel>
<ng-template panelBody><app-chart /></ng-template>
</app-lazy-panel>The log for open, then close:
Chart constructed
opened lazy panel, html=<section><app-chart>[chart]</app-chart></section>
Chart destroyed
closed lazy panelNow the chart’s lifetime follows the panel’s @if. It’s created on open and destroyed on close. Opening again creates a fresh instance. (The Node test declared the query with the @ContentChild decorator, because the JIT compiler used there doesn’t pick up signal queries; the behaviour is the same.)
Use this pattern when the host needs control over when or how many times content renders: lazy panels, tab bodies, table row templates, virtual scroll items. ngTemplateOutlet also accepts a context ([ngTemplateOutletContext]="{ $implicit: row }"), which is how a table component hands each row object back to the caller’s template.
Angular’s @defer block is a different tool: it lazy-loads the code for a part of a template. You might combine the two, but @defer inside the caller’s projected content doesn’t change who owns it.
Querying projected content
contentChild() and contentChildren() let the host find things the caller projected, like the PanelBody template above or every <app-tab> inside a tab group. Two points matter:
- They see only content projected into this component, not its own template (that’s
viewChild). Mixing them up is a common source of “my query is always undefined”. - By default,
contentChildrenlooks only at direct children of the host element. Pass{ descendants: true }to find matches nested deeper in the projected markup.
The note on view query timing covers when these queries resolve.
Interview practice
A dashboard uses a <app-tabs> component. Each tab is written as <app-tab label="Sales"><app-sales-report /></app-tab>, and app-tab shows its content with @if (active()) { <ng-content /> }. Users complain the dashboard is slow to load, and the network tab shows every report’s API call firing on page load, even for tabs nobody opened. Why, and what would you change?
Answer
Every <app-sales-report> is declared in the dashboard’s template, so the dashboard creates all of them when it renders, regardless of which tab is active. Each report’s constructor or ngOnInit fires its API call immediately. The @if inside app-tab only controls whether the slot is attached to the DOM; it never controlled whether the reports exist. Switching tabs also doesn’t destroy anything, so inactive reports keep their subscriptions alive.
To make tab bodies lazy, the caller has to pass templates instead of content:
<app-tab label="Sales">
<ng-template tabBody><app-sales-report /></ng-template>
</app-tab>app-tab queries the template with contentChild(TabBody, { read: TemplateRef }) and renders it with ngTemplateOutlet inside its @if. Now a report is created when its tab opens and destroyed when it closes. If the team wants to keep a report alive after the first visit, the tab can track a visited signal and switch to @if (visited()) for creation while hiding inactive tabs with CSS. And if the report components are large, wrapping the template’s content in @defer (on viewport) or @defer (when active()) also splits their code out of the initial bundle.