Content Projection Overview
Estimated Time: 2 hours | Difficulty: Intermediate | Prerequisites: Components, Templates
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Content Projection Types │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Single-slot Projection │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ <app-card> │ ───► │ Card Component │ │
│ │ <p>Content</p> │ │ ┌──────────────┐ │ │
│ │ </app-card> │ │ │ <ng-content>│ │ │
│ └────────────────────┘ │ └──────────────┘ │ │
│ └────────────────────┘ │
│ │
│ 2. Multi-slot Projection (Named slots) │
│ ┌────────────────────────┐ ┌────────────────────────┐ │
│ │ <app-modal> │ │ Modal Component │ │
│ │ <h2 slot="header"> │ ──► │ ┌────────────────┐ │ │
│ │ <div slot="body"> │ │ │ header slot │ │ │
│ │ <button slot="footer│ │ ├────────────────┤ │ │
│ │ </app-modal> │ │ │ body slot │ │ │
│ └────────────────────────┘ │ ├────────────────┤ │ │
│ │ │ footer slot │ │ │
│ │ └────────────────┘ │ │
│ └────────────────────────┘ │
│ │
│ 3. Conditional Projection │
│ Content shown/hidden based on component logic │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Single-Slot Projection
Copy
// card.component.ts
@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card" [class.elevated]="elevated()">
<ng-content />
</div>
`,
styles: `
.card {
padding: 1.5rem;
border-radius: 8px;
background: white;
border: 1px solid #e2e8f0;
}
.card.elevated {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
`
})
export class CardComponent {
elevated = input(false);
}
// Usage
@Component({
template: `
<app-card [elevated]="true">
<h2>Card Title</h2>
<p>This content is projected into the card.</p>
<button>Action</button>
</app-card>
`
})
export class PageComponent {}
Multi-Slot Projection
Using select Attribute
Copy
// modal.component.ts
@Component({
selector: 'app-modal',
standalone: true,
template: `
<div class="modal-overlay" (click)="close()">
<div class="modal" (click)="$event.stopPropagation()">
<header class="modal-header">
<ng-content select="[modal-header]" />
<button class="close-btn" (click)="close()">×</button>
</header>
<main class="modal-body">
<ng-content select="[modal-body]" />
</main>
<footer class="modal-footer">
<ng-content select="[modal-footer]" />
</footer>
</div>
</div>
`,
styleUrl: './modal.component.scss'
})
export class ModalComponent {
closed = output<void>();
close() {
this.closed.emit();
}
}
// Usage
@Component({
template: `
@if (showModal()) {
<app-modal (closed)="showModal.set(false)">
<h2 modal-header>Confirm Action</h2>
<div modal-body>
<p>Are you sure you want to proceed?</p>
</div>
<div modal-footer>
<button (click)="cancel()">Cancel</button>
<button class="primary" (click)="confirm()">Confirm</button>
</div>
</app-modal>
}
`
})
export class ConfirmDialogComponent {
showModal = signal(false);
}
Using CSS Selectors
Copy
// data-table.component.ts
@Component({
selector: 'app-data-table',
standalone: true,
template: `
<table class="data-table">
<thead>
<tr>
<!-- Select by element name -->
<ng-content select="th" />
</tr>
</thead>
<tbody>
<!-- Select by CSS class -->
<ng-content select=".table-row" />
</tbody>
<tfoot>
<!-- Select by attribute -->
<ng-content select="[table-footer]" />
</tfoot>
</table>
<!-- Default slot (unmatched content) -->
<ng-content />
`
})
export class DataTableComponent {}
// Usage
@Component({
template: `
<app-data-table>
<th>Name</th>
<th>Email</th>
<th>Actions</th>
@for (user of users(); track user.id) {
<tr class="table-row">
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td><button>Edit</button></td>
</tr>
}
<tr table-footer>
<td colspan="3">Total: {{ users().length }} users</td>
</tr>
</app-data-table>
`
})
export class UserTableComponent {
users = input.required<User[]>();
}
ng-template with Content Projection
Copy
// tabs.component.ts
@Component({
selector: 'app-tabs',
standalone: true,
template: `
<div class="tabs">
<div class="tab-headers">
@for (tab of tabs(); track tab.label; let i = $index) {
<button
class="tab-header"
[class.active]="activeIndex() === i"
(click)="activeIndex.set(i)"
>
{{ tab.label }}
</button>
}
</div>
<div class="tab-content">
<ng-container
[ngTemplateOutlet]="tabs()[activeIndex()].content"
/>
</div>
</div>
`
})
export class TabsComponent {
tabs = input.required<Tab[]>();
activeIndex = signal(0);
}
interface Tab {
label: string;
content: TemplateRef<any>;
}
// tab.component.ts
@Directive({
selector: '[appTab]',
standalone: true
})
export class TabDirective {
label = input.required<string>({ alias: 'appTab' });
constructor(public templateRef: TemplateRef<any>) {}
}
// Usage
@Component({
template: `
<app-tabs [tabs]="tabList()">
</app-tabs>
<ng-template appTab="Profile" #profile>
<app-user-profile [user]="currentUser()" />
</ng-template>
<ng-template appTab="Settings" #settings>
<app-settings />
</ng-template>
<ng-template appTab="Activity" #activity>
<app-activity-log [userId]="currentUser().id" />
</ng-template>
`
})
export class UserDashboardComponent {
@ViewChildren(TabDirective) tabDirectives!: QueryList<TabDirective>;
currentUser = input.required<User>();
tabList = computed(() =>
this.tabDirectives.map(dir => ({
label: dir.label(),
content: dir.templateRef
}))
);
}
ContentChild & ContentChildren
Copy
// accordion.component.ts
@Component({
selector: 'app-accordion',
standalone: true,
template: `
<div class="accordion">
<ng-content />
</div>
`
})
export class AccordionComponent implements AfterContentInit {
@ContentChildren(AccordionItemComponent) items!: QueryList<AccordionItemComponent>;
allowMultiple = input(false);
ngAfterContentInit() {
this.items.forEach(item => {
item.toggled.subscribe((isOpen) => {
if (isOpen && !this.allowMultiple()) {
this.closeOthers(item);
}
});
});
}
private closeOthers(openedItem: AccordionItemComponent) {
this.items.forEach(item => {
if (item !== openedItem) {
item.close();
}
});
}
}
// accordion-item.component.ts
@Component({
selector: 'app-accordion-item',
standalone: true,
template: `
<div class="accordion-item" [class.open]="isOpen()">
<button class="accordion-header" (click)="toggle()">
<ng-content select="[accordion-header]" />
<span class="icon">{{ isOpen() ? '−' : '+' }}</span>
</button>
@if (isOpen()) {
<div class="accordion-body" @slideInOut>
<ng-content select="[accordion-body]" />
</div>
}
</div>
`,
animations: [
trigger('slideInOut', [
transition(':enter', [
style({ height: 0, opacity: 0 }),
animate('200ms ease-out', style({ height: '*', opacity: 1 }))
]),
transition(':leave', [
animate('200ms ease-in', style({ height: 0, opacity: 0 }))
])
])
]
})
export class AccordionItemComponent {
isOpen = signal(false);
toggled = output<boolean>();
toggle() {
this.isOpen.update(v => !v);
this.toggled.emit(this.isOpen());
}
close() {
this.isOpen.set(false);
}
}
// Usage
@Component({
template: `
<app-accordion>
<app-accordion-item>
<h3 accordion-header>Section 1</h3>
<div accordion-body>
<p>Content for section 1</p>
</div>
</app-accordion-item>
<app-accordion-item>
<h3 accordion-header>Section 2</h3>
<div accordion-body>
<p>Content for section 2</p>
</div>
</app-accordion-item>
</app-accordion>
`
})
export class FAQComponent {}
Conditional Content Projection
Copy
// empty-state.component.ts
@Component({
selector: 'app-list-with-empty',
standalone: true,
template: `
@if (hasContent()) {
<ng-content />
} @else {
<div class="empty-state">
<ng-content select="[empty-state]" />
</div>
}
`
})
export class ListWithEmptyComponent {
hasContent = input(true);
}
// Checking if content was provided
@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card">
@if (hasHeader()) {
<header class="card-header">
<ng-content select="[card-header]" />
</header>
}
<main class="card-body">
<ng-content />
</main>
@if (hasFooter()) {
<footer class="card-footer">
<ng-content select="[card-footer]" />
</footer>
}
</div>
`
})
export class CardComponent {
@ContentChild('cardHeader') headerContent?: ElementRef;
@ContentChild('cardFooter') footerContent?: ElementRef;
hasHeader = computed(() => !!this.headerContent);
hasFooter = computed(() => !!this.footerContent);
}
ngProjectAs
Copy
// Use ngProjectAs to match a different selector
@Component({
template: `
<app-modal>
<!-- This div will be projected as if it had [modal-header] attribute -->
<div ngProjectAs="[modal-header]">
<h2>Dynamic Title</h2>
<span class="badge">New</span>
</div>
<app-form-content ngProjectAs="[modal-body]" />
<ng-container ngProjectAs="[modal-footer]">
<button>Cancel</button>
<button class="primary">Save</button>
</ng-container>
</app-modal>
`
})
export class DynamicModalComponent {}
Context-Aware Projection
Copy
// list.component.ts
@Component({
selector: 'app-list',
standalone: true,
template: `
<ul class="list">
@for (item of items(); track trackFn()(item); let i = $index) {
<li class="list-item">
<ng-container
[ngTemplateOutlet]="itemTemplate()"
[ngTemplateOutletContext]="{ $implicit: item, index: i }"
/>
</li>
}
</ul>
`
})
export class ListComponent<T> {
items = input.required<T[]>();
itemTemplate = input.required<TemplateRef<{ $implicit: T; index: number }>>();
trackFn = input<(item: T) => any>(() => (item: any) => item);
}
// Usage
@Component({
template: `
<app-list
[items]="users()"
[itemTemplate]="userTemplate"
[trackFn]="trackById"
/>
<ng-template #userTemplate let-user let-i="index">
<div class="user-card">
<span class="index">{{ i + 1 }}</span>
<img [src]="user.avatar" [alt]="user.name" />
<div class="user-info">
<h4>{{ user.name }}</h4>
<p>{{ user.email }}</p>
</div>
</div>
</ng-template>
`
})
export class UserListComponent {
users = signal<User[]>([]);
trackById = (user: User) => user.id;
}
Advanced: Render Props Pattern
Copy
// data-fetcher.component.ts
@Component({
selector: 'app-data-fetcher',
standalone: true,
template: `
@if (loading()) {
<ng-container
[ngTemplateOutlet]="loadingTemplate() || defaultLoading"
/>
} @else if (error()) {
<ng-container
[ngTemplateOutlet]="errorTemplate() || defaultError"
[ngTemplateOutletContext]="{ $implicit: error() }"
/>
} @else {
<ng-container
[ngTemplateOutlet]="dataTemplate()"
[ngTemplateOutletContext]="{ $implicit: data() }"
/>
}
<ng-template #defaultLoading>
<div class="loading">Loading...</div>
</ng-template>
<ng-template #defaultError let-err>
<div class="error">Error: {{ err.message }}</div>
</ng-template>
`
})
export class DataFetcherComponent<T> {
fetchFn = input.required<() => Observable<T>>();
dataTemplate = input.required<TemplateRef<{ $implicit: T }>>();
loadingTemplate = input<TemplateRef<void>>();
errorTemplate = input<TemplateRef<{ $implicit: Error }>>();
data = signal<T | null>(null);
loading = signal(true);
error = signal<Error | null>(null);
constructor() {
effect(() => {
this.loading.set(true);
this.error.set(null);
this.fetchFn()().subscribe({
next: (data) => {
this.data.set(data);
this.loading.set(false);
},
error: (err) => {
this.error.set(err);
this.loading.set(false);
}
});
});
}
}
// Usage
@Component({
template: `
<app-data-fetcher
[fetchFn]="fetchProducts"
[dataTemplate]="productsTemplate"
[loadingTemplate]="customLoading"
/>
<ng-template #customLoading>
<app-skeleton-loader [count]="6" />
</ng-template>
<ng-template #productsTemplate let-products>
<div class="products-grid">
@for (product of products; track product.id) {
<app-product-card [product]="product" />
}
</div>
</ng-template>
`
})
export class ProductsPageComponent {
private productService = inject(ProductService);
fetchProducts = () => this.productService.getProducts();
}
Best Practices
Name Slots Clearly
Use descriptive attribute names for multi-slot projection
Provide Defaults
Use fallback content when projection is optional
Document Slots
Document expected content in component API
Type Templates
Use generics for type-safe template contexts
Next: Dynamic Components
Learn to create and manage dynamic components