DEV Community

Dayana Jabif
Dayana Jabif

Posted on

Content Projection in Angular: Building Flexible, Reusable Components

Three years ago, when I started working at one project, I was handed the app's UI design and had to figure out how to translate it into Angular components.

We had a clear use case: multiple content categories — events, deals, real estate, partners, among others — that shared a common UI (filters, cards, footer, header, reviews) but also had very distinct pieces from one another.

Thinking through how to find a reusable component logic is what led me to this content projection architecture. Structuring the app this way from the ground up gave me a huge advantage over my three and a half years on the project: as the app grew and new categories kept appearing, the component structure and the way components talked to each other just felt natural, almost like it had been built for exactly that.

The same logic applied to smaller, encapsulated components: they shared common behavior, but their UI needed to be customizable depending on where they showed up — the same card, for example, would look different depending on which listing it was rendered in. CSS variables were key to building those small components in a flexible way.

Designing this logic and building it all out was one of the parts of that project I enjoyed the most. Taking the time to structure your app's components to be as encapsulated and reusable as possible is what gives you the real advantage as your app keeps changing and growing.

That's why in this post I want to share a bit of the logic I used, and everything you can do with Angular content projection.

Creating reusable and flexible components is one of the fundamentals of building robust applications. In Angular, the feature that makes this possible is content projection — also known as transclusion. It lets a parent component pass content into a child component's template, so the same component can render very different things depending on who's using it.

This post walks through content projection from the ground up: the core building blocks, single- and multi-slot projection, fallback content, ngProjectAs, and conditional projection.

The building blocks

Before diving into examples, here are the pieces you'll see throughout this post:

  • ng-content — a placeholder that marks where projected content should render inside a component's template.
  • ng-container — a logical wrapper you can attach a directive to without adding an extra DOM element.
  • ng-template — defines a block of template content that isn't rendered by default.
  • TemplateRef — a reference to the content inside an ng-template, used to instantiate views from it.
  • ViewContainerRef — a container that views can be attached to, typically the anchor point for something rendered from a TemplateRef.
  • NgTemplateOutlet — a directive that inserts an embedded view from a given TemplateRef.

If you've spent time in an Angular codebase, you've likely already met most of these.

Single-slot content projection

The simplest form of content projection lets you project one block of content into a component. You place an <ng-content> element wherever you want that content to appear:

import { Component } from '@angular/core';

@Component({
  selector: 'app-container-component',
  template: `
    <h2>Single-slot content projection</h2>
    <ng-content />
    <div>Here we can have more code.</div>
  `,
})
export class ContainerBasicComponent {}
Enter fullscreen mode Exit fullscreen mode

Components are standalone by default, so there's no need for a standalone: true flag here.

Using it looks like this:

<app-container-component>
  This content will be projected where the ng-content slot is.
</app-container-component>
Enter fullscreen mode Exit fullscreen mode

Which renders as:

<h2>Single-slot content projection</h2>
This content will be projected where the ng-content slot is.
<div>Here we can have more code.</div>
Enter fullscreen mode Exit fullscreen mode

<ng-content> isn't a real DOM element — Angular's compiler resolves it at build time, and it can't be inserted, removed, or styled at runtime. Custom attributes like class on <ng-content> are ignored too.

Diagram showing a parent template projecting a paragraph into a CustomCard component's ng-content placeholder

An example you can relate to

Say your app shows content from a few different categories — events, restaurants, and hotels. Call them "items." Each item type shares a lot: images, location, reviews, contact info. But each also has something unique — restaurants need a menu and pre-order button, events need a list of services, hotels need room types.

You could build a separate detail page per category and duplicate all the shared markup. You'll feel that pain quickly. A better option is one shared ItemDetailComponent that owns the common layout, with content projection handling the parts that differ:

import { Component, input } from '@angular/core';

@Component({
  selector: 'app-item-detail',
  template: `
    <h2>{{ item().title }}</h2>

    <ng-content select="[mainContent]" />

    <div class="footer">
      <ng-content select="[footer]">
        <p>No additional details for this item.</p>
      </ng-content>
    </div>

    @for (review of item().reviews; track review.id) {
      <p>{{ review.rate }} – {{ review.message }}</p>
    }
  `,
})
export class ItemDetailComponent {
  item = input.required<Item>();
}
Enter fullscreen mode Exit fullscreen mode

The item input uses input.required<Item>(), Angular's signal-based input API, and the review list uses the @for block to iterate.

Each category then builds a thin wrapper around the shared component. Restaurants:

import { Component } from '@angular/core';
import { ItemDetailComponent } from './item-detail.component';

@Component({
  selector: 'app-restaurant-page',
  imports: [ItemDetailComponent],
  template: `
    <app-item-detail [item]="restaurant">
      <div mainContent>{{ restaurant.menu }}</div>
      <div footer>Pre-order available — order ahead and skip the line.</div>
    </app-item-detail>
  `,
})
export class RestaurantComponent {
  restaurant: Restaurant = {
    // restaurant data
  };
}
Enter fullscreen mode Exit fullscreen mode

Events:

import { Component } from '@angular/core';
import { ItemDetailComponent } from './item-detail.component';

@Component({
  selector: 'app-event-page',
  imports: [ItemDetailComponent],
  template: `
    <app-item-detail [item]="event">
      <div mainContent>{{ event.services }}</div>
    </app-item-detail>
  `,
})
export class EventComponent {
  event: Event = {
    // event data
  };
}
Enter fullscreen mode Exit fullscreen mode

Notice the event page doesn't provide a footer block at all — which is exactly the case fallback content is built for (more on that below). Hotels would follow the same shape, with room types slotted into mainContent.

Diagram showing RestaurantPage, EventPage, and HotelPage all projecting content into a shared ItemDetailComponent

Multi-slot content projection

A component can expose more than one slot. Each <ng-content> can carry a select attribute specifying a CSS selector — tag name, attribute, class, or even :not() — that determines which projected content lands in that slot.

@Component({
  selector: 'card-title',
  template: `<ng-content />`,
})
export class CardTitle {}

@Component({
  selector: 'card-body',
  template: `<ng-content />`,
})
export class CardBody {}

@Component({
  selector: 'custom-card',
  imports: [CardTitle, CardBody],
  template: `
    <div class="card-shadow">
      <ng-content select="card-title" />
      <div class="card-divider"></div>
      <ng-content select="card-body" />
    </div>
  `,
})
export class CustomCard {}
Enter fullscreen mode Exit fullscreen mode
<custom-card>
  <card-title>Hello</card-title>
  <card-body>Welcome to the example</card-body>
</custom-card>
Enter fullscreen mode Exit fullscreen mode

If you include one <ng-content> with a select and one without, the unselected one captures everything that didn't match another selector. If there's no unselected <ng-content> at all, unmatched elements simply don't render.

Diagram showing card-title and card-body content routed into two separate ng-content select slots

Fallback content

<ng-content> slots can define fallback content that renders automatically when nothing matches them. You define it by putting content directly inside the <ng-content> tag:

<!-- Component template -->
<div class="card-shadow">
  <ng-content select="card-title">Untitled</ng-content>
  <div class="card-divider"></div>
  <ng-content select="card-body">No description provided.</ng-content>
</div>
Enter fullscreen mode Exit fullscreen mode
<!-- Using the component -->
<custom-card>
  <card-title>Hello</card-title>
  <!-- no card-body provided -->
</custom-card>
Enter fullscreen mode Exit fullscreen mode
<!-- Rendered DOM -->
<div class="card-shadow">
  <card-title>Hello</card-title>
  <div class="card-divider"></div>
  No description provided.
</div>
Enter fullscreen mode Exit fullscreen mode

This is a clean way to give a slot a sensible default without writing any extra logic in the component class.

Diagram showing a card-body slot rendering its default fallback text when the parent provides no content

Aliasing content with ngProjectAs

Sometimes the element you want to project doesn't natively match a slot's selector — maybe you're projecting an <h3> into a slot that expects card-title. ngProjectAs lets you tell Angular to treat an element as if it matched a different selector:

<!-- Component template -->
<div class="card-shadow">
  <ng-content select="card-title" />
  <div class="card-divider"></div>
  <ng-content />
</div>
Enter fullscreen mode Exit fullscreen mode
<!-- Using the component -->
<custom-card>
  <h3 ngProjectAs="card-title">Hello</h3>
  <p>Welcome to the example</p>
</custom-card>
Enter fullscreen mode Exit fullscreen mode

ngProjectAs only accepts static values — it can't be bound to a dynamic expression.

Conditional content projection

If a component needs to conditionally render projected content, or render it more than once, wrap that content in an <ng-template> so it isn't rendered until something explicitly instantiates it — typically through @ContentChild/contentChild plus NgTemplateOutlet, or through a structural directive.

One important rule: don't wrap <ng-content> itself in @if, @for, or @switch. Angular always instantiates the DOM nodes for whatever's projected into an <ng-content> slot, even if that slot is hidden — so conditionally gating the <ng-content> tag doesn't skip that work, it just hides it.

For a lot of "show this or show that" cases, you can express the intent directly with @if/@else across two separate slots instead:

@Component({
  selector: 'app-item-detail',
  template: `
    @if (isLoading()) {
      <ng-content select="[loading]">
        <p>Loading…</p>
      </ng-content>
    } @else {
      <ng-content select="[content]" />
    }
  `,
})
export class ItemDetailComponent {
  isLoading = input(false);
}
Enter fullscreen mode Exit fullscreen mode

ng-template is still what powers structural directives internally — *ngIf, *ngFor, and *ngSwitch all desugar into it. If you're querying a TemplateRef from a parent's projected content, @ContentChild(TemplateRef) works, and there's also a signal-based contentChild(TemplateRef) alternative that fits naturally alongside signal inputs.

ng-container

ng-container gives you a place to attach a structural directive to a group of elements without adding an extra node to the DOM. That matters whenever you rely on precise DOM structure for styling — flex containers, :first-child selectors, and so on — since an unnecessary wrapper <div> can break all of it.

Wrapping up

Content projection is what makes Angular components genuinely reusable: it separates a component's structure from its content, so the same component can adapt to whatever a consumer passes in. ng-content handles the simple cases, multi-slot projection with select handles layouts with multiple distinct regions, fallback content covers sensible defaults, ngProjectAs handles content that doesn't natively match a selector, and ng-template combined with structural directives or the @if/@for/@switch blocks covers anything conditional.

Once you start designing components around what they host rather than what they contain, you end up writing a lot less duplicate code — and the components you build stay useful well beyond the feature you first built them for.

FAQs

How does Angular content projection improve reusable component design?
It lets you separate a component's structure from its content, so the same component can be reused across contexts with different content supplied by each consumer at runtime — reducing duplication without sacrificing flexibility.

When should you use ng-content in Angular components?
Whenever a component's job is to wrap or decorate content rather than dictate it — cards, modals, layout wrappers, and similar container components. If you find yourself creating near-identical component variants just to change what's inside, that's a signal to reach for projection instead.

What's the easiest way to give a projected slot a sensible default?
Fallback content. Put the default markup directly inside the <ng-content> tag and Angular renders it automatically whenever nothing is projected into that slot — no extra component logic required.

Which use cases benefit most from content projection?
Layout containers where content varies (cards, panels, modals), wrapper components that add styling or behavior without fixing the inner markup, and reusable UI patterns — like the shared item-detail example above — that need to host different content per consumer.

Top comments (0)