Skip to main content
Content Projection

Content Projection Overview

Estimated Time: 2 hours | Difficulty: Intermediate | Prerequisites: Components, Templates
Content projection allows you to create flexible, reusable components by inserting content from a parent component into designated slots in the child component.
┌─────────────────────────────────────────────────────────────────────────┐
│                 Content Projection Types                                 │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   1. Single-slot Projection                                              │
│   ┌────────────────────┐         ┌────────────────────┐                 │
│   │  <app-card>        │   ───►  │  Card Component    │                 │
│   │    <p>Content</p>  │         │  ┌──────────────┐  │                 │
│   │  </app-card>       │         │  │  <ng-content>│  │                 │
│   └────────────────────┘         │  └──────────────┘  │                 │
│                                   └────────────────────┘                 │
│                                                                          │
│   2. Multi-slot Projection (Named slots)                                │
│   ┌────────────────────────┐     ┌────────────────────────┐             │
│   │  <app-modal>           │     │  Modal Component       │             │
│   │    <h2 slot="header">  │ ──► │  ┌────────────────┐    │             │
│   │    <div slot="body">   │     │  │ header slot    │    │             │
│   │    <button slot="footer│     │  ├────────────────┤    │             │
│   │  </app-modal>          │     │  │ body slot      │    │             │
│   └────────────────────────┘     │  ├────────────────┤    │             │
│                                   │  │ footer slot    │    │             │
│                                   │  └────────────────┘    │             │
│                                   └────────────────────────┘             │
│                                                                          │
│   3. Conditional Projection                                              │
│   Content shown/hidden based on component logic                         │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Single-Slot Projection

// card.component.ts
@Component({
  selector: 'app-card',
  standalone: true,
  template: `
    <div class="card" [class.elevated]="elevated()">
      <ng-content />
    </div>
  `,
  styles: `
    .card {
      padding: 1.5rem;
      border-radius: 8px;
      background: white;
      border: 1px solid #e2e8f0;
    }
    .card.elevated {
      box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
    }
  `
})
export class CardComponent {
  elevated = input(false);
}

// Usage
@Component({
  template: `
    <app-card [elevated]="true">
      <h2>Card Title</h2>
      <p>This content is projected into the card.</p>
      <button>Action</button>
    </app-card>
  `
})
export class PageComponent {}

Multi-Slot Projection

Using select Attribute

// modal.component.ts
@Component({
  selector: 'app-modal',
  standalone: true,
  template: `
    <div class="modal-overlay" (click)="close()">
      <div class="modal" (click)="$event.stopPropagation()">
        <header class="modal-header">
          <ng-content select="[modal-header]" />
          <button class="close-btn" (click)="close()">×</button>
        </header>
        
        <main class="modal-body">
          <ng-content select="[modal-body]" />
        </main>
        
        <footer class="modal-footer">
          <ng-content select="[modal-footer]" />
        </footer>
      </div>
    </div>
  `,
  styleUrl: './modal.component.scss'
})
export class ModalComponent {
  closed = output<void>();
  
  close() {
    this.closed.emit();
  }
}

// Usage
@Component({
  template: `
    @if (showModal()) {
      <app-modal (closed)="showModal.set(false)">
        <h2 modal-header>Confirm Action</h2>
        
        <div modal-body>
          <p>Are you sure you want to proceed?</p>
        </div>
        
        <div modal-footer>
          <button (click)="cancel()">Cancel</button>
          <button class="primary" (click)="confirm()">Confirm</button>
        </div>
      </app-modal>
    }
  `
})
export class ConfirmDialogComponent {
  showModal = signal(false);
}

Using CSS Selectors

// data-table.component.ts
@Component({
  selector: 'app-data-table',
  standalone: true,
  template: `
    <table class="data-table">
      <thead>
        <tr>
          <!-- Select by element name -->
          <ng-content select="th" />
        </tr>
      </thead>
      <tbody>
        <!-- Select by CSS class -->
        <ng-content select=".table-row" />
      </tbody>
      <tfoot>
        <!-- Select by attribute -->
        <ng-content select="[table-footer]" />
      </tfoot>
    </table>
    
    <!-- Default slot (unmatched content) -->
    <ng-content />
  `
})
export class DataTableComponent {}

// Usage
@Component({
  template: `
    <app-data-table>
      <th>Name</th>
      <th>Email</th>
      <th>Actions</th>
      
      @for (user of users(); track user.id) {
        <tr class="table-row">
          <td>{{ user.name }}</td>
          <td>{{ user.email }}</td>
          <td><button>Edit</button></td>
        </tr>
      }
      
      <tr table-footer>
        <td colspan="3">Total: {{ users().length }} users</td>
      </tr>
    </app-data-table>
  `
})
export class UserTableComponent {
  users = input.required<User[]>();
}

ng-template with Content Projection

// tabs.component.ts
@Component({
  selector: 'app-tabs',
  standalone: true,
  template: `
    <div class="tabs">
      <div class="tab-headers">
        @for (tab of tabs(); track tab.label; let i = $index) {
          <button 
            class="tab-header"
            [class.active]="activeIndex() === i"
            (click)="activeIndex.set(i)"
          >
            {{ tab.label }}
          </button>
        }
      </div>
      
      <div class="tab-content">
        <ng-container 
          [ngTemplateOutlet]="tabs()[activeIndex()].content"
        />
      </div>
    </div>
  `
})
export class TabsComponent {
  tabs = input.required<Tab[]>();
  activeIndex = signal(0);
}

interface Tab {
  label: string;
  content: TemplateRef<any>;
}

// tab.component.ts
@Directive({
  selector: '[appTab]',
  standalone: true
})
export class TabDirective {
  label = input.required<string>({ alias: 'appTab' });
  
  constructor(public templateRef: TemplateRef<any>) {}
}

// Usage
@Component({
  template: `
    <app-tabs [tabs]="tabList()">
    </app-tabs>
    
    <ng-template appTab="Profile" #profile>
      <app-user-profile [user]="currentUser()" />
    </ng-template>
    
    <ng-template appTab="Settings" #settings>
      <app-settings />
    </ng-template>
    
    <ng-template appTab="Activity" #activity>
      <app-activity-log [userId]="currentUser().id" />
    </ng-template>
  `
})
export class UserDashboardComponent {
  @ViewChildren(TabDirective) tabDirectives!: QueryList<TabDirective>;
  
  currentUser = input.required<User>();
  
  tabList = computed(() => 
    this.tabDirectives.map(dir => ({
      label: dir.label(),
      content: dir.templateRef
    }))
  );
}

ContentChild & ContentChildren

// accordion.component.ts
@Component({
  selector: 'app-accordion',
  standalone: true,
  template: `
    <div class="accordion">
      <ng-content />
    </div>
  `
})
export class AccordionComponent implements AfterContentInit {
  @ContentChildren(AccordionItemComponent) items!: QueryList<AccordionItemComponent>;
  
  allowMultiple = input(false);
  
  ngAfterContentInit() {
    this.items.forEach(item => {
      item.toggled.subscribe((isOpen) => {
        if (isOpen && !this.allowMultiple()) {
          this.closeOthers(item);
        }
      });
    });
  }
  
  private closeOthers(openedItem: AccordionItemComponent) {
    this.items.forEach(item => {
      if (item !== openedItem) {
        item.close();
      }
    });
  }
}

// accordion-item.component.ts
@Component({
  selector: 'app-accordion-item',
  standalone: true,
  template: `
    <div class="accordion-item" [class.open]="isOpen()">
      <button class="accordion-header" (click)="toggle()">
        <ng-content select="[accordion-header]" />
        <span class="icon">{{ isOpen() ? '−' : '+' }}</span>
      </button>
      
      @if (isOpen()) {
        <div class="accordion-body" @slideInOut>
          <ng-content select="[accordion-body]" />
        </div>
      }
    </div>
  `,
  animations: [
    trigger('slideInOut', [
      transition(':enter', [
        style({ height: 0, opacity: 0 }),
        animate('200ms ease-out', style({ height: '*', opacity: 1 }))
      ]),
      transition(':leave', [
        animate('200ms ease-in', style({ height: 0, opacity: 0 }))
      ])
    ])
  ]
})
export class AccordionItemComponent {
  isOpen = signal(false);
  toggled = output<boolean>();
  
  toggle() {
    this.isOpen.update(v => !v);
    this.toggled.emit(this.isOpen());
  }
  
  close() {
    this.isOpen.set(false);
  }
}

// Usage
@Component({
  template: `
    <app-accordion>
      <app-accordion-item>
        <h3 accordion-header>Section 1</h3>
        <div accordion-body>
          <p>Content for section 1</p>
        </div>
      </app-accordion-item>
      
      <app-accordion-item>
        <h3 accordion-header>Section 2</h3>
        <div accordion-body>
          <p>Content for section 2</p>
        </div>
      </app-accordion-item>
    </app-accordion>
  `
})
export class FAQComponent {}

Conditional Content Projection

// empty-state.component.ts
@Component({
  selector: 'app-list-with-empty',
  standalone: true,
  template: `
    @if (hasContent()) {
      <ng-content />
    } @else {
      <div class="empty-state">
        <ng-content select="[empty-state]" />
      </div>
    }
  `
})
export class ListWithEmptyComponent {
  hasContent = input(true);
}

// Checking if content was provided
@Component({
  selector: 'app-card',
  standalone: true,
  template: `
    <div class="card">
      @if (hasHeader()) {
        <header class="card-header">
          <ng-content select="[card-header]" />
        </header>
      }
      
      <main class="card-body">
        <ng-content />
      </main>
      
      @if (hasFooter()) {
        <footer class="card-footer">
          <ng-content select="[card-footer]" />
        </footer>
      }
    </div>
  `
})
export class CardComponent {
  @ContentChild('cardHeader') headerContent?: ElementRef;
  @ContentChild('cardFooter') footerContent?: ElementRef;
  
  hasHeader = computed(() => !!this.headerContent);
  hasFooter = computed(() => !!this.footerContent);
}

ngProjectAs

// Use ngProjectAs to match a different selector
@Component({
  template: `
    <app-modal>
      <!-- This div will be projected as if it had [modal-header] attribute -->
      <div ngProjectAs="[modal-header]">
        <h2>Dynamic Title</h2>
        <span class="badge">New</span>
      </div>
      
      <app-form-content ngProjectAs="[modal-body]" />
      
      <ng-container ngProjectAs="[modal-footer]">
        <button>Cancel</button>
        <button class="primary">Save</button>
      </ng-container>
    </app-modal>
  `
})
export class DynamicModalComponent {}

Context-Aware Projection

// list.component.ts
@Component({
  selector: 'app-list',
  standalone: true,
  template: `
    <ul class="list">
      @for (item of items(); track trackFn()(item); let i = $index) {
        <li class="list-item">
          <ng-container
            [ngTemplateOutlet]="itemTemplate()"
            [ngTemplateOutletContext]="{ $implicit: item, index: i }"
          />
        </li>
      }
    </ul>
  `
})
export class ListComponent<T> {
  items = input.required<T[]>();
  itemTemplate = input.required<TemplateRef<{ $implicit: T; index: number }>>();
  trackFn = input<(item: T) => any>(() => (item: any) => item);
}

// Usage
@Component({
  template: `
    <app-list 
      [items]="users()" 
      [itemTemplate]="userTemplate"
      [trackFn]="trackById"
    />
    
    <ng-template #userTemplate let-user let-i="index">
      <div class="user-card">
        <span class="index">{{ i + 1 }}</span>
        <img [src]="user.avatar" [alt]="user.name" />
        <div class="user-info">
          <h4>{{ user.name }}</h4>
          <p>{{ user.email }}</p>
        </div>
      </div>
    </ng-template>
  `
})
export class UserListComponent {
  users = signal<User[]>([]);
  
  trackById = (user: User) => user.id;
}

Advanced: Render Props Pattern

// data-fetcher.component.ts
@Component({
  selector: 'app-data-fetcher',
  standalone: true,
  template: `
    @if (loading()) {
      <ng-container
        [ngTemplateOutlet]="loadingTemplate() || defaultLoading"
      />
    } @else if (error()) {
      <ng-container
        [ngTemplateOutlet]="errorTemplate() || defaultError"
        [ngTemplateOutletContext]="{ $implicit: error() }"
      />
    } @else {
      <ng-container
        [ngTemplateOutlet]="dataTemplate()"
        [ngTemplateOutletContext]="{ $implicit: data() }"
      />
    }
    
    <ng-template #defaultLoading>
      <div class="loading">Loading...</div>
    </ng-template>
    
    <ng-template #defaultError let-err>
      <div class="error">Error: {{ err.message }}</div>
    </ng-template>
  `
})
export class DataFetcherComponent<T> {
  fetchFn = input.required<() => Observable<T>>();
  dataTemplate = input.required<TemplateRef<{ $implicit: T }>>();
  loadingTemplate = input<TemplateRef<void>>();
  errorTemplate = input<TemplateRef<{ $implicit: Error }>>();
  
  data = signal<T | null>(null);
  loading = signal(true);
  error = signal<Error | null>(null);
  
  constructor() {
    effect(() => {
      this.loading.set(true);
      this.error.set(null);
      
      this.fetchFn()().subscribe({
        next: (data) => {
          this.data.set(data);
          this.loading.set(false);
        },
        error: (err) => {
          this.error.set(err);
          this.loading.set(false);
        }
      });
    });
  }
}

// Usage
@Component({
  template: `
    <app-data-fetcher
      [fetchFn]="fetchProducts"
      [dataTemplate]="productsTemplate"
      [loadingTemplate]="customLoading"
    />
    
    <ng-template #customLoading>
      <app-skeleton-loader [count]="6" />
    </ng-template>
    
    <ng-template #productsTemplate let-products>
      <div class="products-grid">
        @for (product of products; track product.id) {
          <app-product-card [product]="product" />
        }
      </div>
    </ng-template>
  `
})
export class ProductsPageComponent {
  private productService = inject(ProductService);
  
  fetchProducts = () => this.productService.getProducts();
}

Best Practices

Name Slots Clearly

Use descriptive attribute names for multi-slot projection

Provide Defaults

Use fallback content when projection is optional

Document Slots

Document expected content in component API

Type Templates

Use generics for type-safe template contexts

Next: Dynamic Components

Learn to create and manage dynamic components