Content Projection with ng-content

Content projection is Angular’s slot mechanism โ€” it lets a parent component inject HTML content into specific locations inside a child component’s template. This is what makes truly reusable layout components possible: a CardComponent that defines a header, body, and footer layout but lets the parent fill in the actual content; a ModalComponent with a pre-built overlay and close button that accepts any body content; a TabComponent where each tab’s label and content comes from outside. ng-content is how Angular implements this pattern, including multi-slot projection with the select attribute.

ng-content Variants

Syntax Projects Matches
<ng-content> Default slot โ€” all unmatched content Anything not matched by a named slot
<ng-content select="h2"> Tag name selector <h2> elements
<ng-content select="[header]"> Attribute selector Elements with header attribute
<ng-content select=".footer"> CSS class selector Elements with class footer
<ng-content select="#main"> ID selector Element with id="main"
<ng-content select="app-icon"> Component selector <app-icon> components

ng-content Behaviour Rules

Rule Detail
Projected content ownership Content belongs to the parent โ€” the child component cannot access parent bindings on it
Styles Parent’s styles apply to projected content; child’s encapsulated styles do NOT apply to projected content by default
Change detection Projected content is checked by the parent’s change detector
Conditional projection ng-content cannot conditionally show/hide projected content with *ngIf; use ngTemplateOutlet instead
Default content Content inside <ng-content></ng-content> is NOT shown as default โ€” the tags must be empty; use ngTemplateOutlet for default content
Note: Content projection does not copy content โ€” it moves it. The projected HTML is rendered exactly once. If you project the same content into two <ng-content> slots (by duplicating the ng-content in the child template), Angular only projects into the first matching slot. The content is owned by the parent component’s view โ€” this is why parent styles apply but child encapsulated styles do not. The child component can read projected content with @ContentChild but not with @ViewChild.
Tip: For layout components (cards, panels, dialogs) that need a consistent shell with flexible inner content, multi-slot projection with attribute selectors is the cleanest API. Use attribute names like cardHeader, cardBody, cardFooter rather than component selectors, because the parent can apply them to any HTML element โ€” a simple <h2 cardHeader> works without wrapping in a special component. This gives maximum flexibility to the parent while keeping the layout controlled by the child.
Warning: Be careful with styles when using content projection. A child component’s ViewEncapsulation.Emulated styles (the default) add a unique attribute to every element in the child’s own template, scoping them. But projected content from the parent does NOT get the child’s scoping attributes โ€” the child’s styles will NOT apply to projected content. To style projected content from the child, either use the deprecated ::ng-deep combinator or, preferably, design the child to accept styling via CSS custom properties (--card-header-color).

Complete ng-content Examples

// โ”€โ”€ Single-slot projection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@Component({
    selector: 'app-panel',
    standalone: true,
    template: `
        <div class="panel">
            <div class="panel__inner">
                <ng-content></ng-content>   <!-- any content from parent -->
            </div>
        </div>
    `,
    styles: [`.panel { border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; }`]
})
export class PanelComponent {}

// Parent usage:
// <app-panel>
//     <h2>My Title</h2>        <!-- projected into ng-content -->
//     <p>Some body text.</p>  <!-- also projected -->
// </app-panel>

// โ”€โ”€ Multi-slot projection with attribute selectors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@Component({
    selector: 'app-card',
    standalone: true,
    template: `
        <article class="card">
            <header class="card__header">
                <!-- Projects elements with [cardHeader] attribute -->
                <ng-content select="[cardHeader]"></ng-content>
            </header>

            <section class="card__body">
                <!-- Projects elements with [cardBody] attribute -->
                <ng-content select="[cardBody]"></ng-content>
            </section>

            <footer class="card__footer">
                <!-- Default slot โ€” everything NOT matched above -->
                <ng-content></ng-content>
            </footer>
        </article>
    `,
})
export class CardComponent {}

// Parent usage โ€” rich slot control:
// <app-card>
//     <h2 cardHeader>Task Details</h2>
//     <p cardBody>This is the card body content.</p>
//     <!-- Default slot -->
//     <button class="btn">Save</button>
// </app-card>

// โ”€โ”€ Modal with ng-content โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@Component({
    selector: 'app-modal',
    standalone: true,
    imports: [CommonModule],
    template: `
        <div class="modal-overlay" *ngIf="isOpen" (click)="close()">
            <div class="modal" (click)="$event.stopPropagation()">
                <header class="modal__header">
                    <ng-content select="[modalTitle]"></ng-content>
                    <button class="modal__close" (click)="close()">&times;</button>
                </header>
                <section class="modal__body">
                    <ng-content></ng-content>
                </section>
                <footer *ngIf="hasFooter" class="modal__footer">
                    <ng-content select="[modalFooter]"></ng-content>
                </footer>
            </div>
        </div>
    `,
})
export class ModalComponent {
    @Input() isOpen = false;
    @Output() closed = new EventEmitter<void>();
    @ContentChild('[modalFooter]') footerContent?: ElementRef;

    get hasFooter(): boolean { return !!this.footerContent; }
    close(): void { this.closed.emit(); }
}

// Parent usage:
// <app-modal [isOpen]="showDeleteModal" (closed)="showDeleteModal = false">
//     <h2 modalTitle>Confirm Delete</h2>
//     <p>Are you sure you want to delete this task?</p>
//     <div modalFooter>
//         <button (click)="showDeleteModal = false">Cancel</button>
//         <button class="btn--danger" (click)="onConfirmDelete()">Delete</button>
//     </div>
// </app-modal>

// โ”€โ”€ ngTemplateOutlet โ€” conditional default content โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@Component({
    selector: 'app-empty-state',
    standalone: true,
    imports: [CommonModule],
    template: `
        <div class="empty-state">
            <!-- Use projected content if provided, else show default template -->
            <ng-content></ng-content>
            <!-- Cannot conditionally show default โ€” always show projected content if present -->
        </div>
    `,
})
export class EmptyStateComponent {}

// โ”€โ”€ ngTemplateOutlet for reusable template fragments โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@Component({
    selector: 'app-task-list',
    standalone: true,
    imports: [CommonModule],
    template: `
        <ul>
            @for (task of tasks(); track task._id) {
                <ng-container *ngTemplateOutlet="rowTemplate; context: { task, index: $index }">
                </ng-container>
            }
        </ul>

        <!-- Default row template -->
        <ng-template #rowTemplate let-task="task" let-i="index">
            <li>{{ i + 1 }}. {{ task.title }}</li>
        </ng-template>
    `,
})
export class TaskListComponent {
    tasks = input.required<Task[]>();

    // Optional custom row template from parent
    @ContentChild('customRow') customRowTpl?: TemplateRef<any>;
}

How It Works

Step 1 โ€” ng-content Is a Placeholder, Not a Copy

During compilation, Angular processes <ng-content> tags in the child template and marks their positions as projection slots. When the parent renders <app-card><h2>Title</h2></app-card>, Angular moves the <h2> element from the parent’s view into the card’s slot position. The <h2> is not duplicated โ€” it exists once in the final DOM. The parent’s view maintains ownership of it for change detection and styling purposes.

Step 2 โ€” select Attribute Uses CSS-Like Selectors

The select attribute on <ng-content> works like a CSS selector to match child elements from the parent. Element selectors (h2), attribute selectors ([header]), class selectors (.footer), component selectors (app-icon), and combinations work as expected. Elements that do not match any named ng-content select fall through to the default <ng-content> (the one without a select attribute).

Step 3 โ€” Projected Content Is Checked by the Parent

Even though projected content appears inside the child component’s DOM, Angular checks it as part of the parent’s change detection cycle. Expressions bound in the projected content (like {{ parentProperty }}) refer to the parent component’s scope, not the child’s. This is why you can write <app-card><h2>{{ task.title }}</h2></app-card> and the binding works โ€” task is a property of the parent, not the card.

Step 4 โ€” ngTemplateOutlet Enables Template Composition

ngTemplateOutlet renders a TemplateRef in a specified location. Combined with a context object, it enables powerful patterns: a list component that accepts a custom row template from the parent (customising how each row is rendered), or a dialog that accepts a custom footer. The template is defined in the parent with #myTemplate and rendered in the child with *ngTemplateOutlet="myTemplate; context: { item }".

Step 5 โ€” Styles and Encapsulation with Projected Content

Angular’s default ViewEncapsulation.Emulated adds unique attribute selectors to every element in a component’s own template, scoping its styles. Projected content comes from the parent’s template and does not get the child’s scoping attributes. This means a child’s .card__header { color: red } will not apply to a projected <h2 cardHeader>. Design shared components to accept styling through CSS custom properties (--card-bg: white) which cross encapsulation boundaries naturally.

Common Mistakes

Mistake 1 โ€” Expecting child styles to apply to projected content

โŒ Wrong โ€” child’s scoped styles do not reach projected content:

/* card.component.scss */
h2 { color: navy; font-size: 1.5rem; }  /* will NOT apply to projected <h2 cardHeader> */

✅ Correct โ€” use CSS custom properties for styling projected slots:

/* card.component.scss */
.card__header { color: var(--card-header-color, navy); }
/* Parent sets: <app-card style="--card-header-color: #1e40af"> */

Mistake 2 โ€” Trying to add default content inside ng-content tags

โŒ Wrong โ€” content inside ng-content tags is not shown as default:

<ng-content>
    <p>Default if nothing projected</p>  <!-- NEVER shown โ€” ng-content ignores inner content -->
</ng-content>

✅ Correct โ€” use ContentChild to detect projection, show default with *ngIf:

<ng-content></ng-content>
<p *ngIf="!hasProjectedContent">Default if nothing projected</p>

Mistake 3 โ€” Binding to projected content’s internal state from the child

โŒ Wrong โ€” child cannot access bindings from projected parent content:

// In CardComponent:
@ContentChild('cardTitle') title!: ElementRef;
someMethod() {
    // Can access the DOM element, but cannot access Angular bindings on it
    // Cannot call methods on components projected from the parent
}

✅ Correct โ€” communicate through @Input()/@Output() or shared services:

// Pass data explicitly as @Input to the child
// OR use a shared service for complex communication

Quick Reference

Need ng-content Pattern
Single content slot <ng-content></ng-content>
Named slot by attribute <ng-content select="[mySlot]"> โ†’ parent: <div mySlot>
Named slot by tag <ng-content select="h2">
Named slot by class <ng-content select=".footer">
Default fallback slot <ng-content> (no select โ€” catches unmatched)
Detect if content projected @ContentChild('[slotAttr]') ref?: ElementRef
Render template fragment *ngTemplateOutlet="tplRef; context: { item }"
Style projected content CSS custom properties (--slot-color)

🧠 Test Yourself

A CardComponent has <ng-content select="[cardHeader]"> and <ng-content>. A parent renders <app-card><h2 cardHeader>Title</h2><p>Body</p></app-card>. Where does each element appear?