Angular Content Projection: , Multi-Slot, select, ngProjectAs [2026]

Link copied
Angular Content Projection: , Multi-Slot, select, ngProjectAs [2026]

Angular Content Projection: , Multi-Slot, select, ngProjectAs [2026]

Content projection is how a component lets its parent inject markup into specific spots in its template. A <card> component decides the shell — border, padding, hover state — and lets the consumer fill in the header, body, and footer with whatever they want. The mechanism is a single element: <ng-content>.

This is lesson 2.6 of the Angular Tutorial. It assumes you have read lesson 2.4 (input signals) and lesson 2.5 (outputs). Inputs flow data; content projection flows markup.

The simplest case #

A component that wraps its consumer's content:

@Component({
  selector: 'app-card',
  template: `
    <section style="border:1px solid #ddd; padding:16px; border-radius:8px;">
      <ng-content />
    </section>
  `,
})
export class CardComponent {}

The consumer:

<app-card>
  <h2>Hello</h2>
  <p>Anything I put between the open and close tags shows up where <ng-content/> is.</p>
</app-card>

The rendered HTML:

<app-card>
  <section style="border:1px solid #ddd; padding:16px; border-radius:8px;">
    <h2>Hello</h2>
    <p>Anything I put between...</p>
  </section>
</app-card>

The <ng-content /> was a hole in the template; the consumer's children filled it. The <app-card> element survives in the DOM (encapsulation needs it); the inner <section> wraps the projected content.

Multi-slot projection with select #

A component can carve out multiple labeled slots:

@Component({
  selector: 'app-card',
  template: `
    <section class="card">
      <header><ng-content select="[card-header]" /></header>
      <main><ng-content /></main>
      <footer><ng-content select="[card-footer]" /></footer>
    </section>
  `,
})
export class CardComponent {}

The consumer routes content to each slot using CSS-style selectors:

<app-card>
  <h2 card-header>Settings</h2>
  <p>Choose your preferences below.</p>
  <div card-footer>
    <button>Save</button>
    <button>Cancel</button>
  </div>
</app-card>

The rules:

  1. select accepts any CSS selector — attribute ([card-header]), class (.card-title), element (h2), or compound (button.primary)
  2. A bare <ng-content /> (no select) is the catch-all — content not matching any select lands here
  3. Each element is placed in at most one slot — Angular evaluates select in template order; first match wins

The attribute pattern (card-header) is the convention because it doesn't conflict with classes or element types. Pick a prefix (mat-, app-, etc.) for consistency.

ngProjectAs — overriding the slot match #

Sometimes the natural element doesn't have the attribute you want — say you're projecting an external component or a wrapper directive. ngProjectAs lets you tell the parent how to slot it:

<app-card>
  <my-thirdparty-widget ngProjectAs="[card-header]" />
  <p>Body content...</p>
</app-card>

The widget gets routed to the [card-header] slot even though it doesn't carry that attribute. This is the escape hatch for projecting components you can't modify.

Default content (fallback) #

<ng-content> can wrap default markup that shows if the slot is empty:

@Component({
  selector: 'app-empty-state',
  template: `
    <div class="empty">
      <ng-content>
        <p>Nothing here yet.</p>
      </ng-content>
    </div>
  `,
})
export class EmptyStateComponent {}

If the consumer passes content, it appears. If not, the default <p>Nothing here yet.</p> renders. Useful for components like spinners, placeholder cards, or dialogs that have a sensible default.

Projected content sees the PARENT's bindings, not the child's #

This is the rule that catches everyone the first time:

@Component({
  selector: 'app-card',
  template: `
    <section class="card">
      <p>Inside card: {{ inside }}</p>
      <ng-content />
    </section>
  `,
})
export class CardComponent {
  inside = 'CARD SCOPE';
}

@Component({
  selector: 'app-host',
  imports: [CardComponent],
  template: `
    <app-card>
      <p>Inside slot: {{ outside }}</p>
    </app-card>
  `,
})
export class HostComponent {
  outside = 'HOST SCOPE';
}

The projected <p> reads outside from the host component (the parent that wrote the content), not from CardComponent. The card's inside is invisible to projected content. This is intentional — it means components remain logically encapsulated even when wrapping arbitrary markup.

Selector specificity and ordering #

When multiple <ng-content> slots could match, Angular checks selectors in template order:

@Component({
  template: `
    <ng-content select=".special" />          <!-- 1 -->
    <ng-content select="h2" />                <!-- 2 -->
    <ng-content />                            <!-- 3 (catch-all) -->
  `,
})

A <h2 class="special"> lands in slot 1 (more-specific selector first). A bare <h2> lands in slot 2. Anything else lands in slot 3. If you want priority by specificity rather than declaration order, you have to order the slots yourself.

When NOT to use projection #

Projection is overkill for cases where a string input would do:

<!-- Bad: projection for a simple text label -->
<app-button><span>Save</span></app-button>

<!-- Good: input for the simple case -->
<app-button label="Save" />

Reach for projection when the consumer needs structure — multiple elements, custom markup, nested components. For a single piece of text or a single value, an input is simpler.

The rule of thumb: if you would interpolate into a string input, use an input. If you would wrap markup, use projection.

Projected content + contentChild() #

You can query projected content from the wrapping component. contentChild() (covered in depth in lesson 2.13) returns a signal that points to a projected child element:

@Component({
  selector: 'app-card',
  template: `
    <section>
      <ng-content />
      @if (title()) {
        <p>Card title: {{ title()!.textContent }}</p>
      }
    </section>
  `,
})
export class CardComponent {
  title = contentChild<ElementRef<HTMLElement>>('cardTitle');
}

// Consumer
<app-card>
  <h2 #cardTitle>Settings</h2>
  <p>Choose preferences</p>
</app-card>

The <app-card> can introspect what was projected and react. Use this for libraries (a <tabs> that introspects its child <tab> components) — rarely needed for everyday components.

A real-world pattern: composable Modal #

A modal component with header, body, and footer slots:

@Component({
  selector: 'app-modal',
  template: `
    <div class="backdrop" (click)="close.emit()"></div>
    <div class="modal" role="dialog">
      <header>
        <ng-content select="[modal-title]">
          <h2>Untitled</h2>
        </ng-content>
        <button (click)="close.emit()">×</button>
      </header>
      <main>
        <ng-content />
      </main>
      <footer>
        <ng-content select="[modal-actions]">
          <button (click)="close.emit()">Close</button>
        </ng-content>
      </footer>
    </div>
  `,
  styles: `
    .backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); }
    .modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 24px; border-radius: 12px; min-width: 400px; }
  `,
})
export class ModalComponent {
  close = output<void>();
}

The consumer:

<app-modal (close)="showModal = false">
  <h2 modal-title>Confirm delete</h2>
  <p>Are you sure? This cannot be undone.</p>
  <div modal-actions>
    <button (click)="showModal = false">Cancel</button>
    <button (click)="confirmDelete()">Delete</button>
  </div>
</app-modal>

Three slots, each with a default fallback. The component owns the shell, dimensions, and dismissal logic; the consumer owns the content. The component never has to know what kind of modal it's rendering.

Common gotchas #

Symptom Cause Fix
Projected content doesn't appear The consumer didn't pass anything The <ng-content> with default content (fallback) handles this
Content goes to wrong slot select matches in template order, not specificity Reorder slots so more-specific selectors come first
Outputs from projected components don't bubble Outputs cross component boundaries explicitly, not through projection Forward each output manually in the wrapping component
Styles on projected content don't apply Encapsulation isolates the wrapping component's styles from the projected content Use CSS variables or :host-context (lesson 2.11)
Can't query projected child The wrapping component used viewChild instead of contentChild Use contentChild for projected content; viewChild for the wrapping component's own template

What's next #

Lesson 2.7 covered lifecycle hooks. Lessons 2.8–2.11 covered pipes, directives, template fragments, and styling. Lesson 2.12 picks up host element metadata. Lesson 2.13 covers signal queries (viewChild/contentChild). Lesson 2.14 walks programmatic component creation. After that Module 2 is done and Module 3 takes over with the signal system in depth.

Try it yourself #

A tab system that uses content projection plus signal queries:

@Component({
  selector: 'app-tab',
  template: `
    @if (active()) {
      <ng-content />
    }
  `,
})
export class TabComponent {
  label = input.required<string>();
  active = signal(false);
}

@Component({
  selector: 'app-tabs',
  imports: [TabComponent],
  template: `
    <nav>
      @for (t of tabs(); track t.label(); let i = $index) {
        <button (click)="select(i)" [class.active]="i === activeIdx()">
          {{ t.label() }}
        </button>
      }
    </nav>
    <ng-content />
  `,
})
export class TabsComponent {
  tabs = contentChildren(TabComponent);
  activeIdx = signal(0);

  constructor() {
    effect(() => this.select(this.activeIdx()));
  }

  select(i: number) {
    this.tabs().forEach((tab, idx) => tab.active.set(idx === i));
  }
}

Usage:

<app-tabs>
  <app-tab label="General"><p>General settings</p></app-tab>
  <app-tab label="Privacy"><p>Privacy settings</p></app-tab>
  <app-tab label="Account"><p>Account settings</p></app-tab>
</app-tabs>

The <app-tabs> projects all child tabs into its body, queries them via contentChildren, and toggles active on each. Each <app-tab> projects its own body conditionally based on its active signal. Two layers of projection, one signal-driven UI.

YouShould I use ng-content for a button’s label, or just an input string?
Claude · used get_best_practicesAn input — strings should travel as inputs. ng-content is for the cases where the consumer needs to pass markup: an icon plus text, a heading plus body, a custom layout. If you find yourself reaching for projection just to render {{ name }}, an input is the right tool. Reserve projection for genuine slot-and-fill patterns.

Lesson 2.12 picks up host metadata.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *