- Overview
- Angular Core Concepts
- Component Structure
- Template Syntax
- Component Styling
- TypeScript Implementation
- Animation with Motion.dev
- Angular Lifecycle & Timing
- Reactive State Management
- Angular-Specific Pitfalls
- Best Practices
- Comparison with Vanilla JS
An Angular standalone component with three interactive sections (Where, When, Who) featuring:
- Signal-based reactive state - Modern Angular reactivity without RxJS
- Effects for animations - Declarative side effects that respond to signal changes
- Morphing background indicator - Smooth transitions between sections
- Content panel animations - Top-down dropdown with cross-fade
- afterNextRender timing - Precise measurement after Angular's render cycle
- ✅ Signals - Reactive primitive for state management
- ✅ Effects - Side effects that run when signals change
- ✅ viewChild signals - Type-safe DOM element references
- ✅ afterNextRender - Lifecycle hook for post-render operations
- ✅ Standalone components - No NgModules required
- ✅ Modern template syntax - @if conditional rendering
- ✅ OnPush change detection - Optimized performance
Signals (Modern Angular 16+):
// Creating a signal
const activeSection = signal<SectionType>(null);
// Reading a signal (in template or computed)
const current = activeSection(); // Call like a function
// Updating a signal
activeSection.set('where'); // Set new valueWhy Signals over RxJS for this use case:
- ✅ Simpler API - No subscriptions to manage
- ✅ Automatic cleanup - No need to unsubscribe
- ✅ Fine-grained reactivity - Only updates what changed
- ✅ Better TypeScript inference
// Effect runs automatically when signals it reads change
private animateIndicator = effect(() => {
const active = this.activeSection(); // Tracks this signal
const indicator = this.indicatorRef()?.nativeElement;
// When activeSection changes, this entire block re-runs
if (!indicator) return;
// Animation logic...
});Effect Characteristics:
- Automatically tracks signal dependencies
- Runs in Angular's injection context by default
- Runs at least once on initialization
- Cleans up automatically when component destroys
Old way (decorators):
@ViewChild('indicator') indicator!: ElementRef;
ngAfterViewInit() {
console.log(this.indicator.nativeElement); // Available after view init
}New way (signals):
indicatorRef = viewChild<ElementRef>('indicator');
// Use in effect - automatically available when ready
private animate = effect(() => {
const indicator = this.indicatorRef()?.nativeElement;
if (!indicator) return; // Not ready yet
// Use indicator...
});Benefits:
- ✅ No lifecycle hooks needed
- ✅ Reactive - effects wait until available
- ✅ Type-safe with generics
- ✅ Simpler code
afterNextRender(() => {
// Code here runs AFTER Angular has updated the DOM
const width = element.offsetWidth; // Accurate measurements
}, { injector: this.injector });When to use:
- Measuring DOM elements after content changes
- Initializing third-party libraries that need real DOM
- Animations that depend on final rendered dimensions
Why injector is needed:
- Effects provide injection context automatically
afterNextRenderinside effect needs explicit injector- Allows Angular to track and clean up the callback
import { Component, effect, ElementRef, signal, viewChild, afterNextRender, Injector, inject } from '@angular/core';
import { animate } from 'motion';
import { DestinationSuggestions } from './destination-suggestions/destination-suggestions';
import { DatePicker } from './date-picker/date-picker';
import { GuestPicker } from './guest-picker/guest-picker';
// Type for section state
type SectionType = 'where' | 'when' | 'who' | null;
@Component({
selector: 'app-airbnb-search',
imports: [DestinationSuggestions, DatePicker, GuestPicker], // Standalone: direct imports
templateUrl: './airbnb-search.html',
styleUrl: './airbnb-search.css'
})
export class AirbnbSearch {
// Inject Injector for afterNextRender inside effects
private injector = inject(Injector);
// State signals
activeSection = signal<SectionType>(null);
whereValue = signal('');
whenValue = signal('');
whoValue = signal('');
// ViewChild signals for DOM references
whereRef = viewChild<ElementRef>('whereSection');
whenRef = viewChild<ElementRef>('whenSection');
whoRef = viewChild<ElementRef>('whoSection');
indicatorRef = viewChild<ElementRef>('indicator');
searchBarRef = viewChild<ElementRef>('searchBar');
contentPanelRef = viewChild<ElementRef>('contentPanel');
// Effects for animations (defined below)
private animateIndicator = effect(() => { /* ... */ });
private animateContentPanel = effect(() => { /* ... */ });
// Public methods
setActiveSection(section: SectionType): void { /* ... */ }
onDestinationSelected(destination: string): void { /* ... */ }
handleSearch(): void { /* ... */ }
}- No NgModule - Standalone component imports dependencies directly
- Injector injection - Required for
afterNextRenderinside effects - Signal-based state - All reactive values are signals
- viewChild signals - Modern DOM reference approach
- Private effects - Effects as class properties, run automatically
<div class="airbnb-search-container">
<!-- Search Bar -->
<div class="search-bar" #searchBar>
<!-- Active Indicator Background -->
<div class="active-indicator" #indicator></div>
<!-- Where Section -->
<button
type="button"
class="search-section"
#whereSection
[class.active]="activeSection() === 'where'"
(click)="setActiveSection('where')">
<div class="section-label">Where</div>
<div class="section-value">
{{ whereValue() || 'Search destinations' }}
</div>
</button>
<!-- Divider -->
<div class="section-divider"></div>
<!-- When Section -->
<button
type="button"
class="search-section"
#whenSection
[class.active]="activeSection() === 'when'"
(click)="setActiveSection('when')">
<div class="section-label">When</div>
<div class="section-value">
{{ whenValue() || 'Add dates' }}
</div>
</button>
<!-- Divider -->
<div class="section-divider"></div>
<!-- Who Section -->
<button
type="button"
class="search-section who-section"
#whoSection
[class.active]="activeSection() === 'who'"
(click)="setActiveSection('who')">
<div class="section-content">
<div class="section-label">Who</div>
<div class="section-value">
{{ whoValue() || 'Add guests' }}
</div>
</div>
<!-- Search Button -->
<button
type="button"
class="search-btn"
(click)="handleSearch(); $event.stopPropagation()">
<svg width="16" height="16" viewBox="0 0 16 16" fill="white">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
</svg>
<span>Search</span>
</button>
</button>
</div>
<!-- Content Panel - Conditional Rendering with @if -->
@if (activeSection()) {
<div class="content-panel" #contentPanel>
@if (activeSection() === 'where') {
<app-destination-suggestions (destinationSelected)="onDestinationSelected($event)" />
}
@if (activeSection() === 'when') {
<app-date-picker />
}
@if (activeSection() === 'who') {
<app-guest-picker />
}
</div>
}
</div>- Template references -
#searchBar,#indicator, etc. bind to viewChild signals - Signal reading in templates -
activeSection()called as function - Property binding -
[class.active]="..."for dynamic classes - Event binding -
(click)="..."for click handlers - @if directive - Modern Angular 17+ conditional rendering (replaces *ngIf)
- Interpolation -
{{ whereValue() || 'default' }}with fallback - Event output -
(destinationSelected)="onDestinationSelected($event)"
The CSS is identical to the vanilla version. Angular component styles are scoped by default.
.airbnb-search-container {
position: relative;
margin: 2rem auto;
max-width: 850px;
}
.search-bar {
position: relative;
display: flex;
align-items: center;
background: white;
border: 1px solid #ddd;
border-radius: 60px;
padding: 0.5rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.05);
}
/* CRITICAL: Initial hidden state for content panel */
.content-panel {
position: absolute;
top: calc(100% + 0.75rem);
left: 0;
width: fit-content;
background: white;
border-radius: 32px;
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.12), 0 4px 24px rgba(0, 0, 0, 0.08);
z-index: 10;
overflow: hidden;
will-change: transform, opacity;
/* Initial hidden state - prevents flash on first render */
transform-origin: top center;
opacity: 0;
transform: translateY(-16px);
}
/* See vanilla guide for complete CSS */Angular-specific CSS notes:
- Component styles are scoped (ViewEncapsulation.Emulated by default)
:hostselector targets component element itself- No need for BEM or strict naming conventions
- Can use global styles in styles.css if needed
import { Component, effect, ElementRef, signal, viewChild, afterNextRender, Injector, inject } from '@angular/core';
import { animate } from 'motion';
import { DestinationSuggestions } from './destination-suggestions/destination-suggestions';
import { DatePicker } from './date-picker/date-picker';
import { GuestPicker } from './guest-picker/guest-picker';
type SectionType = 'where' | 'when' | 'who' | null;
@Component({
selector: 'app-airbnb-search',
imports: [DestinationSuggestions, DatePicker, GuestPicker],
templateUrl: './airbnb-search.html',
styleUrl: './airbnb-search.css'
})
export class AirbnbSearch {
// STEP 1: Inject Injector for afterNextRender
private injector = inject(Injector);
// STEP 2: Define state signals
activeSection = signal<SectionType>(null);
whereValue = signal('');
whenValue = signal('');
whoValue = signal('');
// STEP 3: Define viewChild signals for DOM references
whereRef = viewChild<ElementRef>('whereSection');
whenRef = viewChild<ElementRef>('whenSection');
whoRef = viewChild<ElementRef>('whoSection');
indicatorRef = viewChild<ElementRef>('indicator');
searchBarRef = viewChild<ElementRef>('searchBar');
contentPanelRef = viewChild<ElementRef>('contentPanel');
// STEP 4: Define effect for indicator animation
private animateIndicator = effect(() => {
const active = this.activeSection(); // Track signal
const indicator = this.indicatorRef()?.nativeElement;
const searchBar = this.searchBarRef()?.nativeElement;
if (!indicator || !searchBar) return;
if (!active) {
// Hide indicator when nothing is active
animate(
indicator,
{ opacity: 0, scale: 0.96 },
{
duration: 0.25,
type: 'spring',
stiffness: 500,
damping: 35
}
);
return;
}
// Get the reference to the active section
let sectionRef: ElementRef | undefined;
if (active === 'where') sectionRef = this.whereRef();
else if (active === 'when') sectionRef = this.whenRef();
else if (active === 'who') sectionRef = this.whoRef();
if (!sectionRef) return;
const section = sectionRef.nativeElement;
const searchBarRect = searchBar.getBoundingClientRect();
const sectionRect = section.getBoundingClientRect();
// Calculate position relative to search bar parent
const left = sectionRect.left - searchBarRect.left;
const top = sectionRect.top - searchBarRect.top;
const width = sectionRect.width;
const height = sectionRect.height;
// Animate indicator to morph into the active section
animate(
indicator,
{
opacity: 1,
scale: 1,
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`
},
{
duration: 0.45,
type: 'spring',
stiffness: 300,
damping: 25,
mass: 0.8
}
);
});
// STEP 5: Define effect for content panel animation
private animateContentPanel = effect(() => {
const active = this.activeSection(); // Track signal
const contentPanel = this.contentPanelRef()?.nativeElement;
const searchBar = this.searchBarRef()?.nativeElement;
if (!contentPanel || !searchBar) return;
// Get the active section to position panel below it
let activeSectionRef: ElementRef | undefined;
if (active === 'where') activeSectionRef = this.whereRef();
else if (active === 'when') activeSectionRef = this.whenRef();
else if (active === 'who') activeSectionRef = this.whoRef();
if (!activeSectionRef) return;
// CRITICAL: Schedule measurement AFTER Angular's render cycle
// This ensures we measure the NEW component dimensions
afterNextRender(() => {
const activeElement = activeSectionRef!.nativeElement;
const searchBarRect = searchBar.getBoundingClientRect();
const sectionRect = activeElement.getBoundingClientRect();
// Get search bar dimensions
const searchBarWidth = searchBar.offsetWidth;
// Get all child elements to animate
const children = Array.from(contentPanel.children) as HTMLElement[];
if (!children.length) return;
// Temporarily reset styles to measure natural dimensions
contentPanel.style.height = 'auto';
contentPanel.style.width = 'fit-content';
// Reset transforms (but keep opacity 0 to avoid flash)
children.forEach(child => {
child.style.transform = 'none';
});
// Force browser reflow to get accurate measurements
const _ = contentPanel.offsetHeight;
// Measure actual panel width
const actualPanelWidth = contentPanel.offsetWidth;
// Smart positioning based on active section
let leftPosition: number;
const searchBarPadding = 8;
if (active === 'where') {
// Where: Align with Where button (left side)
leftPosition = sectionRect.left - searchBarRect.left;
} else if (active === 'when') {
// When: Center the calendar panel
leftPosition = (searchBarWidth - actualPanelWidth) / 2;
} else if (active === 'who') {
// Who: Right-align with search bar
leftPosition = searchBarWidth - actualPanelWidth - searchBarPadding;
} else {
// Fallback: align with active section
leftPosition = sectionRect.left - searchBarRect.left;
}
// Ensure panel stays within bounds
leftPosition = Math.max(
searchBarPadding,
Math.min(leftPosition, searchBarWidth - actualPanelWidth - searchBarPadding)
);
// Set initial state for children cross-fade
children.forEach(child => {
child.style.opacity = '0';
child.style.transform = 'scale(0.96) translateY(8px)';
});
// PHASE 1: Animate panel container (top-down drop)
animate(
contentPanel,
{
opacity: 1,
y: 0,
left: `${leftPosition}px`
},
{
duration: 0.45,
type: 'spring',
stiffness: 300,
damping: 25,
mass: 0.8
}
);
// PHASE 2: Animate children (cross-fade)
animate(
children,
{
opacity: 1,
scale: 1,
y: 0
},
{
duration: 0.45,
type: 'spring',
stiffness: 300,
damping: 25,
mass: 0.8
}
);
}, { injector: this.injector });
});
// STEP 6: Public methods for template
setActiveSection(section: SectionType): void {
if (this.activeSection() === section) {
// Toggle off if clicking the same section
this.activeSection.set(null);
} else {
this.activeSection.set(section);
}
}
onDestinationSelected(destination: string): void {
this.whereValue.set(destination);
this.activeSection.set(null);
}
handleSearch(): void {
console.log('Search:', {
where: this.whereValue(),
when: this.whenValue(),
who: this.whoValue()
});
}
}Step 1: Injector
- Required for
afterNextRenderinside effects - Provides injection context for Angular's DI system
Step 2: State Signals
signal<Type>(initialValue)creates reactive state.set(value)updates the signalsignal()reads the value
Step 3: ViewChild Signals
viewChild<ElementRef>('templateRef')creates reactive DOM reference- Returns
undefineduntil element is rendered - Automatically updates when view changes
Step 4-5: Effects
- Run automatically when tracked signals change
this.activeSection()call registers dependency- Effect re-runs whenever activeSection changes
- Cleanup happens automatically
Step 6: Public Methods
- Called from template event bindings
- Update signals which trigger effects
- Simple imperative logic
npm install motionimport { animate } from 'motion';// Basic animation
animate(
element, // DOM element (from viewChild)
{ opacity: 1, x: 0 }, // Target properties
{ duration: 0.45 } // Options
);
// Spring physics animation
animate(
element,
{ opacity: 1, scale: 1 },
{
duration: 0.45,
type: 'spring',
stiffness: 300, // Higher = more rigid
damping: 25, // Higher = less bouncy
mass: 0.8 // Lower = lighter/faster
}
);
// Animate multiple elements
const children = Array.from(contentPanel.children) as HTMLElement[];
animate(
children, // Array of elements
{ opacity: 1, y: 0 },
{ duration: 0.45 }
);| Parameter | Description | Low Value | High Value |
|---|---|---|---|
| stiffness | Spring tension | Slow, elastic | Fast, snappy |
| damping | Friction/resistance | More oscillation | Less bouncy |
| mass | Object weight | Light, fast | Heavy, slow |
Our configuration:
stiffness: 300- Moderately snappydamping: 25- Slightly bouncymass: 0.8- Light and responsive
afterNextRender(() => {
// Code here runs AFTER Angular's change detection and DOM update
const width = element.offsetWidth; // Safe to measure
}, { injector: this.injector });Why afterNextRender?
User clicks button
↓
Signal updates: activeSection.set('when')
↓
Effect runs: animateContentPanel()
↓
Angular queues re-render (component marked dirty)
↓
afterNextRender schedules callback
↓
Angular runs change detection
↓
Angular updates DOM (renders DatePicker component)
↓
Angular commits changes to browser
↓
afterNextRender callback executes ✅
↓
Measurements are accurate! ✅
Angular (afterNextRender):
effect(() => {
const active = this.activeSection();
if (!active) return;
afterNextRender(() => {
// Runs after Angular updates DOM
const width = element.offsetWidth; // ✅ Accurate
}, { injector: this.injector });
});Vanilla JS (requestAnimationFrame):
function showPanel(section) {
activeSection = section;
updateContent(section);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// Runs after browser paints
const width = element.offsetWidth; // ✅ Accurate
});
});
}Key Difference:
afterNextRender- Tied to Angular's lifecycle (more predictable)requestAnimationFrame- Tied to browser's paint cycle (more manual)
// Inside effect - needs explicit injector
private animate = effect(() => {
afterNextRender(() => {
// ...
}, { injector: this.injector }); // ✅ Required
});
// Outside effect - automatic injection context
ngOnInit() {
afterNextRender(() => {
// ...
}); // ✅ No injector needed (already in context)
}Reason: Effects run outside Angular's normal injection context, so afterNextRender needs explicit injector to:
- Track the callback lifecycle
- Clean up when component destroys
- Access Angular's services if needed
1. Writable Signals (State)
activeSection = signal<SectionType>(null);
// Update
this.activeSection.set('where');
// Read
const current = this.activeSection();2. Computed Signals (Derived State)
// Example: Computed value from multiple signals
isAnyActive = computed(() => this.activeSection() !== null);
// In template
@if (isAnyActive()) {
<div>Something is active</div>
}3. Effects (Side Effects)
// Runs when activeSection changes
private logChanges = effect(() => {
console.log('Active section:', this.activeSection());
});private animate = effect(() => {
// Reading these signals registers them as dependencies
const active = this.activeSection(); // ✅ Tracked
const indicator = this.indicatorRef(); // ✅ Tracked
// This effect re-runs when activeSection OR indicatorRef changes
// NOT tracked - just a function call
this.doAnimation(active, indicator); // ❌ Not tracked
});Important: Only signal reads (function calls) within the effect callback are tracked.
✅ Good: Direct signal updates
setActiveSection(section: SectionType): void {
if (this.activeSection() === section) {
this.activeSection.set(null); // Toggle off
} else {
this.activeSection.set(section); // Set new
}
}❌ Bad: Mutating objects
// DON'T DO THIS
const config = this.config();
config.value = 'new'; // ❌ Mutation doesn't trigger updates
this.config.set(config); // ❌ Same reference, no change detected
// DO THIS
this.config.set({ ...this.config(), value: 'new' }); // ✅ New referenceprivate subscription = effect((onCleanup) => {
const id = setTimeout(() => {
console.log('Delayed action');
}, 1000);
// Cleanup runs when effect re-runs or component destroys
onCleanup(() => {
clearTimeout(id);
});
});Automatic cleanup for:
- Component destruction
- Effect re-execution (dependencies changed)
- Manual effect destruction
Problem:
private animate = effect(() => {
afterNextRender(() => {
// Measurements...
}); // ❌ ERROR: No injection context
});Error Message:
NG0203: `afterNextRender` can only be used during construction, such as in a constructor or field initializer.
Solution:
// Inject Injector at class level
private injector = inject(Injector);
private animate = effect(() => {
afterNextRender(() => {
// Measurements...
}, { injector: this.injector }); // ✅ Works
});Problem:
// In template
<div>{{ activeSection }}</div> <!-- ❌ Shows function, not value -->
// In TypeScript
const section = this.activeSection; // ❌ Gets function referenceSolution:
// In template
<div>{{ activeSection() }}</div> <!-- ✅ Calls function, shows value -->
// In TypeScript
const section = this.activeSection(); // ✅ Calls function, gets valueProblem:
private animate = effect(() => {
const element = this.elementRef()?.nativeElement;
if (!element) return; // ❌ Always returns early on first run
// Never reaches here initially...
});Why: Effects run immediately, but viewChild signals are undefined until view initializes.
Solution: Guard clauses (already implemented correctly)
private animate = effect(() => {
const element = this.elementRef()?.nativeElement;
if (!element) return; // ✅ Guard clause - effect will re-run when available
// Safe to use element here
});Problem:
afterNextRender(() => {
// ❌ This hides visible panel when switching sections!
contentPanel.style.opacity = '0';
contentPanel.style.transform = 'translateY(-16px)';
// Then animate back to visible...
});Solution: Set initial state in CSS only
.content-panel {
opacity: 0;
transform: translateY(-16px);
}afterNextRender(() => {
// ✅ Only animate TO visible, never manually hide
animate(contentPanel, { opacity: 1, y: 0 }, { ... });
});Problem:
effect(() => {
const active = this.activeSection();
const panel = this.contentPanelRef()?.nativeElement;
// ❌ Measures OLD component before Angular updates to NEW component
const width = panel.offsetWidth;
});Solution: Use afterNextRender
effect(() => {
const active = this.activeSection();
const panel = this.contentPanelRef()?.nativeElement;
afterNextRender(() => {
// ✅ Measures NEW component after Angular update
const width = panel.offsetWidth;
}, { injector: this.injector });
});✅ Modern Angular:
@Component({
selector: 'app-search',
imports: [DatePicker, GuestPicker], // Direct imports
standalone: true // Default in Angular 17+
})❌ Old Angular:
// app.module.ts
@NgModule({
declarations: [SearchComponent, DatePicker, GuestPicker],
// ...boilerplate
})@Component({
selector: 'app-search',
changeDetection: ChangeDetectionStrategy.OnPush, // ✅ Add this
// ...
})Benefits with Signals:
- Signals automatically notify change detection
- OnPush skips unnecessary checks
- Better performance for large apps
// State signals - noun
activeSection = signal<SectionType>(null);
whereValue = signal('');
// Computed signals - adjective or "is/has" prefix
isActive = computed(() => this.activeSection() !== null);
hasValue = computed(() => this.whereValue().length > 0);
// ViewChild signals - suffix with "Ref"
indicatorRef = viewChild<ElementRef>('indicator');
contentPanelRef = viewChild<ElementRef>('contentPanel');// ✅ Define union types for state
type SectionType = 'where' | 'when' | 'who' | null;
// ✅ Generic viewChild
viewChild<ElementRef>('indicator'); // Type-safe nativeElement access
// ✅ Typed signal
signal<SectionType>(null); // TypeScript validates updatesexport class Component {
// Group related effects
// UI Effects
private animateIndicator = effect(() => { /* ... */ });
private animateContentPanel = effect(() => { /* ... */ });
// Data Effects
private syncToBackend = effect(() => { /* ... */ });
private updateAnalytics = effect(() => { /* ... */ });
}// ❌ BAD: Nested signals
config = signal({
section: signal('where'), // ❌ Signal inside signal
value: signal('')
});
// ✅ GOOD: Flat signals
activeSection = signal<SectionType>('where');
sectionValue = signal('');/* Tell browser to optimize these properties */
.content-panel {
will-change: transform, opacity;
}
.active-indicator {
will-change: transform, opacity, width, height, left, top;
}<!-- ARIA attributes for screen readers -->
<button
class="search-section"
[attr.aria-expanded]="activeSection() === 'where'"
aria-controls="contentPanel">
Where
</button>
<div
class="content-panel"
id="contentPanel"
role="region"
[attr.aria-hidden]="!activeSection()">
<!-- Content -->
</div>| Aspect | Vanilla JS | Angular Signals |
|---|---|---|
| Declare state | let state = null; |
state = signal(null); |
| Read state | state |
state() |
| Update state | state = value; |
state.set(value); |
| Reactivity | Manual (event listeners) | Automatic (effects) |
| Cleanup | Manual (removeEventListener) | Automatic |
| Aspect | Vanilla JS | Angular |
|---|---|---|
| Get element | document.getElementById('el') |
viewChild<ElementRef>('el') |
| Availability | Immediately (if exists) | Reactive (when rendered) |
| Type safety | ❌ No (returns Element | null) | ✅ Yes (with generic) |
| Reactivity | ❌ No (static reference) | ✅ Yes (updates automatically) |
Vanilla JS:
button.addEventListener('click', () => {
setActiveSection('where');
});
// Must cleanup
button.removeEventListener('click', handler);Angular:
<button (click)="setActiveSection('where')">Where</button>- Automatic cleanup when component destroys
- Type-safe method calls
- Template syntax checks at compile time
Vanilla JS:
if (activeSection) {
contentPanel.style.display = 'block';
contentPanel.innerHTML = getContent(activeSection);
} else {
contentPanel.style.display = 'none';
}Angular:
@if (activeSection()) {
<div class="content-panel">
@if (activeSection() === 'where') {
<app-destination-suggestions />
}
</div>
}- Declarative (what, not how)
- Components automatically mounted/unmounted
- Lifecycle hooks handled automatically
Vanilla JS:
updateContent(section);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const width = element.offsetWidth; // After paint
});
});Angular:
effect(() => {
const section = this.activeSection();
afterNextRender(() => {
const width = element.offsetWidth; // After Angular update
}, { injector: this.injector });
});Vanilla JS (100+ lines):
// State
let activeSection = null;
// DOM references
const indicator = document.getElementById('indicator');
const sections = {
where: document.getElementById('whereSection'),
// ...
};
// Event listeners
sections.where.addEventListener('click', () => {
setActiveSection('where');
});
// Manual reactivity
function setActiveSection(section) {
activeSection = section;
updateUI();
animateIndicator();
showContentPanel();
}
// Must cleanup
window.removeEventListener('click', outsideClickHandler);Angular (30 lines):
// State
activeSection = signal<SectionType>(null);
// DOM references (reactive)
indicatorRef = viewChild<ElementRef>('indicator');
whereRef = viewChild<ElementRef>('whereSection');
// Automatic reactivity
private animateIndicator = effect(() => {
const active = this.activeSection(); // Tracks dependency
// Animation logic...
});
// Template handles events & cleanup
setActiveSection(section: SectionType): void {
this.activeSection.set(section);
}✅ Less boilerplate
- No manual DOM queries
- No manual event cleanup
- No manual reactivity tracking
✅ Better type safety
- TypeScript throughout
- Template type checking
- Generic viewChild references
✅ Automatic cleanup
- Effects destroy with component
- Event listeners auto-removed
- No memory leaks
✅ Declarative
- Template shows structure clearly
- Effects declare dependencies
- Less imperative code
✅ Testable
- Components are classes
- Signals can be mocked
- Templates can be unit tested
- Signals for State - Replace class properties with
signal() - Effects for Side Effects - Replace lifecycle hooks with
effect() - viewChild for DOM - Replace
@ViewChilddecorator withviewChild()signal - afterNextRender for Timing - Measure DOM after Angular's render cycle
- Use standalone components (no NgModules)
- Declare state as signals
- Use viewChild signals for DOM references
- Inject Injector for afterNextRender in effects
- Set initial CSS state to prevent flashes
- Use afterNextRender for post-render measurements
- Guard against undefined viewChild values
- Implement OnPush change detection
- Add ARIA attributes for accessibility
- Always inject Injector when using
afterNextRenderinside effects - Read signals with () -
activeSection()notactiveSection - Guard viewChild signals - Check for
undefinedbefore accessing - Never hide panel in JS - Use CSS for initial hidden state
- Measure after render - Use
afterNextRenderfor accurate dimensions
- Automatic reactivity - No manual tracking or updating
- Type safety - Full TypeScript support throughout
- Declarative templates - HTML describes UI, not imperative code
- Automatic cleanup - No memory leaks
- Better testing - Components are testable classes
- OnPush change detection with signals
will-changeCSS properties- Batch DOM reads in afterNextRender
- Prefer
transformandopacityfor animations - Component-scoped styles (no global pollution)
- Signals Guide: https://angular.dev/guide/signals
- Effects: https://angular.dev/guide/signals/effects
- afterNextRender: https://angular.dev/api/core/afterNextRender
- Standalone Components: https://angular.dev/guide/components/standalone
- Motion.dev: https://motion.dev
- Web Animations API: https://developer.mozilla.org/en-US/Web/API/Web_Animations_API
- Angular Performance: https://angular.dev/best-practices/runtime-performance
- Change Detection: https://angular.dev/best-practices/zone-pollution
Created: 2025-10-18
Version: 1.0
Based on: Implementation at src/app/airbnb-search/airbnb-search.ts
Companion Guide: AIRBNB_SEARCH_ANIMATION_GUIDE.md (Vanilla JS version)