Foreword
Content Projection is, well, a pretty basic concept on the surface. Most Angular developers know that you can slap an <ng-content />
on your template to achieve basic content projection in Angular. While basic, Content Projection is one of those must have for composing complex UIs from smaller, reusable components.
Where things get interesting though, and where most tutorials about Content Projection fall short; is how the projected content interacts with Angular’s Dependency Injection (DI). This is important if you’re building context-aware UI components: Components that need to access their parent context like Tab
in a TabGroup
, like Column
/ Row
in a Table
etc…
To put it frankly, the way <ng-content />
interacts with Angular’s DI is surprisingly counter-intuitive. In this blog post, I want to explore this often-overlooked relationship between content projection and DI. The solution we’ll arrive at might feel like a workaround but that’s just how Angular works today. And we’ll need to wait and see how the future looks like.
Hopefully, you’ll have a deeper understanding of content projection in Angular.
The refresher you might not need
At its simplest form, content projection allows you to insert content from a parent component into a designated spot in a child component
1@Component({2 selector: 'app-card',3 template: `4 <div class="card">5 <div class="card-body">6 <ng-content />7 </div>8 </div>9 `10})11export class Card {}
and you would use it like this
1<app-card>2 <h2>Card title</h2>3 <p>This content will be projected</p>4</app-card>
Default content
Recent Angular versions have introduced the ability to provide a default fallback for <ng-content />
by declaring the default content in between <ng-content></ng-content>
tag
1@Component({2 selector: 'app-card',3 template: `4 <div class="card">5 <div class="card-body">6 <ng-content>7 <!-- This will show if nothing is projected -->8 <p>No content was provided</p>9 </ng-content>10 </div>11 </div>12 `13})14export class Card {}
Named content
For more complex components, you might want to project different content into different places using the select
attribute
1@Component({2 selector: 'app-card',3 template: `4 <div class="card">5 <div class="card-header">6 <ng-content select="[card-title]" />7 </div>8 <div class="card-body">9 <ng-content select="[card-content]" />10 </div>11 <div class="card-footer">12 <ng-content select="[card-footer]" />13 </div>14 </div>15 `16})17export class Card {}
and you would use it like this
1<app-card>2 <h2 card-title>My Card Title</h2>3 <div card-content>4 <p>This is the main content of the card.</p>5 </div>6 <button card-footer>Read More</button>7</app-card>
Aliasing ngProjectAs
Considering the following component:
1@Component({2 selector: 'app-card',3 template: `4 <div class="card">5 <div class="card-header">6 <ng-content select="app-card-title" />7 </div>8 <div class="card-body">9 <ng-content select="app-card-body" />10 </div>11 <div class="card-footer">12 <ng-content select="app-card-footer" />13 </div>14 </div>15 `16})17export class Card {}
The named slots have changed to app-card-title
, app-card-body
, and app-card-footer
. This means that the consumers would have to use <app-card-title />
for the card-header
projection slot and so on.
This, in a sense, allows the consumers to use the Card
component more correctly but it is also more strict. Sometimes, you might want to render more elements than just <app-card-title />
for the header slot. This is where ngProjectAs
comes in
1<app-card>2 <app-card-title>Card Title</app-card-title>3</app-card>4
5<app-card>6 <ng-container ngProjectAs="app-card-title">7 <app-card-title>Card Title</app-card-title>8 <i>some_icon</i>9 </ng-contaier>10</app-card>
NgTemplateOutlet
So, that section was probably the content projection that you have in mind but my definition of Content Projection also includes NgTemplateOutlet
, or the almighty <ng-template />
tag. There is a subtle distinction that you might disagree with me about including NgTemplateOutlet
under the Content Projection umbrella: <ng-content />
only projects the content AFTER it has been rendered; <ng-template />
actually renders the content in specific spot on the template. In other words, <ng-content />
allows you to control the where, <ng-template />
allows you to control the where and when.
A common use-case of <ng-template />
is to provide custom template for list components.
1@Component({2 selector: 'app-table',3 template: `4 @for (item of items(); track item.id) {5 @if (itemTmpl(); as itemTmpl) {6 <ng-container7 [ngTemplateOutlet]="itemTmpl"8 [ngTemplateOutletContext]="{ $implicit: item }"9 />10 } @else {11 <ng-container12 [ngTemplateOutlet]="defaultContent"13 [ngTemplateOutletContext]="{ $implicit: item }"14 />15 }16 }17
18 <ng-template #defaultContent let-item>19 <p>{{ item.id }}</p>20 </ng-template>21 `,22 imports: [NgTemplateOutlet]23})24export class Table {25 readonly items = input<Item[]>([]);26
27 protected readonly itemTmpl = contentChild(TemplateRef);28}
and you would use it like this
1<app-table [items]="employees" />2<app-table [items]="employees">3 <ng-template let-employee>4 <!-- your custom item template goes here -->5 </ng-template>6</app-table>
Conditional content projection
In lieu of content project, <ng-template />
allows for conditional content projection.
1@Component({2 selector: 'app-card',3 template: `4 <div class="card">5 <div class="card-header">6 <ng-content select="app-card-title" />7 </div>8 <div class="card-body">9 @if (isCompact()) {10 <ng-content select="app-card-body" />11 } @else {12 <div class="card-body-fancy">13 <ng-content select="app-card-body" />14 </div>15 }16 </div>17 <div class="card-footer">18 <ng-content select="app-card-footer" />19 </div>20 </div>21 `22})23export class Card {24 isCompact = input(true);25}
In this example, the Card
component conditionally renders a compact card body or a fancy card body. The problem in this code is that Angular only sees the last <ng-content select="app-card-body" />
which means that when isCompact() === true
, Card
cannot project anything.
The way to solve this is to use <ng-template />
1@Component({2 selector: 'app-card',3 template: `4 <div class="card">5 <div class="card-header">6 <ng-content select="app-card-title" />7 </div>8 <div class="card-body">9 @if (isCompact()) {10 <ng-container [ngTemplateOutlet]="bodyTmpl" />11 } @else {12 <div class="card-body-fancy">13 <ng-container [ngTemplateOutlet]="bodyTmpl" />14 </div>15 }16 </div>17 <div class="card-footer">18 <ng-content select="app-card-footer" />19 </div>20 </div>21
22 <ng-template #bodyTmpl>23 <ng-content select="app-card-body" />24 </ng-template>25 `,26 imports: [NgTemplateOutlet]27})28export class Card {29 isCompact = input(true);30}
The problem with content projection
Now that we’ve covered the basics, let’s address the issue. To quickly point out the problem, let’s answer the question: Have you ever tried to abstract MatTable
or MatTabGroup
from @angular/material
? Like, you want to wrap the <table>
element in some container with some default classes to use across your application? If you haven’t tried this before, you’ll quickly discover that it just won’t work. Here’s what I’m referring to
1<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">2
3 <!--- Note that these columns can be defined in any order.4 The actual rendered columns are set as a property on the row definition" -->5
6 <!-- Position Column -->7 <ng-container matColumnDef="position">8 <th mat-header-cell *matHeaderCellDef> No. </th>9 <td mat-cell *matCellDef="let element"> {{element.position}} </td>10 </ng-container>11
12 <!-- Name Column -->13 <ng-container matColumnDef="name">14 <th mat-header-cell *matHeaderCellDef> Name </th>15 <td mat-cell *matCellDef="let element"> {{element.name}} </td>16 </ng-container>17
18 <!-- Weight Column -->19 <ng-container matColumnDef="weight">20 <th mat-header-cell *matHeaderCellDef> Weight </th>21 <td mat-cell *matCellDef="let element"> {{element.weight}} </td>22 </ng-container>23
24 <!-- Symbol Column -->25 <ng-container matColumnDef="symbol">26 <th mat-header-cell *matHeaderCellDef> Symbol </th>27 <td mat-cell *matCellDef="let element"> {{element.symbol}} </td>28 </ng-container>29
30 <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>31 <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>32</table>
Imagine I like to have a MyTable
component
1@Component({2 selector: 'my-table',3 template: `4 <div class="my-table-container">5 <table mat-table [dataSource]="dataSource()" class="mat-elevation-z8">6 <ng-content />7 </table>8 </div>9 `10})11export class MyTable {12 dataSource = input<any[]>([]);13}
And I would use it like this
1<my-table [dataSource]="dataSource">2
3 <!--- Note that these columns can be defined in any order.4 The actual rendered columns are set as a property on the row definition" -->5
6 <!-- Position Column -->7 <ng-container matColumnDef="position">8 <th mat-header-cell *matHeaderCellDef> No. </th>9 <td mat-cell *matCellDef="let element"> {{element.position}} </td>10 </ng-container>11
12 <!-- Name Column -->13 <ng-container matColumnDef="name">14 <th mat-header-cell *matHeaderCellDef> Name </th>15 <td mat-cell *matCellDef="let element"> {{element.name}} </td>16 </ng-container>17
18 <!-- Weight Column -->19 <ng-container matColumnDef="weight">20 <th mat-header-cell *matHeaderCellDef> Weight </th>21 <td mat-cell *matCellDef="let element"> {{element.weight}} </td>22 </ng-container>23
24 <!-- Symbol Column -->25 <ng-container matColumnDef="symbol">26 <th mat-header-cell *matHeaderCellDef> Symbol </th>27 <td mat-cell *matCellDef="let element"> {{element.symbol}} </td>28 </ng-container>29
30 <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>31 <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>32</my-table>
Well, this doesn’t work. This is because the content under my-table
is rendered before <table mat-table>
in MyTable
component. In other words, the Material Table columns and cells directives are instantiated before the main table directive.
Angular Three example
Let’s take a look at a realer use-case for this with Angular Three
In Angular Three, the most important symbol is the NGT_STORE
which is an object containing the main building blocks of a THREE.js
scene: the renderer, the THREE.Scene
, the current active THREE.Camera
etc…
All components in Angular Three get access to this store via Dependency Injection injectStore()
.
Additionally, Angular Three has a concept of portal via NgtPortal
which will create a layered NGT_STORE
. This means that components in Angular Three are context-aware; they can access different instances of NGT_STORE
depending on where they’re rendered in the template. If they are used under a NgtPortal
, they will use that layered NGT_SRORE
via injectStore()
. An example would look like this
1<ngt-canvas>2 <ng-template canvasContent>3 <!-- this will set the default camera on the root NGT_STORE -->4 <ngts-perspective-camera [options]="{ makeDefault: true }" />5
6 <ngt-portal>7 <ng-template portalContent>8 <!-- this will set the default camera on the layered NGT_STORE -->9 <ngts-orthographic-camera [options]="{ makeDefault: true }" />10 </ng-template>11 </ngt-portal>12 </ng-template>13</ngt-canvas>
So depending on where you render things, the camera they use might be different and this makes the components robust in Angular Three. NgtPortal
is what makes the following examples work
Animated GIF of NgtPortal examples (Sorry for the low quality GIF)
Angular Three and Content Projection
Back to content projection, NgtPortal
is rather a low-level API where other abstractions makes use of like: NgtsMeshPortalMaterial
(check out my MeshPortalMaterial tutorial) or NgtsRenderTexture
.
You don’t have to understand THREE.js to understand this section. The key point is about how components need to access the correct context through multiple levels of abstractions. The gist is here’s how I’d imagine my consumers would use NgtsRenderTexture
1<!-- they have an object in 3d space -->2<ngt-mesh>3 <!-- it is a cube/box -->4 <ngt-box-geometry />5 <!-- it has some material (styles) -->6 <ngt-mesh-standard-material>7 <!-- material has texture; kinda like CSS background-image -->8
9 <!-- consumers can render a _offscreen_ Scene -->10 <!-- then use RenderTexture to project the scene as the material's texture -->11 <ngts-render-texture>12 <!-- the offscreen scene -->13 </ngts-render-texture>14 </ngt-mesh-standard-material15</ngt-mesh>
If you look the last example in the GIF above, the letters use NgtsRenderTexture
to render different scenes as their materials.
With that in mind, you might be able to imagine how NgtsRenderTexture
template looks like
1<ngt-portal [container]="virtualScene" [state]="{ events: { compute: compute(), priority: eventPriority() } }">2 <ng-template portalContent>3 <!-- something something goes here -->4 <!-- is it just ng-content or something else? -->5 </ng-template>6</ngt-portal>
No, it cannot be just <ng-content />
because the content would be instantiated with the wrong injection context, disconnected from the NgtPortal
. As you probably already figure out that abstracting things with basic <ng-content />
won’t work for complex UI components like MatTabGroup
or MatTable
. The same thing applies to NgtsTextureContent
.
This is where <ng-template />
comes to the rescue (and why I personally group <ng-template />
under content projection). And not just <ng-template />
, it has to be <ng-template />
with [ngTemplateOutletInjector]
.
You see, the way that <ng-template />
works is that things wrapped with <ng-template />
get rendered dynamically at runtime with the ability to be rendered with a certain NodeInjector
(I specifically call out NodeInjector
here because EnvironmentInjector
won’t be applicable). What I’m pointing at is if we can forward the NodeInjector
from NgtPortal
all the way down to NgtsRenderTexture
’s content, then we’re good which [ngTemplateOutletInjector]
allows us to do.
ViewContainerRef#createEmbeddedView
also allows passing in aninjector
if you’re wondering.
There is one HUGE caveat though: everything has to follow the same pattern for it to work. In other words, the <ng-template />
with [ngTemplateOutletInjector]
pipeline has to be on every single level of abstractions. In our case, NgtPortal
has to implement it, NgtsRenderTexture
also has to implement it.
Let’s take a look at a more complete version of NgtsRenderTexture
and NgtPortal
1@Component({2 selector: 'ngt-portal',3 template: `4 <!-- NgtPortal renders via ViewContainerRef instead -->5 `,6})7export class NgtPortal {8 private contentRef = contentChild.required(NgtPortalContent, { read: TemplateRef });9 private anchorRef = contentChild.required(NgtPortalContent, { read: ViewContainerRef });10
11 private injector = inject(Injector);12
13 constructor() {14 effect(() => {15 // initialize the layered store16
17 // when ready18 const view = this.anchorRef().createEmbeddedView(19 this.contentRef(),20 // this is the context. Allows `let-injector="injector"` to be available21 { injector: this.injector },22 { injector: this.injector }23 );24 })25 }26}
1@Component({2 selector: 'ngts-render-texture',3 template: `4 <ngt-portal>5 <!-- NgtPortalContent exposes the injector from `NgtPortal` -->6 <ng-template portalContent let-injector="injector">7 <!-- use the injector from Portal to render the RenderTexture's content -->8 <!-- also pipe through the injector from Portal to the content template so further forwarding can work -->9 <ng-container10 [ngTemplateOutlet]="content()"11 [ngTemplateOutletInjector]="injector"12 [ngTemplateOutletContext]="{ injector }"13 />14 </ng-template>15 </ngt-portal>16 `,17 imports: [NgtPortal, NgTemplateOutlet]18})19export class NgtsRenderTexture {20 // enforcing the consumers to use `NgtsRenderTextureContent`21 content = contentChild.required(NgtsRenderTextureContent, { read: TemplateRef });22}
Now, NgtsRenderTexture
can be used like this
1<ngt-mesh>2 <ngt-box-geometry />3 <ngt-mesh-standard-material>4 <ngts-render-texture>5 <ng-template renderTextureContent>6 <!-- things rendered in here knows about the NgtPortal injector that NgtsRenderTexture abstracts -->7 <!-- this camera is set as default camera for the NgtPortal store -->8 <ngts-perspective-camera9 [options]="{ manual: true, makeDefault: true, aspect: 1, position: [0, 0, 5] }"10 />11 <!-- this color is set as the background for the portal scene -->12 <ngt-color attach="background" *args="['orange']" />13 </ng-template>14 </ngts-render-texture>15 </ngt-mesh-standard-material>16</ngt-mesh>
Conclusion
Hopefully, you’ve gained a deeper understanding of content projection in Angular. We’ve explored how basic content projection works with <ng-content />
, how to use named slots, and conditional projection, and most importantly how content projection interacts with Angular’s DI.
The challenges we’ve discussed with complex UI components like Material Table or Angular Three’s portals highlight an important limitation in Angular’s current content projection model. While the solution using <ng-template />
with [ngTemplateOutletInjector]
isn’t the most elegant, it provides a workable pattern for creating context-aware, composable components.
The Angular team does recognize that Content Projection needs improvements. As the framework evolves, we may see more intuitive APIs for handling complex content projection scenarios.
Until then, I’ll see you around. Thank you for reading!