skip to content

In Angular, why can a card footer wrapped in <ng-container> or a multi-node @if block miss its select slot, and how does ngProjectAs fix it?

level: middleimportance: should knowfreq 38%

answer

  1. the wrapper is the node matched
  2. ng-container has no attributes to match
  3. single-root control flow projects fine
  4. a static selector alias
  5. NG8011 warns about it

basics

~20 s

Slots match the host's direct children, so a wrapper that does not match the selector goes to the catch-all. Put ngProjectAs="[card-footer]" on the wrapper so it is matched as if it had that selector; the value must be static.

solid answer

~40 s

Angular distributes only the **direct children** of the component's host. When a consumer groups several footer buttons in an `<ng-container>`, the container is the node being matched, and it carries no `card-footer` attribute, so the whole group lands in the catch-all slot. `ngProjectAs` fixes this: `<ng-container ngProjectAs="[card-footer]">` is compared against the slots as if it matched `[card-footer]`. Control-flow blocks follow a related rule: an `@if` or `@for` block whose content has a **single root** element is projected according to that element, but if the block has more than one root node (an extra element or stray text), it is projected as a unit into the default slot, and the compiler's extended diagnostic NG8011 (`controlFlowPreventingContentProjection`) warns about it. `ngProjectAs` accepts only a static value.

go deeper

for a junior

Remember that the top-level node is what gets matched, so a wrapper needs ngProjectAs to reach a named slot.

for a middle

Explain the single-root rule for control-flow blocks, what NG8011 reports and why ngProjectAs must be static.

for a senior

Spot content landing in the catch-all during code review and fix it at the consumer with ngProjectAs or by restructuring blocks.

for a principal

Decide whether the library's slot contract should rely on consumers writing markers, or offer marker components that make misprojection hard.

## The rule behind the bug Angular decides which `<ng-content>` receives a node by looking at the **direct children of the component's host element** in the consumer's template. Each one is compared with the slots' `select` values, first match wins, and anything unmatched goes to the `<ng-content>` without `select`. Only the direct child's own tag, attributes and classes count. That makes wrappers significant. If the node at the top level does not match, whatever is inside it follows it. ## Case 1: grouping with ng-container A card declares `<ng-content select="[card-footer]" />` in its footer. A consumer wants two buttons there and groups them: ```html <app-card> <p>Order summary</p> <ng-container> <button card-footer>Cancel</button> <button card-footer>Pay</button> </ng-container> </app-card> ``` The `<ng-container>` is the direct child. It has no `card-footer` attribute, so it and both buttons go to the body's catch-all slot. The fix is to tell Angular what the container should be treated as: ```html <ng-container ngProjectAs="[card-footer]"> <button>Cancel</button> <button>Pay</button> </ng-container> ``` Now the container matches `[card-footer]` and the whole group is projected into the footer. ## Case 2: control-flow blocks Control flow is common inside content, for example a footer that appears only for unpaid invoices: ```html <app-card> @if (unpaid()) { <button card-footer>Pay</button> } </app-card> ``` This works. When a block has a **single root element**, the compiler projects the block according to that element, so the button's `card-footer` attribute decides the slot. Add a second root node and it stops working: ```html @if (unpaid()) { <button card-footer>Pay</button> Due in 3 days } ``` The text is a second root node, so the block can no longer be matched by the button's attribute and is projected into the default slot. The compiler reports this with the extended diagnostic **NG8011**, named `controlFlowPreventingContentProjection`. Its message suggests three fixes: 1. Wrap the block's content in an `<ng-container>` that matches the slot, which in practice means `ngProjectAs`. 2. Split the block into several blocks with one projectable root each. 3. Remove everything but the node being projected. ## What ngProjectAs is and is not - It is a special attribute you can put on any element or `<ng-container>` in content. When the node is matched against the slots, Angular uses the selector in `ngProjectAs` **instead of** the node's real tag and attributes. - It takes a **single** selector in the same syntax as `select`, such as `card-header` or `[card-footer]`. If you write a comma-separated list, the compiler keeps only the first selector. - It accepts **only a static value**. `[ngProjectAs]="slot"` is not supported, because slot assignment is fixed when content is created. - It only affects slot matching. It does not change the element's tag, classes or styling. ## When the same trap appears elsewhere | Consumer markup | Slot the group lands in | Fix | |---|---|---| | `<ng-container>` grouping marked children | catch-all | `ngProjectAs` on the container | | `<div>` wrapper for layout | catch-all | mark the `div` itself, or use `ngProjectAs` | | `@if` with one marked root | the marked slot | none needed | | `@if` with extra roots | catch-all, NG8011 warning | wrap in a matching `ng-container`, or split blocks | | a component that renders marked elements in its own template | whatever its host matches | give the host component the marker or `ngProjectAs` | The last row matters for library authors: slot matching sees the consumer's template only, so a reusable `<app-pay-button>` should either carry the footer marker itself or be written with `ngProjectAs="[card-footer]"` where it is used. ## Catching it early - Keep the NG8011 extended diagnostic enabled; it is on by default and only needs attention when someone suppresses it through `extendedDiagnostics.checks`. - In component tests for the card, project each supported consumer shape, including a grouped footer and a conditional footer, and assert where each node ended up. - Document in the card's API which markers exist, and show the `ngProjectAs` form for grouped content, so consumers do not discover the catch-all behaviour in production. ## Why the rule exists Projection is resolved from the consumer's template structure, not from the rendered DOM. That keeps it cheap and predictable: the compiler knows every candidate node and every slot, and nothing is re-sorted at runtime. The cost is that wrappers have to declare their destination explicitly, which is exactly what `ngProjectAs` is for.

  • Can ngProjectAs be bound to a component property to choose a slot at runtime?
    No. `ngProjectAs` supports only static values; slot assignment is fixed when the content is created. To choose a destination at runtime, pass templates as inputs and render them where needed instead of projecting them.
  • Why does an @if with a single <button card-footer> project into the footer without ngProjectAs?
    For a control-flow block with exactly one root element, the compiler projects the block according to that element's selector, so the button's attribute decides the slot. The rule breaks only when the block has more than one root node, which NG8011 reports.

saying these in an interview costs you the question

  • ng-container is ignored during slot matching, so its children are matched individually
  • Any @if block around marked content breaks projection
  • ngProjectAs can be bound with [ngProjectAs] to pick a slot dynamically
  • ngProjectAs accepts a comma-separated list so one wrapper can match several slots
  • Stray text inside an @if has no effect on which slot it lands in