Week 7: Styling, View Encapsulation & Content Projection

Your components work — now make them composable. This week is about the boundary of a component: what styles stay inside it, what leaks out on purpose, and how to let a parent hand it arbitrary content without the component needing to know what that content is.

Phase 2 of 7 Week 7 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Choose the right view-encapsulation strategy for a component
  • Build components that accept projected content and named slots
  • Use the CDK's layout utilities instead of hand-rolled media queries in TypeScript

1. Component Styles & :host / :host-context

A component's stylesheet, by default, only applies to that component's own template — not to children, not to the rest of the app. :host is how a component styles its own root element from the inside, since that element doesn't appear in the component's own template.

card.css
:host {
  display: block; /* components render as an unstyled inline element by default */
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 16px;
}

:host(.is-selected) {
  border-color: var(--accent);
}

:host-context(.theme-dark) {
  background: #1a1a1a;
  color: #eee;
}

:host(.is-selected) applies only when the host element itself has that class — set via [class.is-selected] from a parent. :host-context() is different: it matches based on an ancestor, anywhere up the DOM tree, which is exactly how theme classes applied way up on <body> can still affect a deeply nested component's styles.

2. View Encapsulation Modes

Angular scopes component styles by rewriting selectors with a unique attribute behind the scenes — that's the default, and it's configurable per component via the encapsulation option.

encapsulation.ts
import { Component, ViewEncapsulation } from '@angular/core';

@Component({
  selector: 'app-card',
  templateUrl: './card.html',
  styleUrl: './card.css',
  encapsulation: ViewEncapsulation.Emulated, // default -- scoped via attribute selectors
})
export class Card {}
  • Emulated (default) — Angular adds a unique attribute to your component's elements and rewrites your CSS selectors to match it. Behaves like real scoping without needing actual Shadow DOM support.
  • ShadowDom — uses the browser's real Shadow DOM. True isolation (global styles can't leak in either), but styles like your app's design tokens won't reach in unless passed via CSS custom properties, which do cross the shadow boundary.
  • None — no scoping at all; the component's styles become global. Occasionally useful for a component whose entire job is to define global styles, but easy to misuse by accident.

3. Host Bindings & Host Listeners

The host object in @Component lets you bind properties and listen for events directly on a component's own host element — no wrapper <div> required inside the template.

dropdown.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-dropdown',
  templateUrl: './dropdown.html',
  host: {
    '[class.is-open]': 'isOpen()',
    '[attr.aria-expanded]': 'isOpen()',
    '(document:click)': 'onDocumentClick($event)',
  },
})
export class Dropdown {
  isOpen = signal(false);

  onDocumentClick(event: MouseEvent) {
    if (!(event.target as HTMLElement).closest('app-dropdown')) {
      this.isOpen.set(false);
    }
  }
}

This is the same underlying mechanism as the older @HostBinding/@HostListener decorators — the host object is simply the current, preferred way to write it, keeping all host-level bindings in one place instead of scattered across decorated class members.

4. ng-content, Multi-Slot Projection & Fallback Content

Content projection lets a parent pass arbitrary markup into a component, which renders it wherever <ng-content> appears in its template — the same idea as JavaScript's children prop in other component frameworks.

panel.html — component template
<div class="panel">
  <header class="panel__header">
    <ng-content select="[panel-header]">
      <span>Untitled panel</span> <!-- fallback if nothing is projected -->
    </ng-content>
  </header>

  <div class="panel__body">
    <ng-content></ng-content> <!-- default slot: anything with no matching selector -->
  </div>
</div>
using it — parent.html
<app-panel>
  <h3 panel-header>Account Settings</h3>
  <p>This content lands in the default slot.</p>
</app-panel>

select="[panel-header]" matches any projected element carrying that attribute; content with no matching select anywhere falls through to the plain, selector-less <ng-content> — which is why order and specificity of your select attributes matters once a component has more than one slot.

5. Responsive Layout with the Angular CDK Layout Module

CSS media queries handle most responsive styling, but sometimes a component needs to know the current breakpoint in TypeScript — to render entirely different markup on mobile, for instance. BreakpointObserver is the CDK's answer.

responsive-nav.ts
import { Component, inject } from '@angular/core';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';

@Component({
  selector: 'app-responsive-nav',
  templateUrl: './responsive-nav.html',
})
export class ResponsiveNav {
  private breakpointObserver = inject(BreakpointObserver);

  isMobile = toSignal(
    this.breakpointObserver.observe(Breakpoints.Handset).pipe(
      map((result) => result.matches)
    ),
    { initialValue: false }
  );
}

You met toSignal() only in passing back in Week 5 — this is a preview of Week 16, where bridging RxJS and signals gets a full lesson. For now, notice the shape: an Observable-based CDK API, converted to a signal at the boundary so the template only ever deals with a plain, callable value.

6. Building a Small Design-System Component

Everything this week combines into one recognizable pattern: a component with scoped styles, host bindings for state, and projected content for flexibility. A button component is the simplest realistic example:

button.ts
import { Component, input } from '@angular/core';

@Component({
  selector: 'app-button',
  template: `<ng-content />`,
  host: {
    'class': 'app-button',
    '[class.app-button--primary]': "variant() === 'primary'",
    '[attr.disabled]': 'disabled() || null',
  },
})
export class Button {
  variant = input<'primary' | 'secondary'>('secondary');
  disabled = input(false);
}

Notice there's no wrapping element in the template at all — host bindings style the component's own tag directly, and <ng-content /> (Angular's self-closing shorthand) projects whatever the caller puts inside <app-button>...</app-button>. This is the exact shape you'll extend in this week's exercise.

7. Hands-on Exercise

Hands-on

Build a modal with named projection slots and CDK focus trapping

Combine content projection, host bindings, and one new CDK import to build a reusable modal.

Requirements:

  1. Create a Modal component with three named slots — [modal-header], [modal-body], and [modal-footer] — each with sensible fallback content if the caller omits it.
  2. An open input (model(), so it's two-way bindable) controls visibility via a host class, e.g. [class.is-open].
  3. Install @angular/cdk if you haven't yet, import A11yModule, and apply the cdkTrapFocus directive to the modal's content wrapper so Tab key focus can't escape the modal while it's open.
  4. Add a host (document:keydown.escape) listener that closes the modal.
  5. Use the component from Section 6's Button in the footer slot for a "Close" action, proving your two components compose together.
Hint

If projected content isn't landing in the right slot, double-check the attribute selector matches exactly — select="[modal-header]" only matches elements carrying that exact attribute, not a class or a tag name.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What's the difference between :host(.is-selected) and :host-context(.theme-dark)?

:host(.is-selected) matches when the component's own host element carries that class. :host-context(.theme-dark) matches when that class exists on any ancestor, anywhere up the tree — it's how a global theme class on <body> can still style a component nested many levels deep.

Q2

Why might ViewEncapsulation.ShadowDom cause a component's colors to look wrong even though the CSS looks correct?

Real Shadow DOM is genuinely isolated — global stylesheets and app-wide design tokens defined as regular CSS classes can't cross into it. Only CSS custom properties (--variable-name) cross the shadow boundary, so a Shadow DOM component needs its colors driven by custom properties, not classes set globally.

Q3

In the Panel example, what happens to a projected element that matches no select attribute on any ng-content?

It falls through to the plain, selector-less <ng-content></ng-content> — the "default slot." If a component has no such default slot at all, unmatched projected content is simply not rendered anywhere.

Q4

Why convert BreakpointObserver's Observable to a signal with toSignal() instead of just subscribing manually in the component?

A manual subscription needs its own field to store the latest value, plus an ngOnDestroy to unsubscribe and avoid a memory leak. toSignal() handles both automatically — it manages the subscription's lifecycle and gives the template a plain, callable signal, consistent with everything else you're reading in the component.