Angular – Content Projection and ng-template

June 21, 20263 min readUpdated 8/21/2026

Some components exist to wrap content they know nothing about — a modal, a card, a panel. The component owns the frame; the caller owns what goes in it. <ng-content> is the hole you leave for the caller's markup.

<div class="modal-body">
  <ng-content select="[modal-body]" />
</div>

It is props.children. The difference is that a component can have several holes, chosen by CSS selector.

Named slots

The modal has three — a title, a body and a footer:

<div class="modal-header">
  <h2 class="modal-title h5"><ng-content select="[modal-title]" /></h2>
  <button
    #closeButton
    type="button"
    class="btn-close"
    aria-label="Close"
    (click)="closed.emit()"
  ></button>
</div>

The caller marks each piece with the matching attribute and Angular routes them:

<app-modal [open]="editing() !== null" (closed)="editing.set(null)">
  <span modal-title>
    {{ editing() === 'new' ? 'New crust' : 'Edit ' + form.controls.name.value }}
  </span>

  <div modal-body>

The attributes modal-title, modal-body and modal-footer are not real HTML attributes and are not bound to anything — they are labels the select matches on. Any CSS selector works, so select="h2" or select=".summary" are equally valid; an attribute is conventional because it says "this is a slot marker" and nothing else.

react-bootstrap solves the same problem with three sub-components — Modal.Header, Modal.Body, Modal.Footer. Named slots get there with one component and no exported namespace.

The unnamed slot

An <ng-content> with no select catches everything left over. If you have named slots and a default, the default should be last, because matching is in order.

Two things that surprise people

Projected content is created by the caller, not by you. It is instantiated in the parent's context, with the parent's injector and the parent's data. Your component only decides where it lands. That means a slot cannot see your component's local variables — the caller's markup is evaluated against the caller.

Projected content is created even when the slot is not rendered. Angular builds the caller's markup regardless of whether an @if in your template ends up showing it. If that content is expensive, projecting it is not the way to defer it.

ng-container

A grouping element that emits no DOM. Useful when you need somewhere to hang a directive or a block but must not add a wrapper — inside a table row, a flex container, or a <select>, where a stray <div> would break the layout or the semantics:

<ng-container *ngTemplateOutlet="row; context: { $implicit: order }" />

ng-template

Markup that is defined but not rendered until something asks for it. On its own it produces nothing:

<ng-template #emptyState>
  <p class="text-muted">Nothing here yet.</p>
</ng-template>

Render it with ngTemplateOutlet, optionally passing a context:

<ng-container [ngTemplateOutlet]="emptyState" />

This is the escape hatch for the case a slot cannot cover: when the caller needs to supply markup that your component fills in with its data — a row template for a table component, say. A slot is projected as-is; a template can be rendered many times, with different values each time, which is Angular's answer to a render prop.

The pizza app uses none of this. Its modal needs three fixed slots and nothing more, and reaching for ngTemplateOutlet when <ng-content> would do is a reliable way to make a simple component hard to read. Learn it, then wait until a slot is genuinely not enough.

What is next

Behaviour you attach to an element you did not write: directives.