April 17, 2019
Content Projection in Angular
What is Content Projection
Those of you who already worked with angular.js 1.x might be already familiar with the concepts described in this post. In angular.js 1.x you might know this concept under the infamous name transclusion. It’s not a concept invented by the Angular Team but rather one that describes how content of any document can be projected into another. Read more about it on Wikipedia. As the term transclusion caused a lot of confusion in the angular 1 days the core team has decided to ban the term and go with a more meaningful name: Content Projection and this post focusses on one part of it, namely <ng-content>.
Classic Angular Component
When designing simple Angular components you’re probably working with @Input() to pass data to your Component. This is mostly fine, but let’s spin up an imaginary scenario and work through it within the following pages. Lets say you are supposed to create a card component, just like the Angular Material Card. The Angular Material team works heavily with the concept we’re going to explore now. The card component that we are going to design should include a default styling and shall have a headline, content and footer as string only inputs.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
//card.component.ts import { Component, Input } from '@angular/core'; @Component({ selector: 'app-card', templateUrl: './card.component.html', styleUrls: ['./card.component.css'] }) export class CardComponent { @Input() headline: string; @Input() body: string; @Input() footer: string; constructor() { } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<!-- card.component.html --> <div class="card"> <div class="header"> <h2>{{headline}}</h2> </div> <hr> <div class="body"> <div>{{body}}</div> </div> <hr> <div class="footer"> <div>{{footer}}</div> </div> </div> |
The usage of the Component would look like this
1 2 3 4 |
<app-card headline="My Headline" body="My Body" footer="My Footer"> </app-card> |
you can see it live in action on Stackblitz: https://stackblitz.com/edit/angular-card-example
Until now everything is fine, but all of a sudden your PM approaches you and says that we need to display additional HMTL inside the body of our Card Component. How would you solve it? One approach would be to use ng-content.
ng-content
ng-content can be used to pass HTML content to a child component. You can not only pass in plain HTML but also property bindings and events. The bindings and events are bound to the parent component and not the child component. Using this approach you can get rid of your @Input() that defines the body text of the Card Component. There is not much you have to do, just by throwing in a <ng-content></ng-content> into the HTML file of your child component you define the spot Angular will render (project) the content to. The content that gets projected into the <ng-content> tag is defined inside the tag of your child component (the card component in our case). Let’s look at how it looks like in the code. In the card-component.ts we’re just going to remove the @Input() for the body.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
//card.component.ts import { Component, Input } from '@angular/core'; @Component({ selector: 'app-card', templateUrl: './card.component.html', styleUrls: ['./card.component.css'] }) export class CardComponent { @Input() headline: string; @Input() footer: string; constructor() { } } |
In the card.component.html we’re just going to add a <ng-content> tag where the {{ body }} binding was previously
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<!-- card.component.html --> <div class="card"> <div class="header"> <h2>{{headline}}</h2> </div> <hr> <div class="body"> <ng-content></ng-content> </div> <hr> <div class="footer"> <div>{{footer}}</div> </div> </div> |
The usage of the Component would look like this
1 2 3 4 |
<app-card headline="My Headline" footer="My Footer"> <h3>My Body as ng-content</h3> </app-card> |
Now you’re all good but again you PM approaches you and tells you that in some of the Card Components we now have to display a button that triggers some action and in some other cards he wants to just display text as before. Now we’re running into a bit of trouble, we could either expand the public interface of our Card Component like to add some @Input() or even duplicate the component but we recently learned about <ng-content> and I’m happy to tell you, we can solve the requirements with nearly the same technique as examined above.
Multiple ng-content slots
Angular allows you to have more than one <ng-content> slot to project your content. The only exception is that you have to somehow declare which content should be projected into which <ng-content> in the child component. There are multiple ways to do it, but we’re going to stick with the one that’s most often used in the numerous open source projects and you might have already wondered how they did.
We want to pass the header, footer and body as HTML content to the Card Component. In Order do this we are going to extend the card.component.ts as follows.
We are going to introduce 3 new directives. If you don’t know already a directive is pretty much the same as a component, except it does not have it’s own template. In our case we are using these directives as a marker directive which simply means that we’re assigning meaning to those directives but they do not contain any sort of logic, but are just used for the purpose of defining which element should be projected into which <ng-content> slot. Make sure to also add these Directives to the declarations Array of the corresponding Module.
The second thing we are doing is to define a @ContentChild for every of those 3 mentioned directives. A ContentChild is used to get the first element or the directive matching the selector from the content DOM. We use these properties later on to conditionally show/hide whole blocks within our card template, like showing <hr> only if a specific ContentChild is there or not.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
//card.component.ts import { Component, Input, ContentChild, Directive} from '@angular/core'; @Directive({ selector: '[appCardBody]' }) export class CardBodyDirective { } @Directive({ selector: '[appCardFooter]' }) export class CardFooterDirective { } @Directive({ selector: '[appCardHeader]' }) export class CardHeaderDirective { } @Component({ selector: 'app-card', templateUrl: './card.component.html', styleUrls: ['./card.component.css'] }) export class CardComponent { @ContentChild(CardHeaderDirective) header?: CardHeaderDirective; @ContentChild(CardBodyDirective) body?: CardBodyDirective; @ContentChild(CardFooterDirective) footer?: CardFooterDirective; constructor() { } } |
In the template we now have to define where we want to render the content. The two thing I want to highlight are:
- The 3 <ng-content> tags each having a [select] attribute. The select attribute takes an arbitrary css selector as an argument and uses the selector to find the DOM Nodes we want to project. This is where we are going to user our marker directives. We use the exact same selector here as we have used in the definition of the Directives.
- We can use a *ngIf to conditionally hide whole building blocks when there is no ViewChild. You can see that we want to hide a <hr> in the footer if there is no footer to display at all, this is why we added the @ContentChild() decorators in the component file.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<!-- card.component.html --> <div class="card"> <div class="header" *ngIf="header"> <ng-content select="[appCardHeader]"></ng-content> <hr> </div> <div class="body" *ngIf="body"> <ng-content select="[appCardBody]"></ng-content> </div> <div class="footer" *ngIf="footer"> <hr> <ng-content select="[appCardFooter]"></ng-content> </div> </div> |
The usage now looks like this. The important thing to look at is how we use the marker directives (attributes) to mark the DOM Nodes according to where we want to display them. The order of them does not matter, they could be all mixed up and Angular would find them because we select them by a css selector (attribute selector) in the card.component.html
We can now pass any HTML to the Card Component, we can even use property bindings and all of the nice Angular templating features.
1 2 3 4 5 6 7 8 |
<app-card> <h2 appCardHeader>My Header</h2> <div appCardBody>My Body</div> <div appCardFooter> <span>My Footer</span> <button>My Button</button> </div> </app-card> |
Pros / Cons
Pros
- Very Flexible Components, like Angular Material Card. You can display everything you want to without interfering with the base styling of the component, it just works.
- You don’t have to add @Import() if you want to extend your component.
Cons
- There is not IDE Support / IntelliSense for this feature, you have to know how the component you are using is working internally. This can be solved by having a good documentation, but let’s be honest: This works for large community driver projects like Angular Material, but rarely does in your own projects.
- Creating marker directives is tedious. You could work with class selectors but this makes everything more fragile.
- Can break when refactoring/renaming the marker directives
- Not very good to unit test. You need more code to setup your unit test.
Further Reading
- Stackblitz example with @Input()
- Stackblitz example with multiple <ng-content>
- Angular Material Card Source Code
- Angular Material Card Docs
[…] have a look at an example we used earlier in this blogpost about Content Projection in Angular. The following code shows the old version in Angular 7, where we included three directives with the […]
hi what do you mean by this ‘Creating marker directives is tedious. You could work with class selectors but this makes everything more fragile.’
can you explain briefly why class selectors are more fragile. i sort of briefly think it is because classes can be conflicting between the parent and the wrap component and classes names can easily change but one can just be careful and save a lot of trouble in making the directives etc. am i right?