Content projection is Angular’s slot mechanism โ it lets a parent component inject HTML content into specific locations inside a child component’s template. This is what makes truly reusable layout components possible: a CardComponent that defines a header, body, and footer layout but lets the parent fill in the actual content; a ModalComponent with a pre-built overlay and close button that accepts any body content; a TabComponent where each tab’s label and content comes from outside. ng-content is how Angular implements this pattern, including multi-slot projection with the select attribute.
ng-content Variants
| Syntax | Projects | Matches |
|---|---|---|
<ng-content> |
Default slot โ all unmatched content | Anything not matched by a named slot |
<ng-content select="h2"> |
Tag name selector | <h2> elements |
<ng-content select="[header]"> |
Attribute selector | Elements with header attribute |
<ng-content select=".footer"> |
CSS class selector | Elements with class footer |
<ng-content select="#main"> |
ID selector | Element with id="main" |
<ng-content select="app-icon"> |
Component selector | <app-icon> components |
ng-content Behaviour Rules
| Rule | Detail |
|---|---|
| Projected content ownership | Content belongs to the parent โ the child component cannot access parent bindings on it |
| Styles | Parent’s styles apply to projected content; child’s encapsulated styles do NOT apply to projected content by default |
| Change detection | Projected content is checked by the parent’s change detector |
| Conditional projection | ng-content cannot conditionally show/hide projected content with *ngIf; use ngTemplateOutlet instead |
| Default content | Content inside <ng-content></ng-content> is NOT shown as default โ the tags must be empty; use ngTemplateOutlet for default content |
<ng-content> slots (by duplicating the ng-content in the child template), Angular only projects into the first matching slot. The content is owned by the parent component’s view โ this is why parent styles apply but child encapsulated styles do not. The child component can read projected content with @ContentChild but not with @ViewChild.cardHeader, cardBody, cardFooter rather than component selectors, because the parent can apply them to any HTML element โ a simple <h2 cardHeader> works without wrapping in a special component. This gives maximum flexibility to the parent while keeping the layout controlled by the child.ViewEncapsulation.Emulated styles (the default) add a unique attribute to every element in the child’s own template, scoping them. But projected content from the parent does NOT get the child’s scoping attributes โ the child’s styles will NOT apply to projected content. To style projected content from the child, either use the deprecated ::ng-deep combinator or, preferably, design the child to accept styling via CSS custom properties (--card-header-color).Complete ng-content Examples
// โโ Single-slot projection โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@Component({
selector: 'app-panel',
standalone: true,
template: `
<div class="panel">
<div class="panel__inner">
<ng-content></ng-content> <!-- any content from parent -->
</div>
</div>
`,
styles: [`.panel { border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; }`]
})
export class PanelComponent {}
// Parent usage:
// <app-panel>
// <h2>My Title</h2> <!-- projected into ng-content -->
// <p>Some body text.</p> <!-- also projected -->
// </app-panel>
// โโ Multi-slot projection with attribute selectors โโโโโโโโโโโโโโโโโโโโโโโโ
@Component({
selector: 'app-card',
standalone: true,
template: `
<article class="card">
<header class="card__header">
<!-- Projects elements with [cardHeader] attribute -->
<ng-content select="[cardHeader]"></ng-content>
</header>
<section class="card__body">
<!-- Projects elements with [cardBody] attribute -->
<ng-content select="[cardBody]"></ng-content>
</section>
<footer class="card__footer">
<!-- Default slot โ everything NOT matched above -->
<ng-content></ng-content>
</footer>
</article>
`,
})
export class CardComponent {}
// Parent usage โ rich slot control:
// <app-card>
// <h2 cardHeader>Task Details</h2>
// <p cardBody>This is the card body content.</p>
// <!-- Default slot -->
// <button class="btn">Save</button>
// </app-card>
// โโ Modal with ng-content โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@Component({
selector: 'app-modal',
standalone: true,
imports: [CommonModule],
template: `
<div class="modal-overlay" *ngIf="isOpen" (click)="close()">
<div class="modal" (click)="$event.stopPropagation()">
<header class="modal__header">
<ng-content select="[modalTitle]"></ng-content>
<button class="modal__close" (click)="close()">×</button>
</header>
<section class="modal__body">
<ng-content></ng-content>
</section>
<footer *ngIf="hasFooter" class="modal__footer">
<ng-content select="[modalFooter]"></ng-content>
</footer>
</div>
</div>
`,
})
export class ModalComponent {
@Input() isOpen = false;
@Output() closed = new EventEmitter<void>();
@ContentChild('[modalFooter]') footerContent?: ElementRef;
get hasFooter(): boolean { return !!this.footerContent; }
close(): void { this.closed.emit(); }
}
// Parent usage:
// <app-modal [isOpen]="showDeleteModal" (closed)="showDeleteModal = false">
// <h2 modalTitle>Confirm Delete</h2>
// <p>Are you sure you want to delete this task?</p>
// <div modalFooter>
// <button (click)="showDeleteModal = false">Cancel</button>
// <button class="btn--danger" (click)="onConfirmDelete()">Delete</button>
// </div>
// </app-modal>
// โโ ngTemplateOutlet โ conditional default content โโโโโโโโโโโโโโโโโโโโโโโโโ
@Component({
selector: 'app-empty-state',
standalone: true,
imports: [CommonModule],
template: `
<div class="empty-state">
<!-- Use projected content if provided, else show default template -->
<ng-content></ng-content>
<!-- Cannot conditionally show default โ always show projected content if present -->
</div>
`,
})
export class EmptyStateComponent {}
// โโ ngTemplateOutlet for reusable template fragments โโโโโโโโโโโโโโโโโโโโโโ
@Component({
selector: 'app-task-list',
standalone: true,
imports: [CommonModule],
template: `
<ul>
@for (task of tasks(); track task._id) {
<ng-container *ngTemplateOutlet="rowTemplate; context: { task, index: $index }">
</ng-container>
}
</ul>
<!-- Default row template -->
<ng-template #rowTemplate let-task="task" let-i="index">
<li>{{ i + 1 }}. {{ task.title }}</li>
</ng-template>
`,
})
export class TaskListComponent {
tasks = input.required<Task[]>();
// Optional custom row template from parent
@ContentChild('customRow') customRowTpl?: TemplateRef<any>;
}
How It Works
Step 1 โ ng-content Is a Placeholder, Not a Copy
During compilation, Angular processes <ng-content> tags in the child template and marks their positions as projection slots. When the parent renders <app-card><h2>Title</h2></app-card>, Angular moves the <h2> element from the parent’s view into the card’s slot position. The <h2> is not duplicated โ it exists once in the final DOM. The parent’s view maintains ownership of it for change detection and styling purposes.
Step 2 โ select Attribute Uses CSS-Like Selectors
The select attribute on <ng-content> works like a CSS selector to match child elements from the parent. Element selectors (h2), attribute selectors ([header]), class selectors (.footer), component selectors (app-icon), and combinations work as expected. Elements that do not match any named ng-content select fall through to the default <ng-content> (the one without a select attribute).
Step 3 โ Projected Content Is Checked by the Parent
Even though projected content appears inside the child component’s DOM, Angular checks it as part of the parent’s change detection cycle. Expressions bound in the projected content (like {{ parentProperty }}) refer to the parent component’s scope, not the child’s. This is why you can write <app-card><h2>{{ task.title }}</h2></app-card> and the binding works โ task is a property of the parent, not the card.
Step 4 โ ngTemplateOutlet Enables Template Composition
ngTemplateOutlet renders a TemplateRef in a specified location. Combined with a context object, it enables powerful patterns: a list component that accepts a custom row template from the parent (customising how each row is rendered), or a dialog that accepts a custom footer. The template is defined in the parent with #myTemplate and rendered in the child with *ngTemplateOutlet="myTemplate; context: { item }".
Step 5 โ Styles and Encapsulation with Projected Content
Angular’s default ViewEncapsulation.Emulated adds unique attribute selectors to every element in a component’s own template, scoping its styles. Projected content comes from the parent’s template and does not get the child’s scoping attributes. This means a child’s .card__header { color: red } will not apply to a projected <h2 cardHeader>. Design shared components to accept styling through CSS custom properties (--card-bg: white) which cross encapsulation boundaries naturally.
Common Mistakes
Mistake 1 โ Expecting child styles to apply to projected content
โ Wrong โ child’s scoped styles do not reach projected content:
/* card.component.scss */
h2 { color: navy; font-size: 1.5rem; } /* will NOT apply to projected <h2 cardHeader> */
✅ Correct โ use CSS custom properties for styling projected slots:
/* card.component.scss */
.card__header { color: var(--card-header-color, navy); }
/* Parent sets: <app-card style="--card-header-color: #1e40af"> */
Mistake 2 โ Trying to add default content inside ng-content tags
โ Wrong โ content inside ng-content tags is not shown as default:
<ng-content>
<p>Default if nothing projected</p> <!-- NEVER shown โ ng-content ignores inner content -->
</ng-content>
✅ Correct โ use ContentChild to detect projection, show default with *ngIf:
<ng-content></ng-content>
<p *ngIf="!hasProjectedContent">Default if nothing projected</p>
Mistake 3 โ Binding to projected content’s internal state from the child
โ Wrong โ child cannot access bindings from projected parent content:
// In CardComponent:
@ContentChild('cardTitle') title!: ElementRef;
someMethod() {
// Can access the DOM element, but cannot access Angular bindings on it
// Cannot call methods on components projected from the parent
}
✅ Correct โ communicate through @Input()/@Output() or shared services:
// Pass data explicitly as @Input to the child
// OR use a shared service for complex communication
Quick Reference
| Need | ng-content Pattern |
|---|---|
| Single content slot | <ng-content></ng-content> |
| Named slot by attribute | <ng-content select="[mySlot]"> โ parent: <div mySlot> |
| Named slot by tag | <ng-content select="h2"> |
| Named slot by class | <ng-content select=".footer"> |
| Default fallback slot | <ng-content> (no select โ catches unmatched) |
| Detect if content projected | @ContentChild('[slotAttr]') ref?: ElementRef |
| Render template fragment | *ngTemplateOutlet="tplRef; context: { item }" |
| Style projected content | CSS custom properties (--slot-color) |