Back to CourseIntermediate4 min read
Angular /

Content Projection with ng-content

Pass markup into a component with ng-content, use named slots, and build flexible layout components.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: June 5, 2026
Last updated: September 7, 2026
Intermediate Topic

Passing markup in

Inputs pass data. Content projection passes markup.

@Component({
  selector: 'app-card',
  template: `
    <div class="card">
      <ng-content />
    </div>
  `,
})
export class Card {}
<app-card>
  <h2>Title</h2>
  <p>Anything at all goes here.</p>
</app-card>

Whatever sits between the tags is rendered where <ng-content /> is. The card supplies the wrapper and the styling, and the caller supplies the content.

This is how you build reusable layout components: cards, dialogs, panels, tabs, page shells.

Named slots

One ng-content takes everything. Several named ones let the caller fill specific places.

@Component({
  selector: 'app-panel',
  template: `
    <section class="panel">
      <header><ng-content select="[panel-title]" /></header>
      <div class="body"><ng-content /></div>
      <footer><ng-content select="[panel-actions]" /></footer>
    </section>
  `,
})
export class Panel {}
<app-panel>
  <h2 panel-title>Settings</h2>

  <p>Body content goes in the unnamed slot.</p>

  <div panel-actions>
    <button>Cancel</button>
    <button>Save</button>
  </div>
</app-panel>

The select attribute takes any CSS selector:

<ng-content select="[panel-title]" />      <!-- attribute -->
<ng-content select=".header" />            <!-- class -->
<ng-content select="h2" />                 <!-- element -->
<ng-content select="app-icon" />           <!-- component -->
<ng-content select="button.primary" />     <!-- combined -->

Attribute selectors are the usual choice, because they do not constrain what element the caller uses.

The unnamed slot is a catch-all

Anything not matching a named slot goes to the unnamed one. Order matters in a subtle way: put the unnamed slot last in your thinking, since it collects the leftovers regardless of where it appears.

With no unnamed slot, unmatched content is simply dropped.

@Component({
  selector: 'app-strict',
  template: `<ng-content select="[header]" />`,
})
<app-strict>
  <h2 header>Shown</h2>
  <p>Silently discarded</p>
</app-strict>

That silent discarding catches people out, so include an unnamed slot unless you mean to reject extra content.

Fallback content

If nothing is projected into a slot, Angular renders whatever is inside the ng-content tags instead.

<ng-content select="[card-title]">
  <h2>Untitled</h2>
</ng-content>

This landed in Angular 18 and removes a lot of awkward @if checks around empty slots.

Conditional projection

Wrapping ng-content in @if does not work the way you expect.

@if (expanded()) {
  <ng-content />       <!-- projected content is NOT re-created -->
}

Projected content is created by the parent, once. The child cannot create or destroy it, only choose where to place it, and only in one place.

For content that should be created lazily, take a template instead:

@Component({
  selector: 'app-collapsible',
  template: `
    <button (click)="toggle()">Toggle</button>
    @if (open()) {
      <ng-container [ngTemplateOutlet]="body()" />
    }
  `,
  imports: [NgTemplateOutlet],
})
export class Collapsible {
  readonly body = contentChild.required(TemplateRef);
  protected readonly open = signal(false);
  protected toggle(): void { this.open.update(v => !v); }
}
<app-collapsible>
  <ng-template>
    <p>Only created when opened.</p>
  </ng-template>
</app-collapsible>

Now the content genuinely is not created until it is needed.

Projecting into several places

A single ng-content can only appear once. Using the same selector twice renders the content in the last position only.

<!-- does not duplicate the content -->
<ng-content select="[title]" />
<ng-content select="[title]" />

Use a template and ngTemplateOutlet when you need the same content in more than one place.

Querying projected content

contentChild and contentChildren reach into what was projected.

@Component({
  selector: 'app-tabs',
  template: `
    <nav>
      @for (tab of tabs(); track tab.id()) {
        <button (click)="select(tab)" [class.active]="tab === active()">
          {{ tab.label() }}
        </button>
      }
    </nav>
    <ng-content />
  `,
})
export class Tabs {
  readonly tabs = contentChildren(Tab);
  protected readonly active = signal<Tab | undefined>(undefined);

  protected select(tab: Tab): void { this.active.set(tab); }
}
<app-tabs>
  <app-tab label="First">...</app-tab>
  <app-tab label="Second">...</app-tab>
</app-tabs>

The tabs component discovers its children and builds the navigation. This is how most tab, accordion and stepper components work.

Note contentChildren returns a signal, so the tab list updates automatically when children are added or removed.

Styling projected content

Projected content keeps the parent’s encapsulation, not the child’s. The child’s own styles cannot reach it.

@Component({
  selector: 'app-card',
  template: `<div class="card"><ng-content /></div>`,
  styles: `
    .card { padding: 1rem; }
    .card h2 { color: red; }         /* does NOT affect projected h2 */
    :host ::ng-deep h2 { color: red; }  /* does */
  `,
})

This surprises people regularly. The content belongs to whoever wrote it, so it carries their scoping attributes.

Better than ::ng-deep: style the wrapper and expose custom properties for the rest.

Projection or an input?

<!-- an input: for data -->
<app-card title="Settings" [count]="5" />

<!-- projection: for markup -->
<app-card>
  <h2>Settings <app-badge>New</app-badge></h2>
</app-card>

Use an input when the value is data the component formats. Use projection when the caller needs control over the markup, including nesting other components.

A component often takes both.

Quick recap

  • <ng-content /> renders whatever the caller put between the tags.
  • select="[attr]" creates named slots, and unmatched content falls into the unnamed one.
  • With no unnamed slot, extra content is silently discarded.
  • Content inside ng-content tags is fallback for when nothing is projected.
  • Projected content is created by the parent, so @if around ng-content does not delay it.
  • Use TemplateRef with ngTemplateOutlet for genuinely lazy or repeated content.
  • Projected content keeps the parent’s style encapsulation, not the child’s.
app.ts

import { Component, signal, contentChild, contentChildren, TemplateRef, input } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
// Inputs pass DATA. Content projection passes MARKUP.
@Component({
  selector: 'app-panel',
  template: `
    <section class="panel">
      <!-- named slots, matched by CSS selector -->
      <header>
        <ng-content select="[panel-title]">
          <h2>Untitled</h2>         <!-- fallback when nothing is projected -->
        </ng-content>
      </header>
      <div class="body"><ng-content /></div>   <!-- catch-all -->
      <footer><ng-content select="[panel-actions]" /></footer>
    </section>
  `,
  styles: `
    .panel h2 { color: red; }             /* does NOT reach projected content */
    :host ::ng-deep h2 { color: red; }    /* does, because projection keeps the
                                             PARENT's encapsulation */
  `,
})
export class Panel {}
// Usage:
//   <app-panel>
//     <h2 panel-title>Settings</h2>
//     <p>Body goes in the unnamed slot.</p>
//     <div panel-actions><button>Save</button></div>
//   </app-panel>
// With NO unnamed slot, unmatched content is silently DISCARDED.
// @if around ng-content does not delay creation. The PARENT creates it, once.
// For genuinely lazy content, take a TemplateRef instead:
@Component({
  selector: 'app-collapsible',
  imports: [NgTemplateOutlet],
  template: `
    <button (click)="open.set(!open())">Toggle</button>
    @if (open()) { <ng-container [ngTemplateOutlet]="body()" /> }
  `,
})
export class Collapsible {
  readonly body = contentChild.required(TemplateRef);
  protected readonly open = signal(false);
}
// contentChildren discovers projected components. This is how tabs work.
@Component({ selector: 'app-tabs', template: `<ng-content />` })
export class Tabs {
  readonly tabs = contentChildren(Tab);      // a signal, updates automatically
}
@Component({ selector: 'app-tab', template: `` })
export class Tab { readonly label = input(''); }

Was this explanation helpful?

Your feedback helps us improve the content for everyone.