Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

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. The analogy is a picture frame: the frame (child component) provides the structure and styling, but the picture (projected content) comes from whoever uses the frame. This is the fundamental pattern behind every reusable layout component — cards, modals, tabs, accordions, and data tables. Why this matters: Without content projection, you end up with one of two bad outcomes: either your components are too rigid (hard-coded templates that cannot be customized) or too flexible (accepting dozens of inputs to cover every variation). Content projection hits the sweet spot — the component controls the layout and behavior, while the consumer controls the content.
┌─────────────────────────────────────────────────────────────────────────┐
│                 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

Multi-slot projection uses CSS-like selectors on <ng-content> to route projected content into the right slot. The select attribute accepts element names, CSS classes, and attribute selectors. Any projected content that does not match a select goes into the default (un-selected) <ng-content> slot. This is analogous to named slots in Vue or React’s children vs named props pattern.
// 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

ng-template combined with ngTemplateOutlet is the Angular equivalent of React’s “render props” pattern. The parent provides a template (the “how to render each item”) and the child provides the data and controls when to render it. This inverts the typical control flow: the child component decides when and where to render, while the parent decides what to render. It is the ultimate tool for building highly flexible components like tabs, tables, and lists.
Practical tip: When a component accepts a TemplateRef input, always provide a sensible default template using a local ng-template with #defaultTemplate. This way, the component works out of the box without requiring the consumer to always pass a custom template.
// 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

@ContentChild and @ContentChildren let a component inspect and interact with the content projected into it. Think of a playlist app: the playlist component (parent) does not just display songs (projected children) — it also needs to know about them to handle “play next” logic, enforce “only one song playing at a time,” or reorder them. @ContentChildren gives the parent a QueryList of projected child components, enabling exactly this kind of coordination.
// 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

ngProjectAs solves a subtle problem: what if you want to project a component or ng-container into a named slot, but its tag name or attributes do not match the slot’s select selector? ngProjectAs lets you tell Angular “treat this element as if it had the specified selector.” It is like giving a backstage pass to someone whose name is not on the guest list — you are vouching that they belong in that slot.
// 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