Only this pageAll pages
Powered by GitBook
Couldn't generate the PDF for 544 pages, generation stopped at 100.
Extend with 50 more pages.
1 of 100

The Aurelia 2 Docs

Loading...

Introduction

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Getting Started

Loading...

Loading...

Loading...

Loading...

Loading...

Coming from Another Framework?

Loading...

Loading...

Loading...

Templates

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Components

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Getting to know Aurelia

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Essentials

Aurelia is a modern JavaScript framework that empowers you to build exceptional web applications with clean, maintainable code. If you're familiar with HTML, CSS, and modern JavaScript/TypeScript, this essentials guide will introduce you to Aurelia's core concepts and get you productive quickly.

This section provides a practical overview of Aurelia's fundamental building blocks. Each concept links to comprehensive documentation where you can dive deeper, but these essentials give you everything needed to start building with confidence.

What Makes Aurelia Different

Aurelia stands out by embracing web standards and keeping things simple:

  • Standards-based: Built on modern web standards without unnecessary abstractions

  • Convention over configuration: Intuitive patterns that reduce boilerplate

  • Powerful templating: Rich binding system with excellent performance

  • Dependency injection: Clean, testable architecture out of the box

Core Concepts

Components are the building blocks of your Aurelia application. Learn how to create reusable UI elements with clean separation between view and view-model logic.

Aurelia's templating system provides powerful data binding, event handling, and conditional rendering with a syntax that feels natural and expressive.

Aurelia's dependency injection system promotes clean architecture by managing your application's services and their dependencies automatically.

Understand how Aurelia's observation system automatically tracks changes and updates your UI efficiently without virtual DOM overhead.

Next Steps

Ready to build something? Try our to create your first Aurelia application. For comprehensive coverage of all features, explore the full documentation sections on , , and .

The section contains step-by-step guides for building real applications, while covers advanced topics and best practices.

Components
Templates
Dependency Injection
Reactivity
Quick Start Guide
Templates
Components
Getting to Know Aurelia
Tutorials
Developer Guides

Routing

Forms

Master Aurelia 2 forms with comprehensive coverage of binding patterns, advanced collections, validation integration, and performance optimization for production applications.

Component Recipes

Real-World Recipes

Template compilation

Introduction

Startup & enhancement

Fundamentals

SVG

A developer guide for enabling SVG binding in the Aurelia.

Learn about enabling SVG binding in Aurelia template.

Adding SVG registration

By default, Aurelia won't work with SVG elements, since SVG elements and their attributes require different parsing rules. To teach Aurelia how to handle SVG element bindings, add the SVGAnalyzer like the following example:

import { SVGAnalyzer } from '@aurelia/runtime-html';
import { Aurelia } from 'aurelia';

Aurelia
    .register(SVGAnalyzer) // <-- add this line
    ...

After adding this registration, bindings with attributes will work as expected and the syntax is the same with the other bindings. Readmore on the basic binding syntax of Aurelia here.

Template Syntax

Aurelia 2's templating system is the cornerstone of crafting rich, interactive user interfaces for your web applications. It transcends the limitations of static HTML, empowering you to create truly dynamic views that respond intelligently to both application data and user interactions. At its core, Aurelia templating establishes a fluid and intuitive connection between your HTML templates and your application logic written in JavaScript or TypeScript, resulting in a responsive and data-driven UI development experience.

Forget static HTML pages. Aurelia 2 templates are living, breathing views that actively engage with your application's underlying code. They react in real-time to data modifications and user actions, ensuring your UI is always in sync and providing a seamless user experience. This deep integration not only streamlines your development workflow but also significantly reduces boilerplate, allowing you to build sophisticated UIs with greater clarity and efficiency.

From the moment you initiate an Aurelia 2 project, you'll find yourself working with templates that are both comfortably familiar in their HTML structure and remarkably powerful in their extended capabilities. Whether you're structuring the layout for a complex component or simply displaying data within your HTML, Aurelia 2's templating syntax is meticulously designed to be both highly expressive and exceptionally developer-friendly, making UI development a truly enjoyable and productive process.

If you need even finer control, dive into the focused guides linked throughout this section—for example, for forcing one-time/one-way flow on demand, or the new for tailoring DOM events.

@aurelia/router

Key Features of Aurelia Templating

Aurelia's templating engine is packed with features designed to enhance your UI development workflow and capabilities:

  • Effortless Two-Way Data Binding: Experience truly seamless synchronization between your application's data model and the rendered view. Aurelia's robust two-way data binding automatically keeps your model and UI in perfect harmony, eliminating manual DOM manipulation and ensuring data consistency with minimal effort.

  • Extendable HTML with Custom Elements and Attributes: Break free from standard HTML limitations by creating your own reusable components and HTML attributes. Encapsulate complex UI logic and behavior into custom elements and attributes, promoting modularity, code reuse, and a more maintainable codebase. This allows you to tailor HTML to the specific needs of your application.

  • Adaptive Dynamic Composition for Flexible UIs: Build truly dynamic and adaptable user interfaces with Aurelia's dynamic composition. Render different components and templates on-the-fly based on your application's state, user interactions, or any dynamic condition. This enables you to create flexible layouts and UI structures that respond intelligently to changing requirements.

  • Expressive and Intuitive Templating Syntax: Harness the power of Aurelia's rich templating syntax to handle common UI patterns with ease. From iterating over lists of data and conditionally rendering UI elements to effortlessly managing user events, Aurelia's syntax is designed to be both powerful and remarkably intuitive, reducing complexity and boosting productivity.

  • Simplified Data Integration with Expressions and Interpolation: Seamlessly integrate your application data into your templates using Aurelia's straightforward expression syntax. Effortlessly bind data to HTML elements and manipulate attributes directly within your templates using interpolation, making data display and interaction a breeze.

Aurelia 2's templating system is more than just a way to write HTML; it's a comprehensive toolkit for building modern, dynamic web applications with efficiency and elegance. By embracing its features, you'll unlock a more productive and enjoyable UI development experience.

Learn the Syntax by Topic

Jump straight into the focused articles that break down each template capability:

  • Text & expression binding – Start with the text interpolation guide to master ${ } expressions and formatting tips.

  • Attribute & property binding – Control DOM attributes, classes, and styles with the attribute binding reference.

  • Event handling – Wire up DOM interactions plus modifiers using the event binding guide.

  • Template references – Capture DOM elements, child components, or controllers via the .

  • Template variables – Share computed values inside markup with the .

  • Async UI flows – Render placeholders or await data using the .

  • Advanced scenarios – Combine bindings with conditionals, loops, and partials in the and .

binding mode behaviors
event modifier catalog

Introduction

Get acquainted with Aurelia, the documentation, and how to get started.

What is Aurelia?

Aurelia is the web framework that feels native to the browser. Built on web standards with zero overhead from virtual DOMs or magic abstractions, Aurelia delivers exceptional performance while keeping your code clean and maintainable.

Why developers choose Aurelia:

  • Efficient bundle sizes with smart code splitting and async loading

  • Standards-based architecture that leverages browser capabilities

  • TypeScript-first with powerful dependency injection built-in

  • No breaking changes since 2.0 - stable, production-ready platform

If you value web standards, performance, and developer experience over framework hype, Aurelia is built for you.

Choose Your Path

👋 New to Aurelia? Start with our - build a real app in 15 minutes.

🔧 Want the concepts first? Begin with for focused introduction to core concepts.

🚀 Migrating from another framework?

  • - Better performance, cleaner code

  • - All the simplicity, better performance

  • - Keep the good, lose the complexity

⚡ Just want to see it work? Try our for immediate setup.

Why Aurelia Outperforms

Performance That Matters

  • Direct DOM manipulation - no virtual DOM overhead

  • Batched rendering for optimal browser performance

  • Efficient memory usage - applications that don't drain batteries

  • Optimized bundle sizes - smart code splitting and async loading

Standards-First Development

  • Web Components foundation - build for the future of the web

  • Modern JavaScript/TypeScript - no proprietary abstractions

  • Seamless third-party integration - works with any library

  • Future-proof architecture - leverages browser capabilities

Developer Experience

  • Powerful dependency injection built-in, no external libraries needed

  • Two-way data binding with unidirectional safety

  • Complete ecosystem - routing, validation, i18n, testing, CLI

  • API stability - no breaking changes since 2.0, stable production platform

Production Ready

  • Used by enterprise companies worldwide

  • MIT licensed open-source with active development

  • Comprehensive testing tools built-in

  • Strong TypeScript support from day one

Built for the Future of Web Development

Aurelia isn't chasing the latest trends - it's built on the fundamental technologies that power the web. By embracing web standards and leveraging browser capabilities, Aurelia applications are inherently future-proof and performant.

Our focused approach means:

  • Thoughtful feature development - every addition serves a clear purpose

  • Direct access to the core team - your feedback directly shapes the framework

  • Stable, reliable releases - no breaking changes that disrupt your projects

  • Quality ecosystem - carefully curated tools and plugins that work seamlessly together

Direct DOM: Maximum Performance, Minimum Overhead

Aurelia delivers superior performance by working directly with the browser's DOM instead of creating unnecessary abstraction layers.

Why Direct DOM Wins

Performance Advantages:

  • Zero virtual DOM overhead - no diffing algorithms or reconciliation cycles

  • Faster rendering - direct updates where they're needed

  • Smaller memory footprint - no duplicate DOM trees in memory

  • Better battery life - efficient resource usage on mobile devices

Developer Benefits:

  • Predictable behavior - work directly with web platform APIs

  • Easier debugging - inspect actual DOM elements, not virtual representations

  • Better third-party integration - no compatibility issues with DOM-based libraries

  • Standards-aligned - leverage browser capabilities instead of fighting them

Modern browsers are incredibly fast at DOM manipulation. Aurelia's intelligent observation and batching system ensures you get maximum performance without the complexity and overhead of virtual DOM solutions.

Your Learning Journey

Start Here:

  1. - Build your first app in 15 minutes

  2. - Core concepts explained clearly

  3. - Build reusable UI components

Build Real Apps: 4. - Master Aurelia's powerful templating 5. - Add navigation to your applications 6. - Handle user input effectively 7. - Test your applications thoroughly 8. - Optimize for production 9. - Complex application patterns

Need Help? Join our or check for support.

Join the Aurelia Community

Ready to contribute or need support? The Aurelia community welcomes developers of all skill levels.

Get Support:

  • - Real-time chat with the community

  • - Q&A and feature discussions

  • - Technical questions and answers

Contribute:

  • - How to contribute code and documentation

  • - Report bugs or request features

  • - Help improve these docs

Ready to Start Building?

Jump into our and build your first Aurelia application in 15 minutes. You'll be amazed at how intuitive and powerful web development can be with the right tools.

Templates

Aurelia's templating system provides powerful data binding, event handling, and control flow with intuitive syntax. Templates are HTML files enhanced with binding expressions and template controls.

Data Binding

Text Interpolation

Local templates (inline templates)

Learn how to define, use, and optimize local (inline) templates in Aurelia 2 to remove boilerplate and simplify your components.

Introduction

Most of the time, when working with templated views in Aurelia, you want to create reusable components. However, there are scenarios where reusability isn’t necessary or might cause unnecessary overhead. Local (inline) templates allow you to define a template as a "one-off" custom element, usable only within the scope of its parent view. This helps reduce boilerplate and fosters clear, localized organization of your code.

By defining <template as-custom-element="person-info">, you create a local component named person-info, which can only be used in this file (my-app.html). It accepts a bindable property person (specified via the <bindable>

template reference walkthrough
template variables guide
template promises article
recipes collection
forms guide
Complete Getting Started Guide
Essentials
From React to Aurelia
From Vue to Aurelia
From Angular to Aurelia
Quick Install Guide
Complete Getting Started Guide
Complete Getting Started Guide
Essentials
Components
Templates & Binding
Router
Forms & Validation
Testing
Performance
Advanced Scenarios
Discord community
GitHub Discussions
Discord Server
GitHub Discussions
Stack Overflow
Contributor Guide
GitHub Issues
Documentation
Complete Getting Started Guide
Display dynamic content using string interpolation:

Property Binding

Bind to element properties and attributes:

Two-way Binding

Create two-way data flow with .bind:

As you type, both the input and paragraph update automatically.

Event Handling

Handle user interactions with .trigger:

Conditional Rendering

Show or hide content based on conditions:

List Rendering

Display dynamic lists with repeat.for:

Template References

Access DOM elements directly:

Template Variables

Create local variables within templates:

What's Next

  • Explore the complete template syntax overview

  • Learn about value converters for data transformation

  • Discover custom attributes for reusable behaviors

tag). You can now reuse
<person-info>
repeatedly in this view without creating a separate file or global custom element.

Why Use Local Templates?

Local templates are similar to HTML-Only Custom Elements, with the major difference that local templates are scoped to the file that defines them. They are ideal for:

  • One-off Components: When you need a snippet repeated multiple times in a single view but have no intention of reusing it elsewhere.

  • Reducing Boilerplate: You don’t have to create a new .html and .ts file for every small piece of UI logic.

  • Maintain High Cohesion: Local templates can be optimized for a specific context without worrying about external usage. They can contain deeply nested markup or references to local data without polluting your global component space.

That said, if you find your local template would be useful across multiple views or components, consider extracting it into a shared component.


Syntax and Basic Usage

A local template must be declared with <template as-custom-element="your-element-name">. Inside this

<h1>${title}</h1>
<p>Welcome, ${user.firstName} ${user.lastName}!</p>
<!-- Property binding -->
<input value.bind="message">
<img src.bind="imageUrl" alt.bind="imageAlt">

<!-- Attribute binding -->
<div class.bind="cssClass" id.bind="elementId">

<!-- Boolean attributes -->
<button disabled.bind="isLoading">Submit</button>
<input value.bind="searchQuery">
<p>Searching for: ${searchQuery}</p>
<button click.trigger="save()">Save</button>
<form submit.trigger="handleSubmit($event)">
  <input keyup.trigger="validateInput($event)">
</form>
export class MyComponent {
  save() {
    console.log('Saving...');
  }

  handleSubmit(event: Event) {
    event.preventDefault();
    // Handle form submission
  }

  validateInput(event: KeyboardEvent) {
    // Validate as user types
  }
}
<!-- Show/hide elements -->
<div if.bind="isLoggedIn">
  <p>Welcome back!</p>
</div>

<div else>
  <p>Please log in</p>
</div>

<!-- Conditionally show content -->
<p show.bind="hasMessages">You have new messages</p>
<p hide.bind="isLoading">Content loaded</p>
<ul>
  <li repeat.for="item of items">${item.name}</li>
</ul>

<!-- With index -->
<div repeat.for="product of products">
  <h3>${$index + 1}. ${product.title}</h3>
  <p>${product.description}</p>
</div>
<input ref="searchInput" value.bind="query">
<button click.trigger="focusSearch()">Focus Search</button>
export class MyComponent {
  searchInput: HTMLInputElement;

  focusSearch() {
    this.searchInput.focus();
  }
}
<div with.bind="user">
  <h2>${firstName} ${lastName}</h2>
  <p>${email}</p>
</div>

<!-- Using let for computed values -->
<div let="fullName.bind="firstName + ' ' + lastName">
  <h2>${fullName}</h2>
</div>
<template as-custom-element="person-info">
  <bindable name="person"></bindable>
  <div>
    <label>Name:</label>
    <span>${person.name}</span>
  </div>
  <div>
    <label>Address:</label>
    <span>${person.address}</span>
  </div>
</template>

<h2>Sleuths</h2>
<person-info repeat.for="sleuth of sleuths" person.bind="sleuth"></person-info>

<h2>Nemeses</h2>
<person-info
  repeat.for="nemesis of nemeses"
  person.bind="nemesis"
></person-info>
export class MyApp {
  public readonly sleuths: Person[] = [
    new Person("Byomkesh Bakshi", "66, Harrison Road"),
    new Person("Sherlock Holmes", "221b Baker Street"),
  ];
  public readonly nemeses: Person[] = [
    new Person("Anukul Guha", "unknown"),
    new Person("James Moriarty", "unknown"),
  ];
}

class Person {
  public constructor(public name: string, public address: string) {}
}

The Aurelia Philosophy

These are our fighting words

The web development industry has lost its mind. Frameworks reinvent perfectly good web standards. Developers rewrite applications every eighteen months to chase the new hotness. Testing requires PhD-level framework knowledge just to mock a simple service.

We refuse to participate in this insanity.

We believe frameworks should enhance the web, not replace it. We believe your knowledge should compound, not expire. We believe testing should be trivial, not heroic.

Most importantly, we believe you shouldn't have to forget everything you know about web development just to use our framework.

These beliefs have made us unfashionable. Good. We'd rather be right than trendy.

Why Not Just Use React?

Because the biggest crowd is not the only path to victory.

React is the industry default. It commands stadium keynotes, spawns endless packages, and fills job boards with requirements. We salute the React team for making component thinking mainstream and showing the browser can power ambitious interfaces. That legacy is undeniable.

But scale changes the game. React's strength is modular freedom. Every decision, from routing to state management to forms to dependency injection to testing, invites you to draft your own lineup of supporting libraries. That freedom can be exhilarating. It can also demand constant attention as packages shift, maintainers rotate out, and best practices rewrite themselves overnight. Teams do not just learn React; they learn React plus the custom stack they assembled on day one and have to re-evaluate each quarter.

Aurelia stays intentionally smaller so we can ship the essentials as one cohesive strike force. Our router, dependency injection, binding engine, and testing story are forged together by the same team with the same philosophy. You spend your energy shipping features, not benchmarking which combination of libraries will still be supported next quarter. When we add capability, we do it without erasing the knowledge you already earned.

Choosing Aurelia means joining a community that prizes stability over hype, standards over novelty, and craftsmanship over churn. You gain direct access to maintainers who care about your use case, not just the next keynote demo. Our ecosystem is tighter, but it is aligned, predictable, and built to compound value release after release.

Use React if you want the world of mix-and-match options. Choose Aurelia if you want a unified framework that already lives the principles this manifesto defends.

Web Standards, Enhanced

We build on the web, not around it.

The web platform is brilliant. HTML gives us declarative markup that's intuitive to read and write. CSS provides powerful styling capabilities that scale from simple pages to complex applications. JavaScript offers a flexible, evolving language that runs everywhere. These aren't bugs to be fixed. They're features to be leveraged.

Yet somewhere along the way, the industry decided the web platform wasn't sophisticated enough for "real" applications. Everyone needed their own templating language that looked nothing like HTML. Their own styling solution that avoided CSS. Their own module system that ignored JavaScript's evolution. Learn our special syntax. Forget everything you know about web development. Trust us, we know better than the web standards committees and the collective wisdom of millions of developers.

This is breathtaking arrogance disguised as innovation.

Consider what this means in practice: A developer who's spent years mastering HTML, CSS, and JavaScript sits down with a modern framework and discovers that none of that knowledge applies. The templating syntax is completely foreign. The styling approach bypasses CSS entirely. The component model bears no resemblance to anything they've learned about web development.

We're essentially telling experienced web developers that their expertise is worthless. That the web platform they've mastered is inadequate. That they need to start over with our special way of doing things.

Aurelia takes the opposite approach. We enhance web standards instead of replacing them. Your templates are HTML with binding attributes that read like natural language: value.bind, click.trigger, repeat.for. A web developer can look at Aurelia templates and immediately understand what's happening, even if they've never seen the framework before.

Your components are JavaScript classes with predictable lifecycle methods. No magical decorators that fundamentally change how JavaScript works. No special compilation steps that transform your code into something unrecognizable. Just classes with methods that get called at logical points in the component's life.

Your styles are CSS. Not CSS-in-JS. Not a special styling language. Not a build-time transformation that generates CSS for you. Just CSS, working exactly like CSS should work, with all the power and flexibility you expect.

This philosophy extends to how we handle emerging standards. When Web Components were still experimental, we didn't bet the entire framework on them. When they became stable, we didn't ignore them. We built compatibility layers that let you use Aurelia components as Web Components when that makes sense, while keeping our core architecture independent of any single standard's success or failure.

We've watched frameworks rise and fall based on their alignment with web standards. The ones that fought against the platform eventually lost. The ones that embraced it survived and thrived. We're not just building for today's web. We're building for the web's long-term evolution.

When you learn Aurelia, you're not just learning a framework. You're deepening your understanding of the web platform itself. The binding concepts translate to vanilla JavaScript. The component patterns work with or without the framework. The architectural principles apply to any web application.

Your knowledge doesn't expire when the next framework revolution comes along. It compounds, making you a better web developer regardless of what tools you use next. That's the difference between building on the web platform and building around it.

Want to see this philosophy in action? Check out our comprehensive guide on to learn how Aurelia enhances native web APIs like Fetch, Intersection Observer, Geolocation, and many more.

Convention Over Configuration

Stop making the same decisions over and over.

Here's a thought experiment: How many different ways can you structure a web application? How many reasonable approaches are there to naming components, organizing files, or wiring up dependencies?

The answer is: a few good ways, and hundreds of terrible ways.

Yet modern web development has become obsessed with giving you infinite configuration options. Choose your file naming strategy from seventeen possibilities. Configure your binding syntax from forty-three variants. Set up your directory structure using our flexible, powerful, completely overwhelming configuration system. Make four hundred decisions before you can render "Hello World."

This is madness disguised as flexibility.

Consider what this means for a team. Every developer has their own preferences. The React developer wants JSX everywhere. The Vue developer prefers template syntax. The Angular developer expects decorators. The result? Endless bike-shedding discussions about tooling choices instead of productive work on actual features.

Or consider what this means for learning. A new developer doesn't just need to learn the framework. They need to learn your team's specific configuration choices, your particular file organization, your custom naming conventions. Every project becomes a unique snowflake with its own special setup.

Convention over configuration means we make the boring decisions so you don't have to. Create a file called user-profile.ts and another called user-profile.html, and you automatically get a <user-profile> component. Mark a property with @bindable, and a method called propertyChanged automatically becomes a change callback. Put components in a components folder, put services in a services folder, and everything just works.

These aren't arbitrary restrictions. They're carefully chosen defaults based on years of experience building real applications. We've seen what works and what doesn't. We've observed the patterns that emerge naturally when teams build maintainable applications. We've codified those patterns into conventions that guide you toward good practices.

But here's the crucial part: conventions should accelerate you, not constrain you. When you genuinely need something different, when your specific use case demands a different approach, every convention has an escape hatch. Need a custom component name? Use @customElement('custom-name'). Need different binding behavior? Configure it explicitly. Need a non-standard file organization? Override the defaults.

The key word is "earn." You have to consciously choose to deviate from the convention. You have to be explicit about what you want instead. This creates a natural pressure toward consistency while preserving flexibility for genuine edge cases.

Some developers hate this philosophy. They see conventions as limitations on their creativity. They want to configure every detail of the framework's behavior. They view our defaults as opinions imposed on their artistic vision.

We think they're optimizing for the wrong thing. Yes, you could spend three days configuring your perfect setup. You could create a unique file organization that perfectly reflects your mental model. You could customize every aspect of the framework's behavior to match your preferences.

But why? Your user registration component isn't fundamentally different from everyone else's. Your data binding requirements aren't uniquely artistic. Your architectural needs aren't so special that they require a completely novel approach.

Time spent on configuration is time not spent on features. Energy devoted to framework setup is energy not devoted to solving user problems. Cognitive load consumed by tooling choices is cognitive load unavailable for business logic.

Conventions are liberation from meaningless choices. They're shared understanding across teams and projects. They're productivity multipliers that let you focus on what actually matters: building great applications that solve real problems for real people.

Intuitive Syntax That Reads Like You Think

Code should express intent, not implementation details.

Here's a simple test: Show a piece of code to someone who's never seen your framework before. Can they understand what it's trying to accomplish? Or do they need to memorize a dozen framework-specific concepts before the code makes sense?

Most modern frameworks fail this test spectacularly. Their templates are filled with cryptic symbols, magical directives, and abstract concepts that have no relationship to the underlying intent. A simple list becomes a mystical incantation that only the initiated can decipher.

Consider this common pattern across different frameworks: You want to show a user's name if they're logged in, and a login button if they're not. This is basic conditional rendering. Something any web developer should be able to understand at a glance.

In many frameworks, this simple intent gets buried under layers of framework-specific syntax. Special directives with obscure names. Conditional operators that work differently than JavaScript. Template languages that require you to think in framework abstractions rather than your actual problem domain.

Aurelia takes a different approach. Our syntax reads like natural language: <div if.bind="user.isLoggedIn">Welcome, ${user.name}!</div>. An experienced web developer can look at this and immediately understand what's happening, even if they've never seen Aurelia before. The intent is clear. The behavior is predictable. The syntax maps directly to the concept.

This isn't an accident. It's the result of obsessive attention to developer experience. We believe that the cognitive load of understanding your framework should be as close to zero as possible. Your mental energy should be devoted to solving business problems, not translating between human intent and framework abstractions.

The benefits of intuitive syntax extend far beyond personal productivity. When your code reads like natural language, it becomes self-documenting. New team members can understand existing code without extensive framework training. Code reviews focus on business logic rather than framework syntax. Bug reports can be understood by non-technical stakeholders.

But intuitive syntax is about more than just readability. It's about predictability. When syntax clearly expresses intent, the behavior becomes obvious. There are no hidden side effects, no mysterious edge cases, no framework magic that changes behavior based on context you can't see.

Consider error handling. In many frameworks, when something goes wrong, the error messages are filled with framework internals. Stack traces point to generated code. Error descriptions use framework jargon that tells you nothing about what actually went wrong in your application.

Aurelia's errors tell you exactly what went wrong and where. When a binding fails, we tell you which property couldn't be found and in which component. When a lifecycle hook throws an exception, we show you the exact method and component that caused the problem. The error messages use the same vocabulary as your code, not our internal implementation details.

This philosophy extends to debugging. When you inspect an Aurelia application in browser dev tools, you see your components, your properties, your methods. The framework doesn't hide behind layers of generated code or proxy objects. What you wrote is what you see. What you debug is what you ship.

The industry has somehow convinced itself that complexity is inevitable. That developer tools must be complicated because the problems they solve are complicated. That you need deep framework knowledge to be productive.

We reject this premise entirely. Complex problems don't require complex tools. They require powerful tools with simple interfaces. The best frameworks make hard things possible and easy things effortless, all while staying out of your way.

When your framework's syntax fights against your natural thinking patterns, every day becomes a translation exercise. You know what you want to accomplish, but you have to constantly convert your intent into framework-specific abstractions. This cognitive overhead accumulates over time, making you slower, more error-prone, and ultimately less satisfied with your work.

Intuitive syntax isn't just about making code easier to write. It's about making development more humane. Your tools should amplify your capabilities, not drain your mental energy. They should feel like natural extensions of your thinking, not obstacles to overcome.

Built for Testing

If you can't test it easily, we designed it wrong.

Aurelia's dependency injection system isn't just for organization. It's what makes testing actually pleasant. Need to test a component that depends on services? Just pass in mocks. Everything is classes and interfaces, which means everything is mockable.

No elaborate test setup, no framework gymnastics, no "testing utilities" that are more complex than the code you're testing. Our component lifecycle is predictable and hookable. You can test each phase independently, spy on lifecycle methods, and verify that cleanup happens when it should.

This isn't an accident. We've worked on too many projects where testing was painful, so we made it a first-class concern. If testing something in Aurelia feels hard, that's a bug we want to fix.

Stability Over the Latest Hotness

We're boring, and proud of it.

Framework churn is a disease. Every six months there's a new paradigm that's going to "revolutionize" web development. Everyone rewrites their applications to chase the shiny new thing. Six months later, that thing is deprecated in favor of the next revolution.

We refuse to participate in this madness.

There are Aurelia applications running in production today that were built in 2015. Nine years later, they still work. The same patterns, the same APIs, the same mental models. The Aurelia of 2015 is, in many fundamental ways, still the Aurelia of today.

This is not an accident. It's a philosophical choice.

While other frameworks have gone through complete rewrites that invalidated entire ecosystems, we've evolved gradually. We've added capabilities without breaking existing ones. We've improved performance without changing programming models.

This makes us unfashionable. Conferences don't invite us to give talks about the revolutionary new paradigm we've invented. Tech Twitter doesn't buzz about our latest rewrite. We don't get to claim we've "solved" web development with our groundbreaking new approach.

Fine by us. Your applications work. Your knowledge compounds instead of becoming obsolete. Your teams stay productive instead of spending months learning the new hotness. That's worth more than being trendy.

Architecture for Growing Applications

Start simple, scale thoughtfully.

The web development industry has a scaling problem, but it's not the one you think. The real problem isn't technical. It's architectural. Most frameworks force you into a false choice: build quick and dirty prototypes that collapse under their own weight, or start with enterprise-grade complexity that crushes productivity from day one.

This is a failure of imagination, not engineering.

Real applications don't start complex. They start simple and become complex over time as requirements evolve, user needs change, and business logic accumulates. The framework you choose should support this natural progression, not fight against it.

Too many frameworks optimize for one end of the spectrum. The trendy ones make demos look effortless but leave you stranded when you need real architecture. The enterprise ones handle massive complexity beautifully but make simple things unnecessarily complicated. You're forced to choose between fast starts and sustainable growth.

Aurelia refuses this false choice. We designed every system to scale gracefully from simple to sophisticated without forcing you to rewrite your mental model or refactor your foundation.

Consider dependency injection. In a small Aurelia application, you might start with simple classes and direct instantiation. As your application grows and testing becomes important, you naturally introduce interfaces and constructor injection. When complexity demands it, you add scoped instances, factory patterns, and custom resolution strategies. Each step builds on the previous one. Your early decisions remain valid even as your architecture evolves.

The component system works the same way. Start with simple, self-contained components that handle their own data and logic. As requirements grow, introduce shared services, component communication patterns, and hierarchical state management. The simple components don't become wrong or need rewriting. They just become part of a larger, more sophisticated system.

Our conventions support this progression naturally. The file-based naming that works for a dozen components still works for hundreds. The lifecycle methods that handle simple initialization also handle complex orchestration. The binding patterns that manage basic data flow scale to handle intricate component interactions.

This isn't theoretical scaling. We've seen applications grow from weekend prototypes to enterprise systems serving millions of users. The fundamental patterns remain consistent. The code written in the early days doesn't become technical debt that needs to be paid down. It becomes the foundation that everything else builds on.

Compare this to frameworks that require architectural rewrites at different scales. Start with simple state management, then throw it away for a "proper" state solution. Begin with basic routing, then replace it with enterprise routing when you need features. Use simple components initially, then refactor everything when you need composition patterns.

This constant rewriting isn't progress. It's waste. It's time spent fighting your tools instead of building features. It's knowledge that expires instead of compounds. It's technical debt disguised as best practices.

We've watched too many projects hit the scaling wall and collapse under their own success. They start fast with a framework that makes demos look effortless. Six months later, they're drowning in unmanageable code, competing patterns, and architectural inconsistencies. The very framework that gave them early velocity becomes the bottleneck that prevents growth.

We've also seen projects start with enterprise-grade complexity from day one. Multiple abstraction layers, sophisticated patterns, elaborate architectures. Six months later, they're still building infrastructure instead of features. The complexity that was supposed to enable scale becomes the burden that prevents delivery.

Aurelia's approach is different. The patterns that work for small applications are the same patterns that work for large applications, just applied at different scales. Dependency injection scales from simple constructor parameters to complex service hierarchies. Component composition scales from basic parent-child relationships to sophisticated architectural patterns. Convention-based structure scales from individual files to team-based module organization.

This consistency has profound implications for team productivity. New developers don't need to learn different patterns as the application grows. Senior developers don't need to constantly re-architect as requirements evolve. The mental model that serves you well early in the project continues serving you well as the project matures.

Your testing strategies scale the same way. The mocking patterns that work for simple components work for complex service interactions. The lifecycle testing that validates basic behavior also validates sophisticated orchestration. The integration tests that cover simple workflows extend naturally to cover complex business processes.

Your debugging experience remains consistent as complexity grows. The error messages that help you understand simple binding issues also help you understand complex dependency resolution problems. The development tools that illuminate basic component behavior continue working as your component hierarchies become more sophisticated.

The industry has convinced itself that scaling requires fundamental architectural changes. That simple patterns must be abandoned for complex ones. That early code must be rewritten for production use.

We believe that well-designed simple patterns naturally evolve into well-designed complex patterns. That the best foundation for a large application is a small application that grew thoughtfully. That architectural consistency across scales is more valuable than perfect optimization at any individual scale.

When you build with Aurelia, you're not just building an application. You're growing an architecture. The decisions you make early don't constrain your future options. They create a foundation that supports whatever your application becomes.

Start simple. Scale thoughtfully. Never rewrite your foundation. That's how real applications get built.

Popular Is Overrated

We don't chase GitHub stars. We chase solutions.

Let's be honest about where we stand. We're not the popular choice. We don't have billion-dollar tech giants throwing marketing budgets at us. We don't have armies of developer advocates making conference circuit rounds. We don't have influencers creating viral videos about the Aurelia revolution.

React has more GitHub stars than we'll ever see. Vue gets more downloads in a day than we get in a month. Angular dominates job postings and Stack Overflow questions.

And we're completely fine with that.

Popularity is a lagging indicator, not a leading one. It tells you what was trendy yesterday, not what will solve your problems tomorrow. The most popular framework isn't necessarily the best framework for your specific needs. It's just the one with the most mindshare at this particular moment.

We've been around long enough to watch the cycles. Technologies rise and fall based on hype as much as merit. Yesterday's revolutionary breakthrough becomes tomorrow's legacy system. The graveyard of web development is full of frameworks that were once the absolute hottest thing in tech.

While the popular frameworks optimize for conference buzz and Twitter engagement, we optimize for applications that work reliably over years, not months. We worry about whether our abstractions will make sense to your team three years from now, not whether they'll trend on Hacker News next week.

The tech industry loves its popularity contests. Industry analysts write reports about the "big three" and mention us as a footnote, if at all. Bootcamps teach the frameworks that help graduates get hired fastest, not necessarily the ones that will serve them best in the long run.

That's the game, and we understand it. But we're playing a different game entirely.

We're optimizing for developers who value substance over signals. For teams who care more about shipping reliable software than using the latest hotness. For organizations who measure success by user satisfaction, not developer satisfaction surveys.

The core team doesn't maintain Aurelia as a side project or resume builder. We stake our professional reputations on it. We bet our careers on it. We build production applications with it every day. When we make a design mistake, we live with it in our own codebases. When we get something right, we benefit directly. Our incentives are perfectly aligned with yours.

Being the unpopular choice gives us something invaluable: complete intellectual freedom. We don't have to pretend every new JavaScript trend is revolutionary. We don't have to generate artificial excitement to satisfy venture capitalists. We don't have to compromise our engineering principles to chase market share.

We can afford to be boring when boring is more reliable. We can afford to be unfashionable when unfashionable is more sustainable. We can afford to be right instead of popular.

The frameworks that are popular today won't necessarily be popular tomorrow. There will be new paradigms, new solutions, new ways of thinking about web development. The cycle will continue, as it always has.

But the applications built with Aurelia will keep running. The teams using Aurelia will keep shipping. The problems solved with Aurelia will stay solved.

Because quality outlasts popularity. Substance outlasts hype. Reliability outlasts trendiness.

We're not trying to win a popularity contest. We're trying to build the most thoughtful, sustainable, and useful framework we can. For developers and teams who share those values.

That's exactly the position we want to be in.

We Trust You With Power Tools

No training wheels, no safety scissors.

Most frameworks treat you like you can't be trusted with real power. They give you a carefully curated set of approved patterns and lock everything else behind "here be dragons" warnings. They make architectural decisions for you and provide no way to change them. They assume you'll hurt yourself if given too much control.

We think that's insulting to your intelligence.

Aurelia is designed around a radical premise: you know what you're doing. You understand your requirements better than we do. You should have the power to make your own architectural decisions, even if we wouldn't make the same ones.

Don't like our default binding syntax? Configure it. Want custom template behaviors? Build them. Need different binding modes? Create them. The templating and binding systems are designed for extensibility at every level.

This isn't theoretical power. It's real, practical extensibility that teams use in production applications. We've seen developers create custom binding behaviors that handle complex scenarios we never anticipated. We've seen teams build specialized value converters that transform data in domain-specific ways. We've seen companies extend the templating system with custom attributes that encapsulate their business logic.

But here's the beautiful part: you don't need any of this complexity to get started. Aurelia works perfectly out of the box without any configuration. Most teams never need to replace a single component. The power is there when you need it, invisible when you don't.

Compare this to frameworks that make you choose between "simple but limited" and "powerful but complex." We give you simple AND powerful. The default experience just works. The advanced capabilities are there when your requirements demand them.

Need direct DOM access? Take it. The framework won't fight you or wrap everything in virtual abstractions. Need to hook into the component lifecycle at a granular level? Every lifecycle method is available and predictable. Need to customize how dependency injection works? The entire container is configurable.

Want to integrate a third-party library that expects to own part of the DOM? Go ahead. Aurelia won't interfere with your jQuery plugins, your D3 visualizations, or your WebGL canvases. We give you escape hatches everywhere because we know real applications have messy requirements.

Our dependency injection system exemplifies this philosophy. Out of the box, it handles constructor injection with sensible defaults. But if you need custom resolution strategies, scoped instances, factory functions, or completely custom behaviors, the system is flexible enough to handle it all.

The templating system works the same way. The default syntax handles 95% of use cases elegantly. But when you need custom binding behaviors, specialized value converters, or completely novel template constructs, the architecture supports it without forcing you to fight the framework.

Yes, this means you can hurt yourself. You can create circular dependencies that crash at runtime. You can bind to expensive computations that tank performance. You can write components that leak memory all over the place. You can architect systems that are impossible to maintain.

We're not going to stop you. We're also not going to assume you're incompetent enough to do these things accidentally.

The industry trend is toward frameworks that make dangerous things impossible. Every API is locked down. Every extension point is carefully controlled. Every architectural decision is made for you "for your own good."

We prefer frameworks that make powerful things possible. We trust you to understand the trade-offs. We trust you to test your code. We trust you to make responsible decisions about performance and maintainability.

This philosophy extends to our error handling. When something goes wrong, we don't hide it behind friendly abstractions. We show you exactly what failed, where it failed, and why it failed. Our error messages assume you're capable of understanding technical details and fixing the underlying problem.

Other frameworks optimize for protecting developers from themselves. We optimize for empowering developers to solve their problems. Sometimes those problems require sharp tools, direct access, and the freedom to make unconventional choices.

The difference is trust. We trust that you're a professional who can handle professional tools. We trust that you'll read the documentation, understand the implications, and make informed decisions about your architecture.

When you need to do something unusual, something the framework designers never anticipated, you shouldn't have to fight your tools or work around artificial limitations. You should be able to extend, customize, and override as needed.

That's what real power looks like. Not just the ability to configure options, but the ability to fundamentally change how the framework behaves when your requirements demand it.

The training wheels come off. The safety scissors get put away. We hand you the real tools and trust you to build something amazing.

Complete, Not Cobbled Together

Everything works together because we designed it that way.

Modern web development has become an exercise in integration hell. Install seventeen npm packages with incompatible APIs. Configure twenty-three build tools that don't know about each other. Wire together forty-nine loosely related libraries that were never designed to work as a system. Then spend three days debugging version conflicts, peer dependency warnings, and mysterious build failures.

This is insanity disguised as flexibility.

The JavaScript ecosystem's obsession with modularity has created a paradox: in trying to make everything composable, we've made nothing actually compose well. Every package is an island. Every library has its own conventions, its own configuration format, its own way of handling errors. Integrating them requires endless glue code, adapter layers, and configuration files that exist solely to make incompatible things pretend to work together.

Aurelia takes a radically different approach. We're a complete system designed as a complete system from day one.

Our router doesn't just handle navigation. It understands our component lifecycle, integrates with our dependency injection system, and works seamlessly with our templating engine. When you navigate to a route, the router knows how to instantiate components with their dependencies, bind their properties, and manage their lifecycle hooks. No adapters, no glue code, no configuration mapping.

Our templating system doesn't exist in isolation. It's deeply integrated with our binding engine, our component system, and our validation framework. When you bind to a property in a template, the system understands the component's lifecycle, respects the validation rules, and integrates with the change detection system. Everything flows together naturally.

Our testing utilities aren't afterthoughts built by the community. They're designed specifically for Aurelia's architecture. They understand our dependency injection system, so mocking is trivial. They know our component lifecycle, so testing different phases is straightforward. They integrate with our templating system, so testing complex UI interactions doesn't require elaborate setup.

This integration goes deeper than just APIs that work well together. Our error messages are consistent across all systems because they're built by the same team with the same philosophy. Our debugging experience is coherent because all the pieces understand each other. Our performance optimizations work across the entire stack because we control the entire stack.

Compare this to the typical modern web application. You have a routing library that knows nothing about your state management solution. A component framework that's unaware of your validation library. A testing framework that requires custom adapters to work with your template syntax. Each piece is excellent in isolation, but together they create a fragmented experience.

When something goes wrong in a cobbled-together system, good luck figuring out where. Is it the router? The state manager? The component library? The build tool? The integration layer between two of them? You'll spend more time debugging the interactions between your tools than the actual business logic they're supposed to support.

When something goes wrong in Aurelia, the error messages tell you exactly what happened and where. The stack traces point to your code, not framework internals or integration adapters. The debugging experience is consistent because there's one coherent system, not a collection of independent libraries pretending to be friends.

This approach has real costs. We can't always use the "best of breed" solution for every individual piece. Our router might not have every feature of the most advanced standalone routing library. Our state management might not be as sophisticated as the latest state management trend. We have to build and maintain more code instead of delegating to the community.

But the benefits are transformative. You spend your time building features, not integrating tools. You debug your application logic, not framework compatibility issues. You learn one coherent system instead of a dozen different libraries with conflicting philosophies.

Your team onboards faster because there's one consistent way of doing things across the entire application. Your application performs better because all the pieces are optimized to work together. Your codebase is more maintainable because there are fewer integration layers and compatibility shims.

When you need to add a new feature, you're working with the framework, not against a collection of competing libraries. When you need to debug a problem, you're working within one system with consistent patterns, not trying to understand the interaction between multiple independent systems.

The industry has convinced itself that maximum modularity leads to maximum flexibility. That the best system is one assembled from the best individual components. That integration is someone else's problem.

We believe integration is the most important problem. That a complete system designed to work together will always be more reliable than a collection of perfect pieces that weren't designed for each other.

We built Aurelia as a complete, integrated solution because that's what real applications need. Not maximum theoretical flexibility, but maximum practical reliability. Not the coolest individual components, but the most coherent overall experience.

You don't have to become an expert in seventeen different libraries just to build a web application. You don't have to spend weeks researching compatibility matrices and integration guides. You don't have to maintain a tower of adapters and glue code that adds complexity without adding value.

You just build your application. The framework handles the rest.

No Surprises

What you see is what you get.

Framework magic is a disease. Hidden behaviors, implicit conventions that change based on context, APIs that behave differently depending on what phase of the moon it is. Too many frameworks treat mystery as a feature.

Aurelia is boringly predictable.

When you bind to a property, it binds to that property. When you trigger an event, it triggers that event. When you inject a dependency, you get that dependency. The behavior you see is the behavior you get.

Our error messages tell you exactly what went wrong and how to fix it. Our lifecycle hooks run in the order you'd expect. Our binding system does what the syntax suggests it does.

This predictability isn't an accident. It's a core design principle. Complex applications require dependable foundations. When your framework behaves surprisingly, everything built on top of it becomes fragile.

Boring reliability beats clever unpredictability every time.

Progress Through Evolution, Not Revolution

We improve by addition, not destruction.

The tech industry is obsessed with dramatic rewrites. Every few years, someone declares that everything we've learned is wrong and we need to start over from scratch. New paradigms emerge that invalidate entire ecosystems. Frameworks abandon their users in pursuit of architectural purity.

This is progress theater, not actual progress.

Real progress preserves what works while improving what doesn't. It builds on existing knowledge instead of discarding it. It respects the investments people have made in learning your platform, building their applications, and training their teams.

Too many frameworks treat major versions like clean slates. They change fundamental concepts, abandon proven patterns, and force you to relearn everything. The justification is always the same: "We had to break things to make them better."

We believe progress and stability aren't opposites. They're requirements that must be balanced.

Consider how we approached Aurelia 2. We rebuilt the framework's internals for dramatically better performance. We redesigned the binding system for more predictable behavior. We modernized the architecture to support emerging web standards. We fixed years of accumulated design debt.

But we didn't throw away what worked.

If you know how to build an Aurelia 1 application, you know how to build an Aurelia 2 application. The same conventions work. The same mental models apply. The same patterns scale. Your components are still classes with lifecycle methods. Your templates still use the same binding syntax. Your dependency injection still works the same way.

We made the performance improvements invisible to your application code. We enhanced the binding system without changing its behavior from your perspective. We modernized the architecture while preserving the programming model you'd learned.

Yes, some things changed. Some APIs became more consistent. Some edge cases were fixed. Some deprecated features were finally removed. But the core experience of building with Aurelia remained fundamentally the same.

This approach has costs. We couldn't make radical performance improvements that would have required completely different programming models. We couldn't chase architectural trends that would have invalidated existing knowledge. We had to think carefully about every change because we knew people were depending on the current behavior.

But this approach also has benefits. Your Aurelia 1 knowledge transferred directly to Aurelia 2. Your team didn't need months of retraining. Your migration path was evolutionary, not revolutionary. Your investment in the platform continued paying dividends.

When other frameworks release major versions, teams often decide it's easier to rewrite their applications than migrate them. When we released Aurelia 2, teams migrated because it was the obvious next step, not a leap into the unknown.

Progress without preservation is just churn. We choose sustainable progress over revolutionary change because we're building for the long term. Your applications deserve better than constant rewrites. Your teams deserve better than endless relearning. Your users deserve better than instability disguised as innovation.


These aren't just marketing principles. They're the beliefs that shaped every architectural decision, every API design, and every line of documentation. When you choose Aurelia, you're choosing more than a framework. You're choosing an approach to web development that values your time, respects your intelligence, and bets on the long-term health of the web platform.

Whether you're building your first application or your hundredth, whether you're working solo or on a team of dozens, whether you're prototyping or preparing for production, Aurelia is designed to meet you where you are and grow with you where you're going.

This is why we build.

Recipes Overview

Real-world, production-ready examples showing Aurelia templates in action. Each recipe is a complete, working example demonstrating multiple templating features working together.

Available Recipes

E-Commerce & Shopping

  • - Search, filter, sort products with real-time updates

  • - Add/remove items, update quantities, calculate totals

Data Display & Tables

  • - Complete data table with sorting, filtering, pagination, row selection

UI Components

  • - Global notifications with auto-dismiss, multiple types, and queue management

  • - Typeahead search with keyboard navigation and highlighting

How to Use These Recipes

Each recipe includes:

  • Complete working code - Copy and paste to get started

  • Feature breakdown - What templating features are being used

  • Variations - Common modifications and extensions

  • Related patterns - Links to similar recipes

Recipe Template

Looking to contribute a recipe? Follow this structure:

Contributing

Have a great real-world example? We'd love to include it! Submit a PR with your recipe following the template above.

Recipes Overview

Practical component recipes for building common UI elements in Aurelia

This section contains practical, real-world examples of building reusable UI components in Aurelia. Each recipe shows you how to create a complete, production-ready component from scratch.

What You'll Find Here

These recipes demonstrate:

  • Best practices for component architecture

  • Accessibility considerations

  • TypeScript with proper typing

  • Testing patterns for each component

  • Real-world features and edge cases

Available Recipes

UI Components

  • : A fully-featured dropdown with keyboard navigation and accessibility

  • : A flexible modal system with backdrop, animations, and focus management

  • : An accessible tab interface with dynamic content

  • : Position-aware tooltips with smart placement

How to Use These Recipes

Each recipe includes:

  1. Overview: What the component does and when to use it

  2. Complete Code: TypeScript and HTML for the component

  3. Usage Examples: How to consume the component

  4. Styling: Base CSS to get you started

Prerequisites

These recipes assume you're familiar with:

Code Standards

All recipes follow Aurelia 2 best practices:

  • Use resolve() for dependency injection (not decorators)

  • No <template> wrappers in HTML files

  • Named exports for reusable components

  • Proper cleanup in

Contributing

Have a component recipe you'd like to share? Contributions are welcome! Make sure your recipe includes:

  • Complete, working code

  • Accessibility considerations

  • Usage examples

  • Tests


Ready to build something? Pick a recipe and start coding!

Class & style binding

Bind CSS classes and inline styles in Aurelia templates using expressive syntax.

Class and style bindings in Aurelia allow you to bind to CSS properties and add one or more classes to your HTML elements inside of your views.

Binding to the class attribute

The class binding allows you to bind one or more classes to an element and its native class attribute.

Binding to a single class

Adding or removing a single class value from an element can be done using the .class binding. By prefixing the .class binding with the name of the class you want to conditionally display, for example, selected.class="myBool" you can add a selected class to an element. The value you pass into this binding is a boolean value (either true or false), if it is true the class will be added, otherwise, it will be removed.

Inside of your view model, you would specify isSelected as a property and depending on the value, the class would be added or removed.

Here is a working example of a boolean value being toggled using .class bindings.

Binding to multiple classes

Unlike singular class binding, you cannot use the .class binding syntax to conditionally bind multiple CSS classes. However, there is a multitude of different ways in which this can be achieved.

Syntax
Input Type
Example

Binding to the style attribute

Dynamically set CSS styles on elements in your view templates.

Binding to a single style

You can dynamically add a CSS style value to an element using the .style binding in Aurelia.

Inside of your view model, you would specify bg as a string value on your class.

Here is a working example of a style binding setting the background color to blue:

Binding to multiple styles

To bind to one or more CSS style properties you can either use a string containing your style values (including dynamic values) or an object containing styles.

Style binding using strings

This is what a style string looks like, notice the interpolation here? It almost resembles just a plain native style attribute, with exception of the interpolation for certain values. Notice how you can also mix normal styles with interpolation as well?

You can also bind a string from your view model to the style property instead of inline string assignment by using style.bind="myString" where myString is a string of styles inside of your view model.

Style binding using objects

Styles can be passed into an element by binding to the styles property and using .bind to pass in an object of style properties. We can rewrite the above example to use style objects.

From a styling perspective, both examples above do the same thing. However, we are passing in an object and binding it to the style property instead of a string.

Hello World Tutorial

Learn the basics of Aurelia by building an interactive Hello, World! application from scratch

Build your first Aurelia app in 10 minutes! This complete guide takes you from zero to a working interactive application with live data binding.

What You'll Build

An interactive hello world app where typing in a text field instantly updates the greeting. No page refreshes, no complex setup - just pure Aurelia magic.

Attribute mapping

Learn about binding values to attributes of DOM elements and how to extend the attribute mapping with great ease.

Attribute mapping is Aurelia's way of keeping template syntax concise. After an attribute pattern parses the attribute name but before a binding command emits instructions, the mapper answers two questions:

  1. Which DOM property does this attribute target?

  2. Should .bind implicitly behave like .two-way for this attribute?

This is the mechanism that lets you write <input value.bind="message">

Overview

A guided tour of Aurelia fundamentals; start here before diving into the deeper topic guides.

Use this section as your orientation to Aurelia. Each topic builds on the last, moving from first render through composition patterns, state management, and the services that power real applications.

How to Navigate

  • Start with the introductions to see Aurelia's templating flavor and ergonomics in action.

Aurelia for New Developers

New to Javascript, Node.js and front-end development in general? Don't worry, we got you.

Welcome to the magical world of Javascript development. This guide is for any newcomer to front-end development who isn't that experienced with modern tooling or Javascript frameworks.

Getting started

For the purposes of this tutorial and as a general rule for any modern framework like Aurelia, you will be using a terminal of some sort. On Windows, this can be the Command Prompt or Powershell. On macOS, it'll be Terminal (or any other Terminal alternative), the same thing with Linux.

To work with Aurelia, you will need to install Node.js. If you are new to Node.js, it is used by almost every tool in the front-end ecosystem now, from Webpack to other niche bundlers and tools. It underpins the front-end ecosystem.

Working with Web Standards

Accordion: Collapsible content panels with smooth animations

Testing: How to test the component

  • Enhancements: Ideas for extending the component

  • detaching()
    lifecycle hooks
  • Accessible markup with ARIA attributes

  • Clear explanations
    Dropdown Menu
    Modal Dialog
    Tabs Component
    Tooltip
    Aurelia components
    Bindable properties
    Template syntax
    Dependency injection
    Product Catalog with Search & Filters
    Shopping Cart
    Data Table with Sorting, Filtering & Pagination
    Notification/Toast System
    Search with Autocomplete
    and automatically get a two-way binding. By teaching the mapper about your own elements, you can bring the same ergonomics to Web Components, design systems, or DSLs.

    When to extend IAttrMapper

    Reach for the mapper when:

    • Bridging custom elements – Third-party components often expose camelCase properties such as valueAsDate or formNoValidate.

    • Designing DSLs – Attributes like data-track or foo-bar need to land on specific DOM properties regardless of casing.

    • Improving authoring ergonomics – Upgrading progress.bind to two-way on slider-like controls keeps templates readable.

    If you need to invent new attribute syntaxes ([(value)], @click, etc.), start with attribute patterns. If you need to observe DOM properties, follow up with the node observer locator.

    How the mapper decides

    When Aurelia encounters an attribute that does not belong to a custom element bindable, it walks through the mapper logic:

    1. Check tag-specific mappings registered via useMapping.

    2. Fall back to global mappings from useGlobalMapping.

    3. If no mapping exists, camelCase the attribute name.

    4. If the binding command is bind, ask each predicate registered via useTwoWay whether the attribute should become two-way.

    Your extensions only run for attributes that are not already handled by custom element bindables, so you can layer mappings without unintentionally overriding component contracts.

    Registering mappings during startup

    Use AppTask.creating to register mappings before Aurelia instantiates the root component:

    Keys inside useMapping must match the element's tagName (uppercase). The destination values must match the actual DOM property names exactly (formNoValidate, not formnovalidate).

    With the mapping above in place, templates stay clean:

    Enabling implicit two-way bindings

    Some controls should default to two-way binding even when authors write .bind. Use useTwoWay to register a predicate (element, attrName) => boolean:

    Predicates receive the live element, so you can inspect classes, attributes, or even dataset values before opting into two-way. Keep the logic lightweight—these predicates run for every *.bind attribute Aurelia encounters.

    Troubleshooting and best practices

    • Uppercase tag names – Browsers expose element.tagName in uppercase; use the same casing in useMapping.

    • Avoid duplicates – Registering the same tag/attribute combination twice throws. Remove or consolidate old mappings before adding new ones.

    • Destination accuracy – Mistyped destination properties silently fall back to camelCase conversion. Inspect the element in devtools and read Object.keys(element) if unsure.

    • Predicate order matters – useTwoWay predicates run in registration order. Put the most specific check first.

    • Verify manually – Toggle the DOM property in devtools. If the UI updates but Aurelia does not, revisit the observer configuration. If neither updates, revisit the mapping.

    • Pair with observers – Mapping alone does not teach Aurelia how to observe custom properties. Follow up with INodeObserverLocator.useConfig so bindings know which events to listen to.

    With the mapper tailored to your components, you can keep templates expressive while relying on the full power of Aurelia's binding system.

    # Recipe Name
    
    Brief description of what this recipe demonstrates.
    
    ## Live Demo
    
    [Open in StackBlitz](link-to-stackblitz)
    
    ## Features Demonstrated
    
    - Feature 1
    - Feature 2
    - Feature 3
    
    ## Code
    
    ### View Model (TypeScript)
    
    [code]
    
    ### Template (HTML)
    
    [code]
    
    ### Styles (CSS) - Optional
    
    [code]
    
    ## How It Works
    
    Step-by-step explanation...
    
    ## Variations
    
    - Variation 1: Description and code
    - Variation 2: Description and code
    
    ## Related
    
    - [Related Recipe](link)
    - [Related Docs](link)
    import Aurelia, { AppTask, IAttrMapper } from 'aurelia';
    
    Aurelia.register(
      AppTask.creating(IAttrMapper, attrMapper => {
        attrMapper.useMapping({
          'MY-CE': { 'fizz-buzz': 'FizzBuzz' },
          INPUT: { 'fizz-buzz': 'fizzbuzz' },
        });
        attrMapper.useGlobalMapping({
          'foo-bar': 'FooBar',
        });
      })
    );
    <input fizz-buzz.bind="userLimit" foo-bar.bind="hint" ref="input">
    <my-ce fizz-buzz.bind="42" foo-bar.bind="43" ref="myCe"></my-ce>
    export class App {
      private input!: HTMLInputElement;
      private myCe!: HTMLElement & { FizzBuzz?: number; FooBar?: number };
    
      public attached() {
        console.log(this.input.fizzbuzz); //  userLimit
        console.log(this.myCe.FizzBuzz);  //  42
      }
    }
    import Aurelia, { AppTask, IAttrMapper } from 'aurelia';
    
    Aurelia.register(
      AppTask.creating(IAttrMapper, attrMapper => {
        attrMapper.useTwoWay((element, attrName) =>
          element.tagName === 'MY-CE' && attrName === 'fizz-buzz');
      })
    );
    Prerequisites
    • Recent version of Node.js installed

    • Basic knowledge of JavaScript, HTML, and CSS

    Step 1: Create Your Project

    Open your terminal and create a new Aurelia project:

    When prompted:

    • Project name: hello-world

    • Setup: Choose TypeScript or ESNext (JavaScript)

    • Install dependencies: yes

    Navigate to your project and start the development server:

    A browser window opens showing "Hello World". Congratulations! You just ran your first Aurelia app.

    Step 2: Understand the Basics

    Aurelia apps are built with components that have two parts:

    • View-model (.ts/.js): Your logic and data

    • View (.html): Your template

    Open src/my-app.ts to see your first view-model:

    And src/my-app.html for the view:

    The ${message} syntax is string interpolation - it displays the value from your view-model.

    Step 3: Create an Interactive Component

    Let's build something more interesting. Create a new component for personalized greetings.

    Create src/hello-name.ts:

    Create src/hello-name.html:

    The magic is in value.bind="name" - this creates two-way binding between the input and your view-model property. Change one, and the other updates automatically.

    Step 4: Use Your Component

    Update src/my-app.html to use your new component:

    Important: The <import> element is required to use your component. It tells Aurelia to load the hello-name component from the specified path. Without it, the <hello-name> tag won't work and nothing will render (with no error message).

    Alternative: You can also register components globally in main.ts if you want to use them everywhere without imports.

    Step 5: Test Your App

    Save your files and watch the browser automatically refresh. You'll see:

    • A heading that says "Hello, World!"

    • A text input with "World" pre-filled

    • As you type in the input, the heading updates instantly

    That's it! You've built a reactive Aurelia application with:

    • Custom components

    • Data binding

    • Real-time updates

    Key Concepts You Learned

    1. Components: Building blocks made of view-models and views

    2. String Interpolation: ${property} displays data in templates

    3. Two-way Binding: value.bind keeps input and data synchronized

    4. Component Registration: <import> and custom element tags

    5. Conventions: File names become component names

    Next Steps

    Ready to dive deeper? Explore:

    • Template Syntax - loops, conditionals, and more bindings

    • Component Lifecycle - hooks for advanced behavior

    • Dependency Injection - sharing services between components

    • Router - multi-page applications

    The fundamentals you learned here apply to every Aurelia app you'll build. Start experimenting and see what you can create!

    Pick the boot path (full app vs. enhancement) that matches your project.

  • Add capabilities incrementally: routing, composition, observation, without waiting for a rewrite.

  • Dip into advanced topics last, once the fundamentals are comfortable.

  • Topic Map

    Theme
    Read this first
    Follow with

    Templates & syntax

    ,

    Bootstrapping

    Navigation

    Router fundamentals, navigation, lifecycle, and advanced guides for each router package

    Composition patterns

    Suggested Learning Path

    1. Skim the templating introductions to get comfortable with Aurelia's binding language.

    2. Choose your startup approach: full SPA bootstrap or incremental enhancement.

    3. Layer on routing once you have more than one screen.

    4. Explore composition and state tools to keep components small and expressive.

    5. Wire in services and lifecycle hooks as the app grows in complexity.

    6. Only then crack open the framework internals for architecture digging or advanced debugging.

    Each article calls out prerequisites and links forward so you can keep learning without losing the big picture.

    The easiest way to install Node.js is from the official website here. Download the installer for your operating system and then follow the prompts.

    Download a code editor

    To write code, you need an editor that will help you. The most popular choice for Javascript development is Visual Studio Code. It is a completely free and open-source code editor made by Microsoft, which has great support for Aurelia applications and Node.js.

    Create a new Aurelia project

    We will be following the instructions in the Quick install guide to bootstrap a new Aurelia application. After installing Node.js, that's it. You don't need to install anything else to create a new Aurelia application, here's how we do it.

    Open up a Terminal/Command Prompt window and run the following:

    You are going to be presented with a few options when you run this command. Don't worry, we'll go through each screen step by step.

    Step 1. Name your project

    You will be asked to enter a name for your project, this can be anything you want. If you can't think of a name just enter my-app and then hit enter.

    Step 2. Choose your options

    In step 2 you will be presented with three options.

    • Option one: "Default ESNext Aurelia 2 App" this is a basic Aurelia 2 Javascript application using Babel for transpiling and Webpack for the bundler.

    • Option two: "Default Typescript Aurelia 2 App" this is a basic Aurelia 2 TypeScript application with Webpack for the bundler.

    • Option three: "Custom Aurelia 2 App" no defaults, you choose everything.

    In this guide, we are going to go with the most straightforward option, option #1.

    Step 3. Install the dependencies

    You are going to be asked if you want to install the Npm dependencies and the answer is yes. For this guide we are using Npm, so select option #2.

    Depending on your internet connection speed, this can take a while.

    Step 4. Run the sample app

    After the installation is finished you should see a little block of text with the heading, "Get Started" follow the instructions. Firstly, cd my-app to go into the directory where we installed our app. Then run npm start to run our example app.

    Your web browser should open automatically and point to http://localhost:9000

    Any changes you make to the files in the src directory of your app will cause the dev server to refresh the page with your new changes. Edit my-app.html and save it to see the browser update. Cool!

    Building your app

    In the last section we created a new application and ran the development server, but in the "real world" you will build and deploy your site for production.

    Run the Npm build command by running the following in your Terminal or Command Prompt window:

    This will build your application for production and create a new folder called dist.

    Keep Learning

    Once the CLI basics feel comfortable, continue with these focused guides:

    • Build something interactive – Follow the Hello World tutorial for a guided, hands-on walkthrough.

    • See the bigger picture – Work through the Complete getting started guide to assemble a full-featured sample app.

    • Understand components – Dive into the component essentials to learn how Aurelia pairs view-models with HTML.

    • Master templates & binding – Explore the plus the .

    • Share data across components – Learn how and when to use the DI container in the .

    • Troubleshoot faster – Bookmark for common fixes and CLI tips.

    class.bind="someString"

    string

    'col-md-4 bg-${bgColor}'

    class="${someString}"

    string

    col-md-4 ${someString}

    Built-in template features

    Use Aurelia's built-in template commands such as if, show, repeat, and switch to control markup dynamically.

    This topic demonstrates how to work with Aurelia's built-in template commands which allow you to conditionally show and hide content, loop over collections of content, conditional rendering using switch/case syntax in your views and a trove of other built-in template features.

    Adding or removing an element using if.bind

    You can add or remove an element by specifying an if.bind on an element and passing in a true or false value.

    When if.bind is passed false Aurelia will remove the element all of its children from the view. When an element is removed, if it is a custom element or has any events associated with it, they will be cleaned up, thus freeing up memory and other resources they were using.

    In the following example, we are passing a value called isLoading which is populated whenever something is loading from the server. We will use it to show a loading message in our view.

    When isLoading is a truthy value, the element will be displayed and added to the DOM. When isLoading is falsy, the element will be removed from the DOM, disposing of any events or child components inside of it.

    Showing or hiding an element using show.bind

    You can conditionally show or hide an element by specifying a show.bind and passing in a true or false value.

    When show.bind is passed false the element will be hidden, but unlike if.bind it will not be removed from the DOM. Any resources, events or bindings will remain. It's the equivalent of display: none; in CSS, the element is hidden, but not removed.

    In the following example, we are passing a value called isLoading which is populated whenever something is loading from the server. We will use it to show a loading message in our view.

    When isLoading is a truthy value, the element will be visible. When isLoading is falsy, the element will be hidden, but remain in the view.

    Conditionally add or remove elements using switch.bind

    In Javascript we have the ability to use switch/case statements which act as neater if statements. We can use switch.bind to achieve the same thing within our templates.

    The switch.bind controller will watch the bound value, which in our case is selectedAction and when it changes, match it against our case values. It is important to note that this will add and remove elements from the DOM like the if.bind does.

    Using promises in templates with promise.bind

    When working with promises in Aurelia, previously in version 1 you had to resolve them in your view model and then pass the values to your view templates. It worked, but it meant you had to write code to handle those promise requests. In Aurelia 2 we can work with promises directly inside of our templates.

    The promise.bind template controller allows you to use then, pending and catch in your views removing unnecessary boilerplate.

    In the following example, notice how we have a parent div with the promise.bind binding and then a method called fetchAdvice? Followed by other attributes inside then and catch which handle both the resolved value as well as any errors.

    Ignore the i variable being incremented, this is only there to make Aurelia fire off a call to our fetchAdvice method as it sees the parameter value has changed.

    Looping over collections with repeat.for

    To see live examples of repeat.for being used, visit the .

    You can use the repeat.for binding to iterate over collections of data in your templates. Think of repeat.for as a for loop, it can iterate arrays, maps and sets.

    Breaking this intuitive syntax down, it works like this:

    • Loop over every item in the items array

    • Store each iterative value in the local variable item on the left hand side

    • For each iteration, make the current item available

    If you were to write the above in Javascript form, it would look like this:

    Creating ranges with repeat.for

    The repeat.for functionality doesn't just allow you to work with collections, it can be used to generate ranges.

    In the following example, we generate a range of numbers to 10. We subtract the value from the index inside to create a reverse countdown.

    Getting the index (and other contextual properties) inside of repeat.for

    Aurelia's binding engine makes several special properties available to you in your binding expressions. Some properties are available everywhere, while others are only available in a particular context. Below is a brief summary of the available contextual properties within repeats.

    • $index - In a repeat template, the index of the item in the collection.

    • $first - In a repeat template, is true if the item is the first item in the array.

    • $last - In a repeat template, is true if the item is the last item in the array.

    Inside of the repeat.for these can be accessed. In the following example we display the current index value.

    Quick Install Guide

    Get Aurelia running in under 5 minutes with this quick installation guide.

    Get Aurelia up and running in 5 minutes or less.

    Prerequisites

    • Node.js (latest version recommended)

    • A code editor of your choice

    Option 1: Try Aurelia Instantly (No Setup Required)

    Want to try Aurelia immediately? Copy this into an HTML file and open it in your browser:

    No installation required! This uses Aurelia directly from a CDN. Perfect for experimentation or simple projects. For a more complete example, see the which demonstrates a full application with routing.

    Option 2: Create Your App

    Aurelia uses the scaffolding tool. No global installs required.

    When prompted:

    • Project name: Enter your project name

    • Setup: Choose TypeScript (recommended) or ESNext

    • Install dependencies: Select "Yes"

    Why TypeScript? Get intellisense, type safety, and better tooling support out of the box.

    Run Your App

    Navigate to your project and start the development server:

    Your browser will automatically open to http://localhost:8080 showing your new Aurelia app.

    Verify Everything Works

    You should see "Hello World!" displayed in your browser. The development server watches for changes and auto-reloads.

    What's Next?

    • New to Aurelia? Try our for a hands-on introduction

    • Ready for more? Explore our and

    • Need help? Check out

    Recommended Reading

    Want deeper context after the quick start? These guides build on the concepts introduced here:

    • Component basics – Understand how views and view-models pair up plus when to use imports vs. global registration in .

    • Project structure & conventions – See how the scaffolded files fit together in the .

    • Template syntax & binding – Explore binding commands, loops, and conditionals in the .

    Core Concepts (Optional Reading)

    Aurelia is built on familiar web technologies with a few key concepts:

    • Components: Made of view-models (.ts/.js) and views (.html)

    • Conventions: File names and structure follow predictable patterns

    • Dependency Injection: Built-in system for managing services and dependencies

    These concepts become clearer as you build with Aurelia. Start with the tutorial above to see them in action!

    Extending binding language

    The Aurelia template compiler is powerful and developer-friendly, allowing you extend its binding language with great ease.

    The Aurelia binding language provides commands like .bind, .one-way, .trigger, .for, .class etc. These commands are used in the view to express the intent of the binding, or in other words, to build binding instructions.

    Although the out-of-box binding language is sufficient for most use cases, Aurelia also provides a way to extend the binding language so that developers can create their own incredible stuff when needed.

    In this article, we will build an example to demonstrate how to introduce your own binding commands using the @bindingCommand decorator from the template compiler.

    Binding command

    Before jumping directly into the example, let's first understand what a binding command is. In a nutshell, a binding command is a piece of code used to register "keywords" in the binding language and provide a way to build binding instructions from that.

    To understand it better, we start our discussion with the template compiler. The template compiler is responsible for parsing templates and, among all, creating attribute syntaxes. This is where the come into play. Depending on how you define your attribute patterns, the attribute syntaxes will be created with or without a binding command name, such as bind, one-way, trigger, for, class, etc. The template compiler then instantiates binding commands for the attribute syntaxes with a binding command name. Later, binding instructions are built from these binding commands, which are "rendered" by renderers. Depending on the binding instructions, the " rendering " process can differ. For this article, the rendering process details are unimportant, so we will skip it.

    Creating a custom binding command

    To create a binding command, decorate a class with @bindingCommand and implement the following interface:

    When the template compiler encounters an attribute, it first lets custom elements or attributes claim it. Only when no bindable handles the attribute does it look up a binding command whose name matches the parsed instruction. Setting ignoreAttr = true tells the compiler that your command consumes the attribute as-is and it should not keep probing for other handlers. Built-in commands like .two-way keep this value false, whereas specialized commands such as .attr set it to true so they can short-circuit the remaining checks.

    The more interesting part of the interface is the build method. The template compiler calls this method to build binding instructions. The info parameter contains information about the element, the attribute name, the bindable definition (if present), and the custom element/attribute definition (if present). The parser parameter is used to parse the attribute value into an expression. The mapper parameter of is used to determine the binding mode, the target property name, etc. (for more information, refer to the ). In short, here comes your logic to convert the attribute information into a binding instruction.

    For our example, we want to create a binding command that can trigger a handler when custom events such as bs.foo.bar, bs.fizz.bizz etc. are fired, and we want the following syntax:

    instead of

    We first create a class that implements the BindingCommandInstance interface to do that.

    Note that from the build method, we are creating a ListenerBindingInstruction with bs. prefixed to the event name used in the markup. Thus, we are saying that the handler should be invoked when a bs.* event is raised.

    To register the custom binding command, it needs to be registered with the dependency injection container.

    Registering the custom binding command

    Because @bindingCommand wires up the resource metadata, registering the class is all the compiler needs to find it.

    Why ignoreAttr = true?

    Setting ignoreAttr = true tells the compiler that this binding command fully manages the attribute in the view. Without this flag, Aurelia might attempt to interpret the same attribute as a custom attribute or a normal bindable property. This can lead to conflicts or warnings if you reuse attribute names already in use by other features.

    Debugging custom binding commands

    If your command doesn't behave as expected:

    • Make sure you've registered it before Aurelia starts (see the main.ts snippet above).

    • Double-check that the command name (e.g., 'bs') matches in both the @bindingCommand('bs') decorator and your view markup (foo.bar.bs="...").

    • Use browser dev tools to confirm whether your event is fired and that the method in your view model is triggered.

    And that's it! We have created our own binding command and registered it. This means the following syntax will work:

    Live example

    This binding command can be seen in action below.

    Note that the example defines a custom attribute pattern to support foo.bar.fizz.bs="ev => handle(ev)" syntax.

    Scope and context

    Master the art of scope and binding context - the secret sauce behind Aurelia's powerful data binding magic.

    Ever wondered how Aurelia knows where to find your data when you write ${message} in a template? Or why $parent.something works like magic in nested components? Welcome to the world of scope and binding context – the invisible machinery that makes Aurelia's data binding so delightfully intuitive.

    Think of scope as Aurelia's GPS system for finding your data. Just like GPS needs to know your current location to give you directions, Aurelia's binding expressions need to know their current context to find the right data.

    What you'll learn in this guide:

    • How scope and binding context work under the hood

    • The difference between binding context and override context (and why you should care)

    • How to navigate between parent and child scopes like a pro

    • When and why component boundaries matter

    The Big Picture: What is Scope?

    Before diving into the details, let's start with a simple analogy. Imagine you're at a family reunion, and someone shouts "Hey, John!" Three different people named John might turn around. To know which John they meant, you need context – are they talking to Uncle John, Cousin John, or Little Johnny?

    Aurelia faces the same challenge. When it sees ${name} in your template, it needs to know:

    • Which object contains the name property?

    • Should it look in the current component's data?

    • What about parent components?

    • Are there any special contextual values (like $index

    This is where scope comes in. A scope is like a GPS coordinate that tells Aurelia exactly where to look for data.

    The JavaScript Analogy

    If you're familiar with JavaScript's function binding, you'll find this concept familiar:

    Just like JavaScript's this binding, Aurelia expressions need a context object to work with. The difference is that Aurelia's scope system is more sophisticated – it can look through multiple layers of context to find what it needs.

    Anatomy of a Scope

    Every scope in Aurelia has three main parts:

    1. Binding Context: Your Data Home

    The binding context is usually your component's instance – the object containing all your properties and methods:

    Best Practices

    1. Keep Component Boundaries in Mind

    Always remember that component boundaries exist. If you need parent data, be explicit:

    2. Use Override Context Sparingly

    Override context is powerful but can be confusing. Use it for:

    • Template controller values ($index, $first, etc.)

    • Temporary view-only values (let bindings)

    • Slot projection context ($host)

    Avoid it for regular component data.

    3. Set Up Override Context Early

    If you need override context values, set them up during binding or earlier:

    4. Debug Scope Issues Systematically

    When debugging scope issues:

    1. Check the current scope structure

    2. Verify component boundaries

    3. Trace the property resolution path

    4. Test with explicit $parent usage

    Summary

    Scope and binding context are the foundation of Aurelia's data binding system. Understanding them helps you:

    • Write more predictable binding expressions

    • Debug data binding issues effectively

    • Use advanced features like slot projections

    • Create more maintainable component hierarchies

    Remember the key concepts:

    • Binding context = your component's data

    • Override context = special contextual values

    • Component boundaries = where automatic scope traversal stops

    • $parent = explicit parent access

    With this knowledge, you're well-equipped to master Aurelia's data binding system and build sophisticated, data-driven applications!

    Want to learn more? Check out our guides on , , and to see scope and binding context in action.

    Choosing a router

    Compare @aurelia/router and @aurelia/router-direct so you can pick the best fit for your application.

    Aurelia ships two routers. They share the same navigation primitives such as viewports, lifecycle hooks, and the load attribute, but differ in how you configure and reason about routes. The table below distills the trade-offs so you can adopt the router that matches your team’s preferences and app shape.

    Feature comparison

    Capability

    @aurelia/router

    Pick @aurelia/router when…

    • You want a centrally managed route tree that mirrors Angular’s configured router or Aurelia 1’s classic router.

    • Your layout uses multiple named viewports or nested shells that need to coordinate transitions.

    • You rely on the navigation model (IRouter.navigation) to generate menus or breadcrumbs.

    • You need to orchestrate error recovery or state restoration across feature areas; see

    Pick @aurelia/router-direct when…

    • You want each feature component to declare its own routes with minimal bootstrap configuration.

    • You are migrating an Aurelia 1 app and want a familiar mental model with inline instructions and load-driven navigation.

    • You plan to mix direct routing and configured routes, letting legacy features live alongside new component-driven areas.

    • You need fine-grained control over how viewports swap content (

    Mixing routers

    Both routers can exist in the same workspace, even within the same solution, so long as each Aurelia app instance registers the configuration it needs. Common patterns include:

    • Using @aurelia/router for the main shell and embedding micro frontends that depend on @aurelia/router-direct.

    • Building documentation or marketing microsites with router-direct while keeping complex dashboards on the standard router.

    • Gradually migrating from router-direct to the standard router (or vice versa) by bootstrapping separate Aurelia apps on different DOM roots.

    Next steps

    • New to routing? Start with the or the .

    • Need concrete examples? Walk through and .

    • Looking for lifecycle coverage? Compare with the router-direct .

    CSS classes and styling

    Learn how to style elements, components and other facets of an Aurelia application using classes and CSS. Strategies for different approaches are discussed in this section.

    Aurelia makes it easy to modify an element inline class list and styles. You can work with not only strings but also objects to manipulate elements.

    Binding HTML Classes

    The class binding allows you to bind one or more classes to an element and its native class attribute.

    Binding to a single class

    Adding or removing a single class value from an element can be done using the .class binding. By prefixing the .class binding with the name of the class you want to display conditionally selected.class="myBool" you can add a selected class to an element. The value you pass into this binding is a boolean value (either true or false), if it is true the class will be added; otherwise, it will be removed.

    Inside of your view model, you would specify isSelected as a property and depending on the value, the class would be added or removed.

    Here is a working example of a boolean value being toggled using .class bindings.

    Binding to multiple classes

    In addition to binding single classes conditionally, you can also bind multiple classes based on a single boolean expression using a comma-separated list in the .class binding syntax. This allows you to toggle a set of related classes together.

    In this example, if the hasError property in your view model is truthy, all four classes (alert, alert-danger, fade-in, and bold-text) will be added to the div element. If hasError is falsy, all four classes will be removed. Important Note: When using the comma-separated syntax for multiple classes, ensure there are no spaces around the commas. The parser expects a direct list of class names separated only by commas (e.g., class1,class2,class3).

    Besides the .class syntax, there are other ways to achieve dynamic class binding, especially when dealing with complex logic or generating class strings:

    Syntax
    Input Type
    Example

    Once you have your CSS imported and ready to use in your components, there might be instances where you want to dynamically bind to the style attribute on an element (think setting dynamic widths or backgrounds).

    Binding Inline Styles

    Binding to a single style

    You can dynamically add a CSS style value to an element using the .style binding in Aurelia.

    Inside of your view model, you would specify bg as a string value on your class.

    Here is a working example of a style binding setting the background colour to blue:

    Binding to multiple styles

    To bind to one or more CSS style properties you can either use a string containing your style values (including dynamic values) or an object containing styles.

    Style binding using strings

    This is what a style string looks like, notice the interpolation here? It almost resembles just a plain native style attribute, with exception of the interpolation for certain values. Notice how you can also mix normal styles with interpolation as well?

    You can also bind a string from your view model to the style property instead of inline string assignment by using style.bind="myString" where myString is a string of styles inside of your view model.

    Style binding using objects

    Styles can be passed into an element by binding to the styles property and using .bind to pass in an object of style properties. We can rewrite the above example to use style objects.

    From a styling perspective, both examples above do the same thing. However, we are passing in an object and binding it to the style property instead of a string.

    File Uploads

    Learn how to handle file inputs and uploads in Aurelia forms.

    Basic File Input

    Single File Upload

    Globals

    Learn how Aurelia 2 handles global variables in templates, the built-in list of accessible globals, and when to use them effectively.

    By design, Aurelia templates limit direct access to global variables like window or document for security and maintainability reasons. However, Aurelia recognizes that some JavaScript globals are frequently needed—like Math, JSON, or Array—and therefore provides a predefined list of global objects that can be safely accessed in template expressions.


    Template variables

    Aurelia 2 allows you to manage variables directly within your view templates: the <let> custom element. This element allows you to declare and initialize variables inline in your HTML, making your templates more dynamic and readable. <let> is incredibly versatile, supporting a range of value assignments, from simple strings and interpolation to complex expressions and bindings to your view model. This capability significantly enhances template flexibility and reduces the need for excessive view model code for simple template-specific logic.

    Declaring Template Variables with <let>

    The <let>

    Getting started

    Bundler note: These examples import '.html' files as raw strings (showing '?raw' for Vite/esbuild). Configure your bundler as described in so the imports resolve to strings on Webpack, Parcel, etc.

    Routing with Aurelia feels like a natural part of the framework. It can easily be implemented into your applications in a way that feels familiar if you have worked with other frameworks and library routers. Here is a basic example of routing in an Aurelia application using router.

    The following getting started guide assumes you have an Aurelia application already created. If not, to get Aurelia installed in minutes.

    npx makes aurelia
    cd hello-world
    npm start
    export class MyApp {
      message = 'Hello World!';
    }
    <div class="message">${message}</div>
    export class HelloName {
      name = 'World';
    }
    <div>
      <h2>Hello, ${name}!</h2>
      <p>
        <label>Enter your name:</label>
        <input type="text" value.bind="name">
      </p>
    </div>
    <import from="./hello-name"></import>
    
    <div class="app">
      <h1>My Aurelia App</h1>
      <hello-name></hello-name>
    </div>
    npx makes aurelia
    npm run build
    <p selected.class="isSelected">I am selected (I think)</p>
    <p background.style="bg">My background is blue</p>
    my-app.ts
    export class MyApp {
      private backgroundColor = 'black';
      private textColor = '#FFF';
    }
    my-app.html
    <p style="color: ${textColor}; font-weight: bold; background: ${backgroundColor};">Hello there</p>
    
    my-app.ts
    export class MyApp {
      private styleObject = {
        background: 'black',
        color: '#FFF'
      };
    }
    my-app.html
    <p style.bind="styleObject">Hello there</p>
    
    templates overview
    template syntax deep dive
    dependency injection overview
    debugging and troubleshooting

    Dynamic composition, Portalling elements

    State & observation

    Understanding the binding system

    Observation overview, Watching data

    Services & runtime hooks

    Dependency injection primer

    App tasks, Task queue, Event aggregator, Logging

    Deep dives

    None

    Framework internals

    Built-in template features
    Class & style binding
    Attribute transferring
    App configuration & startup
    Enhance
    Choosing a router
    Template controllers
    .
  • You prefer to co-locate child route definitions with feature modules via the @route decorator while still driving a parent route table.

  • swapOrder
    , navigation sync states) without maintaining a large central tree.
  • You prefer to opt into pushState or hash mode per app without touching a large configuration object.

  • @aurelia/router-direct

    Notes

    Authoring style

    Declarative route tables with @route, hierarchical configuration

    Component-owned routes via static routes/@routes, inline template instructions

    Both support strongly typed navigation APIs.

    Layout composition

    Multiple named viewports, child routers, transition plans

    Single or multiple viewports, favoring direct component control

    Router-direct can mix direct and configured routes when needed.

    Lifecycle hooks

    Component lifecycle (canLoad, loading, etc.) plus global hooks via IRouterEvents

    Same hook names, plus direct routing hooks and indicators for fine-grained control

    See Routing lifecycle and Router hooks.

    Navigation state

    Central IRouter, ICurrentRoute, navigation model, transition history

    Lightweight state via Navigation and Navigator, opt-in stateful history length

    Router-direct keeps state scoped to the components that declare routes.

    Title management

    buildTitle function, per-route titles, navigation model integration

    TitleOptions placeholders and transformTitle, component-level ownership

    Either router supports i18n integration via custom title builders.

    Hash vs pushState

    useUrlFragmentHash default false; pushState ready

    useUrlFragmentHash default true; switch off for pushState

    Both respect <base href> when using pushState.

    Advanced scenarios

    Transition plans, navigation guards, error recovery, navigation model

    Direct instruction syntax, swap-order control, navigation sync states

    Choose based on whether you prefer centralized orchestration or per-component control.

    @aurelia/router overview
    @aurelia/router-direct overview
    Configuring routes
    Creating routes
    Routing lifecycle
    routing lifecycle
    Router state management
  • $even - In a repeat template, is true if the item has an even numbered index.

  • $odd - In a repeat template, is true if the item has an odd numbered index.

  • $length - In a repeat template, this indicates the length of the collection.

  • $parent - Explicitly accesses the outer scope from within a repeat template. You may need this when a property on the current scope masks a property on the outer scope. Note that this property is chainable, e.g. $parent.$parent.foo is supported.

  • repeat.for examples
    Dependency injection
    – Learn how Aurelia's DI container works and when to call
    resolve()
    in the
    .
  • Routing – When you need multiple pages, follow the router getting started guide to add navigation.

  • Enhanced HTML: Templates use familiar HTML with powerful binding syntax

    realworld-vanilla example
    Makes
    Hello World Tutorial
    developer guides
    tutorials
    troubleshooting
    Component essentials
    Complete guide's project structure section
    template syntax overview
    DI overview
    The magic behind $parent, $host, and other special keywords
  • How to debug those tricky scope-related issues

  • from a
    repeat.for
    )?
  • $host = slot projection context

  • Custom Attributes
    Template Controllers
    Shadow DOM and Slots
    File Preview

    Validation

    Progress Tracking

    Best Practices

    1. Validate on both client and server - Always verify file types and sizes server-side

    2. Clean up object URLs - Revoke object URLs in detaching() to prevent memory leaks

    3. Show progress for large files - Use XMLHttpRequest for progress tracking

    4. Provide clear feedback - Show file names, sizes, and upload status

    5. Handle errors gracefully - Display meaningful error messages

    Security Considerations

    • Validate file types server-side (don't trust accept attribute)

    • Check file sizes to prevent DoS attacks

    • Scan uploaded files for malware

    • Store files outside web root

    • Use unique filenames to prevent overwrites

    • Implement rate limiting

    Related

    • Form Basics

    • Form Submission

    • Form Examples

    Why Limit Global Access?
    • Security: Restricting direct access to browser globals reduces the risk of accidental or malicious operations on sensitive objects.

    • Maintainability: Encourages developers to keep logic in their view models, improving code clarity.

    • Performance: Minimizes the amount of unnecessary logic in templates, preventing overuse of global operations in tight rendering loops.

    Despite these constraints, Aurelia acknowledges the utility of common global constructors and functions. Below is the canonical list accessible within Aurelia 2 templates without additional configuration:

    • Infinity

    • NaN

    • isFinite

    • isNaN

    • parseFloat

    • parseInt

    • decodeURI

    • decodeURIComponent

    • encodeURI

    • encodeURIComponent

    • Array

    • BigInt

    • Boolean

    • Date

    • Map

    • Number

    • Object

    • RegExp

    • Set

    • String

    • JSON

    • Math

    • Intl


    Example Usages of Built-In Globals

    Below are illustrative examples showing how to use these built-in globals in Aurelia templates. The syntax is identical to standard JavaScript, but you simply call them within Aurelia’s binding expressions.

    1. Working with JSON

    Serialize an object for debugging or quick display:

    2. Mathematical Operations with Math

    Perform simple or complex calculations:

    3. Conditional Rendering with isNaN

    Use global numeric checks to conditionally display elements:

    4. Regular Expressions with RegExp

    Construct inline regular expressions for quick validation:

    5. Dynamic Property Access with Object

    Use Object methods for reflection or retrieval:

    6. Set Operations with Set

    De-duplicate arrays or combine sets inline:

    7. Encoding & Decoding URLs

    Leverage encodeURI / decodeURI for safe link construction:

    8. Number Formatting with Intl.NumberFormat

    Localize numbers, currency, or dates easily:

    9. Complex Array Manipulations

    Filter, map, and transform arrays:


    Best Practices and Considerations

    1. Use Sparingly

      • Keep business logic in your view models, not in templates. Inline calls to complex global functions (e.g., JSON.stringify on large data) can degrade performance and reduce readability.

    2. Security

      • Even though Aurelia limits global access, treat any data you process via global functions (e.g., decodeURI) with caution to prevent potential XSS attacks or other vulnerabilities.

    3. Performance

      • Template expressions run on each re-render. If you repeatedly perform expensive operations (like JSON.stringify on large objects), consider handling them in the view model and binding to a computed property instead.

    4. Reactivity

      • Accessing global objects doesn’t magically become reactive. If you want to update the UI when data changes, store and manipulate it in the view model, ensuring Aurelia’s change detection can pick it up.

    5. Clarity and Testing

      • Test heavy logic in a view model or service, not in templates. This approach keeps your code testable with unit tests and fosters a separation of concerns.

    By sticking to these guidelines, you can leverage Aurelia’s built-in global access without sacrificing maintainability or performance.

    element provides a straightforward syntax for declaring variables directly within your templates. The basic structure is as follows:
    • <let>: The custom element tag that signals the declaration of a template variable.

    • variable-name: The name you choose for your template variable. In templates, you will reference this variable name in its camelCase form (e.g., variableName).

    • "variable value": The initial value assigned to the variable. This can be a string literal, an interpolation expression, a binding expression, or any valid JavaScript expression that Aurelia can evaluate within the template context.

    Basic String Assignment

    You can assign simple string literals to <let> variables:

    To display the value of this variable in your template, use interpolation with the camelCase version of the variable name:

    This will render:

    Binding Expressions for Dynamic Values

    <let> variables are not limited to static strings. You can use binding expressions to assign dynamic values that are calculated or updated based on your view model or other template logic.

    Example: Simple Mathematical Expression

    Now, you can display the result of this calculation:

    This will output:

    Example: Binding to View Model Properties

    You can bind a <let> variable to properties defined in your view model, making template variables reactive to changes in your data:

    In this example, both ${userName} interpolations will display "John Doe". If you update the userName property in your view model, both interpolations will dynamically reflect the change.

    Example: Using Template Expressions

    <let> variables can also be assigned values derived from template expressions, including function calls, ternary operators, and more:

    Here, isEvening will be a boolean value based on the current hour, and timeOfDayMessage will be dynamically set to either "Good evening" or "Good day" based on the value of isEvening.

    Scoping of Template Variables

    <let> variables are scoped to the template in which they are declared. This means a variable declared with <let> is only accessible within the template block where it's defined. This scoping helps prevent naming conflicts and keeps your templates organized and predictable.

    Example: Scoped Variables in repeat.for

    When using <let> within a repeat.for loop, each iteration of the loop will have its own instance of the <let> variable, ensuring that variables are correctly associated with each repeated item.

    In this example, itemIndex is scoped to each <li> element within the repeat.for loop, correctly displaying the index for each item in the list.

    Practical Use Cases for <let>

    <let> is incredibly useful in various template scenarios. Here are a few common use cases:

    1. Simplifying Complex Expressions

    When you have complex expressions that are used multiple times within a template, you can use <let> to assign the result of the expression to a variable, improving readability and maintainability.

    Before using <let>:

    After using <let>:

    Using <let subtotal.bind="quantity * price"> makes the template cleaner and easier to understand, especially if the calculation is more complex.

    2. Conditional Rendering Logic

    You can use <let> in conjunction with conditional attributes like if.bind or else to manage template variables based on conditions.

    Here, showDetails is used to control both the button text and the visibility of the details section, simplifying the conditional logic within the template.

    3. Data Transformation within Templates

    You can perform simple data transformations directly within your templates using <let>, although for more complex transformations, value converters are generally recommended.

    Example: Formatting a Date

    This example formats the current date using toLocaleDateString() and stores it in formattedDate for display.

    4. Creating Reusable Template Snippets

    While not its primary purpose, <let> can indirectly contribute to creating reusable template snippets by encapsulating logic and variables within a specific section of your template. Combined with custom elements or template parts, <let> helps in modularizing your view templates.

    Considerations when Using <let>

    • Keep it Simple: While <let> is powerful, it's best used for template-specific variables and simple logic. For complex data manipulation or business logic, keep that in your view model.

    • Readability: Use descriptive variable names for <let> to maintain template readability.

    • Scoping: Be mindful of the scope of <let> variables. They are limited to the template in which they are declared.

    • Alternatives: For complex data transformations or reusable formatting logic, consider using Aurelia's value converters, which are designed for these purposes and promote better separation of concerns.

    <div if.bind="isLoading">Loading...</div>
    <div show.bind="isLoading">Loading...</div>
    <p switch.bind="selectedAction">
      <span case="mask">You are more protected from aerosol particles, and others are protected from you.</span>
      <span case="sanitizer">You are making sure viruses won't be spreaded easily.</span>
      <span case="wash">You are helping eliminate the virus.</span>
      <span case="all">You are protecting yourself and people around you. You rock!</span>
      <span default-case>Unknown.</span>
    </p>
    <let i.bind="0"></let>
    
    <div promise.bind="fetchAdvice(i)">
      <span pending>Fetching advice...</span>
      <span then="data">
        Advice id: ${data.slip.id}<br>
        ${data.slip.advice}
        <button click.trigger="i = i+1">try again</button>
      </span>
      <span catch="err">
        Cannot get an addvice, error: ${err}
        <button click.trigger="i = i+1">try again</button>
      </span>
    </div>
    export class MyApp {
      fetchAdvice() {
        return fetch("https://api.adviceslip.com/advice")
          .then(r => r.ok ? r.json() : (() => { throw new Error('Unable to fetch NASA APOD data') }))
      }
    }
    
    <ul>
        <li repeat.for="item of items">${item.name}</li>
    </ul>
    for (let item of items) {
        console.log(item.name);
    }
    <p repeat.for="i of 10">${10-i}</p>
    <p>Blast Off!<p>
    <ul>
        <li repeat.for="item of items">${$index}</li>
    </ul>
    <!doctype html>
    <html>
      <head>
        <meta charset="utf-8" />
        <title>Aurelia 2 Quick Try</title>
        <base href="/" />
        <link rel="dns-prefetch" href="//cdn.jsdelivr.net">
        <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
        <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/aurelia@latest/+esm" crossorigin fetchpriority="high">
      </head>
      <body>
        <app-root></app-root>
        <script type="module">
          import { Aurelia, CustomElement } from 'https://cdn.jsdelivr.net/npm/aurelia@latest/+esm';
    
          const App = CustomElement.define({
            name: 'app-root',
            template: `
              <h1>Hello, \${name}!</h1>
              <input value.bind="name" placeholder="Enter your name">
              <p>You typed: \${name}</p>
            `
          }, class {
            name = 'World';
          });
    
          new Aurelia()
            .app({ component: App, host: document.querySelector('app-root') })
            .start();
        </script>
      </body>
    </html>
    npx makes aurelia
    cd your-project-name
    npm start
    function greet() {
      return `Hello, ${this.name}!`;
    }
    
    const person1 = { name: 'Alice' };
    const person2 = { name: 'Bob' };
    
    console.log(greet.call(person1)); // "Hello, Alice!"
    console.log(greet.call(person2)); // "Hello, Bob!"
    ┌─────────────────────────┐
    │       Scope             │
    │                         │
    │  ┌─────────────────┐    │
    │  │ bindingContext  │    │ ← Your component's data
    │  └─────────────────┘    │
    │                         │
    │  ┌─────────────────┐    │
    │  │ overrideContext │    │ ← Special contextual values
    │  └─────────────────┘    │
    │                         │
    │  parent ────────────────┼─→ Points to parent scope
    │  isBoundary: boolean    │ ← Component boundary marker
    └─────────────────────────┘
    Then in the browser console:
    ```javascript
    // Explore the scope structure
    debugScope.bindingContext
    debugScope.overrideContext
    debugScope.parent
    
    // Test property resolution
    Scope.getContext(debugScope, 'propertyName', 0)
    <!-- Good: Explicit about crossing boundaries -->
    <div>${$parent.parentProperty}</div>
    
    <!-- Confusing: Relies on scope traversal -->
    <div>${parentProperty}</div>
    public binding(): void {
      // Good: Set before binding establishment
      this.$controller.scope.overrideContext.customValue = 'something';
    }
    <form>
      <label for="fileUpload">Select files to upload:</label>
      <input
        id="fileUpload"
        type="file"
        multiple
        accept="image/*"
        change.trigger="handleFileSelect($event)" />
    
      <button
        click.trigger="uploadFiles()"
        disabled.bind="!selectedFiles.length">
        Upload
      </button>
    </form>
    export class FileUploadComponent {
      selectedFiles: File[] = [];
    
      handleFileSelect(event: Event) {
        const input = event.target as HTMLInputElement;
        if (!input.files?.length) return;
    
        this.selectedFiles = Array.from(input.files);
      }
    
      async uploadFiles() {
        if (this.selectedFiles.length === 0) return;
    
        const formData = new FormData();
        for (const file of this.selectedFiles) {
          formData.append('files', file, file.name);
        }
    
        try {
          const response = await fetch('/api/upload', {
            method: 'POST',
            body: formData
          });
    
          if (!response.ok) {
            throw new Error(`Upload failed with status ${response.status}`);
          }
    
          const result = await response.json();
          console.log('Upload successful:', result);
          this.selectedFiles = [];
        } catch (error) {
          console.error('Error uploading files:', error);
        }
      }
    }
    <input type="file" accept="image/*" change.trigger="handleFileSelect($event)" />
    handleFileSelect(event: Event) {
      const input = event.target as HTMLInputElement;
      this.selectedFiles = input.files?.length ? [input.files[0]] : [];
    }
    export class FilePreviewComponent {
      selectedFile: File | null = null;
      previewUrl: string | null = null;
    
      handleFileSelect(event: Event) {
        const input = event.target as HTMLInputElement;
        const file = input.files?.[0];
    
        if (file) {
          this.selectedFile = file;
          this.createPreview(file);
        }
      }
    
      private createPreview(file: File) {
        if (this.previewUrl) {
          URL.revokeObjectURL(this.previewUrl);
        }
    
        this.previewUrl = URL.createObjectURL(file);
      }
    
      detaching() {
        if (this.previewUrl) {
          URL.revokeObjectURL(this.previewUrl);
        }
      }
    }
    <input type="file" accept="image/*" change.trigger="handleFileSelect($event)" />
    
    <div if.bind="previewUrl" class="preview">
      <img src.bind="previewUrl" alt="Preview" />
      <p>${selectedFile.name} (${(selectedFile.size / 1024).toFixed(2)} KB)</p>
    </div>
    export class ValidatedFileUpload {
      selectedFile: File | null = null;
      error: string | null = null;
    
      maxSize = 5 * 1024 * 1024; // 5MB
      allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
    
      handleFileSelect(event: Event) {
        this.error = null;
        const input = event.target as HTMLInputElement;
        const file = input.files?.[0];
    
        if (!file) return;
    
        if (!this.allowedTypes.includes(file.type)) {
          this.error = 'Only JPEG, PNG, and GIF images are allowed';
          input.value = '';
          return;
        }
    
        if (file.size > this.maxSize) {
          this.error = `File size must be less than ${this.maxSize / (1024 * 1024)}MB`;
          input.value = '';
          return;
        }
    
        this.selectedFile = file;
      }
    }
    export class FileUploadWithProgress {
      uploadProgress = 0;
      isUploading = false;
    
      async uploadWithProgress(file: File) {
        this.isUploading = true;
        this.uploadProgress = 0;
    
        const xhr = new XMLHttpRequest();
    
        return new Promise((resolve, reject) => {
          xhr.upload.addEventListener('progress', (e) => {
            if (e.lengthComputable) {
              this.uploadProgress = (e.loaded / e.total) * 100;
            }
          });
    
          xhr.addEventListener('load', () => {
            this.isUploading = false;
            if (xhr.status >= 200 && xhr.status < 300) {
              resolve(JSON.parse(xhr.responseText));
            } else {
              reject(new Error(`Upload failed: ${xhr.status}`));
            }
          });
    
          xhr.addEventListener('error', () => {
            this.isUploading = false;
            reject(new Error('Upload failed'));
          });
    
          const formData = new FormData();
          formData.append('file', file);
    
          xhr.open('POST', '/api/upload');
          xhr.send(formData);
        });
      }
    }
    <input type="file" change.trigger="handleFileSelect($event)" />
    
    <div if.bind="isUploading" class="progress">
      <div class="progress-bar" css="width: ${uploadProgress}%"></div>
      <span>${uploadProgress.toFixed(0)}%</span>
    </div>
    <template>
      <pre>${JSON.stringify(user, null, 2)}</pre>
    </template>
    <template>
      <p>The square root of 16 is: ${Math.sqrt(16)}</p>
    </template>
    <template>
      <input type="text" value.bind="value" />
      <p if.bind="isNaN(value)">This is not a valid number!</p>
    </template>
    <template>
      <input value.bind="email" placeholder="Enter email" />
      <p if.bind="new RegExp('^\\S+@\\S+\\.\\S+$').test(email)">
        Valid Email Address
      </p>
    </template>
    <template>
      <p>Property Value: ${Object.getOwnPropertyDescriptor(user, selectedProp)?.value}</p>
    </template>
    <template>
      <p>Unique Values: ${[...new Set(numbersArray)]}</p>
    </template>
    <template>
      <a href.bind="encodeURI(externalLink)">Visit External Site</a>
      <p>Original URL: ${decodeURI(externalLink)}</p>
    </template>
    <template>
      <p>Price: ${new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price)}</p>
    </template>
    <template>
      <p>Active Items: ${Array.from(dataSet).filter(i => i.active).map(i => i.name).join(', ')}</p>
    </template>
    <let user-name.bind="userName"></let>
    
    <h1>Welcome, ${userName}!</h1>
    <p>Your username variable (from &lt;let&gt;) is: ${userName}</p>
    <p>Your username property (from view model) is: ${userName}</p>
    export class MyApp {
      userName = 'John Doe';
    }
    <let is-evening.bind="currentHour >= 18"></let>
    <let time-of-day-message.bind="isEvening ? 'Good evening' : 'Good day'"></let>
    
    <p>${timeOfDayMessage}, user!</p>
    export class MyApp {
      currentHour = new Date().getHours();
    }
    <let variable-name="variable value"></let>
    <let greeting-message="Hello, Aurelia!"></let>
    <p>${greetingMessage}</p>
    <p>Hello, Aurelia!</p>
    <let calculation-result.bind="10 + 5 * 2"></let>
    <p>The result is: ${calculationResult}</p>
    <p>The result is: 20</p>
    <ul>
      <template repeat.for="item of items">
        <li>
          <let item-index.bind="$index"></let>
          Item ${itemIndex + 1}: ${item.name}
        </li>
      </template>
    </ul>
    <p>Total price (excluding tax): $${quantity * price}</p>
    <p>Tax amount (10%): $${(quantity * price) * 0.10}</p>
    <p>Final price (including tax): $${(quantity * price) * 1.10}</p>
    <let subtotal.bind="quantity * price"></let>
    <p>Total price (excluding tax): $${subtotal}</p>
    <p>Tax amount (10%): $${subtotal * 0.10}</p>
    <p>Final price (including tax): $${subtotal * 1.10}</p>
    <let show-details.bind="isDetailsVisible"></let>
    
    <button click.trigger="isDetailsVisible = !isDetailsVisible">
      ${showDetails ? 'Hide Details' : 'Show Details'}
    </button>
    
    <div if.bind="showDetails">
      <!-- Details content here -->
      <p>Detailed information is displayed.</p>
    </div>
    <let formatted-date.bind="new Date().toLocaleDateString()"></let>
    <p>Today's date: ${formattedDate}</p>
    Installation

    Configure the router

    To use the router, we have to register it with Aurelia. We do this at the bootstrapping phase.

    To know more about the different configuration options for router, please refer the documentation on that topic.

    Create the routable components

    For this example, we have a root component which is MyApp. And then we are going to define two routes for the root component, namely home and about.

    Let us define these components first.

    Configure the routes

    With the routable components in place, let's define the routes. To this end, we need to add the route configurations to our root component MyApp.

    There are couple of stuffs to note here. We start by looking at the configurations defined using the @route decorator where we list out the routes under the routes property in the configuration object in the @route decorator. The most important things in every route configurations are the path and the component properties. This instructs the router to use the defined component in the viewport when it sees the associated path.

    To know more about configuring routes, please refer to the respective documentation.

    The viewport is specified in the view (see my-app.html) by using the <au-viewport> custom element. For example, the router will use this element to display the Home component when it sees the / (the empty path) or the /home paths.

    The nav>a elements are added to navigate from one view to another.

    See this in action:

    Using pushstate

    If you have opened the demo then you can notice that the URL in the address bar or the URLs in the nav>a elements contains a # (example: /#home, /#about etc.). Depending on your project need and esthetics you may want to get rid of the #-character. To this end, you need set the useUrlFragmentHash to false, which is also the default.

    Live examples

    For the documentation of router, many live examples are prepared. It is recommended to use the live examples as you read along the documentation. However, if you are feeling adventurous enough to explore the features by yourself, here is the complete collection of the live examples at your disposal.

    The examples are created using StackBlitz. Sometimes, you need to open the examples in a new tab to see changes in the URL, title etc. To this end, copy the URL appearing on the address bar on the right pane and open that in a new tab.

    Importing external HTML templates with bundlers
    consult our Quick Start
    attribute patterns
    type IAttrMapper
    documentation

    class.bind="someString"

    string

    'col-md-4 bg-${bgColor}'

    class="${someString}"

    string

    col-md-4 ${someString}

    Template references

    Template references in Aurelia 2 offer a powerful and declarative mechanism to establish direct links between elements in your HTML templates and properties in your JavaScript or TypeScript view models. Using the ref attribute, you can easily obtain references to specific DOM elements, custom element instances, custom attribute instances, or even Aurelia controllers, enabling efficient DOM manipulation and streamlined interaction with template elements.

    Declaring Template References

    Basic Usage: Referencing DOM Elements

    To create a template reference to a standard HTML element, simply add the ref attribute to the element within your template. The value assigned to ref will be the name of the property in your view model that will hold the reference.

    In this basic example, firstNameInput is declared as a template reference. Aurelia will automatically populate a property in your view model with the same name, making the <input> element directly accessible.

    Accessing References in Templates

    Template references become immediately available for use within the template itself. You can directly access properties and methods of the referenced element using the reference name.

    For example, to dynamically display the current value of the firstNameInput field:

    As the user types in the input field, the <p> element will update in real-time, displaying the current value accessed through firstNameInput.value.

    Accessing References in View Models

    To access a template reference in your view model, you need to declare a property in your view model class that matches the reference name you used in the template. For TypeScript projects, it's strongly recommended to explicitly type this property for enhanced type safety and code maintainability.

    Important Notes:

    • Property Naming: The property name in your view model must exactly match the value of the ref attribute in your template (firstNameInput in the example above).

    • Type Safety: In TypeScript, always declare the type of your template reference property (e.g., HTMLInputElement, HTMLDivElement, MyCustomElement). This improves code readability and helps catch type-related errors early.

    Advanced Usage: Referencing Components and Controllers

    Aurelia's ref attribute extends beyond simple DOM elements. It provides powerful options to reference component instances and controllers of custom elements and attributes.

    1. component.ref: Referencing Custom Element Instances (View Models)

    To obtain a reference to the view model instance of a custom element, use component.ref="expression". This was previously known as view-model.ref in Aurelia v1.

    In your view model:

    component.ref is invaluable when you need to directly interact with the logic and data encapsulated within a custom element's view model from a parent component.

    2. custom-attribute.ref: Referencing Custom Attribute Instances (View Models)

    Similarly, to reference the view model instance of a custom attribute applied to an element, use custom-attribute.ref="expression".

    In your view model:

    custom-attribute.ref is useful when you need to interact with the behavior or state managed by a custom attribute from the surrounding view model.

    3. controller.ref: Referencing Aurelia Controller Instances (Advanced)

    For more advanced scenarios, controller.ref="expression" allows you to access the Aurelia Controller instance of a custom element. The Controller provides access to Aurelia's internal workings and lifecycle management for the element. This is less commonly needed but can be powerful for framework-level integrations or very specific use cases.

    In your view model:

    controller.ref provides access to the Aurelia Controller, which is an advanced API and typically used for framework extension or very specific control over component lifecycle and binding. For most application development, component.ref or direct DOM element references are sufficient.

    Practical Applications and Benefits

    Template references significantly enhance Aurelia development by providing a clean, framework-integrated way to interact with elements and components. They offer several key advantages:

    • Direct DOM Manipulation: Template references provide a structured and type-safe way to obtain direct references to DOM elements, which is essential for tasks like:

      • Focusing input fields programmatically (elementRef.focus()).

      • Imperative DOM manipulation when integrating with third-party libraries that require direct element access (e.g., initializing jQuery plugins, interacting with canvas elements, etc.).

    By using template references, you move away from string-based DOM queries and embrace a more declarative and type-safe approach to DOM and component interaction within your Aurelia applications. This leads to more robust, maintainable, and efficient code, especially when dealing with complex UI interactions or integrations with external libraries.

    Text interpolation

    Text interpolation allows you to display dynamic values in your views. By wrapping an expression with ${}, you can render variables, object properties, function results, and more within your HTML. This is conceptually similar to .

    Template expressions

    Expressions inside ${} can perform operations such as arithmetic, function calls, or ternaries:

    Conditional Rendering

    Learn about the various methods for conditionally rendering content in Aurelia 2, with detailed explanations and examples.

    Conditional rendering allows you to dynamically show or hide parts of your view based on your application's state. Aurelia 2 provides three primary directives for conditional rendering, each suited for different scenarios.

    Quick Reference

    Directive
    Use Case
    DOM Behavior
    Performance

    processContent

    Learn how to manipulate the DOM from the usage-side of a custom element using the processContent hook.

    There are scenarios where we would like to transform the template provided by the usage-side. The 'processContent' hook lets us define a pre-compilation hook to make that transformation.

    The signature of the hook function is as follows.

    There are two important things to note here.

    First is the node argument. It is the DOM tree on the usage-side for the custom element. For example, if there is a custom element named my-element, on which a 'processContent' hook is defined, and it is used somewhere as shown in the following markup, then when the hook is invoked, the node argument will provide the DOM tree that represents the following markup.

    Then inside the hook this DOM tree can be transformed/mutated into a different DOM tree. The mutation can be addition/removal of attributes or element nodes.

    Route parameters

    Declare, read, and validate route parameters in Aurelia's router, including required, optional, wildcard, and constrained segments.

    Route parameters let you map dynamic pieces of a URL to runtime data. This guide covers how to declare each parameter style, consume values inside components, and coordinate parent/child segments.

    1. Declare parameterized paths

    Use the path field inside @route (or route configs) to describe parameters.

    Overview

    Understand the @aurelia/router package, its core concepts, and how to navigate the rest of the routing documentation.

    Aurelia's primary router gives you a declarative, component-first navigation system with strong type safety, multi-viewport layouts, and deep integration with dependency injection. If you have used Angular's router or the classic Aurelia 1 router, the mental model will feel familiar: define a route table, map URLs to components, nest layouts, guard navigation, lazy-load feature areas, and respond to lifecycle events. The Aurelia router stays HTML-friendly and convention-driven, letting you compose navigation without wrapper modules or excessive configuration.

    Still deciding between routers? Start with .

    Highlights

    import { customElement } from '@aurelia/runtime-html';
    import template from './home.html?raw';
    
    @customElement({ name: 'ho-me', template })
    export class Home {
      private readonly message: string = 'Welcome to Aurelia2 router!';
    }
    <h1>${message}</h1>
    import { customElement } from '@aurelia/runtime-html';
    import template from './about.html?raw';
    
    @customElement({ name: 'ab-out', template })
    export class About {
      private readonly message = 'Aurelia2 router is simple';
    }
    <h1>${message}</h1>
    import { customElement } from '@aurelia/runtime-html';
    import { route } from '@aurelia/router';
    import template from './my-app.html?raw';
    import { Home } from './home';
    import { About } from './about';
    
    @route({
      routes: [
        {
          path: ['', 'home'],
          component: Home,
          title: 'Home',
        },
        {
          path: 'about',
          component: About,
          title: 'About',
        },
      ],
    })
    @customElement({ name: 'my-app', template })
    export class MyApp {}
    
    <nav>
      <a href="home">Home</a>
      <a href="about">About</a>
    </nav>
    
    <au-viewport></au-viewport>
    npm i @aurelia/router
    main.ts
    import Aurelia from 'aurelia';
    import { RouterConfiguration } from '@aurelia/router';
    import { MyApp } from './my-app';
    
    Aurelia
      .register(RouterConfiguration.customize({
          useUrlFragmentHash: true, // <-- enables the routing using the URL `hash`
        }))
      .app(MyApp)
      .start();
    interface BindingCommandInstance {
      ignoreAttr: boolean;
      build(info: ICommandBuildInfo, parser: IExpressionParser, mapper: IAttrMapper): IInstruction;
    }
    <div foo.bar.bs="ev => handleCustomEvent(ev)"></div>
    <div bs.foo.bar.trigger="ev => handleCustomEvent(ev)"></div>
    import { IExpressionParser } from 'aurelia';
    import {
      BindingCommandInstance,
      ICommandBuildInfo,
      ListenerBindingInstruction,
      bindingCommand,
    } from '@aurelia/template-compiler';
    
    @bindingCommand('bs')
    export class BsBindingCommand implements BindingCommandInstance {
      public ignoreAttr = true; // we fully own attributes that end with .bs
    
      public build(
        info: ICommandBuildInfo,
        exprParser: IExpressionParser,
      ) {
        return new ListenerBindingInstruction(
          /* from           */ exprParser.parse(info.attr.rawValue, 'IsFunction'),
          /* to             */ `bs.${info.attr.target}`,
          /* preventDefault */ true,
          /* capture        */ false,
        );
      }
    }
    import Aurelia from 'aurelia';
    import { BsBindingCommand } from './bs-binding-command';
    import { MyRoot } from './my-root';
    
    Aurelia
      .register(BsBindingCommand)
      .app(MyRoot)
      .start();
    <div foo.bar.bs="ev => handleCustomEvent(ev)"></div>
    <!--         ^^
                 |_________ custom binding command
    -->
    <p selected.class="isSelected">I am selected (I think)</p>
    <div alert,alert-danger,fade-in,bold-text.class="hasError">Something went wrong!</div>
    <p background.style="bg">My background is blue</p>
    my-app.ts
    export class MyApp {
      private backgroundColor = 'black';
      private textColor = '#FFF';
    }
    my-app.html
    <p style="color: ${textColor}; font-weight: bold; background: ${backgroundColor};">Hello there</p>
    my-app.ts
    export class MyApp {
      private styleObject = {
        background: 'black',
        color: '#FFF'
      };
    }
    my-app.html
    <p style.bind="styleObject">Hello there</p>

    Lifecycle Timing: Template references are not available during the view model's constructor. They become available after the view is bound to the view model, typically in lifecycle hooks like bound() or later.

    Fine-grained control over element properties and attributes.

  • Component Interaction: component.ref and custom-attribute.ref enable seamless communication and interaction between parent components and their children (custom elements and attributes). This allows for:

    • Calling methods on child component view models.

    • Accessing data and state within child components.

    • Building more complex and encapsulated component structures.

  • Simplified DOM Access: Template references eliminate the need for manual DOM queries using document.querySelector or similar methods within your view models. This leads to:

    • Cleaner and more readable view model code.

    • Reduced risk of brittle selectors that break if the template structure changes.

    • Improved maintainability and refactoring capabilities.

  • Integration with Third-Party Libraries: Many JavaScript libraries require direct DOM element references for initialization or interaction. Template references provide the ideal mechanism to obtain these references within an Aurelia application without resorting to less maintainable DOM query approaches.

  • Calling functions

    You can call functions defined on your view model. For example:

    Using ternaries

    You can also use ternary operations:

    This will display either "True" or "False" depending on the boolean value of isTrue.

    Complex expressions

    You can use more sophisticated expressions for dynamic content:

    Optional Syntax

    Aurelia supports the following optional chaining and nullish coalescing operators in templates:

    • ??

    • ?.

    • ?.()

    • ?.[]

    Note that ??= is not supported.

    You can use these operators to safely handle null or undefined values:

    This helps avoid lengthy if-statements or ternary checks in your view model when dealing with potentially undefined data.

    HTMLElement Interpolation

    Aurelia supports passing HTMLElement objects directly to template interpolations. This allows you to dynamically create and insert DOM elements into your templates at runtime.

    Creating elements with document.createElement()

    You can create DOM elements in your view model and bind them directly:

    The button element will be directly inserted into the div, maintaining all its properties and event listeners.

    Parsing HTML strings

    You can also parse HTML strings and render the resulting elements:

    When using Document.parseHTMLUnsafe(), be cautious about the source of your HTML strings to avoid XSS vulnerabilities. Only use this with trusted content.

    Security Considerations

    When interpolating HTMLElements, be mindful of security implications:

    Never use innerHTML with user-provided content without proper sanitization. This can lead to XSS vulnerabilities.

    Dynamic element creation

    This feature is particularly useful for dynamic content scenarios:

    Notes on syntax

    While template interpolation is powerful, there are a few limitations to keep in mind:

    1. You cannot chain expressions using ; or ,.

    2. You cannot use certain primitives or operators such as Boolean, String, instanceof, or typeof.

    3. The pipe character | is reserved for Aurelia value converters and cannot be used as a bitwise operator inside interpolation.

    For complex transformations or formatting, consider using Aurelia's value converters instead of cramming too much logic into an interpolation.

    Performance Best Practices

    Avoid Complex Expressions

    Keep interpolation expressions simple for better performance. Complex computations should be moved to getters or methods:

    Array Observation Performance

    Aurelia automatically observes arrays used in interpolation. For large arrays that change frequently, consider using computed getters:

    Memory Considerations

    When using HTMLElement interpolation, ensure proper cleanup to avoid memory leaks:

    Error Handling and Edge Cases

    Handling Null and Undefined Values

    Interpolation gracefully handles null and undefined values by rendering empty strings:

    Error-Prone Expressions

    Some expressions can throw runtime errors. Use defensive patterns:

    Type Coercion Behavior

    Interpolation converts values to strings following JavaScript coercion rules:

    HTMLElement Edge Cases

    When interpolating HTMLElements, be aware of these behaviors:

    Advanced Example: Dynamic Content with Observer Updates

    Here's an example showing how interpolation works with Aurelia's observer system to automatically update the view when data changes:

    The interpolation is automatically updated by Aurelia's array observer whenever items are added to the collection.

    JavaScript template literals

    if.bind

    Simple true/false conditions

    Adds/removes elements

    Best for infrequent changes

    show.bind

    Toggle visibility

    Hides/shows elements

    Best for frequent changes

    switch.bind

    Multiple conditions

    Adds/removes elements

    Best for enum-like values

    Using if.bind

    The if.bind directive conditionally adds or removes elements from the DOM based on a boolean expression. When the expression is false, Aurelia completely removes the element and its descendants, cleaning up resources, events, and custom elements.

    Basic Usage

    If/Else Structures

    Use else immediately after an if.bind element to create branching logic:

    Caching Behavior

    By default, if.bind caches views and view models for performance. Disable caching when you need fresh instances:

    When to Use: Use if.bind when elements change infrequently and you want to completely remove them from the DOM to save memory and improve performance.

    Using show.bind

    The show.bind directive toggles element visibility without removing them from the DOM. This is equivalent to setting display: none in CSS.

    Basic Usage

    When to Use show.bind vs if.bind

    When to Use: Use show.bind when you need to frequently toggle visibility and want to preserve element state, bindings, and avoid re-initialization costs.

    Using switch.bind

    The switch.bind directive handles multiple conditions elegantly, similar to a JavaScript switch statement. It's ideal for enum values or when you have several mutually exclusive conditions.

    Basic Usage

    Grouping Cases

    Handle multiple values with a single case:

    Fall-Through Behavior

    Enable fall-through to show multiple cases:

    Fall-through is false by default. Set fall-through="true" or fall-through.bind="true" to enable it.

    Advanced Techniques

    Dynamic Switch Expressions

    Use computed expressions with switch.bind:

    Conditional Slot Projection

    Combine switch.bind with slots for dynamic content projection:

    Nested Switches

    Handle complex conditional logic with nested switches:

    Performance Guidelines

    Choosing the Right Directive

    • Frequent toggles: Use show.bind to avoid DOM manipulation overhead

    • Infrequent changes: Use if.bind to remove elements and save memory

    • Multiple conditions: Use switch.bind for cleaner, more maintainable code

    Optimization Tips

    Important Restrictions

    Case Usage Rules

    The case attribute must be a direct child of switch.bind:

    Default Case Placement

    Place default-case as the last option for best practices:

    Second is the return type boolean | void. Returning from this function is optional. Only an explicit false return value results in skipping the compilation (and thereby enhancing) of the child nodes in the DOM tree. The implication of skipping the compilation of the child nodes is that Aurelia will not touch those DOM fragments and will be kept as it is. In other words, if the mutated node contains custom elements, custom attributes, or template controllers, those will not be hydrated.

    The platform argument is just the helper to have platform-agnostic operations as it abstracts the platform. Lastly the this argument signifies that the hook function always gets bound to the custom element class function for which the hook is defined.

    The most straight forward way to define the hook is to use the processContent property while defining the custom-element.

    Apart from this, there is also the @processContent decorator which can used class-level or method-level.

    That's the API. Now let us say consider an example. Let us say that we want to create a custom elements that behaves as a tabs control. That is this custom element shows different sets of information grouped under a set of headers, and when the header is clicked the associated content is shown. To this end, we can conceptualize the markup for this custom element as follows.

    The markup has 2 slots for the header and content projection. While using the tabs custom element we want to have the following markup.

    If you are unfamiliar with the au-slot then visit the documentation. 'processContent' can be very potent with au-slot.

    Now note that there is no custom element named tab. The idea is to keep the usage-markup as much dev-friendly as possible, so that it is easy to maintain, and the semantics are quite clear. Also it is easy to refactor as now we know which parts belong together. To support this usage-syntax we will use the 'processContent' hook to rearrange the DOM tree, so that the nodes are correctly projected at the end. A prototype implementation is shown below.

    Note the use of $host scope reference that is used when generating markup that will be projected into the slot. Host is required to access the activeTabId property and the showTab function of the Tabs custom element that is hosting the projected markup. More details available at Slotted content.

    Example transformation function for default [au-slot]

    If you have used au-slot, you might have noticed that in order to provide a projection the usage of [au-slot] attribute is mandatory, even if the projections are targeted to the default au-slot. With the help of the 'processContent' hook we can workaround this minor inconvenience. The following is a sample transformation function that loops over the direct children under node and demotes the nodes without any [au-slot] attribute to a synthetic template[au-slot] node.

    Syntax
    Meaning
    Example

    :id

    Required segment

    /users/42

    :id?

    Optional segment

    /users and /users/42

    *rest

    Wildcard remainder

    /files/src/app/main.ts

    :id{{^\\d+$}}

    Constrained segment (regex)

    /orders/1001 (numbers only)

    Destructure params inside lifecycle hooks

    Each lifecycle hook receives a Params object. The router always supplies a string value (or undefined if optional and missing), so add your own parsing as needed.

    For asynchronous preparation, use loading and throw or return false to fail the navigation if something looks wrong.

    2. Access parent and child parameters together

    Nested routes often need both parent and child IDs (for example /companies/10/projects/17). Resolve IRouteContext and use getRouteParameters to aggregate values.

    Callers can opt in to query parameters too:

    3. Work with query parameters alongside path params

    Even though query parameters are not part of the path definition, you can treat them as a cohesive set.

    Use ICurrentRoute to observe query changes reactively (see Router state management).

    4. Generate links with parameters

    The load attribute on <a> elements handles interpolation for you. Bind params to an object so that the router formats the URL consistently.

    When generating programmatic instructions, pass params alongside the component:

    5. Validate and coerce parameters

    Guard logic belongs in the canLoad hook. You can redirect by returning a string instruction or a ViewportInstruction.

    For stricter validation, pair regex-constrained paths with canLoad type checks so users get feedback before hitting your backend.

    6. Test parameterized routes

    Unit tests can render a component, navigate to a parameterized path, and assert how parameters flow through the lifecycle.

    You can also mock IRouteContext or ICurrentRoute to simulate specific parameter sets without spinning up the full router.

    Outcome recipes

    Bookmarkable search filters

    Goal: encode search term, page number, and filter chips in the URL so users can share the view.

    1. Define the base route /search and keep filters in the query string (?q=aurelia&page=2&tag=forms).

    2. Use ICurrentRoute.query to read the current filters in attached() and hydrate your form.

    3. When filters change, call router.load(this.current.path, { queryParams: newFilters }) to update the URL without reloading the whole app.

    Checklist:

    • Refreshing /search?q=router&page=3 shows the same filter state.

    • router.load uses historyStrategy: 'replace' when only filters change to avoid polluting history (configure via navigation options if needed).

    Parent + child identifiers

    Goal: address /companies/:companyId/projects/:projectId and display both IDs in deeply nested children.

    1. Parent route declares companies/:companyId and renders a <au-viewport> for projects.

    2. Child route declares projects/:projectId.

    3. Inside any descendant component call routeContext.getRouteParameters({ mergeStrategy: 'parent-first' }) to get both IDs.

    Checklist:

    • Navigating between different projects preserves the company context.

    • routeContext.getRouteParameters() returns { companyId: '10', projectId: '17' } at every depth.

    Redirect invalid params

    Goal: keep /reports/:date constrained to valid ISO dates.

    1. Constrain the route with :date{{^\\d{4}-\\d{2}-\\d{2}$}} to block obviously bad paths.

    2. Inside canLoad, parse the date and return 'reports/today' if invalid.

    3. Emit a toast notification through a shared service to explain the redirect.

    Checklist:

    • /reports/2024-13-01 never renders the detail view; users land on /reports/today.

    • Valid dates continue to the report screen.

    Related resources

    • Configuring routes

    • Routing lifecycle

    • Navigating

    • Child routing playbook

    <input type="text" ref="firstNameInput" placeholder="First name">
    <p>You are typing: "${firstNameInput.value}"</p>
    import { customElement } from 'aurelia';
    
    @customElement({ name: 'my-app', template: `<input type="text" ref="firstNameInput" placeholder="First name">` })
    export class MyApp {
      firstNameInput: HTMLInputElement; // Explicitly typed template reference
    
      bound() {
        // 'firstNameInput' is now available after the view is bound
        console.log('Input element reference:', this.firstNameInput);
      }
    
      focusInput() {
        if (this.firstNameInput) {
          this.firstNameInput.focus(); // Programmatically focus the input element
        }
      }
    }
    <my-custom-element component.ref="customElementViewModel"></my-custom-element>
    import { customElement } from 'aurelia';
    import { MyCustomElement } from './my-custom-element'; // Assuming MyCustomElement is defined elsewhere
    
    @customElement({ name: 'app', template: `<my-custom-element component.ref="customElementViewModel"></my-custom-element>` })
    export class App {
      customElementViewModel: MyCustomElement; // Typed reference to the custom element's view model
    
      interactWithCustomElement() {
        if (this.customElementViewModel) {
          this.customElementViewModel.someMethodOnViewModel(); // Call a method on the custom element's view model
        }
      }
    }
    <div my-custom-attribute custom-attribute.ref="customAttributeViewModel"></div>
    import { customElement } from 'aurelia';
    import { MyCustomAttribute } from './my-custom-attribute'; // Assuming MyCustomAttribute is defined elsewhere
    
    @customElement({ name: 'app', template: `<div my-custom-attribute custom-attribute.ref="customAttributeViewModel"></div>` })
    export class App {
      customAttributeViewModel: MyCustomAttribute; // Typed reference to the custom attribute's view model
    
      useCustomAttribute() {
        if (this.customAttributeViewModel) {
          this.customAttributeViewModel.doSomethingWithAttribute(); // Call a method on the custom attribute's view model
        }
      }
    }
    <my-custom-element controller.ref="customElementController"></my-custom-element>
    import { customElement, Controller } from 'aurelia';
    
    @customElement({ name: 'app', template: `<my-custom-element controller.ref="customElementController"></my-custom-element>` })
    export class App {
      customElementController: Controller; // Typed reference to the custom element's Controller
    
      accessControllerDetails() {
        if (this.customElementController) {
          console.log('Custom Element Controller:', this.customElementController);
          // You can access lifecycle state, bindings, etc. through the controller
        }
      }
    }
    Addition example
    <p>Quick maths: ${2 + 2}</p>
    <!-- Outputs "Quick maths: 4" -->
    my-app.ts
    export class MyApp {
      adder(val1: number, val2: number): number {
        return parseInt(val1) + parseInt(val2);
      }
    }
    my-app.html
    <p>Behold mathematics, 6 + 1 = ${adder(6, 1)}</p>
    <!-- Outputs "Behold mathematics, 6 + 1 = 7" -->
    my-app.html
    <p>${isTrue ? 'True' : 'False'}</p>
    Array operations
    export class MyApp {
      items = [
        { name: 'Apple', price: 1.50, category: 'fruit' },
        { name: 'Banana', price: 0.80, category: 'fruit' },
        { name: 'Carrot', price: 0.90, category: 'vegetable' }
      ];
    }
    Object property access
    export class MyApp {
      user = {
        profile: {
          personal: { firstName: 'John', lastName: 'Doe' },
          settings: { theme: 'dark', notifications: true }
        }
      };
    }
    Conditional and logical operations
    <p>Status: ${isLoggedIn && user ? 'Authenticated' : 'Guest'}</p>
    <p>Display: ${showDetails || showSummary ? 'Visible' : 'Hidden'}</p>
    <p>Count: ${count || 0}</p>
    <p>Message: ${message?.trim() || 'No message'}</p>
    Optional chaining and nullish coalescing
    <p>User Name: ${user?.name ?? 'Anonymous'}</p>
    my-app.ts
    export class MyApp {
      content = document.createElement('button');
    
      constructor() {
        this.content.textContent = 'Click me!';
        this.content.addEventListener('click', () => {
          alert('Button clicked!');
        });
      }
    }
    my-app.html
    <div>${content}</div>
    my-app.ts
    export class MyApp {
      content = Document.parseHTMLUnsafe('<button>Parsed Button</button>').documentElement;
    }
    my-app.html
    <div>${content}</div>
    Safe practices
    export class MyApp {
      // ✅ Safe: Creating known elements
      createSafeButton() {
        const button = document.createElement('button');
        button.textContent = 'Safe Button'; // textContent escapes content
        button.className = 'safe-class';
        return button;
      }
    
      // ❌ Dangerous: Using innerHTML with user input
      createUnsafeElement(userInput: string) {
        const div = document.createElement('div');
        div.innerHTML = userInput; // Can execute scripts!
        return div;
      }
    
      // ✅ Better: Sanitize user input or use textContent
      createSafeElement(userInput: string) {
        const div = document.createElement('div');
        div.textContent = userInput; // Escapes all HTML
        return div;
      }
    }
    my-app.ts
    export class MyApp {
      elements: HTMLElement[] = [];
    
      addElement() {
        const newElement = document.createElement('span');
        newElement.textContent = `Element ${this.elements.length + 1}`;
        newElement.style.color = 'blue';
        this.elements.push(newElement);
      }
    }
    my-app.html
    <button click.trigger="addElement()">Add Element</button>
    <div repeat.for="element of elements">${element}</div>
    ❌ Not recommended
    <p>${items.filter(i => i.active).map(i => i.name.toUpperCase()).join(', ')}</p>
    ✅ Better approach
    export class MyApp {
      get activeItemNames() {
        return this.items
          .filter(i => i.active)
          .map(i => i.name.toUpperCase())
          .join(', ');
      }
    }
    Large array optimization
    export class MyApp {
      private _cachedResult: string = '';
      private _lastArrayLength: number = 0;
    
      get expensiveArrayComputation() {
        if (this.largeArray.length !== this._lastArrayLength) {
          this._cachedResult = this.largeArray
            .filter(/* complex filter */)
            .reduce(/* expensive operation */, '');
          this._lastArrayLength = this.largeArray.length;
        }
        return this._cachedResult;
      }
    }
    Proper cleanup example
    export class MyApp {
      elements: HTMLElement[] = [];
    
      detaching() {
        // Clean up event listeners and references
        this.elements.forEach(el => {
          el.removeEventListener('click', this.handleClick);
        });
        this.elements = [];
      }
    }
    Null/undefined handling
    export class MyApp {
      name: string | null = null;
      data: any = undefined;
    }
    ❌ Can throw errors
    <p>${user.profile.name}</p>           <!-- Error if user or profile is null -->
    <p>${items[selectedIndex].title}</p>  <!-- Error if index out of bounds -->
    <p>${calculateTotal()}</p>            <!-- Error if method throws -->
    ✅ Defensive patterns
    <p>${user?.profile?.name ?? 'Anonymous'}</p>
    <p>${items[selectedIndex]?.title ?? 'No item selected'}</p>
    <p>${safeCalculateTotal()}</p>
    Type conversion examples
    export class MyApp {
      number = 42;
      boolean = true;
      array = [1, 2, 3];
      object = { name: 'test' };
    }
    HTMLElement considerations
    export class MyApp {
      nullElement: HTMLElement | null = null;
      detachedElement = document.createElement('div');
    
      constructor() {
        this.detachedElement.textContent = 'Detached';
        // Element not in DOM yet
      }
    }
    my-app.ts
    export class MyApp {
      items = [];
    
      constructor() {
        this.items.push({ name: 'Item 1' }, { name: 'Item 2' });
      }
    
      addItem() {
        this.items.push({ name: `Item ${this.items.length + 1}` });
      }
    }
    my-app.html
    <ul>
      <li repeat.for="item of items">${item.name}</li>
    </ul>
    <button click.trigger="addItem()">Add Item</button>
    <div if.bind="isLoading">Loading...</div>
    <div if.bind="user.isAuthenticated">Welcome back, ${user.name}!</div>
    <div if.bind="user.isAuthenticated">
      Welcome back, ${user.name}!
    </div>
    <div else>
      Please log in to continue.
    </div>
    <custom-element if="value.bind: canShow; cache: false"></custom-element>
    <div show.bind="isDataLoaded">Data loaded successfully!</div>
    <div show.bind="!isLoading">Content is ready</div>
    <!-- Use show.bind for frequent toggles -->
    <div show.bind="isExpanded">
      <expensive-component></expensive-component>
    </div>
    
    <!-- Use if.bind for infrequent changes -->
    <admin-panel if.bind="user.isAdmin"></admin-panel>
    // Status.ts
    enum OrderStatus {
      Received   = 'received',
      Processing = 'processing',
      Dispatched = 'dispatched',
      Delivered  = 'delivered'
    }
    <!-- order-status.html -->
    <template switch.bind="orderStatus">
      <span case="received">Order received</span>
      <span case="processing">Processing your order</span>
      <span case="dispatched">On the way</span>
      <span case="delivered">Delivered</span>
      <span default-case>Unknown status</span>
    </template>
    <template switch.bind="orderStatus">
      <span case.bind="['received', 'processing']">
        Order is being processed
      </span>
      <span case="dispatched">On the way</span>
      <span case="delivered">Delivered</span>
    </template>
    <template switch.bind="orderStatus">
      <span case="received" fall-through="true">Order received</span>
      <span case="processing">Processing your order</span>
    </template>
    <template repeat.for="num of numbers">
      <template switch.bind="true">
        <span case.bind="num % 15 === 0">FizzBuzz</span>
        <span case.bind="num % 3 === 0">Fizz</span>
        <span case.bind="num % 5 === 0">Buzz</span>
        <span default-case>${num}</span>
      </template>
    </template>
    <template as-custom-element="status-card">
      <au-slot name="content"></au-slot>
    </template>
    
    <status-card>
      <template au-slot="content" switch.bind="status">
        <div case="loading">Loading...</div>
        <div case="error">Something went wrong</div>
        <div case="success">Operation completed</div>
      </template>
    </status-card>
    <template switch.bind="userRole">
      <div case="admin">
        <template switch.bind="adminSection">
          <admin-users case="users"></admin-users>
          <admin-settings case="settings"></admin-settings>
          <admin-dashboard default-case></admin-dashboard>
        </template>
      </div>
      <user-dashboard case="user"></user-dashboard>
      <guest-welcome default-case></guest-welcome>
    </template>
    <!-- Good: Group related conditions -->
    <template switch.bind="appState">
      <loading-screen case="loading"></loading-screen>
      <error-screen case="error"></error-screen>
      <main-content case="ready"></main-content>
    </template>
    
    <!-- Avoid: Multiple separate if statements -->
    <loading-screen if.bind="appState === 'loading'"></loading-screen>
    <error-screen if.bind="appState === 'error'"></error-screen>
    <main-content if.bind="appState === 'ready'"></main-content>
    <!-- ✅ Correct -->
    <template switch.bind="status">
      <span case="active">Active</span>
    </template>
    
    <!-- ❌ Incorrect: case not direct child -->
    <template switch.bind="status">
      <div if.bind="someCondition">
        <span case="active">Active</span>
      </div>
    </template>
    <template switch.bind="status">
      <span case="received">Received</span>
      <span case="processing">Processing</span>
      <span default-case>Unknown</span> <!-- Last -->
    </template>
    // pseudo-code; `typeof TCustomElement` doesn't work in Generics form.
    <TCustomElement>(this: typeof TCustomElement, node: INode, platform: IPlatform) => boolean | void;
    <my-element>
     <foo></foo>
     <bar></bar>
    </my-element>
    import { customElement, INode, IPlatform } from '@aurelia/runtime-html';
    
    // Use a standalone function
    function processContent(node: INode, platform: IPlatform) { }
    @customElement({ name: 'my-element', processContent })
    export class MyElement { }
    
    // ... or use a static method named 'processContent' (convention)
    @customElement({ name: 'my-element' })
    export class MyElement {
      static processContent(node: INode, platform: IPlatform) { }
    }
    import { customElement, INode, IPlatform, processContent } from '@aurelia/runtime-html';
    
    // ...or a standalone method
    function processContent(this: typeof MyElement, node: INode, platform: IPlatform) { }
    @processContent(processContent)
    export class MyElement {
    }
    
    // ...or the method-level decorator
    export class MyElement {
      @processContent()
      static processContent(node: INode, platform: IPlatform) { }
    }
    <!--tabs.html-->
    <div class="header">
      <au-slot name="header"></au-slot>
    </div>
    <div class="content">
      <au-slot name="content"></au-slot>
    </div>
    <!--app.html-->
    <tabs>
      <tab header="Tab one">
        <span>content for first tab.</span>
      </tab>
      <tab header="Tab two">
        <span>content for second tab.</span>
      </tab>
      <tab header="Tab three">
        <span>content for third tab.</span>
      </tab>
    </tabs>
    // tabs.ts
    import { INode, IPlatform, processContent } from '@aurelia/runtime-html';
    
    class Tabs {
    
      @processContent()
      public static processTabs(node: INode, p: IPlatform): boolean {
        const el = node as Element;
    
        // At first we prepare two templates that will provide the projections to the `header` and `content` slot respectively.
        const headerTemplate = p.document.createElement('template');
        headerTemplate.setAttribute('au-slot', 'header');
        const contentTemplate = p.document.createElement('template');
        contentTemplate.setAttribute('au-slot', 'content');
    
        // Query the `<tab>` elements present in the `node`.
        const tabs = toArray(el.querySelectorAll('tab'));
        for (let i = 0; i < tabs.length; i++) {
          const tab = tabs[i];
    
          // Add header.
          const header = p.document.createElement('button');
          // Add a class binding to mark the active tab.
          header.setAttribute('class.bind', `$host.activeTabId=='${i}'?'active':''`);
          // Add a click delegate to activate a tab.
          header.setAttribute('click.trigger', `$host.showTab('${i}')`);
          header.appendChild(p.document.createTextNode(tab.getAttribute('header')));
          headerTemplate.content.appendChild(header);
    
          // Add content.
          const content = p.document.createElement('div');
          // Show the content if the tab is activated.
          content.setAttribute('if.bind', `$host.activeTabId=='${i}'`);
          content.append(...toArray(tab.childNodes));
          contentTemplate.content.appendChild(content);
    
          el.removeChild(tab);
        }
        // Set the first tab as the initial active tab.
        el.setAttribute('active-tab-id', '0');
    
        el.append(headerTemplate, contentTemplate);
      }
    
      @bindable public activeTabId: string;
      public showTab(tabId: string) {
        this.activeTabId = tabId;
      }
    }
    processContent(node: INode, p: IPlatform) {
      const projection = p.document.createElement('template');
      projection.setAttribute('au-slot', '');
      const content = projection.content;
      for (const child of toArray(node.childNodes)) {
        if (!(child as Element).hasAttribute('au-slot')) {
          content.append(child);
        }
      }
      if (content.childElementCount > 0) {
        node.appendChild(projection);
      }
    }
    import { route } from '@aurelia/router';
    
    @route({
      routes: [
        { path: 'users/:id', component: UserDetail },
        { path: 'users/:id/edit', component: UserEditor },
        { path: 'files/*path', component: FileViewer },
      ]
    })
    export class AdminLayout {}
    import { IRouteViewModel, Params } from '@aurelia/router';
    
    export class UserDetail implements IRouteViewModel {
      userId = '';
    
      canLoad(params: Params) {
        this.userId = params.id ?? '';
        return !!this.userId;
      }
    }
    import { IRouteContext } from '@aurelia/router';
    import { resolve } from '@aurelia/kernel';
    
    export class ProjectDetail {
      private readonly routeContext = resolve(IRouteContext);
    
      get identifiers() {
        return this.routeContext.getRouteParameters({
          mergeStrategy: 'parent-first',
        });
      }
    }
    this.routeContext.getRouteParameters({ includeQueryParams: true });
    import { IRouter, ICurrentRoute } from '@aurelia/router';
    import { resolve } from '@aurelia/kernel';
    
    export class FilterPanel {
      private readonly router = resolve(IRouter);
      private readonly current = resolve(ICurrentRoute);
    
      apply(filters: Record<string, string>) {
        return this.router.load(this.current.path, {
          queryParams: filters,
        });
      }
    }
    <a load="route: users/:id; params.bind: { id: user.id }">Open profile</a>
    <a load="route: companies/:companyId/projects/:projectId; params.bind: { companyId, projectId }"></a>
    router.load({ component: ProjectDetail, params: { companyId: '10', projectId: '17' } });
    import { IRouteViewModel, Params } from '@aurelia/router';
    
    export class ReportLoader implements IRouteViewModel {
      canLoad(params: Params) {
        const date = params.date;
        if (!date || Number.isNaN(Date.parse(date))) {
          return 'reports/today';
        }
    
        return true;
      }
    }
    const fixture = await createFixture(UserDetail).startApp();
    await fixture.router.load('users/10');
    expect(fixture.root.controller.viewModel.userId).toBe('10');

    Structured route tree lets you describe nested layouts, auxiliary viewports, and fallback routes in one place while still co-locating child routes with their components via @route.

  • Viewport-first layouts allow flexible page composition: declare multiple <au-viewport> elements, name them, and target them from route definitions. This makes responsive shells and split views straightforward.

  • Lifecycle hooks and events mirror the intent of Angular guards (canLoad, canActivate, etc.) while using Aurelia conventions (canLoad, loading, canUnload, unloading). Hooks execute in well-defined order and support async work.

  • Navigation state management gives you centralized insight into route activation, title building, and analytics hooks, ideal for larger apps.

  • Progressive enhancement via the load and href attributes keeps markup readable and usable even before hydration.

  • Refer to the package README for release notes and API exports: packages/router/README.md.

    Choose the right guide

    Work through the topics in this order when you are new to the router:

    1. Fundamentals

      • Getting started

      • Router configuration

      • Defining routes and viewports · Viewports in depth

    2. Navigation patterns

    3. Lifecycle, hooks, and events

    4. Advanced scenarios

    5. Support resources

    Keep the live StackBlitz examples handy while you read; most topics embed a runnable demo.

    Feature map

    Capability
    How to use it
    Related doc

    Configure base path, hash vs pushState, title building

    RouterConfiguration.customize and RouterOptions

    Map URLs to components with strong typing

    @route decorator inside your component

    Compose multiple viewports or named layouts

    <au-viewport> and named viewports

    Control navigation flow

    Lifecycle hooks (canLoad, loading, canUnload, unloading)

    Router vs. router-direct

    Use the standard @aurelia/router when you want a centrally managed route tree, multiple viewports, or explicit lifecycle control. The @aurelia/router-direct guides describe a component-driven option that minimizes up-front configuration by letting each feature declare its own routes. Both routers can coexist in the same documentation set, but they solve different problems, so pick the router that matches your app's navigation style.

    Where to go next

    • Explore targeted recipes in the developer guides.

    • Pair routing with state management via the store plugin or your preferred data layer.

    • Review the router package CHANGELOG when upgrading between versions.

    Choosing the right Aurelia router

    Dependency Injection

    Aurelia's dependency injection (DI) system manages your application's services and their dependencies automatically, promoting clean architecture and testable code.

    Creating Services

    Services are regular classes that encapsulate state and call out to other dependencies. Rather than assigning collaborators manually, grab them from the container via resolve():

    Service Registration

    Register services using the interface pattern so components depend on tokens, not classes:

    When this module loads the container wires IUserService to a singleton UserService instance. Swapping implementations (e.g., a mock service in tests) only requires a different registration.

    Using Services in Components

    Use the resolve() function to inject services into components:

    Why resolve()?

    • Clean, modern syntax

    • No decorators needed

    • Better TypeScript inference

    • Easier to test

    Service Dependencies

    Services can depend on other services using resolve():

    You can resolve multiple dependencies - Aurelia handles the wiring automatically.

    Service Lifetimes

    Control how services are instantiated:

    Configuration Services

    Create configuration objects for your services:

    Resolver toolbox

    The DI container ships a set of resolver helpers in @aurelia/kernel. Resolvers change how a dependency is located at runtime—perfect for optional services, per-scope instances, or discovering every implementation of an interface. Every resolver works both as a decorator (@all(IMetricSink)) and inside resolve(...).

    Resolver
    Example
    What it does

    Creating custom resolvers

    If none of the built-ins fit, use createResolver to craft your own semantics. The helper wires up decorator + runtime support automatically:

    Because resolvers are plain DI registrations, you can package them inside libraries or register them globally via Aurelia.register(...), keeping consumption ergonomic in templates and services alike.

    Testing with DI

    DI makes testing straightforward by allowing easy mocking:

    What's Next

    • Learn more about

    • Explore

    • Understand for advanced scenarios

    Template promises

    Aurelia 2 significantly simplifies the handling of Promises directly within your templates. Unlike previous versions where promise resolution typically occurred in the view model, Aurelia 2 empowers you to manage asynchronous operations directly in the view.

    This is accomplished through the promise.bind template controller. It intelligently manages the different states of a Promise: pending, resolved (then), and rejected (catch). This approach reduces boilerplate code and makes asynchronous data handling in templates more declarative and intuitive.

    Basic Usage

    The promise.bind attribute allows you to bind a Promise to a template, rendering different content based on the Promise's current state.

    In this example:

    • promise.bind="myPromise": Binds the div to the Promise named myPromise in your view model.

    • <template pending>: Content rendered while myPromise is in the pending state (still resolving).

    Simple Example with Different Promise States

    Let's illustrate with a view model that manages different promise scenarios:

    In this example, promise1 is set to resolve after 2 seconds, and promise2 is set to reject after 3 seconds. The template dynamically updates to reflect each promise's state. Notice in promise2's then template, we don't specify a variable, indicating we only care about the resolved state, not the resolved value itself.

    Promise Binding with Functions and Parameters

    You can directly bind a function call to promise.bind. Aurelia is smart enough to re-invoke the function only when its parameters change, treating function calls in templates as pure operations.

    The following example fetches a random advice slip from an API each time a button is clicked:

    Key Points:

    • adviceIndex: This variable, initialized with let adviceIndex.bind="0", acts as a parameter to fetchAdvice. Incrementing adviceIndex via the button click triggers Aurelia to re-evaluate fetchAdvice(adviceIndex).

    • Function Re-execution: Aurelia re-executes fetchAdvice only when adviceIndex

    Isolated Promise Binding Scope

    The promise.bind template controller creates its own isolated scope. This is crucial to prevent naming conflicts and unintended modification of the parent view model or scope.

    In this example:

    • userData and userError: These variables are scoped only within the promise.bind context. They do not pollute the parent view model scope.

    • Component Communication: To pass data to child components (like <user-profile>), use property binding (e.g., user-data.bind="userData").

    Nested Promise Bindings

    Aurelia 2 supports nesting promise.bind controllers to handle scenarios where one asynchronous operation depends on the result of another.

    Flow of Execution:

    1. initialFetchPromise: The outer promise.bind starts with initialFetchPromise.

    2. Pending State: While initialFetchPromise is pending, "Fetching initial data..." is displayed.

    3. First

    Promise Bindings in repeat.for Loops

    When using promise.bind within a repeat.for loop, it's crucial to manage scope correctly, especially if you need to access data from each promise iteration. Using let bindings within the <template promise.bind="..."> is highly recommended to create proper scoping for each iteration.

    Importance of <let> Bindings:

    • Scoped Context: The lines <let itemData.bind="null"></let> and <let itemError.bind="null"></let> inside the promise.bind template are essential. They create itemData and itemError properties in the overriding context of each promise.bind iteration.

    • Preventing Overwriting: Without these

    Extending templating syntax

    The Aurelia template compiler is powerful and developer-friendly, allowing you extend its syntax with great ease.

    Context

    Sometimes you will see the following template in an Aurelia application:

    Aurelia understands that value.bind="message" means value.two-way="message", and later creates a two way binding between view model message property, and input value property. How does Aurelia know this?

    By default, Aurelia is taught how to interpret a bind binding command on a property of an element via a Attribute Syntax Mapper. Application can also tap into this class to teach Aurelia some extra knowledge so that it understands more than just value.bind on an <input/> element.

    Examples

    You may sometimes come across some custom input element in a component library, some examples are:

    • Microsoft FAST text-field element:

    • Ionic ion-input element:

    • Polymer paper-input element:

    Regardless of the lib choice an application takes, what is needed in common is the ability to have a concise syntax to describe the two way binding intention with those custom elements. Some examples for the above custom input elements:

    should be treated as:

    In the next section we will look into how to teach Aurelia such knowledge. Before diving in, keep the following mental model in mind:

    1. Attribute patterns (@attributePattern) split attribute names into target + command pairs. Use them when you want to teach the compiler new syntaxes such as [(value)]. See for a full walkthrough.

    2. Attribute syntax mapper (IAttrMapper) decides whether value.bind really means value.two-way

    All three steps are optional, but more advanced templating extensions usually need at least 2 and 3.

    Using the Attribute Syntax Mapper

    The Attribute Syntax Mapper decides which binding command Aurelia should use when you write .bind. Built-in rules already cover native elements (value.bind on <input> becomes .two-way, checked.bind on checkbox becomes .two-way, etc.). When you integrate with design systems or Web Components, you nearly always need to extend the mapper so that your terse syntax keeps working.

    Every Aurelia application uses a single mapper instance. Grab it with resolve(IAttrMapper) wherever you configure your app (typically via AppTask).

    IAttrMapper exposes:

    • useMapping(config) — map attributes (by tag name) to DOM properties.

    • useGlobalMapping(config) — same mapping, but applied to every tag.

    • useTwoWay(predicate) — force .bind to behave like .two-way for certain

    Example: teach Aurelia that <fast-text-field value.bind="..."> should become value.two-way.

    Combining the attribute syntax mapper with the node observer locator

    Teaching Aurelia to map value.bind to value.two-way is the first half of the story. The second half ensures the runtime knows how to observe that DOM property. Do this via the Node Observer Locator. Retrieve it with resolve(INodeObserverLocator) from @aurelia/runtime:

    After grabbing the locator, call useConfig() (per-tag) or useConfigGlobal() (all tags). Each config object describes:

    • events: string[] — events to subscribe to.

    • readonly?: boolean — if true, Aurelia never writes to the property (useful for files).

    • default?: unknown — fallback value when a binding sets

    Example: watch <fast-text-field value> via the change event.

    Similarly, examples for <ion-input> and <paper-input>:

    If an object is passed to the .useConfig API of the Node Observer Locator, it will be used as a multi-registration call, as per following example, where we register <fast-text-field>, <ion-input>, <paper-input> all in a single call:

    Putting it together

    Combine both extensions inside AppTask.creating so they run before Aurelia instantiates your root component. The example below integrates a subset of controls:

    With the above, your Aurelia application now understands the concise value.bind syntax and listens to the correct events:

    Troubleshooting and best practices

    • Duplicate mapping errors – IAttrMapper throws if you register the same tag/attribute twice. Remove or consolidate the previous registration before adding new rules.

    • Verify DOM property names – useMapping expects actual property names (valueAsNumber, formNoValidate, etc.). Typos silently fall back to camelCase conversion.

    Once you understand the flow—pattern → mapper → observer—you can make nearly any third-party component feel native inside Aurelia templates.

    App configuration and startup

    Configure Aurelia applications, register global resources, and choose the startup pattern that fits your project.

    Application Startup

    Aurelia provides two main approaches for application startup: a quick setup using static methods with sensible defaults, and a verbose setup that gives you complete control over configuration.

    Before you start: If you have not already chosen a project scaffold, walk through the section overview for context on how this guide fits with enhancement, routing, and composition topics.

    Quick startup

    The quick startup approach uses static methods on the Aurelia class and is the most common choice for new applications.

    Verbose Startup

    The verbose approach gives you complete control over the DI container and configuration. Use this when integrating Aurelia into existing applications or when you need fine-grained control.

    When to use verbose startup:

    • Integrating Aurelia into existing applications

    • Custom DI container configuration needed

    • Multiple Aurelia apps in one page

    • Advanced debugging or testing scenarios

    StandardConfiguration includes essential services like:

    • Template compiler and renderer

    • Binding engine and observers

    • Custom element/attribute support

    • Built-in value converters and binding behaviors

    Registering Global Resources

    Registering a single custom element

    To make a custom element globally available throughout your application, register it before calling app().

    Registering multiple resources

    Group related components into resource modules for better organization.

    src/components/index.ts:

    src/main.ts:

    Registering other resource types

    Advanced Configuration

    Custom DI registrations

    Environment-specific configuration

    Enhancement Mode

    Sometimes you need Aurelia to light up markup that already exists in the DOM. Instead of calling app(), reach for Aurelia.enhance:

    Enhancement is ideal for progressive hydration, CMS integrations, or widgets embedded in non-Aurelia pages. You can register resources before enhancing, provide a custom DI container, and tear down the enhanced view by calling enhanceRoot.deactivate() when you’re done.

    For a full guide, including cleanup patterns, lifecycle hooks, and advanced recipes, see the dedicated article.

    Next steps

    • Continue with for progressive integration scenarios.

    • Wire services using once your shell is running.

    • Explore to add navigation after the app is bootstrapped.

    import { resolve } from '@aurelia/kernel';
    import { IHttpClient } from '@aurelia/fetch-client';
    
    export class UserService {
      private readonly http = resolve(IHttpClient);
      private readonly cache: User[] = [];
    
      async getUsers(): Promise<User[]> {
        const response = await this.http.fetch('/api/users');
        const payload = await response.json();
        this.cache.splice(0, this.cache.length, ...payload);
        return this.cache;
      }
    
      async createUser(userData: CreateUserRequest): Promise<User> {
        const response = await this.http.fetch('/api/users', {
          method: 'POST',
          body: JSON.stringify(userData),
          headers: { 'Content-Type': 'application/json' },
        });
        const user = await response.json();
        this.cache.push(user);
        return user;
      }
    }
    <input value.bind="message">

    Advanced API reference

    Routing lifecycle

    Listen for navigation changes

    Router.addEventListener(...) or DI inject IRouterEvents

    Router events

    Persist and observe route state

    Inject ICurrentRoute / IRouter

    Router state management

    Customize transitions

    Provide a transitionPlan or set per-route strategies

    Transition plans

    Imperative navigation
    Build menus with the navigation model
    Accessing the active route
    Routing lifecycle
    Router hooks
    Router events
    Transition plans
    Router state management
    Error handling
    Testing guide
    Troubleshooting
    Router configuration
    Defining routes
    Viewports

    factory(key)

    resolve(factory(MyModelClass))

    Gives you a function that constructs the service manually (passing constructor args if needed).

    newInstanceForScope(key)

    resolve(newInstanceForScope(IValidationController))

    Creates and registers a brand-new instance in the current component scope, making it available to descendants via resolve(IValidationController).

    newInstanceOf(Type)

    resolve(newInstanceOf(Logger))

    Constructs a fresh instance of a concrete class or interface implementation without polluting the container.

    resource(key) / optionalResource(key) / allResources(key)

    resolve(optionalResource(MyElement))

    Resolves using resource semantics (look in the current component first, then root) which is handy for templating resources.

    ignore

    @ignore private unused?: Foo

    Tells the container to skip a constructor parameter completely.

    all(key)

    resolve(all(IMetricSink))

    Returns all registrations for a key (useful for plugin pipelines).

    last(key)

    resolve(last(ISink))

    Grabs the most recently registered instance.

    lazy(key)

    resolve(lazy(IHttpClient))

    Injects a function that resolves the dependency on demand.

    optional(key) / own(key)

    resolve(optional(IMaybeService))

    dependency injection concepts
    service creation patterns
    DI resolvers

    Returns undefined (or the child container value) when nothing is registered.

    and many more...

    , and how attribute names map onto DOM properties.
  • Node observer locator (INodeObserverLocator) teaches the runtime how to observe those DOM properties (which events fire, whether values are readonly, etc.).

  • (element, attrName)
    pairs.
    attrName
    is the kebab-case attribute name; return
    true
    to enable two-way.
    null
    /
    undefined
    .
  • type?: INodeObserverConstructor — provide a custom observer implementation.

  • Mind attribute casing – The mapper receives attributes in kebab-case. If your component exposes camelCase properties (common for Web Components), map 'my-prop' → 'myProp'.
  • Use 'new' containers sparingly – When augmenting INodeObserverLocator, you rarely need custom observers. Prefer event-only configs before writing a new observer type.

  • Test with devtools – Toggle your custom elements in the browser and inspect element.value. If the value updates but Aurelia bindings do not, double-check the observer config. If bindings update but DOM does not, revisit useMapping.

  • https://explore.fast.design/components/fast-text-field
    https://ionicframework.com/docs/api/input
    https://www.webcomponents.org/element/@polymer/paper-input
    Attribute Patterns
    Microsoft FAST
    DOM event handling and delegation
  • Shadow DOM and CSS module support

  • Enhance
    Enhance
    dependency injection
    choosing a router
    import { DI } from '@aurelia/kernel';
    
    export interface IUserService {
      getUsers(): Promise<User[]>;
      createUser(userData: CreateUserRequest): Promise<User>;
    }
    
    export const IUserService = DI.createInterface<IUserService>('IUserService', x => x.singleton(UserService));
    import { resolve } from '@aurelia/kernel';
    import { IUserService } from './user-service';
    
    export class UserList {
      private users: User[] = [];
      private userService = resolve(IUserService);
    
      async created() {
        this.users = await this.userService.getUsers();
      }
    
      async addUser(userData: CreateUserRequest) {
        const newUser = await this.userService.createUser(userData);
        this.users.push(newUser);
      }
    }
    import { resolve } from '@aurelia/kernel';
    import { IHttpClient } from '@aurelia/fetch-client';
    import { ILogger } from '@aurelia/kernel';
    
    export class UserService {
      private http = resolve(IHttpClient);
      private logger = resolve(ILogger);
    
      async getUsers(): Promise<User[]> {
        try {
          const response = await this.http.fetch('/api/users');
          return await response.json();
        } catch (error) {
          this.logger.error('Failed to fetch users', error);
          throw error;
        }
      }
    }
    // Singleton (default) - one instance per application
    export const IUserService = DI.createInterface<IUserService>('IUserService', x => x.singleton(UserService));
    export type IUserService = UserService;
    
    // Transient - new instance every time
    export const IEventLogger = DI.createInterface<IEventLogger>('IEventLogger', x => x.transient(EventLogger));
    export type IEventLogger = EventLogger;
    export interface ApiConfig {
      baseUrl: string;
      timeout: number;
      retries: number;
    }
    
    export const IApiConfig = DI.createInterface<ApiConfig>('IApiConfig');
    
    // Register in main.ts
    import Aurelia, { Registration } from 'aurelia';
    import { IApiConfig } from './services/api-config';
    
    Aurelia.register(
      Registration.instance(IApiConfig, {
        baseUrl: 'https://api.example.com',
        timeout: 5000,
        retries: 3,
      })
    );
    import { resolve } from '@aurelia/kernel';
    import { IHttpClient } from '@aurelia/fetch-client';
    
    export class ApiService {
      private readonly config = resolve(IApiConfig);
      private readonly http = resolve(IHttpClient);
    
      constructor() {
        this.http.configure((cfg) => {
          cfg.baseUrl = this.config.baseUrl;
          cfg.timeout = this.config.timeout;
        });
      }
    }
    import { all, resolve } from '@aurelia/kernel';
    
    export class MetricsPanel {
      private sinks = resolve(all(IMetricSink));
    
      attached() {
        for (const sink of this.sinks) {
          sink.flush();
        }
      }
    }
    import { createResolver, resolve } from '@aurelia/kernel';
    
    const newest = createResolver((key, handler, requestor) => {
      const instances = requestor.getAll(key);
      return instances[instances.length - 1];
    });
    
    export const newestLogger = newest(ILogger);
    
    export class AuditTrail {
      private readonly logger = resolve(newestLogger);
    }
    // Test setup
    const mockUserService = {
      getUsers: () => Promise.resolve([{ id: 1, name: 'Test User' }]),
      createUser: (data) => Promise.resolve({ id: 2, ...data })
    };
    
    const container = DI.createContainer();
    container.register(Registration.instance(IUserService, mockUserService));
    
    // Test your component with mocked dependencies
    const component = container.get(UserList);
    <fast-text-field value.bind="message">
    <ion-input value.bind="message">
    <paper-input value.bind="message">
    <fast-text-field value.two-way="message">
    <ion-input value.two-way="message">
    <paper-input value.two-way="message">
    import { IAttrMapper, resolve } from 'aurelia';
    
    export class MyCustomElement {
      private attrMapper = resolve(IAttrMapper);
    
      constructor() {
        // do something with this.attrMapper
      }
    }
    attrMapper.useTwoWay((element, attrName) => {
      switch (element.tagName) {
        case 'FAST-TEXT-FIELD':
        case 'ION-INPUT':
        case 'PAPER-INPUT':
          return attrName === 'value';
        default:
          return false;
      }
    });
    import { resolve } from 'aurelia';
    import { INodeObserverLocator } from '@aurelia/runtime';
    
    export class MyCustomElement {
      private nodeObserverLocator = resolve(INodeObserverLocator);
    
      constructor() {
        // do something with this.nodeObserverLocator
      }
    }
    nodeObserverLocator.useConfig('FAST-TEXT-FIELD', 'value', { events: ['change' ] });
    nodeObserverLocator.useConfig('ION-INPUT', 'value', { events: ['change' ] });
    nodeObserverLocator.useConfig('PAPER-INPUT', 'value', { events: ['change' ] });
    nodeObserverLocator.useConfig({
      'FAST-TEXT-FIELD': {
        value: { events: ['change'] }
      },
      'ION-INPUT': {
        value: { events: ['change'] }
      },
      'PAPER-INPUT': {
        value: { events: ['change'] }
      }
    })
    import Aurelia, { AppTask, IAttrMapper } from 'aurelia';
    import { INodeObserverLocator } from '@aurelia/runtime';
    
    Aurelia
      .register(
        AppTask.creating(IAttrMapper, attrMapper => {
          attrMapper.useTwoWay((el, attrName) => {
            switch (el.tagName) {
              case 'FAST-TEXT-FIELD':
              case 'FAST-TEXT-AREA':
              case 'FAST-SLIDER':
                return attrName === 'value';
              default:
                return false;
            }
          });
        }),
        AppTask.creating(INodeObserverLocator, nodeObserverLocator => {
          nodeObserverLocator.useConfig({
            'FAST-TEXT-FIELD': {
              value: { events: ['change'] }
            },
            'FAST-TEXT-AREA': {
              value: { events: ['change'] }
            },
            'FAST-SLIDER': {
              value: { events: ['change'] }
            }
          });
        })
      )
      .app(class MyApp {})
      .start();
    <fast-text-field value.bind="message"></fast-text-field>
    <fast-text-area value.bind="description"></fast-text-area>
    <fast-slider value.bind="fontSize"></fast-slider>
    import Aurelia from 'aurelia';
    import { RouterConfiguration } from '@aurelia/router';
    
    import { MyRootComponent } from './my-root-component';
    
    // Simplest startup - hosts to <my-root-component> element, or <body> if not found
    Aurelia.app(MyRootComponent).start();
    
    // Register additional features before startup
    Aurelia
      .register(
        RouterConfiguration.customize({ useUrlFragmentHash: false })
      )
      .app(MyRootComponent)
      .start();
    
    // Specify a custom host element
    Aurelia
      .register(
        RouterConfiguration.customize({ useUrlFragmentHash: false })
      )
      .app({
        component: MyRootComponent,
        host: document.querySelector('my-start-tag')
      })
      .start();
    
    // Async startup pattern (recommended)
    const app = Aurelia
      .register(
        RouterConfiguration.customize({ useUrlFragmentHash: false })
      )
      .app(MyRootComponent);
    
    await app.start();
    import { Aurelia, StandardConfiguration } from '@aurelia/runtime-html';
    import { RouterConfiguration } from '@aurelia/router';
    import { LoggerConfiguration, LogLevel } from '@aurelia/kernel';
    import { ShellComponent } from './shell';
    
    // Create Aurelia instance with explicit configuration
    const au = new Aurelia();
    
    au.register(
      StandardConfiguration,  // Essential runtime configuration
      RouterConfiguration.customize({ useUrlFragmentHash: false }),
      LoggerConfiguration.create({ level: LogLevel.debug })
    );
    
    au.app({
      host: document.querySelector('body'),
      component: ShellComponent
    });
    
    // Always await start() for proper error handling
    await au.start();
    import Aurelia from 'aurelia';
    import { CardCustomElement } from './components/card';
    
    // Quick startup
    Aurelia
      .register(CardCustomElement)  // No type casting needed
      .app(MyRootComponent)
      .start();
    
    // Verbose startup
    const au = new Aurelia();
    au.register(
      StandardConfiguration,
      CardCustomElement
    );
    au.app({ host: document.body, component: MyRootComponent });
    await au.start();
    export { CardCustomElement } from './card';
    export { CollapseCustomElement } from './collapse';
    export { ModalCustomElement } from './modal';
    import Aurelia from 'aurelia';
    import * as GlobalComponents from './components';
    
    // Register all exported components at once
    Aurelia
      .register(GlobalComponents)
      .app(MyRootComponent)
      .start();
    import Aurelia from 'aurelia';
    import { MyValueConverter } from './converters/my-value-converter';
    import { MyBindingBehavior } from './behaviors/my-binding-behavior';
    import { MyCustomAttribute } from './attributes/my-custom-attribute';
    
    Aurelia
      .register(
        MyValueConverter,
        MyBindingBehavior,
        MyCustomAttribute
      )
      .app(MyRootComponent)
      .start();
    import { Registration } from '@aurelia/kernel';
    import { MyService, IMyService } from './services/my-service';
    
    Aurelia
      .register(
        Registration.singleton(IMyService, MyService)
      )
      .app(MyRootComponent)
      .start();
    import Aurelia, { LoggerConfiguration, LogLevel } from 'aurelia';
    
    const isProduction = process.env.NODE_ENV === 'production';
    
    Aurelia
      .register(
        LoggerConfiguration.create({
          level: isProduction ? LogLevel.warn : LogLevel.debug
        })
      )
      .app(MyRootComponent)
      .start();
    const enhanceRoot = await Aurelia.enhance({
      host: document.querySelector('#existing-content'),
      component: { message: 'Hello from enhanced content!' }
    });
    <template then="data">: Content rendered when myPromise resolves successfully. The resolved value is available as data within this template.
  • <template catch="error">: Content rendered if myPromise rejects. The rejection reason (typically an Error object) is available as error.

  • changes, ensuring efficient handling of function-based promises.
  • Error Handling: The .catch template gracefully handles fetch errors, providing user-friendly feedback and a "Try Again" button.

  • Parent Scope Access (Discouraged): While you can access the parent scope using $parent, it's generally better to manage data flow through explicit bindings and avoid relying on parent scope access for maintainability.

    then
    (Response)
    : When
    initialFetchPromise
    resolves, the resolved value (
    initialResponse
    ) becomes available in the
    then
    template.
  • Nested promise.bind (JSON Deserialization): Inside the first then template, a nested promise.bind is used: promise.bind="initialResponse.json()". This starts a new promise based on deserializing the initialResponse.

  • Nested then (JSON Data): When initialResponse.json() resolves, the parsed JSON data (jsonData) is available in this then template. "Data received and deserialized: ${jsonData.name}" is displayed.

  • Nested catch (JSON Error): If initialResponse.json() fails (e.g., invalid JSON), the nested catch template handles the error.

  • Outer catch (Fetch Error): If initialFetchPromise initially rejects, the outer catch template handles the initial fetch error.

  • let
    bindings,
    itemData
    and
    itemError
    would be created in the
    binding context
    , which is shared across all iterations of the
    repeat.for
    loop. This would lead to data from later iterations overwriting data from earlier ones, resulting in incorrect or unpredictable behavior.
  • Correct Output: With let bindings, each iteration of the repeat.for loop gets its own isolated scope for itemData and itemError, ensuring correct rendering for each promise in the list.

  • Form Submission

    Learn how to handle form submissions with proper state management, error handling, and user feedback.

    Basic Form Submission

    Preventing Default Submission

    Validation Before Submission

    Submission State Management

    Optimistic UI Updates

    Debounced Auto-Save

    Rate Limiting

    Multi-Step Forms

    See the for complete examples, including community-contributed multi-step flows.

    Best Practices

    1. Always provide feedback - Show loading, success, and error states

    2. Disable submit button - Prevent multiple submissions

    3. Handle errors gracefully - Show user-friendly error messages

    4. Validate before submitting - Client-side validation for UX

    Common Patterns

    Submit on Enter Key

    Confirm Before Submit

    Redirect After Success

    Related

    Component lifecycles

    Aurelia components offer a rich lifecycle that lets you hook into specific moments of a component's existence—from construction, through activation, to eventual disposal. Understanding the order and intent of each hook will help you write components that are predictable, testable, and memory-leak-free.

    All lifecycle callbacks are optional. Implement only what you need. Hooks such as binding/unbinding or attaching/detaching are often implemented in pairs so you can clean up resources you set up in the first hook.

    Lifecycle hooks apply to custom elements and custom attributes. Synthetic views (created by template controllers like if, repeat) do not have lifecycle hooks, but their child components do.

    Quick reference

    Phase
    Hook
    Runs
    Child-parent order
    Async?

    Legend

    • top ➞ down – parent executes before its children

    • bottom ➞ up – children execute before their parent

    Detailed walkthrough

    1. Constructor

    Executed when the instance is created. Inject services here and perform work that does not depend on bindable values.

    2. Define

    • Opportunity to modify the component definition before hydration begins.

    • Can return a partial definition to override aspects of the component's behavior.

    • Runs synchronously, parent before children.

    3. Hydrating

    • Opportunity to register dependencies in controller.container that are needed while compiling the view template.

    • Runs synchronously, parent before children.

    4. Hydrated

    • View template has been compiled, child components are not yet created.

    • Last chance to influence how the soon-to-be-created child components resolve their dependencies.

    5. Created

    • All child components are now constructed and hydrated.

    • Executes once per instance, children before parent.

    • Great for logic that must run after the whole subtree is constructed but before binding.

    6. Binding

    • Bindable properties have been set but bindings in the view are not yet connected.

    • Runs parent ➞ child.

    • Return a Promise (or mark the method async) to block binding/attaching of children until resolved.

    7. Bound

    • View-to-view-model bindings are active; ref, let, and from-view values are available.

    • Executes child ➞ parent.

    8. Attaching

    • The component's host element is now in the DOM but child components may still be attaching.

    • Queue animations or setup 3rd-party libraries here.

    • A returned Promise is awaited before attached is invoked on this component but does not block children.

    9. Attached

    • The entire component subtree is mounted; safe to measure elements or call libraries that need actual layout information.

    • Executes child ➞ parent.

    • Note: Only receives the initiator parameter, not the parent.

    10. Detaching

    • Called when the framework removes the component's element from the DOM.

    • Executes child ➞ parent. Any returned Promise (e.g., an outgoing animation) is awaited in parallel with sibling promises.

    11. Unbinding

    • Runs after detaching finishes and bindings have been disconnected.

    • Executes child ➞ parent.

    12. Dispose

    • Invoked when the instance is permanently discarded—typically when removed from a repeater and the view cache is full, or when the application shuts down.

    • Use to tear down long-lived resources, subscriptions, or manual observers to prevent memory leaks.

    Lifecycle hooks decorator (@lifecycleHooks)

    For cross-cutting concerns like logging, analytics, or debugging, implement lifecycle hooks in a separate class using the @lifecycleHooks decorator. This keeps your component code focused while adding shared behavior.

    Multiple lifecycle hook classes can be registered; the framework executes them in registration order alongside the component's own lifecycle methods.

    Special cases

    • <au-compose> components additionally support activate / deactivate hooks—see the .

    • Router hooks such as canLoad, loading, canUnload, unloading, etc., are documented in the and are available even if you do not use the router.

    Best practices

    1. Prefer early exits—perform checks at the start of hooks and return early to minimise nesting.

    2. Clean up observers, timeouts, event listeners, or 3rd-party widgets in the opposite hook (unbinding/detaching or dispose).

    Child routing playbook

    Build deeply nested navigation trees with Aurelia's router, including layouts, sibling viewports, and relative navigation.

    Child routing lets each routed component own its own navigation tree. Use it to build dashboards with nested layouts, multi-step forms, or resource detail pages that include tabs or auxiliary panels. This guide walks through the patterns you will use most often.

    1. Define parent and child routes

    Every routed component can declare a routes array inside the @route decorator. Parent components stay slim—most of the structure lives in the child components.

    Each child component can keep nesting:

    When the router loads AdminLayout, it automatically instantiates the nested layout components and surfaces their routes inside the <au-viewport> declared in each template.

    2. Render child viewports in parent templates

    Every component that declares child routes must include at least one <au-viewport> in its view:

    You can name child viewports to run siblings in parallel:

    Then target them with multi-viewport instructions such as href="orders@main+profile@details" or router.load([{ component: Orders, viewport: 'main' }, { component: Profile, viewport: 'details' }]). See for more combinations.

    3. Share layout data across child routes

    Load shared data once in the parent and expose it through a service that both parent and children resolve from DI.

    Because the store is a singleton, each child route can read the latest summary without manually passing data down the tree.

    4. Navigate within the current hierarchy

    Relative navigation keeps nested layouts decoupled from the app root. Always resolve IRouteContext (or pass context through router.load) when a child needs to target a sibling or parent.

    You can achieve the same thing in templates:

    The ../ prefix climbs up one routing context before evaluating the rest of the path.

    5. Combine child routes with parameters

    Child routes can declare their own parameters and still reuse parent parameters. The router merges them automatically when you call IRouteContext.getRouteParameters({ includeQueryParams: true }) or receive the Params argument in lifecycle hooks. See the for a complete walkthrough.

    6. Lazy-load nested modules

    You can reference dynamic imports inside any component slot. The router will await the module before instantiating the component.

    This works at every level of the tree, so you pay the cost only when users actually navigate there.

    7. Test nested layouts in isolation

    Create the parent component via the testing harness, call router.load with a path that exercises the child routes, and then assert against the rendered DOM. Because every child route uses a real component (not a string lookup), you get high-confidence integration coverage:

    Scenario recipes

    Tabs inside a detail page

    Outcome: /users/:id loads a layout with tabs (overview, activity, settings) without re-rendering the outer chrome.

    1. Parent layout defines routes for each tab and keeps the <au-viewport> inside the main column.

    2. Tabs use href="tabName" so navigation stays relative to the current user context.

    3. Store the selected tab in a store or read it from ICurrentRoute.fragment if you also want anchor links.

    Validation checklist:

    • Navigating from overview to settings preserves the :id value.

    • Browser back button cycles tabs without losing the parent layout.

    • A deep link to /users/42/settings opens the settings tab immediately.

    Protected admin area with nested guards

    Outcome: Block access to admin child routes unless the parent layout validates the session, while letting each child enforce its own role.

    1. Implement canLoad on the parent layout to check authentication. Return 'login' to redirect unauthorized users.

    2. Register additional router hooks (or per-view-model canLoad) on children for permissions such as reports:read.

    3. Use IRouterEvents

    Validation checklist:

    • Visiting /admin/reports while logged out redirects to /login.

    • Visiting /admin/users with insufficient role triggers the child guard and surfaces an error message.

    • Successful navigation still shows the admin shell.

    Multi-pane dashboards

    Outcome: A dashboard shows a list in the left viewport and detail in the right viewport, both driven by routing.

    1. Parent template declares <au-viewport name="list"> and <au-viewport name="detail">.

    2. Route instructions load both panes simultaneously, e.g. router.load([{ component: ReportsList, viewport: 'list' }, { component: ReportsDetail, params: { id }, viewport: 'detail' }]).

    3. Child components navigate using context: resolve(IRouteContext) to avoid resetting the other viewport.

    Validation checklist:

    • Loading /dashboard shows default list + placeholder detail.

    • Clicking a row updates only the detail viewport.

    • Sharing /dashboard@detail=report/weekly opens the same detail for other users.

    Related resources

    Templates Overview & Quick Reference

    Welcome to the Aurelia templating documentation! This guide covers everything you need to build dynamic, interactive UIs with Aurelia's powerful templating system.

    Quick Start

    New to Aurelia templates? Start here:

    1. - Quick syntax reference for all template features

    router-lite - getting started - StackBlitzStackBlitz
    <div promise.bind="myPromise">
      <template pending>Loading data...</template>
      <template then="data">Data loaded: ${data}</template>
      <template catch="error">Error: ${error.message}</template>
    </div>
    <div>
      <h3>Promise Example 1</h3>
      <div promise.bind="promise1">
        <template pending>Promise 1: Loading...</template>
        <template then="data">Promise 1: Resolved with: ${data}</template>
        <template catch="err">Promise 1: Rejected with error: ${err.message}</template>
      </div>
    </div>
    
    <div>
      <h3>Promise Example 2 (No data in 'then' state)</h3>
      <div promise.bind="promise2">
        <template pending>Promise 2: Waiting...</template>
        <template then>Promise 2: Successfully Resolved!</template>
        <template catch>Promise 2: An error occurred!</template>
      </div>
    </div>
    export class MyApp {
      promise1: Promise<string>;
      promise2: Promise<void>;
    
      constructor() {
        this.promise1 = this.createDelayedPromise('Promise 1 Data', 2000, true); // Resolves after 2 seconds
        this.promise2 = this.createDelayedPromise(undefined, 3000, false); // Rejects after 3 seconds
      }
    
      createDelayedPromise(data: any, delay: number, shouldResolve: boolean): Promise<any> {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            if (shouldResolve) {
              resolve(data);
            } else {
              reject(new Error('Promise rejected after delay'));
            }
          }, delay);
        });
      }
    }
    <let adviceIndex.bind="0"></let>
    
    <div promise.bind="fetchAdvice(adviceIndex)">
      <span pending>Fetching advice...</span>
      <span then="adviceData">
        Advice ID: ${adviceData.slip.id}<br>
        "${adviceData.slip.advice}"
        <button click.trigger="adviceIndex = adviceIndex + 1">Get New Advice</button>
      </span>
      <span catch="fetchError">
        Failed to get advice. Error: ${fetchError}
        <button click.trigger="adviceIndex = adviceIndex + 1">Try Again</button>
      </span>
    </div>
    export class MyApp {
      adviceIndex = 0; // Initialize adviceIndex
    
      fetchAdvice(index: number): Promise<any> {
        // 'index' parameter ensures function re-execution on parameter change
        console.log(`Fetching advice, attempt: ${index}`);
        return fetch("https://api.adviceslip.com/advice", {
          cache: 'no-store' // Prevents caching for example clarity
        })
        .then(response => response.ok
          ? response.json()
          : Promise.reject(new Error(`HTTP error! status: ${response.status}`))
        )
        .catch(error => {
          console.error("Fetch error:", error);
          throw error; // Re-throw to be caught by the promise template
        });
      }
    }
    <div promise.bind="userPromise">
      <template then="userData">
        <user-profile user-data.bind="userData"></user-profile>
        <p>User ID within promise scope: ${userData.id}</p>
        <!-- Accessing parent scope (if needed, though generally discouraged) -->
        <!-- <p>Some parent property: ${$parent.someProperty}</p> -->
      </template>
      <template catch="userError">
        <error-display error-message.bind="userError.message"></error-display>
      </template>
    </div>
    <div promise.bind="initialFetchPromise">
      <template pending>Fetching initial data...</template>
      <template then="initialResponse" promise.bind="initialResponse.json()">
        <template then="jsonData">
          Data received and deserialized: ${jsonData.name}
        </template>
        <template catch="jsonError">
          Error deserializing JSON: ${jsonError.message}
        </template>
      </template>
      <template catch="fetchError">
        Error fetching initial data: ${fetchError.message}
      </template>
    </div>
    <let promiseItems.bind="[[42, true], ['error-string', false], ['success-string', true]]"></let>
    <ul>
      <template repeat.for="item of promiseItems">
        <li>
          <template promise.bind="createPromise(item[0], item[1])">
            <let itemData.bind="null"></let> <let itemError.bind="null"></let>
            <span pending>Processing item...</span>
            <span then="itemData">Item processed successfully: ${itemData}</span>
            <span catch="itemError">Item processing failed: ${itemError.message}</span>
          </template>
        </li>
      </template>
    </ul>
    export class MyApp {
      promiseItems: any[][]; // Defined in HTML using <let>
    
      createPromise(value: any, shouldResolve: boolean): Promise<any> {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            if (shouldResolve) {
              resolve(value);
            } else {
              reject(new Error(`Promise rejected for value: ${value}`));
            }
          }, 1000); // Simulate async processing
        });
      }
    }
    export class ContactForm {
      formData = {
        name: '',
        email: '',
        message: ''
      };
    
      isSubmitting = false;
      successMessage = '';
      errorMessage = '';
    
      async handleSubmit() {
        this.isSubmitting = true;
        this.errorMessage = '';
        this.successMessage = '';
    
        try {
          const response = await fetch('/api/contact', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(this.formData)
          });
    
          if (!response.ok) {
            throw new Error('Submission failed');
          }
    
          this.successMessage = 'Form submitted successfully!';
          this.resetForm();
        } catch (error) {
          this.errorMessage = 'Failed to submit form. Please try again.';
        } finally {
          this.isSubmitting = false;
        }
      }
    
      resetForm() {
        this.formData = { name: '', email: '', message: '' };
      }
    }
    <form submit.trigger="handleSubmit()">
      <div class="form-group">
        <label>Name</label>
        <input value.bind="formData.name" required />
      </div>
    
      <div class="form-group">
        <label>Email</label>
        <input type="email" value.bind="formData.email" required />
      </div>
    
      <div class="form-group">
        <label>Message</label>
        <textarea value.bind="formData.message" required></textarea>
      </div>
    
      <div if.bind="successMessage" class="alert alert-success">
        ${successMessage}
      </div>
    
      <div if.bind="errorMessage" class="alert alert-error">
        ${errorMessage}
      </div>
    
      <button type="submit" disabled.bind="isSubmitting">
        ${isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
    <!-- Method 1: Use submit.trigger (recommended) -->
    <form submit.trigger="handleSubmit()">
      <!-- form fields -->
    </form>
    
    <!-- Method 2: Prevent default in handler -->
    <form submit.trigger="handleSubmit($event)">
      <!-- form fields -->
    </form>
    handleSubmit(event?: Event) {
      event?.preventDefault();
      // your logic
    }
    import { route } from '@aurelia/router';
    import { UsersPage } from './users/users-page';
    import { ReportsPage } from './reports/reports-page';
    
    @route({
      routes: [
        { path: '', component: UsersPage, title: 'Users' },
        { path: 'reports', component: ReportsPage, title: 'Reports' },
      ]
    })
    export class AdminLayout {}
    Logo

    Reset form after success - Clear form or redirect user

  • Implement rate limiting - Prevent spam submissions

  • Use proper HTTP methods - POST for creation, PUT/PATCH for updates

  • Template Recipes collection
    Form Basics
    Validation
    File Uploads
    Form Examples
    to show a toast whenever a guard cancels navigation.
    Navigating
    Viewports
    Route parameters guide
    Configuring routes
    Viewports
    Routing lifecycle
    Route parameters
    export class ValidatedForm {
      formData = { name: '', email: '' };
    
      get isValid(): boolean {
        return this.formData.name.length > 0 &&
               this.formData.email.includes('@');
      }
    
      handleSubmit() {
        if (!this.isValid) {
          alert('Please fill out all required fields');
          return;
        }
    
        // Submit form
      }
    }
    <form submit.trigger="handleSubmit()">
      <!-- fields -->
      <button type="submit" disabled.bind="!isValid">Submit</button>
    </form>
    interface SubmissionState {
      isSubmitting: boolean;
      success: boolean;
      error: string | null;
      attempts: number;
    }
    
    export class StatefulForm {
      formData = { /* ... */ };
    
      state: SubmissionState = {
        isSubmitting: false,
        success: false,
        error: null,
        attempts: 0
      };
    
      get canSubmit(): boolean {
        return !this.state.isSubmitting && this.state.attempts < 3;
      }
    
      async handleSubmit() {
        if (!this.canSubmit) return;
    
        this.state.isSubmitting = true;
        this.state.error = null;
        this.state.success = false;
    
        try {
          await this.submitData();
          this.state.success = true;
    
          setTimeout(() => this.resetForm(), 2000);
        } catch (error) {
          this.state.error = error.message;
          this.state.attempts++;
        } finally {
          this.state.isSubmitting = false;
        }
      }
    
      private async submitData() {
        const response = await fetch('/api/submit', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(this.formData)
        });
    
        if (!response.ok) {
          throw new Error('Submission failed');
        }
    
        return response.json();
      }
    
      resetForm() {
        this.formData = { /* reset */ };
        this.state = {
          isSubmitting: false,
          success: false,
          error: null,
          attempts: 0
        };
      }
    }
    export class OptimisticForm {
      items: Item[] = [];
      optimisticItem: Item | null = null;
    
      async addItem(item: Item) {
        // Add optimistically
        this.optimisticItem = { ...item, id: 'temp-' + Date.now() };
        this.items.push(this.optimisticItem);
    
        try {
          const result = await this.saveItem(item);
    
          // Replace optimistic item with real one
          const index = this.items.indexOf(this.optimisticItem);
          this.items[index] = result;
          this.optimisticItem = null;
        } catch (error) {
          // Remove optimistic item on error
          this.items = this.items.filter(i => i !== this.optimisticItem);
          this.optimisticItem = null;
          alert('Failed to add item');
        }
      }
    }
    export class AutoSaveForm {
      formData = { title: '', content: '' };
      saveStatus: 'saved' | 'saving' | 'unsaved' = 'saved';
      saveTimer: any = null;
    
      formDataChanged() {
        this.saveStatus = 'unsaved';
    
        // Clear existing timer
        clearTimeout(this.saveTimer);
    
        // Set new timer for auto-save
        this.saveTimer = setTimeout(() => {
          this.autoSave();
        }, 2000);
      }
    
      async autoSave() {
        this.saveStatus = 'saving';
    
        try {
          await fetch('/api/save', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(this.formData)
          });
    
          this.saveStatus = 'saved';
        } catch (error) {
          this.saveStatus = 'unsaved';
        }
      }
    
      detaching() {
        clearTimeout(this.saveTimer);
      }
    }
    <form>
      <input value.bind="formData.title" input.trigger="formDataChanged()" />
      <textarea value.bind="formData.content" input.trigger="formDataChanged()"></textarea>
    
      <span class="save-status">
        <span if.bind="saveStatus === 'saved'">✓ Saved</span>
        <span if.bind="saveStatus === 'saving'">Saving...</span>
        <span if.bind="saveStatus === 'unsaved'">Unsaved changes</span>
      </span>
    </form>
    export class RateLimitedForm {
      lastSubmission: Date | null = null;
      cooldownMs = 30000; // 30 seconds
    
      get canSubmit(): boolean {
        if (!this.lastSubmission) return true;
    
        const timeSince = Date.now() - this.lastSubmission.getTime();
        return timeSince > this.cooldownMs;
      }
    
      get cooldownRemaining(): number {
        if (!this.lastSubmission) return 0;
    
        const timeSince = Date.now() - this.lastSubmission.getTime();
        const remaining = this.cooldownMs - timeSince;
        return Math.max(0, Math.ceil(remaining / 1000));
      }
    
      async handleSubmit() {
        if (!this.canSubmit) {
          alert(`Please wait ${this.cooldownRemaining} seconds before submitting again`);
          return;
        }
    
        // Submit form
        await this.submitData();
        this.lastSubmission = new Date();
      }
    }
    <form submit.trigger="handleSubmit()">
      <input value.bind="query" keydown.trigger:enter="handleSubmit()" />
    </form>
    handleSubmit() {
      if (!confirm('Are you sure you want to submit?')) {
        return;
      }
      // Proceed with submission
    }
    import { resolve } from '@aurelia/kernel';
    import { IRouter } from '@aurelia/router';
    
    export class FormWithRedirect {
      private readonly router = resolve(IRouter);
    
      async handleSubmit() {
        await this.submitData();
        this.router.load('/success');
      }
    }
    import { route } from '@aurelia/router';
    import { UserOverview } from './user-overview';
    import { UserSettings } from './user-settings';
    
    @route({
      routes: [
        { path: ':id', component: UserOverview, title: 'Overview' },
        { path: ':id/settings', component: UserSettings, title: 'Settings' },
      ]
    })
    export class UsersPage {}
    <!-- admin-layout.html -->
    <nav>
      <a load="">Users</a>
      <a load="reports">Reports</a>
    </nav>
    
    <au-viewport></au-viewport>
    <!-- users-page.html -->
    <section class="users">
      <aside>
        <user-list></user-list>
      </aside>
      <main>
        <au-viewport></au-viewport>
      </main>
    </section>
    <au-viewport name="main"></au-viewport>
    <au-viewport name="details"></au-viewport>
    import { singleton } from '@aurelia/kernel';
    
    type AdminSummary = {
      totalUsers: number;
      activeUsers: number;
    };
    
    @singleton()
    export class AdminStatsStore {
      private readonly fallback: AdminSummary = { totalUsers: 0, activeUsers: 0 };
      private _value: AdminSummary | null = null;
    
      set(summary: AdminSummary) {
        this._value = summary;
      }
    
      get value() {
        return this._value ?? this.fallback;
      }
    }
    import { IRouteViewModel, Params } from '@aurelia/router';
    import { resolve } from '@aurelia/kernel';
    
    export class AdminLayout implements IRouteViewModel {
      private readonly store = resolve(AdminStatsStore);
    
      async loading(_params: Params) {
        const summary = await fetch('/api/admin/summary').then(res => res.json());
        this.store.set(summary);
      }
    }
    import { resolve } from '@aurelia/kernel';
    
    export class UsersPage {
      private readonly store = resolve(AdminStatsStore);
    
      get stats() {
        return this.store.value;
      }
    }
    import { IRouter, IRouteContext } from '@aurelia/router';
    import { resolve } from '@aurelia/kernel';
    
    export class UserTabs {
      private readonly router = resolve(IRouter);
      private readonly context = resolve(IRouteContext);
    
      openSettings(id: string) {
        return this.router.load(`${id}/settings`, {
          context: this.context,
        });
      }
    }
    <a href="../">Back to list</a>
    <a href="../${user.id}/settings">Settings</a>
    @route({
      routes: [
        {
          path: 'reports',
          component: () => import('./reports/reports-index').then(m => m.ReportsIndex),
          title: 'Reports',
        },
      ],
    })
    export class AdminLayout {}
    const { appHost, router } = await createFixture(AdminLayout).startApp();
    await router.load('reports/daily');
    expect(appHost.querySelector('reports-daily')).not.toBeNull();

    hydrating

    once

    top ➞ down

    no

    hydrated

    once

    top ➞ down

    no

    created

    once

    bottom ➞ up

    no

    Activation

    binding

    every activation

    top ➞ down

    yes (blocks children)

    bound

    every activation

    bottom ➞ up

    yes (awaits)

    attaching

    every activation

    top ➞ down

    yes (awaits before attached)

    attached

    every activation

    bottom ➞ up

    yes (awaits)

    Deactivation

    detaching

    every deactivation

    bottom ➞ up

    yes (awaits before DOM removal)

    unbinding

    every deactivation

    bottom ➞ up

    yes (awaits)

    Cleanup

    dispose

    when permanently discarded

    –

    –

    Avoid heavy work in the constructor.
    Move anything needing bindables or DOM to later hooks.
  • Mark hooks async and await your operations instead of manually creating Promises for clarity.

  • Keep hooks fast—expensive work can block the component hierarchy.

  • Construction

    constructor

    once

    –

    –

    define

    once

    top ➞ down

    no

    dynamic composition guide
    routing lifecycle section

    Template Syntax Overview - Core concepts and features

  • Real-World Recipes - Complete working examples

  • How Do I...?

    Find what you need quickly with this task-based guide:

    Display Data

    • Show dynamic text? → Text Interpolation - Use ${property}

    • Bind to element properties? → Attribute Binding - Use .bind, .one-way, .two-way

    • Format data for display? → Value Converters - Use ${value | converter}

    • Show/hide elements? → - Use if.bind or show.bind

    Work with Lists

    • Loop through an array? → List Rendering - Use repeat.for="item of items"

    • Get the current index? → List Rendering: Contextual Properties - Use $index, $first, $last

    • Handle empty lists? → Conditional Rendering - Combine if.bind="items.length === 0" with else

    • Optimize large lists? → - Use key.bind or key:

    Handle User Input

    • Capture button clicks? → Event Binding - Use click.trigger="method()"

    • Handle keyboard input? → Event Binding: Keyboard Events - Use keydown.trigger:enter="submit()"

    • Create two-way form bindings? → Forms - Use value.bind="property"

    • Prevent default behavior? → - Use click.trigger:prevent="method()"

    • Debounce rapid input? → - Use input.trigger="search() & debounce:300"

    Conditional Logic

    • Show content based on a condition? → Conditional Rendering: if.bind

    • Toggle visibility frequently? → Conditional Rendering: show.bind

    • Handle multiple conditions? → Conditional Rendering: switch.bind

    • Show if/else branches? → Conditional Rendering: else - Use else after if.bind

    Styling

    • Apply dynamic CSS classes? → Class & Style Binding

    • Bind inline styles? → Class & Style Binding

    • Toggle classes conditionally? → Class & Style Binding

    Components

    • Use a component in my template? → Component Basics: Importing - Use <import from="./my-component"></import>

    • Make a component available globally? → Component Basics: Global Registration

    • Pass data to a component? → Bindable Properties - Use @bindable and bind in parent

    • Get a reference to an element? → - Use ref="elementName"

    • Slot content into a component? →

    Forms

    • Build a form? → Forms: Basic Inputs

    • Validate form input? → Validation Plugin

    • Handle form submission? → Forms: Submission

    • Work with checkboxes? → Forms: Collections

    • Handle file uploads? →

    Advanced

    • Create reusable template behaviors? → Custom Attributes

    • Transform data in templates? → Value Converters

    • Control binding behavior? → Binding Behaviors

    • Work with promises? → Template Promises - Use promise.bind

    • Create local template variables? → - Use <let>

    • Work with SVG? →

    • Use lambda expressions? →

    Documentation Structure

    Core Concepts

    • Template Syntax Overview - Start here for the big picture

    • Cheat Sheet - Quick reference for all syntax

    Template Syntax

    • Text Interpolation - Display data with ${}

    • Attribute Binding - Bind to element properties and attributes

    • Event Binding - Handle user interactions

    • Template References - Access DOM elements with ref

    • - Create local variables with <let>

    • - Handle async data with promise.bind

    • - Built-in global functions and values

    Display Logic

    • Conditional Rendering - Show/hide content with if, show, switch

    • List Rendering - Loop over data with repeat.for

    • Class & Style Bindings - Dynamic CSS

    Data Transformation

    • Value Converters - Format and transform data (like pipes)

    • Binding Behaviors - Control binding flow (debounce, throttle, etc.)

    Forms & Input

    • Forms Overview - Working with form inputs

    • Form Collections - Checkboxes, radios, multi-select

    • Form Submission - Submit forms and handle user feedback

    • File Uploads - Handle file inputs and uploads

    • - Validate user input

    Extensibility

    • Custom Attributes - Create reusable template behaviors

    • Advanced Custom Attributes - Complex attribute patterns

    Other Features

    • Lambda Expressions - Arrow functions in templates

    • Local Templates - Inline component definitions

    • SVG - Working with SVG elements

    Real-World Examples

    • Recipes - Complete, production-ready examples

      • Product Catalog - Search, filter, sort

      • Shopping Cart - Add/remove items, calculate totals

      • More coming soon—follow the contribution guide in the recipes index to share yours!

    Learning Path

    Not sure where to start? Follow this path:

    Beginner

    1. Read Template Syntax Overview

    2. Learn Text Interpolation and Attribute Binding

    3. Try Event Binding for interactivity

    4. Practice with Product Catalog Recipe

    Intermediate

    1. Master Conditional Rendering (if, show, switch)

    2. Learn List Rendering (repeat.for)

    3. Explore Value Converters for data formatting

    4. Build forms with

    5. Try

    Advanced

    1. Create Custom Attributes

    2. Use Binding Behaviors for fine control

    3. Work with Template Promises

    4. Explore Advanced Custom Attributes

    5. Adapt real-world patterns from the

    Performance Tips

    • Use .one-way binding for display-only data

    • Add key to repeat.for for dynamic lists

    • Use show.bind for frequent visibility toggles

    • Use if.bind for infrequent changes

    • Debounce rapid input events

    • Keep expressions simple - move logic to view model

    Common Pitfalls

    • Components not appearing? → Don't forget <import from="./component"></import> (or register globally)

    • Array changes not detected? → Use array methods like push(), splice(), not direct index assignment

    • Form input not updating? → Use .bind or .two-way, not .one-way

    • Performance issues with large lists? → Add key.bind or key: to repeat.for

    • Bindings not working? → Check for typos in property names and binding commands

    Need Help?

    • Check the Cheat Sheet for quick syntax reference

    • Browse Recipes for complete examples

    • Review Template Syntax Overview for core concepts

    • Search the "How Do I...?" section above

    • Visit for community support

    • Check for known issues

    Related Documentation

    • Components - Build reusable UI components

    • Essentials - Core Aurelia concepts

    • Router - Navigation and routing

    • Dependency Injection - Share services between components

    Cheat Sheet

    From React to Aurelia

    React developers: Discover why Aurelia's standards-based approach delivers better performance and cleaner code without the complexity.

    Coming from React? You'll love Aurelia's approach to component development. Get the productivity of React with better performance, cleaner templates, and no virtual DOM overhead.

    Why React Developers Choose Aurelia

    🚀 Performance That Actually Matters

    Result: 30-50% faster rendering with smaller bundle sizes.

    ✨ Cleaner Component Code

    No hooks complexity. No re-render cycles. Just clean, maintainable code.

    🎯 TypeScript-First Development

    🔥 Better Developer Experience

    Feature
    React
    Aurelia

    Your React Knowledge Transfers

    JSX → Aurelia Templates

    Props → Bindable Properties

    State Management

    Quick Migration Path

    1. Start with Familiar Concepts (5 minutes)

    2. Convert a React Component (10 minutes)

    Create your first Aurelia component using familiar React patterns:

    3. Experience the Differences (5 minutes)

    • No build step complexity - just works with any bundler

    • No prop drilling - dependency injection handles state

    • No re-render debugging - direct DOM updates

    • No hooks confusion - simple class properties and methods

    What You'll Gain

    📈 Performance Benefits

    • Faster initial load - no virtual DOM library overhead

    • Faster updates - direct DOM manipulation

    • Smaller bundles - efficient code splitting

    • Better mobile performance - less JavaScript execution

    🧹 Cleaner Codebase

    • Less boilerplate - no prop interfaces, no memo wrapping

    • Intuitive templates - HTML that looks like HTML

    • Simpler state management - class properties instead of hooks

    • Better separation of concerns - HTML, CSS, and TypeScript in separate files

    🚀 Better Developer Experience

    • Stronger TypeScript integration - built from the ground up for TypeScript

    • No re-render optimization needed - automatically efficient

    • Powerful CLI tools - scaffolding and build tools that just work

    • Excellent debugging - inspect actual DOM, not virtual representations

    Ready to Make the Switch?

    Next Steps:

    1. - Build your first app in 15 minutes

    2. - Master Aurelia's component model

    3. - Learn the templating system

    4. - See more migration patterns

    Questions? Join our where developers discuss their experiences with different frameworks.

    Ready to experience the difference? .

    Using the template compiler

    The template compiler is used by Aurelia under the hood to process templates and provides hooks and APIs allowing you intercept and modify how this behavior works in your applications.

    Hooks

    There are scenarios where an application wants to control how to preprocess a template before it is compiled. There could be various reasons, such as accessibility validation, adding debugging attributes etc...

    Aurelia supports this via template compiler hooks, enabled with the default template compiler. To use these features, declare and then register the desired hooks with either global (at startup) or local container (at dependencies (runtime) or <import> with convention).

    An example of declaring global hooks that will be called for every template:

    With VanillaJS

    With decorator

    Supported hooks

    • compiling: this hook will be invoked before the template compiler starts compiling a template. Use this hook if there need to be any changes to a template before any compilation.

    Hooks invocation order

    All hooks from local and global registrations will be invoked: local first, then global.

    Compilation Behavior

    The default compiler will remove all binding expressions while compiling a template. This is to clean the rendered HTML and increase the performance of cloning compiled fragments.

    Though this is not always desirable for debugging, it could be hard to figure out what element mapped to the original part of the code. To enable an easier debugging experience, the default compiler has a property debug that when set to true will keep all expressions intact during the compilation.

    This property can be set early in an application lifecycle via AppTask, so that all the rendered HTML will keep their original form. An example of doing this is:

    List of attributes that are considered expressions:

    • containerless

    • as-element

    • ref

    • attr with binding expression (attr.command="...")

    Scenarios

    Now that we understand how the template compiler works let's create fun scenarios showcasing how you might use it in your Aurelia applications.

    Feature Flagging in Templates

    If your application uses feature flags to toggle features on and off, you may want to modify templates based on these flags conditionally.

    Here, elements with a data-feature attribute will be removed from the template if the corresponding feature flag is set to false, allowing for easy management of feature rollouts.

    Auto-Generating Form Field IDs for Label Association

    For accessibility purposes, form fields must associate label elements with matching for and id attributes. We can automate this process during template compilation.

    In this use case, the hook generates a unique id for each form field that doesn't already have one and updates the corresponding label's for attribute to match. This ensures that form fields are properly labelled for screen readers and other assistive technologies.

    Automatic ARIA Role Assignment

    To enhance accessibility, you might want to automatically assign ARIA roles to certain elements based on their class or other attributes to make your application more accessible without manually annotating each element.

    This hook assigns the role="button" to all elements that have the .btn class and do not already have a role defined. This helps ensure that custom-styled buttons are accessible.

    Content Security Policy (CSP) Compliance

    If your application needs to comply with strict Content Security Policies, you should ensure that inline styles are not used within your templates. A template compiler hook can help you enforce this policy.

    This hook scans for any elements with inline style attributes and removes them, logging a warning for developers to take notice and refactor the styles into external stylesheets.

    Lazy Loading Image Optimization

    For performance optimization, you should implement lazy loading for images. The template compiler can automatically add lazy loading attributes to your image tags.

    This hook finds all img elements without a loading attribute and sets it to lazy, instructing the browser to defer loading the image until it is near the viewport.

    Dynamic Theme Class Injection

    If your application supports multiple themes, you can use a template compiler hook to inject the relevant theme class into the root of your templates based on user preferences.

    This hook adds a theme-specific class to the root element of every template, allowing for theme-specific styles to be applied consistently across the application.

    Node APIs used

    The default template compiler will turn a template, either in string or already an element, into an element before the compilation. During the compilation, these APIs on the Node & Element classes are accessed and invoked:

    • Node.prototype.nodeType

    • Node.prototype.nodeName

    • Node.prototype.childNodes

    If it is desirable to use the default template compiler in any environment other than HTML, ensure the template compiler can hydrate the input string or object into some object with the above APIs.

    Extending the template compiler

    The Aurelia template compiler is highly extensible, providing multiple hooks and extension points for advanced customization. This guide covers the advanced features and extension mechanisms available for developers who need to extend template compilation behavior.

    Template Compiler Hooks

    Registering Compilation Hooks

    import { resolve } from 'aurelia';
    import { IRouter } from '@aurelia/router';
    
    export class MyComponent {
      readonly router = resolve(IRouter);
    }
    define(
      controller: IDryCustomElementController<this>,
      hydrationContext: IHydrationContext | null,
      definition: CustomElementDefinition
    ): PartialCustomElementDefinition | void {}
    hydrating(controller: IContextualCustomElementController<this>): void {}
    hydrated(controller: ICompiledCustomElementController<this>): void {}
    created(controller: ICustomElementController<this> | ICustomAttributeController<this>): void {}
    // Custom Elements
    binding(initiator: IHydratedController, parent: IHydratedController | null): void | Promise<void> {}
    
    // Custom Attributes
    binding(initiator: IHydratedController, parent: IHydratedController): void | Promise<void> {}
    // Custom Elements
    bound(initiator: IHydratedController, parent: IHydratedController | null): void | Promise<void> {}
    
    // Custom Attributes
    bound(initiator: IHydratedController, parent: IHydratedController): void | Promise<void> {}
    // Custom Elements
    attaching(initiator: IHydratedController, parent: IHydratedController | null): void | Promise<void> {}
    
    // Custom Attributes
    attaching(initiator: IHydratedController, parent: IHydratedController): void | Promise<void> {}
    attached(initiator: IHydratedController): void | Promise<void> {}
    // Custom Elements
    detaching(initiator: IHydratedController, parent: IHydratedController | null): void | Promise<void> {}
    
    // Custom Attributes
    detaching(initiator: IHydratedController, parent: IHydratedController): void | Promise<void> {}
    // Custom Elements
    unbinding(initiator: IHydratedController, parent: IHydratedController | null): void | Promise<void> {}
    
    // Custom Attributes
    unbinding(initiator: IHydratedController, parent: IHydratedController): void | Promise<void> {}
    dispose(): void {}
    import { lifecycleHooks, ILifecycleHooks, ICustomElementController, IHydratedController } from 'aurelia';
    
    @lifecycleHooks()
    export class ComponentLogger implements ILifecycleHooks<MyComponent> {
      bound(vm: MyComponent, initiator: IHydratedController, parent: IHydratedController | null) {
        console.log(`${vm.constructor.name} bound with data:`, vm.someProperty);
      }
    
      detaching(vm: MyComponent, initiator: IHydratedController, parent: IHydratedController | null) {
        console.log(`${vm.constructor.name} detaching`);
      }
    }
    Conditional Rendering
    List Rendering: Performance
    Event Binding: Modifiers
    Event Binding: Binding Behaviors
    Template References
    Slotted Content
    Forms: File Uploads
    Template Variables
    SVG
    Lambda Expressions
    Template Variables
    Template Promises
    Globals
    Validation Plugin
    Forms Guide
    Shopping Cart Recipe
    Template Recipes collection
    Aurelia Discourse
    GitHub Issues
    https://stackblitz.com/edit/au2-promise-binding-using-functions-improved?ctl=1&embed=1&file=src/my-app.tsstackblitz.com

    Forms

    Controlled components + validation libs

    Two-way binding + built-in validation

    Component State

    useState, useReducer

    Simple class properties

    Side Effects

    useEffect with dependencies

    @watch decorator or lifecycle hooks

    Performance

    memo, useMemo, useCallback

    Automatic optimization

    Styling

    CSS-in-JS or external files

    Automatic CSS loading + Shadow DOM

    Complete Getting Started Guide
    Component Guide
    Templates Deep Dive
    Migration Examples
    Discord community
    Start building with Aurelia now

    attr with interpolation (attr="${someExpression}")

  • custom attribute

  • custom element bindables

  • Node.prototype.childNode

  • Node.prototype.firstChild

  • Node.prototype.textContent

  • Node.prototype.parentNode

  • Node.prototype.appendChild

  • Node.prototype.insertBefore

  • Element.prototype.attributes

  • Element.prototype.hasAttribute

  • Element.prototype.getAttribute

  • Element.prototype.setAttribute

  • Element.prototype.classList.add

  • Template compiler hooks allow you to modify templates during the compilation process:

    Global vs Component-Level Hooks

    Hooks can be registered globally or at the component level:

    Advanced Attribute Pattern System

    Creating Custom Attribute Syntax

    The attribute pattern system allows you to create custom binding syntax:

    Complex Pattern Matching

    Support for multi-part patterns with custom symbols:

    Custom Binding Commands

    Advanced Binding Command Features

    Binding commands can take full control of attribute processing:

    Multi-Attribute Processing

    Commands can process multiple attributes for complex scenarios:

    Template Element Factory Customization

    Custom Template Caching

    The template element factory supports custom caching strategies:

    Template Wrapping Detection

    Customize how templates are wrapped for proper compilation:

    Advanced Resource Resolution

    Custom Resource Discovery

    Implement custom resource resolution for dynamic components:

    Bindables Information Caching

    Optimize bindable resolution with custom caching:

    Local Template System

    Advanced Local Element Definitions

    Create complex local element hierarchies:

    Dynamic Local Template Creation

    Create local templates programmatically:

    Compilation Context System

    Hierarchical Resource Resolution

    Work with compilation contexts for advanced scenarios:

    Custom Dependency Injection

    Customize DI container behavior during compilation:

    Performance Optimization

    Template Compilation Caching

    Implement aggressive template caching for performance:

    Compilation Mode Optimization

    Configure compilation for different environments:

    Best Practices

    1. Hook Registration

    • Register global hooks early in application bootstrap

    • Use component-level hooks for specific customizations

    • Keep hooks lightweight to avoid compilation performance impact

    2. Pattern and Command Design

    • Design patterns to be intuitive and consistent with Aurelia conventions

    • Use descriptive names and clear syntax

    • Provide good error messages for invalid usage

    3. Resource Resolution

    • Cache expensive resource lookups

    • Implement fallback mechanisms for missing resources

    • Use lazy loading for dynamic components

    4. Performance Considerations

    • Profile template compilation in development

    • Use AOT compilation for production builds

    • Implement smart caching strategies

    • Monitor memory usage with large template caches

    5. Testing Extensions

    • Create unit tests for custom hooks and commands

    • Test compilation output for correctness

    • Verify performance impact of extensions

    • Test edge cases and error handling

    The template compiler's extensibility allows for powerful customizations while maintaining framework performance and consistency. Use these extension points judiciously to enhance your application's template processing capabilities.

    // React: Virtual DOM reconciliation overhead
    function UserList({ users, onUserClick }) {
      return (
        <div>
          {users.filter(u => u.isActive).map(user => (
            <UserCard key={user.id} user={user} onClick={onUserClick} />
          ))}
        </div>
      );
    }
    
    // Aurelia: Direct DOM updates, no virtual overhead
    export class UserList {
      @bindable users: User[];
      @bindable onUserClick: (user: User) => void;
    }
    <!-- Aurelia template: Clean HTML, faster rendering -->
    <div>
      <user-card repeat.for="user of users.filter(u => u.isActive)" 
                 user.bind="user" 
                 on-click.call="onUserClick(user)">
      </user-card>
    </div>
    // React: Hooks complexity and re-render management
    function SearchComponent() {
      const [query, setQuery] = useState('');
      const [results, setResults] = useState([]);
      const [loading, setLoading] = useState(false);
    
      const debouncedSearch = useCallback(
        debounce(async (searchTerm) => {
          setLoading(true);
          try {
            const data = await searchAPI(searchTerm);
            setResults(data);
          } finally {
            setLoading(false);
          }
        }, 300),
        []
      );
    
      useEffect(() => {
        if (query.length > 2) {
          debouncedSearch(query);
        } else {
          setResults([]);
        }
      }, [query, debouncedSearch]);
    
      return (
        <div>
          <input 
            value={query} 
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Search..."
          />
          {loading && <div>Loading...</div>}
          {results.map(result => <Result key={result.id} data={result} />)}
        </div>
      );
    }
    
    // Aurelia: Simple, intuitive code
    export class SearchComponent {
      query = '';
      results: SearchResult[] = [];
      loading = false;
    
      @watch('query')
      async queryChanged(newQuery: string) {
        if (newQuery.length > 2) {
          this.loading = true;
          try {
            this.results = await searchAPI(newQuery);
          } finally {
            this.loading = false;
          }
        } else {
          this.results = [];
        }
      }
    }
    <div>
      <input value.bind="query & debounce:300" placeholder="Search...">
      <div if.bind="loading">Loading...</div>
      <result repeat.for="result of results" data.bind="result"></result>
    </div>
    // React: Complex prop typing
    interface UserCardProps {
      user: User;
      onEdit?: (user: User) => void;
      onDelete?: (user: User) => void;
      className?: string;
      children?: React.ReactNode;
    }
    
    const UserCard: React.FC<UserCardProps> = ({ 
      user, onEdit, onDelete, className, children 
    }) => {
      // Component logic
    };
    
    // Aurelia: Built-in TypeScript support
    export class UserCard {
      @bindable user: User;
      @bindable onEdit?: (user: User) => void;
      @bindable onDelete?: (user: User) => void;
      
      // Automatic type checking, no prop interfaces needed
    }
    // React JSX
    <div className={`card ${isActive ? 'active' : ''}`}>
      <h2>{user.name}</h2>
      <button onClick={() => handleEdit(user)}>Edit</button>
      {showDetails && (
        <div>
          <p>{user.bio}</p>
          {user.posts.map(post => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>
      )}
    </div>
    
    // Aurelia HTML (cleaner, more intuitive)
    <div class="card" active.class="isActive">
      <h2>${user.name}</h2>
      <button click.trigger="handleEdit(user)">Edit</button>
      <div if.bind="showDetails">
        <p>${user.bio}</p>
        <post-card repeat.for="post of user.posts" post.bind="post"></post-card>
      </div>
    </div>
    // React
    interface Props {
      data: any[];
      onItemClick: (item: any) => void;
      loading?: boolean;
    }
    
    const MyComponent: React.FC<Props> = ({ data, onItemClick, loading = false }) => {
      // Component logic
    };
    
    // Aurelia
    export class MyComponent {
      @bindable data: any[];
      @bindable onItemClick: (item: any) => void;
      @bindable loading = false;
      
      // That's it - cleaner and more intuitive
    }
    // React: Context + useReducer or external library
    const UserContext = createContext();
    
    function UserProvider({ children }) {
      const [state, dispatch] = useReducer(userReducer, initialState);
      return (
        <UserContext.Provider value={{ state, dispatch }}>
          {children}
        </UserContext.Provider>
      );
    }
    
    // Aurelia: Built-in dependency injection
    @singleton()
    export class UserService {
      private users: User[] = [];
      
      addUser(user: User) {
        this.users.push(user);
      }
      
      getUsers() {
        return this.users;
      }
    }
    
    // Use anywhere with simple injection
    export class UserList {
      private userService = resolve(UserService);
      
      get users() {
        return this.userService.getUsers();
      }
    }
    npx makes aurelia my-aurelia-app
    cd my-aurelia-app
    npm run dev
    // React component you're used to
    const TodoList = ({ todos, onToggle, onDelete }) => (
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <input 
              type="checkbox" 
              checked={todo.completed}
              onChange={() => onToggle(todo.id)}
            />
            <span>{todo.text}</span>
            <button onClick={() => onDelete(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    );
    
    // Equivalent Aurelia component
    export class TodoList {
      @bindable todos: Todo[];
      @bindable onToggle: (id: number) => void;
      @bindable onDelete: (id: number) => void;
    }
    <!-- todo-list.html -->
    <ul>
      <li repeat.for="todo of todos">
        <input type="checkbox" 
               checked.bind="todo.completed"
               change.trigger="onToggle(todo.id)">
        <span>${todo.text}</span>
        <button click.trigger="onDelete(todo.id)">Delete</button>
      </li>
    </ul>
    # Try Aurelia now
    npx makes aurelia my-first-aurelia-app
    cd my-first-aurelia-app
    npm run dev
    import Aurelia, { TemplateCompilerHooks } from 'aurelia';
    
    Aurelia
      .register(TemplateCompilerHooks.define(class {
        compiling(template: HTMLElement) {
          template.querySelector('table')?.setAttribute(someAttribute, someValue);
        }
      }));
    import Aurelia, { templateCompilerHooks } from 'aurelia';
    
    @templateCompilerHooks
    class MyTableHook1 {
      compiling(template) {...}
    }
    // paren ok too
    @templateCompilerHooks()
    class MyTableHook1 {
      compiling(template) {...}
    }
    
    Aurelia.register(MyTableHook1);
    import Aurelia, { AppTask, ITemplateCompiler } from 'aurelia';
    import { MyApp } from './my-app';
    
    Aurelia
      .register(AppTask.creating(ITemplateCompiler, compiler => compiler.debug = true))
      .app(MyApp)
      .start();
    import Aurelia, { TemplateCompilerHooks } from 'aurelia';
    
    class FeatureFlagHook {
      compiling(template: HTMLElement) {
        const featureElements = template.querySelectorAll('[data-feature]');
        for (const element of featureElements) {
          const featureName = element.getAttribute('data-feature') ?? '';
          if (!activeFeatureFlags[featureName]) {
            element.remove();
          }
        }
      }
    }
    
    const activeFeatureFlags: Record<string, boolean> = {
      'new-ui': true,
      'beta-feature': false
    };
    
    Aurelia.register(TemplateCompilerHooks.define(FeatureFlagHook))
      .app(MyApp)
      .start();
    import Aurelia, { TemplateCompilerHooks } from 'aurelia';
    
    class FormFieldHook {
      private fieldCounter = 0;
    
      compiling(template: HTMLElement) {
        const formFields = template.querySelectorAll('input, textarea, select');
        for (const field of formFields) {
          if (!field.hasAttribute('id')) {
            const uniqueId = `form-field-${this.fieldCounter++}`;
            field.setAttribute('id', uniqueId);
            
            const label = template.querySelector(`label[for="${field.getAttribute('name')}"]`);
            if (label) {
              label.setAttribute('for', uniqueId);
            }
          }
        }
      }
    }
    
    Aurelia.register(TemplateCompilerHooks.define(FormFieldHook))
      .app(MyApp)
      .start();
    import Aurelia, { TemplateCompilerHooks } from 'aurelia';
    
    class AriaRoleHook {
      compiling(template: HTMLElement) {
        const buttons = template.querySelectorAll('.btn');
        for (const button of buttons) {
          if (!button.hasAttribute('role')) {
            button.setAttribute('role', 'button');
          }
        }
      }
    }
    
    Aurelia.register(TemplateCompilerHooks.define(AriaRoleHook))
      .app(MyApp)
      .start();
    import Aurelia, { TemplateCompilerHooks } from 'aurelia';
    
    class CSPHook {
      compiling(template: HTMLElement) {
        const elementsWithInlineStyles = template.querySelectorAll('[style]');
        for (const element of elementsWithInlineStyles) {
          console.warn(`Inline style removed from element for CSP compliance:`, element);
          element.removeAttribute('style');
        }
      }
    }
    
    Aurelia.register(TemplateCompilerHooks.define(CSPHook))
      .app(MyApp)
      .start();
    import Aurelia, { TemplateCompilerHooks } from 'aurelia';
    
    class LazyLoadingHook {
      compiling(template: HTMLElement) {
        const images = template.querySelectorAll('img:not([loading])');
        for (const img of images) {
          img.setAttribute('loading', 'lazy');
        }
      }
    }
    
    Aurelia.register(TemplateCompilerHooks.define(LazyLoadingHook))
      .app(MyApp)
      .start();
    import Aurelia, { TemplateCompilerHooks } from 'aurelia';
    
    const userSelectedTheme = 'dark'; // For example, a dark theme
    
    class ThemeClassHook {
      private readonly currentTheme = userSelectedTheme;
    
      compiling(template: HTMLElement) {
        const rootElement = template.querySelector(':root');
        if (rootElement) {
          rootElement.classList.add(`theme-${this.currentTheme}`);
        }
      }
    }
    
    Aurelia.register(TemplateCompilerHooks.define(ThemeClassHook))
      .app(MyApp)
      .start();
    import { templateCompilerHooks, ITemplateCompilerHooks } from 'aurelia';
    
    @templateCompilerHooks
    class MyCompilerHook implements ITemplateCompilerHooks {
      compiling(template: HTMLElement): void {
        // Modify template before compilation
        this.addDefaultAttributes(template);
        this.injectDevelopmentHelpers(template);
      }
    
      private addDefaultAttributes(template: HTMLElement): void {
        // Add default attributes to form elements
        template.querySelectorAll('input[type="text"]').forEach(input => {
          if (!input.hasAttribute('autocomplete')) {
            input.setAttribute('autocomplete', 'off');
          }
        });
      }
    
      private injectDevelopmentHelpers(template: HTMLElement): void {
        if (__DEV__) {
          // Add development-only attributes
          template.querySelectorAll('[data-dev-hint]').forEach(el => {
            el.setAttribute('title', el.getAttribute('data-dev-hint')!);
          });
        }
      }
    }
    // Global hook registration
    container.register(MyCompilerHook);
    
    // Component-level hook
    @customElement({
      name: 'my-component',
      template: '<div>...</div>',
      hooks: [MyCompilerHook]
    })
    export class MyComponent { }
    import { attributePattern, AttrSyntax } from 'aurelia';
    
    @attributePattern({ pattern: 'PART.vue:PART', symbols: '.:' })
    class VueStyleAttributePattern {
      'PART.vue:PART'(rawName: string, rawValue: string, parts: string[]): AttrSyntax {
        const [target, event] = parts;
        return new AttrSyntax(rawName, rawValue, target, 'trigger', [event]);
      }
    }
    
    // Usage: <button click.vue:prevent="handleClick()">
    @attributePattern({ pattern: 'PART.PART.PART', symbols: '.' })
    class NestedPropertyPattern {
      'PART.PART.PART'(rawName: string, rawValue: string, parts: string[]): AttrSyntax {
        const [obj, prop, command] = parts;
        return new AttrSyntax(rawName, rawValue, `${obj}.${prop}`, command, parts);
      }
    }
    
    // Usage: <input user.profile.bind="userProfile">
    import { bindingCommand, BindingCommandInstance, IInstruction } from 'aurelia';
    
    @bindingCommand('throttle')
    class ThrottleBindingCommand implements BindingCommandInstance {
      ignoreAttr = true; // Take full control of attribute processing
    
      build(info: ICommandBuildInfo, parser: IExpressionParser): IInstruction {
        const [delay = '250', event = 'input'] = info.attr.rawValue.split(':');
        
        return new ThrottleInstruction(
          parser.parse(info.attr.rawValue),
          parseInt(delay, 10),
          event
        );
      }
    }
    
    // Usage: <input value.throttle="500:input">
    @bindingCommand('form')
    class FormBindingCommand implements BindingCommandInstance {
      build(info: ICommandBuildInfo, parser: IExpressionParser): IInstruction {
        const formAttributes = this.collectFormAttributes(info.attr.syntax.target);
        
        return new FormInstruction(
          parser.parse(info.attr.rawValue),
          formAttributes
        );
      }
    
      private collectFormAttributes(element: Element): Record<string, string> {
        const attrs: Record<string, string> = {};
        for (const attr of element.attributes) {
          if (attr.name.startsWith('form-')) {
            attrs[attr.name.substring(5)] = attr.value;
          }
        }
        return attrs;
      }
    }
    import { ITemplateElementFactory, IMarkupCache } from 'aurelia';
    
    class CustomTemplateElementFactory implements ITemplateElementFactory {
      private customCache = new Map<string, HTMLTemplateElement>();
    
      createTemplate(markup: string): HTMLTemplateElement {
        // Custom caching logic
        const cacheKey = this.generateCacheKey(markup);
        
        if (this.customCache.has(cacheKey)) {
          return this.customCache.get(cacheKey)!.cloneNode(true) as HTMLTemplateElement;
        }
    
        const template = this.createTemplateElement(markup);
        this.customCache.set(cacheKey, template);
        return template;
      }
    
      private generateCacheKey(markup: string): string {
        // Custom cache key generation
        return `${markup.length}-${this.hashCode(markup)}`;
      }
    }
    class SmartTemplateFactory implements ITemplateElementFactory {
      createTemplate(markup: string): HTMLTemplateElement {
        const wrapped = this.intelligentWrap(markup);
        return this.createTemplateElement(wrapped);
      }
    
      private intelligentWrap(markup: string): string {
        // Custom wrapping logic based on content
        if (markup.includes('<tr>')) {
          return `<table><tbody>${markup}</tbody></table>`;
        }
        if (markup.includes('<option>')) {
          return `<select>${markup}</select>`;
        }
        return markup;
      }
    }
    import { IResourceResolver, IResourceDescriptions } from 'aurelia';
    
    class DynamicResourceResolver implements IResourceResolver {
      resolve(name: string, context: IContainer): IResourceDescriptions | null {
        // Check if this is a dynamic component request
        if (name.startsWith('dynamic-')) {
          return this.resolveDynamicComponent(name, context);
        }
        
        return null; // Let default resolver handle it
      }
    
      private resolveDynamicComponent(name: string, context: IContainer): IResourceDescriptions {
        const componentType = this.loadDynamicComponent(name);
        return {
          [name]: {
            type: componentType,
            keyFrom: name,
            definition: componentType.definition
          }
        };
      }
    }
    class OptimizedResourceResolver implements IResourceResolver {
      private bindablesCache = new Map<Function, Record<string, BindableDefinition>>();
    
      getBindables(Type: Function): Record<string, BindableDefinition> {
        if (this.bindablesCache.has(Type)) {
          return this.bindablesCache.get(Type)!;
        }
    
        const bindables = this.computeBindables(Type);
        this.bindablesCache.set(Type, bindables);
        return bindables;
      }
    }
    @customElement({
      name: 'dashboard',
      template: `
        <template as-custom-element="widget">
          <bindable property="title"></bindable>
          <bindable property="data"></bindable>
          <div class="widget">
            <h3>\${title}</h3>
            <div class="content" innerhtml.bind="data"></div>
          </div>
        </template>
        
        <template as-custom-element="chart-widget">
          <bindable property="chart-data"></bindable>
          <widget title="Chart" data.bind="renderChart(chartData)"></widget>
        </template>
        
        <div class="dashboard">
          <chart-widget chart-data.bind="metrics"></chart-widget>
        </div>
      `
    })
    export class Dashboard {
      renderChart(data: any): string {
        return `<canvas data-chart="${JSON.stringify(data)}"></canvas>`;
      }
    }
    @customElement({
      name: 'dynamic-layout',
      template: `<div ref="container"></div>`
    })
    export class DynamicLayout {
      @ViewSlot() container!: ViewSlot;
    
      attached(): void {
        this.createLocalTemplate();
      }
    
      private createLocalTemplate(): void {
        const template = `
          <template as-custom-element="dynamic-item">
            <bindable property="item"></bindable>
            <div class="item">\${item.name}</div>
          </template>
        `;
        
        this.container.add(this.viewFactory.create(template));
      }
    }
    class CustomCompiler {
      compileWithContext(template: string, parentContext?: ICompilationContext): ICompiledTemplate {
        const context = this.createCompilationContext(parentContext);
        
        // Add custom resources to context
        context.addResource('custom-element', MyCustomElement);
        context.addResource('value-converter', MyConverter);
        
        return this.compile(template, context);
      }
    
      private createCompilationContext(parent?: ICompilationContext): ICompilationContext {
        const context = new CompilationContext(parent);
        
        // Configure context for specific compilation needs
        context.resolveResources = true;
        context.debug = __DEV__;
        
        return context;
      }
    }
    class ScopedCompiler {
      compileWithScope(template: string, scope: Record<string, any>): ICompiledTemplate {
        const container = this.createScopedContainer(scope);
        const context = new CompilationContext(container);
        
        return this.compile(template, context);
      }
    
      private createScopedContainer(scope: Record<string, any>): IContainer {
        const container = DI.createContainer();
        
        // Register scope variables as services
        Object.entries(scope).forEach(([key, value]) => {
          container.register(Registration.instance(key, value));
        });
        
        return container;
      }
    }
    class CachedTemplateCompiler {
      private compilationCache = new Map<string, ICompiledTemplate>();
      private templateHashCache = new Map<string, string>();
    
      compile(template: string, context: ICompilationContext): ICompiledTemplate {
        const hash = this.getTemplateHash(template, context);
        
        if (this.compilationCache.has(hash)) {
          return this.compilationCache.get(hash)!;
        }
    
        const compiled = this.performCompilation(template, context);
        this.compilationCache.set(hash, compiled);
        return compiled;
      }
    
      private getTemplateHash(template: string, context: ICompilationContext): string {
        const contextHash = this.getContextHash(context);
        return `${template.length}-${contextHash}`;
      }
    }
    interface CompilationOptions {
      resolveResources?: boolean;
      debug?: boolean;
      enhance?: boolean;
      aot?: boolean;
    }
    
    class OptimizedCompiler {
      compile(template: string, options: CompilationOptions = {}): ICompiledTemplate {
        const context = this.createOptimizedContext(options);
        
        if (options.aot) {
          return this.compileAOT(template, context);
        }
        
        return this.compileJIT(template, context);
      }
    
      private createOptimizedContext(options: CompilationOptions): ICompilationContext {
        const context = new CompilationContext();
        
        context.resolveResources = options.resolveResources ?? true;
        context.debug = options.debug ?? __DEV__;
        context.enhance = options.enhance ?? false;
        
        return context;
      }
    }

    Components

    Components are the fundamental building blocks of Aurelia applications. A component consists of a view-model (TypeScript class) and an optional view (HTML template) that work together to create reusable UI elements.

    Basic Component Structure

    Every Aurelia component starts with a simple class:

    And its corresponding HTML template (no <template> wrapper is needed in Aurelia 2):

    The ${message} syntax creates a binding between your view-model property and the template, automatically updating the UI when the property changes.

    When to Create a Component?

    Before creating a component, consider these guidelines:

    Create a component when:

    • ✅ You need reusable UI that appears in multiple places

    • ✅ The UI has its own behavior and state

    • ✅ You want to encapsulate complexity (a component should do one thing well)

    • ✅ The UI represents a meaningful concept in your domain (e.g., <user-card>, <product-list>

    Use a custom attribute instead when:

    • ✅ You're adding behavior to existing elements without changing structure

    • ✅ You're creating a decorator or modifier (e.g., tooltip, draggable)

    • ✅ Multiple behaviors can be combined on the same element

    Use a value converter when:

    • ✅ You're just formatting data for display

    • ✅ The transformation is pure (same input → same output)

    • ✅ Examples: ${date | dateFormat}, ${price | currency}

    Custom Elements

    To create reusable custom elements, use the @customElement decorator:

    Using Components

    After creating a component, you need to make it available for use. There are two ways to do this:

    Option 1: Import in Templates (Recommended for Most Cases)

    Import the component where you need it using the <import> element:

    This is the recommended approach because:

    • Components are only loaded where they're used

    • Better code organization and maintainability

    • Clear dependencies in each template

    Option 2: Global Registration

    Register components globally in your main.ts to use them anywhere without imports:

    Now <hello-world></hello-world> works in any template without <import>.

    When to use global registration:

    • Components used on almost every page (headers, footers, layout components)

    • Shared UI components used throughout the app

    • Components you want available in all templates by default

    When to use local imports:

    • Feature-specific components

    • Most custom components

    • Better tree-shaking and bundle optimization

    Bindable Properties

    Make component properties configurable from the outside using @bindable:

    Use the component by binding values to its properties:

    Component Lifecycle

    Components have lifecycle hooks for initialization and cleanup:

    Common Component Patterns

    Pattern: Container/Presenter (Smart/Dumb Components)

    Use case: Separate data management from presentation logic.

    Container (Smart) Component - Manages data and business logic:

    Presenter (Dumb) Component - Pure presentation, no data fetching:

    Why this works: Container components handle complexity (data, routing, state), while presenter components are simple, reusable, and easy to test. You can reuse <user-list> anywhere without worrying about data fetching.

    Pattern: Composition with Slots

    Use case: Create flexible container components that accept custom content.

    Usage:

    Why this works: Slots make components flexible without needing dozens of bindable properties. The component controls the structure while consumers control the content.

    Pattern: Form Components with Two-Way Binding

    Use case: Reusable form inputs that work seamlessly with parent form state.

    Usage:

    Why this works: Two-way binding with BindingMode.twoWay keeps the parent and child in sync automatically. Changes in either place propagate to both.

    Pattern: Stateful UI Components

    Use case: Components that manage their own internal state.

    Why this works: The component owns its UI state (which item is expanded) while accepting data as props. This keeps the parent simple - it just provides data, not UI state.

    Pattern: Event-Emitting Components

    Use case: Child components that notify parents of user actions.

    Usage:

    Why this works: Components can communicate via callbacks (tight coupling) or events (loose coupling) depending on your needs. Use callbacks for parent-child communication, events for unrelated components.

    Best Practices

    Keep Components Focused

    • ✅ Each component should have one clear responsibility

    • ✅ If a component is doing too much, split it into smaller components

    • ❌ Avoid "god components" that handle everything

    Favor Composition Over Inheritance

    • ✅ Use slots and component composition

    • ✅ Build complex UIs from simple, reusable pieces

    • ❌ Avoid deep inheritance hierarchies

    Make Components Predictable

    • ✅ Use bindable properties for inputs

    • ✅ Use callbacks or events for outputs

    • ✅ Document what bindables are required vs optional

    • ❌ Don't manipulate parent state directly

    Test-Friendly Components

    • ✅ Presenter components are easy to test (just props)

    • ✅ Keep business logic in services, not components

    • ✅ Use dependency injection for testability

    What's Next

    • Learn more about

    • Explore in detail

    • Understand for advanced composition

    Complete Getting Started Guide

    Complete getting started guide for Aurelia 2 - from installation to building your first interactive application in 15 minutes.

    Build a real Aurelia application in 15 minutes. This hands-on guide shows you why developers choose Aurelia for its performance, simplicity, and standards-based approach. No prior Aurelia experience required.

    What You'll Discover

    Build a polished task management app while experiencing Aurelia's key advantages:

    • 🚀 Instant two-way data binding - no boilerplate code required

    • ⚡ Blazing fast rendering - direct DOM updates, no virtual DOM overhead

    • 🎯 Intuitive component model - clean, testable architecture

    • 🛠️ Modern TypeScript development - with built-in dependency injection

    The result? A production-quality app with clean, maintainable code in just 15 minutes.

    Prerequisites

    You'll need:

    • Node.js 18+ (recommended latest LTS) ()

    • A code editor ( recommended)

    • Basic knowledge of HTML, CSS, and JavaScript

    Quick Try (No Installation)

    Want to see Aurelia in action immediately? Copy this into an HTML file:

    Open it in your browser and start typing! This demonstrates Aurelia's automatic two-way data binding.

    Create Your First Project

    Step 1: Initialize Project

    Create a new project using the makes command:

    When prompted:

    • Project name: my-task-app

    • Choose TypeScript or JavaScript template (TypeScript recommended)

    • Install dependencies: Yes

    Your app opens at http://localhost:9000 showing "Hello World!"

    Step 2: Project Structure

    Your new project contains:

    Key files to understand:

    • main.ts: Starts your Aurelia application

    • my-app.ts: Your root component's logic (TypeScript)

    • my-app.html: Your root component's template (HTML)

    Understanding Aurelia Components

    Aurelia apps are built with components. Each component has two parts:

    View-Model (Logic)

    src/my-app.ts:

    View (Template)

    src/my-app.html:

    The ${} syntax binds data from your view-model to the template. When message changes, the <h1> automatically updates!

    Build Your Task App

    Let's transform the hello world app into a task manager. We'll build it step by step.

    Step 3: Update the Template

    Replace contents of src/my-app.html:

    Step 4: Update the Logic

    Replace contents of src/my-app.ts:

    Step 5: Add Styles

    Replace contents of src/my-app.css:

    Step 6: See It Work!

    Save your files and check your browser. You now have a fully functional task manager! Try:

    • Adding tasks by typing and clicking "Add Task" or pressing Enter

    • Completing tasks by checking the checkboxes

    • Removing tasks by clicking the × button

    • Watching the counters update automatically

    Key Concepts You Just Learned

    1. Data Binding

    Aurelia automatically keeps your HTML in sync with your TypeScript properties.

    2. Event Handling

    Connect user interactions to your methods seamlessly.

    3. Conditional Rendering

    Show or hide elements based on conditions.

    4. List Rendering

    Display dynamic lists that update automatically.

    5. Computed Properties

    Derived values that update automatically when dependencies change.

    Next Steps

    Congratulations! You've built a real Aurelia application. Here's what to explore next:

    Immediate Next Steps

    • - Create reusable components

    • - Master Aurelia's templating

    • - Manage services and data

    Building Real Apps

    • - Add navigation between pages

    • - Handle complex user input

    • - Connect to APIs

    Development Workflow

    • - Optimize your development setup

    • - Test your applications

    • - Debug effectively

    Common Questions

    "Should I use TypeScript or JavaScript?"

    TypeScript is recommended for better development experience, error catching, and IntelliSense. But JavaScript works perfectly fine too.

    "How does this compare to React/Vue/Angular?"

    Aurelia focuses on standards-based development with minimal learning curve. If you know HTML, CSS, and JavaScript, you already know most of Aurelia.

    "Can I use this in production?"

    Absolutely! Aurelia 2 is production-ready and used by companies worldwide. The framework is stable, performant, and well-tested.

    "What if I get stuck?"

    • - Comprehensive guides and API docs

    • - Community Q&A

    • - Real-time chat with the community

    You're Ready!

    You now understand Aurelia's core concepts and have built a working application. The framework's strength lies in its simplicity - what you just learned covers 80% of what you'll use in real applications.

    Ready to build something amazing? Dive into the guides above or start building your next project with Aurelia!

    Form Basics

    Forms are the cornerstone of interactive web applications. Whether you're building simple contact forms, complex data-entry systems, or dynamic configuration interfaces, Aurelia provides a comprehensive and performant forms system.

    This guide assumes familiarity with Aurelia's binding system and template syntax. For fundamentals, see Template Syntax & Features first.

    Quick Navigation

    • - Text, textarea, number, date inputs

    • - Checkboxes, radios, multi-select, arrays

    • - Submit forms, handle events

    • - Handle file inputs and uploads

    Understanding Aurelia's Form Architecture

    Aurelia's forms system is built on sophisticated observer patterns that provide automatic synchronization between your view models and form controls.

    Data Flow Architecture

    Key Components:

    1. Observers: Monitor DOM events and property changes

    2. Bindings: Connect observers to view model properties

    3. Collection Observers: Handle arrays, Sets, and Maps efficiently

    4. Mutation Observers: Track dynamic DOM changes

    Automatic Change Detection

    Aurelia automatically observes:

    • Text inputs: input, change, keyup events

    • Checkboxes/Radio: change events with array synchronization

    • Select elements: change

    This means you typically don't need manual event handlers—Aurelia handles the complexity automatically while providing hooks for customization when needed.

    Basic Input Binding

    Aurelia provides intuitive two-way binding for all standard form elements. Let's start with the fundamentals.

    Simple Text Inputs

    The foundation of most forms is text input binding:

    Key points:

    • Use value.bind for two-way binding

    • Form inputs default to two-way binding automatically

    • Computed properties (like isFormValid) automatically update

    Textarea Binding

    Textareas work identically to text inputs:

    Number and Date Inputs

    For specialized input types, Aurelia handles type coercion automatically:

    Input Types and Binding

    Aurelia supports all HTML5 input types:

    Type
    Value
    Common Use

    Binding Modes

    While value.bind is automatic two-way binding, you can be explicit:

    When to use each:

    • .bind - Default, use for most form inputs

    • .two-way - Explicit two-way, same as .bind for inputs

    • .one-way - Read-only inputs, display-only values

    Real-World Example

    Here's a complete user registration form:

    Next Steps

    Now that you understand basic form inputs, explore:

    • - Checkboxes, radio buttons, and multi-select

    • - Integrate form validation

    • - Handle file inputs

    • - Submit and process forms

    Quick Tips

    1. Always use labels - Associate labels with inputs using for attribute

    2. Validate on submit - Don't validate every keystroke unless needed

    3. Provide feedback - Show errors clearly after user completes input

    4. Use computed properties

    Common Pitfalls

    • Forgetting .bind - Must use .bind for two-way binding

    • Type mismatches - Number inputs return strings, convert if needed

    • Direct object mutation - Use this.form.prop = value, not form[prop] = value

    Related Documentation

    Enhance

    Learn how to use Aurelia's enhance feature to add interactivity to existing HTML, integrate with other frameworks, hydrate server-rendered content, and create multiple Aurelia instances in your applic

    What is Enhancement?

    Enhancement allows you to bring Aurelia's data binding, templating, and component features to existing DOM content without replacing it entirely. Instead of starting with an empty element and rendering into it, enhancement takes existing HTML and makes it "Aurelia-aware".

    This is perfect for:

    • Progressive enhancement of server-rendered pages

    • Integration with existing applications or other frameworks

    • Widget development where you control specific sections of a page

    • Content Management Systems where you want to add interactivity to generated content

    • Legacy application modernization done incrementally

    The showed how to start Aurelia for empty elements. Enhancement lets you work with existing DOM trees instead.

    Before you start: Review to understand the standard bootstrap flow; enhancement builds on those concepts.

    Understanding What Gets Enhanced

    When you enhance an element, Aurelia treats that element as if it were the template of a custom element. The existing HTML becomes the "template" and your component object becomes the "view model" that provides data and behavior.

    Basic Enhancement Syntax

    Component Types: Classes, Instances, or Objects

    You can enhance with three different component types:

    Key Enhancement Concepts

    1. Existing DOM is preserved: Enhancement doesn't replace your HTML - it makes it interactive

    2. Existing event handlers remain: Any JavaScript event listeners you've already attached stay functional

    3. Manual lifecycle management: You're responsible for calling deactivate() when done

    Proper Cleanup

    Always clean up enhanced content to prevent memory leaks:

    Practical Enhancement Examples

    Server-Rendered Content Enhancement

    Suppose your server renders this HTML:

    You can enhance it to make it interactive:

    Widget Integration Example

    Create interactive widgets within existing pages:

    Dynamic Content Enhancement

    Enhancement is perfect for content that gets added to the page after initial load.

    Enhancing Dynamically Loaded Content

    Enhancing Modal or Dialog Content

    Advanced Enhancement Patterns

    Using Custom Containers

    When you need specific services or configurations for enhanced content:

    Lifecycle Hooks in Enhanced Components

    Enhanced components support all standard Aurelia lifecycle hooks:

    Common Enhancement Patterns

    Progressive Enhancement Checklist

    1. Identify enhancement targets: Elements that need interactivity

    2. Preserve existing functionality: Don't break existing event handlers

    3. Plan your data flow: How will data get to enhanced components?

    4. Handle cleanup: Always deactivate when done

    Best Practices

    • Start small: Enhance specific widgets before entire sections

    • Use meaningful component objects: Include methods and properties that make sense

    • Handle errors gracefully: Enhancement might fail if DOM structure changes

    • Document what gets enhanced: Make it clear to other developers

    When NOT to Use Enhancement

    • New applications: Use regular Aurelia.app() for greenfield projects

    • Full page control: When you control the entire page, standard app startup is simpler

    • Simple static content: If content doesn't need interactivity

    Enhancement shines when you need to add Aurelia's power to existing content without rebuilding everything from scratch.

    Next steps

    • Use when you need to render different components inside an enhanced region.

    • Combine enhancement with to react to model changes in legacy markup.

    • Explore for UI that needs to escape its original DOM location.

    @slotted Decorator

    The @slotted decorator provides a declarative way to observe and react to changes in slotted content within your custom elements. This decorator automatically tracks which elements are projected into specific slots and provides your component with an up-to-date array of matching nodes.

    Overview

    When building custom elements that accept slotted content, you often need to:

    • Know which elements were projected into a specific slot

    Accordion

    Build an accessible accordion component with smooth animations and keyboard support

    Learn to build a simple yet powerful accordion component for collapsible content panels. Perfect for FAQs, settings panels, and content organization.

    What We're Building

    An accordion that supports:

    • Expand/collapse panels

    Lambda Expressions

    Master lambda expressions in Aurelia templates to write cleaner, more expressive code. Learn the supported syntax, array operations, event handling, and performance considerations with real examples f

    Lambda expressions in Aurelia templates allow you to use arrow function syntax directly in your HTML bindings. This feature enables inline data transformations, filtering, and event handling without requiring additional methods in your view models.

    Table of Contents

    export class MyComponent {
      message = 'Hello from Aurelia!';
    }
    <h1>${message}</h1>
    )
    ✅ Examples: <button tooltip="Save changes">, <div draggable sortable>
    component lifecycles
    bindable properties
    shadow DOM and slots
    Download here
    VS Code
    Components Guide
    Templates Deep Dive
    Dependency Injection
    Router
    Forms
    HTTP Client
    Build Tools
    Testing
    Debugging
    Documentation
    GitHub Discussions
    Discord

    Validation Plugin - Integrate with @aurelia/validation

    Value Converters & Binding Behaviors: Transform and control data flow

    events with mutation observation
  • Collections: Array mutations, Set/Map changes

  • Object properties: Deep property observation

  • tel

    string

    Phone numbers

    url

    string

    Website URLs

    search

    string

    Search queries

    date

    Date/string

    Date selection

    time

    string

    Time selection

    datetime-local

    Date/string

    Date and time

    color

    string

    Color picker

    range

    number

    Slider input

    .from-view - Capture input without updating view

  • .one-time - Static initial values

  • Template Recipes - Real-world examples

    - Let Aurelia handle form state reactively
  • Keep it simple - Don't overcomplicate with manual DOM manipulation

  • Missing labels - Always include labels for accessibility

    text

    string

    General text input

    email

    string

    Email addresses

    password

    string

    Password fields

    number

    number

    Numeric input

    Basic Inputs
    Collections
    Form Submission
    File Uploads
    Collections
    Validation Plugin
    File Uploads
    Form Submission
    Template Syntax Overview
    Attribute Binding
    Event Binding
    Validation Plugin
    Template compilation: Aurelia compiles the existing HTML for bindings and directives

    Test without JavaScript: Ensure basic functionality works without enhancement

    Consider performance: Don't enhance too many elements at once

    Performance critical sections
    : Enhancement has overhead compared to pre-compiled templates
    startup sections
    App configuration and startup
    dynamic composition
    watching data
    portalling elements

    Single or multiple panels open

  • Smooth animations

  • Keyboard navigation

  • Accessible with ARIA attributes

  • Customizable styling

  • Component Code

    accordion.ts

    accordion.html

    accordion-panel.ts

    accordion-panel.html

    accordion.css

    Usage Examples

    Basic Accordion (Single Panel Open)

    Multiple Panels Open

    Controlled Open Panels

    With Rich Content

    Testing

    Accessibility Features

    This accordion implements WCAG 2.1 guidelines:

    • ✅ ARIA Attributes: aria-expanded indicates panel state

    • ✅ Keyboard Support: Enter and Space keys toggle panels

    • ✅ Focus Management: Buttons are focusable with visible focus indicators

    • ✅ Semantic HTML: Uses <button> for interactive headers

    Enhancements

    1. Add Animation Callbacks

    2. Add Icons

    3. Add Lazy Loading

    Best Practices

    1. Animation Performance: Use max-height instead of height: auto for smooth transitions

    2. Content Height: Set reasonable max-height values or calculate dynamically

    3. Accessibility: Always include aria-expanded for screen readers

    4. Focus Visible: Ensure keyboard focus is clearly visible

    5. Mobile: Test touch interactions and ensure adequate tap targets

    Summary

    You've built a fully-functional accordion with:

    • ✅ Single and multiple panel modes

    • ✅ Smooth animations

    • ✅ Keyboard support

    • ✅ Accessible markup

    • ✅ Easy customization

    This accordion is ready for FAQs, settings panels, and any collapsible content!

    Basic Syntax

  • Supported Patterns

  • Restrictions and Limitations

  • Array Operations

  • Event Handling

  • Text Interpolation

  • Advanced Use Cases

  • Performance and Best Practices

  • Common Pitfalls

  • What Are Lambda Expressions?

    Lambda expressions are arrow functions that you can write directly in Aurelia template bindings. They provide a way to perform inline data transformations, filtering, and calculations without defining separate methods in your view model.

    Key Benefits:

    • Inline Logic: Write simple functions directly in templates

    • Reduced Boilerplate: Avoid creating view model methods for simple operations

    • Better Locality: Keep related logic close to where it's used

    • Reactive Updates: Automatically re-evaluate when dependencies change

    Basic Syntax

    Aurelia supports these arrow function patterns:

    Real Template Usage:

    Supported Patterns

    Lambda expressions work in multiple binding contexts:

    Restrictions and Limitations

    Aurelia's lambda expressions support only expression bodies. The following JavaScript arrow function features are not supported:

    Error Messages: When you attempt to use unsupported features, you'll receive these specific error codes:

    • Block bodies: AUR0178: "arrow function with function body is not supported"

    • Default parameters: AUR0174: "arrow function with default parameters is not supported"

    • Destructuring: AUR0175: "arrow function with destructuring parameters is not supported"

    Array Operations

    Lambda expressions work with all standard JavaScript array methods. Aurelia automatically observes array changes and property access within lambda expressions.

    Basic Array Operations

    Lambda expressions work with all standard JavaScript array methods:

    Chaining Operations

    Combine multiple array methods for complex transformations:

    Reactive Property Access

    Aurelia automatically tracks property access in lambda expressions:

    Event Handling

    Lambda expressions work with all Aurelia event bindings:

    Custom Attributes with Lambdas:

    Text Interpolation

    Use lambda expressions in text interpolation for inline calculations:

    Accessing Template Context

    Lambda expressions can access $this and $parent for scope navigation, maintaining proper binding context:

    Scope Access Patterns:

    • $this: References the current binding context (view model)

    • $parent: References the parent binding context

    • $parent.$parent: Chain to access higher-level scopes

    • Maintains reactivity: Changes to referenced properties trigger updates

    Advanced Use Cases

    Nested Array Processing

    Process complex nested data structures with proper scope handling:

    Nested Processing Benefits:

    • Maintains lexical scope: Outer variables accessible in inner functions

    • Reactive updates: Changes to nested properties trigger re-evaluation

    • Performance optimized: Aurelia observes the right level of nesting

    Dynamic Search and Filtering

    Create responsive search interfaces:

    Immediate Invoked Arrow Functions (IIFE)

    Execute functions immediately within templates using arrow function IIFE patterns:

    IIFE Use Cases:

    • Calculations: Perform complex math without cluttering view model

    • Data transformation: Transform values inline with specific logic

    • Scoped operations: Execute multi-step operations in template context

    Performance and Best Practices

    Automatic Property Observation

    Aurelia automatically observes properties accessed within lambda expressions:

    Array Mutation Handling

    Aurelia observes array mutations for most methods:

    Sorting Considerations:

    Why slice() before sort()?

    • sort() mutates the original array, triggering multiple change notifications

    • slice() creates a copy, preventing mutation conflicts with the repeater

    • Ensures stable rendering when the source array changes

    Performance Guidelines

    • Keep expressions simple: Complex logic should move to view model methods

    • Avoid deep nesting: Limit chained operations for readability

    • Use specific property access: Reference specific properties for optimal observation

    • Profile when needed: Monitor performance in large lists with complex transformations

    Common Pitfalls

    Mutation vs. Immutable Operations

    Expression Complexity

    Debugging Tips

    • Isolate expressions: Test lambda expressions in browser console first

    • Use intermediate variables: Break complex chains into steps in your view model

    • Check property names: Ensure referenced properties exist and are observable

    • Verify data structure: Confirm arrays and objects have expected shape

    • Parser state corruption: If experiencing strange errors, check for syntax issues in other expressions that might corrupt the parser state

    Framework Implementation Notes

    AST Structure:

    • Lambda expressions compile to ArrowFunction AST nodes

    • Support rest parameters via boolean flag

    • Body must be an expression (IsAssign type)

    • Parameters are stored as BindingIdentifier[]

    Error Codes:

    • AUR0173: Invalid arrow parameter list

    • AUR0174: Default parameters not supported

    • AUR0175: Destructuring parameters not supported

    • AUR0176: Rest parameter must be last

    • AUR0178: Function body (block statements) not supported

    Summary

    Lambda expressions in Aurelia templates provide a powerful way to write inline logic without cluttering your view models. They excel at array transformations, simple calculations, and event handling. Remember to:

    • Use only expression bodies (no curly braces)

    • Leverage automatic property observation for reactive updates

    • Keep expressions simple and readable

    • Move complex logic to view model methods when needed

    • Use slice() before mutating operations like sort() for safety

    With these guidelines, lambda expressions can significantly improve your template code's clarity and maintainability.

    What Are Lambda Expressions?
    import { customElement } from 'aurelia';
    
    @customElement('hello-world')
    export class HelloWorld {
      name = 'World';
    }
    <h1>Hello, ${name}!</h1>
    <import from="./hello-world"></import>
    
    <div>
      <hello-world></hello-world>
    </div>
    import Aurelia from 'aurelia';
    import { MyApp } from './my-app';
    import { HelloWorld } from './hello-world';
    
    Aurelia
      .register(HelloWorld)  // Register globally
      .app(MyApp)
      .start();
    import { bindable } from 'aurelia';
    
    export class UserCard {
      @bindable name: string;
      @bindable email: string;
      @bindable avatar: string;
    }
    <div class="user-card">
      <img src.bind="avatar" alt="Avatar">
      <h3>${name}</h3>
      <p>${email}</p>
    </div>
    <user-card name.bind="user.name" email.bind="user.email" avatar.bind="user.avatar"></user-card>
    export class MyComponent {
      created() {
        // Component instance created
      }
    
      binding() {
        // Data binding about to occur
      }
    
      bound() {
        // Data binding completed
      }
    
      attached() {
        // Component attached to DOM
      }
    
      detached() {
        // Component removed from DOM
      }
    }
    import { resolve } from '@aurelia/kernel';
    import { IRouter } from '@aurelia/router';
    
    export class UserListPage {
      private router = resolve(IRouter);
      users: User[] = [];
      isLoading = false;
    
      async binding() {
        this.isLoading = true;
        try {
          const response = await fetch('/api/users');
          this.users = await response.json();
        } finally {
          this.isLoading = false;
        }
      }
    
      viewUser(user: User) {
        this.router.load(`/users/${user.id}`);
      }
    
      deleteUser(user: User) {
        // Handle deletion
      }
    }
    <div class="page">
      <h1>Users</h1>
      <loading-spinner if.bind="isLoading"></loading-spinner>
    
      <user-list
        users.bind="users"
        on-view.call="viewUser($event)"
        on-delete.call="deleteUser($event)">
      </user-list>
    </div>
    import { bindable } from 'aurelia';
    
    export class UserList {
      @bindable users: User[];
      @bindable onView: (user: User) => void;
      @bindable onDelete: (user: User) => void;
    }
    <div class="user-list">
      <div repeat.for="user of users" class="user-card">
        <h3>${user.name}</h3>
        <p>${user.email}</p>
        <button click.trigger="onView(user)">View</button>
        <button click.trigger="onDelete(user)">Delete</button>
      </div>
    </div>
    export class Card {
      @bindable title: string;
      @bindable actions: boolean = false;
    }
    <div class="card">
      <div class="card-header">
        <h2>${title}</h2>
      </div>
    
      <div class="card-body">
        <slot></slot> <!-- Main content goes here -->
      </div>
    
      <div class="card-footer" if.bind="actions">
        <slot name="actions"></slot> <!-- Named slot for actions -->
      </div>
    </div>
    <card title="User Profile" actions.bind="true">
      <!-- Default slot content -->
      <p>Name: ${user.name}</p>
      <p>Email: ${user.email}</p>
    
      <!-- Named slot content -->
      <button slot="actions" click.trigger="edit()">Edit</button>
      <button slot="actions" click.trigger="delete()">Delete</button>
    </card>
    import { bindable, BindingMode } from 'aurelia';
    
    export class FormInput {
      @bindable label: string;
      @bindable({ mode: BindingMode.twoWay }) value: string;
      @bindable type: string = 'text';
      @bindable required: boolean = false;
      @bindable error: string;
    }
    <div class="form-group">
      <label>
        ${label}
        <span if.bind="required" class="required">*</span>
      </label>
    
      <input
        type.bind="type"
        value.bind="value"
        class="form-control ${error ? 'is-invalid' : ''}">
    
      <div class="error-message" if.bind="error">
        ${error}
      </div>
    </div>
    export class RegistrationForm {
      email: string = '';
      password: string = '';
      emailError: string;
    
      validateEmail() {
        this.emailError = this.email.includes('@') ? '' : 'Invalid email';
      }
    }
    <form-input
      label="Email"
      value.bind="email"
      type="email"
      required.bind="true"
      error.bind="emailError"
      blur.trigger="validateEmail()">
    </form-input>
    
    <form-input
      label="Password"
      value.bind="password"
      type="password"
      required.bind="true">
    </form-input>
    import { bindable } from 'aurelia';
    
    export class Accordion {
      @bindable items: AccordionItem[];
      expandedIndex: number | null = null;
    
      toggle(index: number) {
        this.expandedIndex = this.expandedIndex === index ? null : index;
      }
    
      isExpanded(index: number) {
        return this.expandedIndex === index;
      }
    }
    <div class="accordion">
      <div repeat.for="item of items" class="accordion-item">
        <button
          class="accordion-header ${isExpanded($index) ? 'expanded' : ''}"
          click.trigger="toggle($index)">
          ${item.title}
        </button>
    
        <div class="accordion-content" show.bind="isExpanded($index)">
          ${item.content}
        </div>
      </div>
    </div>
    import { bindable } from 'aurelia';
    import { resolve } from '@aurelia/kernel';
    import { IEventAggregator } from '@aurelia/kernel';
    
    export class SearchBox {
      @bindable placeholder: string = 'Search...';
      @bindable onSearch: (query: string) => void;
    
      private ea = resolve(IEventAggregator);
      query: string = '';
    
      handleSearch() {
        // Option 1: Callback binding
        if (this.onSearch) {
          this.onSearch(this.query);
        }
    
        // Option 2: Event aggregator (for loosely coupled components)
        this.ea.publish('search:query', this.query);
      }
    
      handleClear() {
        this.query = '';
        this.handleSearch();
      }
    }
    <div class="search-box">
      <input
        value.bind="query"
        placeholder.bind="placeholder"
        keyup.trigger="handleSearch() & debounce:300">
    
      <button click.trigger="handleClear()" if.bind="query">
        Clear
      </button>
    </div>
    <!-- Using callback -->
    <search-box on-search.call="performSearch($event)"></search-box>
    
    <!-- Or listen via event aggregator -->
    export class ProductCatalog {
      private ea = resolve(IEventAggregator);
    
      binding() {
        this.ea.subscribe('search:query', query => {
          this.performSearch(query);
        });
      }
    }
    <!DOCTYPE html>
    <html>
    <head>
      <title>Aurelia 2 Demo</title>
    </head>
    <body>
      <my-app></my-app>
    
      <script type="module">
        import Aurelia, { CustomElement } from 'https://cdn.jsdelivr.net/npm/aurelia@latest/+esm';
    
        const App = CustomElement.define({
          name: 'my-app',
          template: `
            <h1>Hello, \${name}!</h1>
            <input value.bind="name" placeholder="Enter your name">
            <p>You typed: \${name}</p>
          `
        }, class {
          name = 'World';
        });
    
        new Aurelia()
          .app({ component: App, host: document.querySelector('my-app') })
          .start();
      </script>
    </body>
    </html>
    npx makes aurelia
    cd my-task-app
    npm run dev
    my-task-app/
    ├── src/
    │   ├── main.ts          # Application entry point
    │   ├── my-app.html      # Root component template
    │   ├── my-app.ts        # Root component logic
    │   └── my-app.css       # Component styles
    ├── index.html           # Main HTML file
    ├── vite.config.js       # Vite configuration
    └── package.json         # Dependencies and scripts
    export class MyApp {
      message = 'Hello World!';
    
      // Methods and properties go here
    }
    <h1>${message}</h1>
    <!-- HTML template goes here -->
    <div class="app">
      <h1>My Task Manager</h1>
    
      <!-- Add new task form -->
      <div class="add-task">
        <input
          value.bind="newTaskText"
          placeholder="Enter a new task..."
          keydown.trigger="addTaskOnEnter($event)">
        <button click.trigger="addTask()">Add Task</button>
      </div>
    
      <!-- Task counter -->
      <p class="task-count">
        ${tasks.length} task${tasks.length === 1 ? '' : 's'} total
      </p>
    
      <!-- Task list -->
      <ul class="task-list">
        <li repeat.for="task of tasks" class="task-item">
          <label class="task-label">
            <input
              type="checkbox"
              checked.bind="task.completed"
              change.trigger="updateTaskCount()">
            <span class="${task.completed ? 'completed' : ''}">${task.text}</span>
          </label>
          <button click.trigger="removeTask(task)" class="remove-btn">×</button>
        </li>
      </ul>
    
      <!-- Empty state -->
      <p if.bind="tasks.length === 0" class="empty-state">
        No tasks yet. Add one above!
      </p>
    
      <!-- Completed tasks counter -->
      <p if.bind="completedTaskCount > 0" class="completed-count">
        ✅ ${completedTaskCount} completed
      </p>
    </div>
    export class MyApp {
      newTaskText = '';
      tasks: Task[] = [
        { id: 1, text: 'Learn Aurelia basics', completed: false },
        { id: 2, text: 'Build a task app', completed: false },
        { id: 3, text: 'Celebrate! 🎉', completed: false }
      ];
      private nextId = 4;
    
      get completedTaskCount(): number {
        return this.tasks.filter(task => task.completed).length;
      }
    
      addTask(): void {
        if (this.newTaskText.trim()) {
          this.tasks.push({
            id: this.nextId++,
            text: this.newTaskText.trim(),
            completed: false
          });
          this.newTaskText = '';
        }
      }
    
      addTaskOnEnter(event: KeyboardEvent): void {
        if (event.key === 'Enter') {
          this.addTask();
        }
      }
    
      removeTask(taskToRemove: Task): void {
        this.tasks = this.tasks.filter(task => task !== taskToRemove);
      }
    
      updateTaskCount(): void {
        // This method triggers reactivity update for computed properties
        // In most cases, Aurelia handles this automatically
      }
    }
    
    interface Task {
      id: number;
      text: string;
      completed: boolean;
    }
    /* Reset and base styles */
    * {
      box-sizing: border-box;
    }
    
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      line-height: 1.6;
      margin: 0;
      padding: 20px;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      min-height: 100vh;
    }
    
    .app {
      max-width: 600px;
      margin: 0 auto;
      background: white;
      border-radius: 12px;
      padding: 2rem;
      box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
    }
    
    h1 {
      color: #333;
      text-align: center;
      margin-bottom: 2rem;
      font-size: 2.5rem;
      font-weight: 300;
    }
    
    /* Add task form */
    .add-task {
      display: flex;
      gap: 0.5rem;
      margin-bottom: 1.5rem;
    }
    
    .add-task input {
      flex: 1;
      padding: 0.75rem;
      border: 2px solid #e1e5e9;
      border-radius: 6px;
      font-size: 1rem;
      transition: border-color 0.2s;
    }
    
    .add-task input:focus {
      outline: none;
      border-color: #667eea;
    }
    
    .add-task button {
      padding: 0.75rem 1.5rem;
      background: #667eea;
      color: white;
      border: none;
      border-radius: 6px;
      cursor: pointer;
      font-size: 1rem;
      transition: background 0.2s;
    }
    
    .add-task button:hover {
      background: #5a6fd8;
    }
    
    /* Task counters */
    .task-count, .completed-count {
      color: #666;
      font-size: 0.9rem;
      margin: 1rem 0;
    }
    
    .completed-count {
      color: #22c55e;
      font-weight: 500;
    }
    
    /* Task list */
    .task-list {
      list-style: none;
      padding: 0;
      margin: 0;
    }
    
    .task-item {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 0.75rem;
      border: 1px solid #e1e5e9;
      border-radius: 6px;
      margin-bottom: 0.5rem;
      transition: all 0.2s;
    }
    
    .task-item:hover {
      border-color: #667eea;
      background: #f8fafc;
    }
    
    .task-label {
      display: flex;
      align-items: center;
      cursor: pointer;
      flex: 1;
    }
    
    .task-label input[type="checkbox"] {
      margin-right: 0.75rem;
      transform: scale(1.2);
    }
    
    .task-label span.completed {
      text-decoration: line-through;
      color: #9ca3af;
    }
    
    .remove-btn {
      background: #ef4444;
      color: white;
      border: none;
      border-radius: 4px;
      width: 2rem;
      height: 2rem;
      cursor: pointer;
      font-size: 1.2rem;
      transition: background 0.2s;
    }
    
    .remove-btn:hover {
      background: #dc2626;
    }
    
    /* Empty state */
    .empty-state {
      text-align: center;
      color: #9ca3af;
      font-style: italic;
      padding: 2rem;
    }
    <input value.bind="newTaskText">
    <span>${task.text}</span>
    <button click.trigger="addTask()">Add Task</button>
    <input keydown.trigger="addTaskOnEnter($event)">
    <p if.bind="tasks.length === 0">No tasks yet!</p>
    <li repeat.for="task of tasks">
      ${task.text}
    </li>
    get completedTaskCount(): number {
      return this.tasks.filter(task => task.completed).length;
    }
    User Input → DOM Event → Observer → Binding → View Model → Reactive Updates
         ↑                                                            ↓
    Form Element ← DOM Update ← Binding ← Property Change ← View Model
    <form submit.trigger="handleSubmit()">
      <div class="form-group">
        <label for="email">Email:</label>
        <input id="email"
               type="email"
               value.bind="email"
               placeholder.bind="emailPlaceholder" />
      </div>
      <div class="form-group">
        <label for="password">Password:</label>
        <input id="password"
               type="password"
               value.bind="password" />
      </div>
      <button type="submit" disabled.bind="!isFormValid">Login</button>
    </form>
    export class LoginComponent {
      email = '';
      password = '';
      emailPlaceholder = 'Enter your email address';
    
      get isFormValid(): boolean {
        return this.email.length > 0 && this.password.length >= 8;
      }
    
      handleSubmit() {
        if (this.isFormValid) {
          console.log('Submitting:', { email: this.email, password: this.password });
        }
      }
    }
    <div class="form-group">
      <label for="comments">Comments:</label>
      <textarea id="comments"
                value.bind="comments"
                rows="4"
                maxlength.bind="maxCommentLength"></textarea>
      <small>${comments.length}/${maxCommentLength} characters</small>
    </div>
    export class FeedbackForm {
      comments = '';
      maxCommentLength = 500;
    }
    <div class="form-group">
      <label for="age">Age:</label>
      <input id="age"
             type="number"
             value.bind="age"
             min="18"
             max="120" />
    </div>
    
    <div class="form-group">
      <label for="birthdate">Birth Date:</label>
      <input id="birthdate"
             type="date"
             value.bind="birthDate" />
    </div>
    
    <div class="form-group">
      <label for="appointment">Appointment Time:</label>
      <input id="appointment"
             type="datetime-local"
             value.bind="appointmentTime" />
    </div>
    export class ProfileForm {
      age: number = 25;
      birthDate: Date = new Date('1998-01-01');
      appointmentTime: Date = new Date();
    
      get isAdult(): boolean {
        return this.age >= 18;
      }
    }
    <!-- Two-way binding (default for inputs) -->
    <input value.two-way="username">
    
    <!-- One-way (view model → view) -->
    <input value.one-way="displayName">
    
    <!-- From view (view → view model) -->
    <input value.from-view="searchQuery">
    
    <!-- One-time (set once, no updates) -->
    <input value.one-time="initialValue">
    <form submit.trigger="register()" class="registration-form">
      <h2>Create Account</h2>
    
      <!-- Username -->
      <div class="form-group">
        <label for="username">Username *</label>
        <input id="username"
               type="text"
               value.bind="form.username"
               required
               minlength="3"
               maxlength="20">
        <small>3-20 characters</small>
      </div>
    
      <!-- Email -->
      <div class="form-group">
        <label for="email">Email *</label>
        <input id="email"
               type="email"
               value.bind="form.email"
               required>
      </div>
    
      <!-- Password -->
      <div class="form-group">
        <label for="password">Password *</label>
        <input id="password"
               type="password"
               value.bind="form.password"
               required
               minlength="8">
        <small>At least 8 characters</small>
      </div>
    
      <!-- Confirm Password -->
      <div class="form-group">
        <label for="confirmPassword">Confirm Password *</label>
        <input id="confirmPassword"
               type="password"
               value.bind="form.confirmPassword"
               required>
        <span if.bind="form.password !== form.confirmPassword" class="error">
          Passwords must match
        </span>
      </div>
    
      <!-- Age -->
      <div class="form-group">
        <label for="age">Age</label>
        <input id="age"
               type="number"
               value.bind="form.age"
               min="13"
               max="120">
      </div>
    
      <!-- Bio -->
      <div class="form-group">
        <label for="bio">Bio</label>
        <textarea id="bio"
                  value.bind="form.bio"
                  maxlength="500"
                  rows="4"></textarea>
        <small>${form.bio.length}/500 characters</small>
      </div>
    
      <!-- Submit -->
      <button type="submit"
              disabled.bind="!isFormValid"
              class="btn-primary">
        Create Account
      </button>
    </form>
    export class Registration {
      form = {
        username: '',
        email: '',
        password: '',
        confirmPassword: '',
        age: null,
        bio: ''
      };
    
      get isFormValid(): boolean {
        return this.form.username.length >= 3 &&
               this.form.email.includes('@') &&
               this.form.password.length >= 8 &&
               this.form.password === this.form.confirmPassword;
      }
    
      register() {
        if (this.isFormValid) {
          console.log('Registering user:', this.form);
          // API call here
        }
      }
    }
    // Using the convenience method (recommended)
    const enhanceRoot = await Aurelia.enhance({
      host: document.querySelector('#my-content'),
      component: { message: 'Hello World' }
    });
    
    // Using instance method  
    const au = new Aurelia();
    const enhanceRoot = await au.enhance({
      host: document.querySelector('#my-content'),
      component: { message: 'Hello World' }
    });
    // 1. Plain object (most common for simple cases)
    const enhanceRoot = await Aurelia.enhance({
      host: element,
      component: {
        message: 'Hello',
        items: [1, 2, 3],
        handleClick() { 
          console.log('Clicked!'); 
        }
      }
    });
    
    // 2. Class instance (when you need constructor logic)
    class MyViewModel {
      message = 'Hello';
      constructor() {
        // initialization logic
      }
    }
    const enhanceRoot = await Aurelia.enhance({
      host: element,
      component: new MyViewModel()
    });
    
    // 3. Custom element class (for reusable components)
    @customElement({ name: 'my-widget' })
    class MyWidget {
      @bindable message: string;
    }
    const enhanceRoot = await Aurelia.enhance({
      host: element,
      component: MyWidget
    });
    const enhanceRoot = await Aurelia.enhance({ host, component });
    
    // Later, when you're done:
    await enhanceRoot.deactivate();
    <!-- Server-rendered content -->
    <div id="user-profile">
      <h2>Welcome back!</h2>
      <div class="stats">
        <span>Loading user data...</span>
      </div>
      <button id="refresh-btn">Refresh</button>
    </div>
    import Aurelia from 'aurelia';
    
    // Your existing server-rendered element
    const profileElement = document.querySelector('#user-profile');
    
    // Enhance with Aurelia interactivity
    const enhanceRoot = await Aurelia.enhance({
      host: profileElement,
      component: {
        username: 'Loading...',
        loginCount: 0,
        
        async created() {
          // Load user data when component initializes
          const userData = await fetch('/api/user/profile').then(r => r.json());
          this.username = userData.username;
          this.loginCount = userData.loginCount;
        },
        
        refreshData() {
          this.created(); // Reload data
        }
      }
    });
    
    // Update your HTML to use bindings:
    // <h2>Welcome back, ${username}!</h2>
    // <div class="stats">
    //   <span>Login count: ${loginCount}</span>
    // </div>
    // <button click.delegate="refreshData()">Refresh</button>
    <!-- Existing page content -->
    <div class="article">
      <h1>My Blog Post</h1>
      <p>Some content...</p>
      
      <!-- Widget placeholder -->
      <div id="comment-widget">
        <h3>Comments</h3>
        <div class="loading">Loading comments...</div>
      </div>
    </div>
    // Enhance just the comment widget
    const commentWidget = document.querySelector('#comment-widget');
    
    const enhanceRoot = await Aurelia.enhance({
      host: commentWidget,
      component: {
        comments: [],
        newComment: '',
        
        async created() {
          this.comments = await this.loadComments();
        },
        
        async loadComments() {
          return fetch('/api/comments/123').then(r => r.json());
        },
        
        async addComment() {
          if (!this.newComment.trim()) return;
          
          await fetch('/api/comments', {
            method: 'POST',
            body: JSON.stringify({ text: this.newComment }),
            headers: { 'Content-Type': 'application/json' }
          });
          
          this.newComment = '';
          this.comments = await this.loadComments();
        }
      }
    });
    
    // Update HTML to:
    // <div id="comment-widget">
    //   <h3>Comments (${comments.length})</h3>
    //   <div repeat.for="comment of comments">
    //     <p>${comment.text}</p>
    //   </div>
    //   <div>
    //     <input value.bind="newComment" placeholder="Add comment...">
    //     <button click.delegate="addComment()">Post</button>
    //   </div>
    // </div>
    import { Aurelia, resolve } from 'aurelia';
    
    export class DynamicContentComponent {
      private enhancedRoots: Array<any> = [];
      
      constructor(private au = resolve(Aurelia)) {}
    
      async loadMoreContent() {
        // Load HTML from server
        const response = await fetch('/api/content/next-page');
        const htmlContent = await response.text();
        
        // Create container for new content
        const container = document.createElement('div');
        container.innerHTML = htmlContent;
        document.querySelector('#content-area').appendChild(container);
        
        // Enhance the new content
        const enhanceRoot = await this.au.enhance({
          host: container,
          component: {
            currentUser: this.currentUser,
            likePost: (postId) => this.likePost(postId),
            sharePost: (postId) => this.sharePost(postId)
          }
        });
        
        // Keep track for cleanup
        this.enhancedRoots.push(enhanceRoot);
      }
      
      // Clean up when component is destroyed
      async unbinding() {
        for (const root of this.enhancedRoots) {
          await root.deactivate();
        }
        this.enhancedRoots = [];
      }
    }
    export class ModalService {
      private currentModal: any = null;
      
      async showModal(contentHtml: string, viewModel: any) {
        // Create modal element
        const modal = document.createElement('div');
        modal.className = 'modal';
        modal.innerHTML = `
          <div class="modal-content">
            <button class="close" click.delegate="closeModal()">&times;</button>
            ${contentHtml}
          </div>
        `;
        
        document.body.appendChild(modal);
    
        // Enhance the modal content
        this.currentModal = await Aurelia.enhance({
          host: modal,
          component: {
            ...viewModel,
            closeModal: () => this.closeModal()
          }
        });
      }
      
      async closeModal() {
        if (this.currentModal) {
          await this.currentModal.deactivate();
          document.querySelector('.modal')?.remove();
          this.currentModal = null;
        }
      }
    }
    import { DI, Registration } from '@aurelia/kernel';
    import { LoggerConfiguration, LogLevel } from 'aurelia';
    
    // Create custom container for widget
    const widgetContainer = DI.createContainer()
      .register(
        Registration.singleton('ApiService', MyApiService),
        LoggerConfiguration.create({ level: LogLevel.debug })
      );
    
    const enhanceRoot = await Aurelia.enhance({
      host: document.querySelector('#my-widget'),
      component: MyWidget,
      container: widgetContainer  // Use custom container
    });
    const enhanceRoot = await Aurelia.enhance({
      host: element,
      component: {
        data: null,
        
        // Called when component is being set up
        created() {
          console.log('Component created');
        },
        
        // Called before data binding starts
        binding() {
          console.log('Starting data binding');
        },
        
        // Called after data binding completes
        bound() {
          console.log('Data binding complete');
        },
        
        // Called when component is being attached to DOM
        attaching() {
          console.log('Attaching to DOM');
        },
        
        // Called after component is attached to DOM
        attached() {
          console.log('Attached to DOM - ready for user interaction');
          // Good place for focus, animations, etc.
        },
        
        // Called when component is being removed
        detaching() {
          console.log('Detaching from DOM');
        },
        
        // Called when data bindings are being torn down
        unbinding() {
          console.log('Unbinding data');
          // Cleanup subscriptions, timers, etc.
        }
      }
    });
    import { bindable } from 'aurelia';
    
    export class Accordion {
      @bindable allowMultiple = false;
      @bindable openPanels: number[] = [];
    
      togglePanel(index: number) {
        if (this.allowMultiple) {
          // Multiple panels can be open
          const panelIndex = this.openPanels.indexOf(index);
          if (panelIndex > -1) {
            this.openPanels.splice(panelIndex, 1);
          } else {
            this.openPanels.push(index);
          }
        } else {
          // Only one panel can be open
          if (this.isPanelOpen(index)) {
            this.openPanels = [];
          } else {
            this.openPanels = [index];
          }
        }
      }
    
      isPanelOpen(index: number): boolean {
        return this.openPanels.includes(index);
      }
    }
    <div class="accordion">
      <au-slot></au-slot>
    </div>
    import { bindable, resolve } from 'aurelia';
    import { Accordion } from './accordion';
    
    export class AccordionPanel {
      @bindable title = '';
      @bindable index = 0;
    
      private accordion = resolve(Accordion);
    
      get isOpen(): boolean {
        return this.accordion.isPanelOpen(this.index);
      }
    
      toggle() {
        this.accordion.togglePanel(this.index);
      }
    
      handleKeyDown(event: KeyboardEvent) {
        if (event.key === 'Enter' || event.key === ' ') {
          event.preventDefault();
          this.toggle();
        }
      }
    }
    <div class="accordion-panel \${isOpen ? 'accordion-panel--open' : ''}">
      <button
        type="button"
        class="accordion-panel__header"
        click.trigger="toggle()"
        keydown.trigger="handleKeyDown($event)"
        aria-expanded.bind="isOpen">
    
        <span class="accordion-panel__title">\${title}</span>
    
        <svg class="accordion-panel__icon" width="16" height="16" viewBox="0 0 16 16">
          <path d="M8 12L2 6h12z" fill="currentColor"/>
        </svg>
      </button>
    
      <div
        class="accordion-panel__content"
        aria-hidden.bind="!isOpen">
        <div class="accordion-panel__body">
          <au-slot></au-slot>
        </div>
      </div>
    </div>
    .accordion {
      border: 1px solid #e5e7eb;
      border-radius: 8px;
      overflow: hidden;
    }
    
    .accordion-panel {
      border-bottom: 1px solid #e5e7eb;
    }
    
    .accordion-panel:last-child {
      border-bottom: none;
    }
    
    .accordion-panel__header {
      width: 100%;
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 16px 20px;
      background: white;
      border: none;
      cursor: pointer;
      transition: background 0.15s;
      text-align: left;
      font-size: 16px;
      font-weight: 500;
    }
    
    .accordion-panel__header:hover {
      background: #f9fafb;
    }
    
    .accordion-panel__header:focus {
      outline: 2px solid #3b82f6;
      outline-offset: -2px;
      z-index: 1;
    }
    
    .accordion-panel__title {
      color: #111827;
    }
    
    .accordion-panel__icon {
      color: #6b7280;
      transition: transform 0.2s;
      flex-shrink: 0;
    }
    
    .accordion-panel--open .accordion-panel__icon {
      transform: rotate(180deg);
    }
    
    .accordion-panel__content {
      max-height: 0;
      overflow: hidden;
      transition: max-height 0.3s ease-out;
    }
    
    .accordion-panel--open .accordion-panel__content {
      max-height: 1000px; /* Adjust based on your content */
    }
    
    .accordion-panel__body {
      padding: 0 20px 16px;
      color: #374151;
      line-height: 1.6;
    }
    <accordion>
      <accordion-panel index="0" title="What is Aurelia?">
        Aurelia is a modern JavaScript framework for building web applications.
      </accordion-panel>
    
      <accordion-panel index="1" title="How do I install Aurelia?">
        You can install Aurelia using npm: <code>npm install aurelia</code>
      </accordion-panel>
    
      <accordion-panel index="2" title="Where can I learn more?">
        Check out the official documentation at docs.aurelia.io
      </accordion-panel>
    </accordion>
    <accordion allow-multiple.bind="true">
      <accordion-panel index="0" title="Account Settings">
        <p>Manage your account settings here.</p>
      </accordion-panel>
    
      <accordion-panel index="1" title="Privacy Settings">
        <p>Control your privacy preferences.</p>
      </accordion-panel>
    
      <accordion-panel index="2" title="Notification Settings">
        <p>Configure your notification preferences.</p>
      </accordion-panel>
    </accordion>
    // your-component.ts
    export class FAQPage {
      openPanels = [0]; // First panel open by default
    
      openAll() {
        this.openPanels = [0, 1, 2, 3];
      }
    
      closeAll() {
        this.openPanels = [];
      }
    }
    <!-- your-component.html -->
    <div>
      <button click.trigger="openAll()">Expand All</button>
      <button click.trigger="closeAll()">Collapse All</button>
    </div>
    
    <accordion allow-multiple.bind="true" open-panels.bind="openPanels">
      <accordion-panel index="0" title="Question 1">Answer 1</accordion-panel>
      <accordion-panel index="1" title="Question 2">Answer 2</accordion-panel>
      <accordion-panel index="2" title="Question 3">Answer 3</accordion-panel>
      <accordion-panel index="3" title="Question 4">Answer 4</accordion-panel>
    </accordion>
    <accordion>
      <accordion-panel index="0" title="Product Features">
        <ul>
          <li>Feature 1: Fast performance</li>
          <li>Feature 2: Easy to use</li>
          <li>Feature 3: Highly customizable</li>
        </ul>
      </accordion-panel>
    
      <accordion-panel index="1" title="Pricing">
        <div class="pricing-grid">
          <div class="plan">
            <h3>Basic</h3>
            <p>$9/month</p>
          </div>
          <div class="plan">
            <h3>Pro</h3>
            <p>$29/month</p>
          </div>
        </div>
      </accordion-panel>
    </accordion>
    import { createFixture } from '@aurelia/testing';
    import { Accordion } from './accordion';
    import { AccordionPanel } from './accordion-panel';
    
    describe('Accordion', () => {
      it('toggles panel open/closed', async () => {
        const { getAllBy, trigger, stop } = await createFixture
          .html`
            <accordion>
              <accordion-panel index="0" title="Panel 1">Content 1</accordion-panel>
            </accordion>
          `
          .deps(Accordion, AccordionPanel)
          .build()
          .started;
    
        const panel = getAllBy('.accordion-panel')[0];
        const button = getAllBy('.accordion-panel__header')[0];
    
        // Initially closed
        expect(panel.classList.contains('accordion-panel--open')).toBe(false);
    
        // Click to open
        trigger.click(button);
        expect(panel.classList.contains('accordion-panel--open')).toBe(true);
    
        // Click to close
        trigger.click(button);
        expect(panel.classList.contains('accordion-panel--open')).toBe(false);
    
        await stop(true);
      });
    
      it('allows only one panel open when allowMultiple=false', async () => {
        const { component, getAllBy, trigger, stop } = await createFixture
          .html`
            <accordion allow-multiple.bind="false">
              <accordion-panel index="0" title="Panel 1">Content 1</accordion-panel>
              <accordion-panel index="1" title="Panel 2">Content 2</accordion-panel>
            </accordion>
          `
          .deps(Accordion, AccordionPanel)
          .build()
          .started;
    
        const buttons = getAllBy('.accordion-panel__header');
    
        // Open first panel
        trigger.click(buttons[0]);
        expect(component.openPanels).toEqual([0]);
    
        // Open second panel
        trigger.click(buttons[1]);
        expect(component.openPanels).toEqual([1]); // First closed, second open
    
        await stop(true);
      });
    
      it('allows multiple panels open when allowMultiple=true', async () => {
        const { component, getAllBy, trigger, stop } = await createFixture
          .html`
            <accordion allow-multiple.bind="true">
              <accordion-panel index="0" title="Panel 1">Content 1</accordion-panel>
              <accordion-panel index="1" title="Panel 2">Content 2</accordion-panel>
            </accordion>
          `
          .deps(Accordion, AccordionPanel)
          .build()
          .started;
    
        const buttons = getAllBy('.accordion-panel__header');
    
        // Open first panel
        trigger.click(buttons[0]);
        expect(component.openPanels).toEqual([0]);
    
        // Open second panel
        trigger.click(buttons[1]);
        expect(component.openPanels).toEqual([0, 1]); // Both open
    
        await stop(true);
      });
    
      it('supports keyboard navigation', async () => {
        const { getAllBy, trigger, stop } = await createFixture
          .html`
            <accordion>
              <accordion-panel index="0" title="Panel 1">Content 1</accordion-panel>
            </accordion>
          `
          .deps(Accordion, AccordionPanel)
          .build()
          .started;
    
        const button = getAllBy('.accordion-panel__header')[0];
        const panel = getAllBy('.accordion-panel')[0];
    
        // Press Enter to open
        trigger.keydown(button, { key: 'Enter' });
        expect(panel.classList.contains('accordion-panel--open')).toBe(true);
    
        // Press Space to close
        trigger.keydown(button, { key: ' ' });
        expect(panel.classList.contains('accordion-panel--open')).toBe(false);
    
        await stop(true);
      });
    });
    export class AnimatedAccordion extends Accordion {
      @bindable onBeforeOpen?: (index: number) => void;
      @bindable onAfterOpen?: (index: number) => void;
    
      togglePanel(index: number) {
        const wasOpen = this.isPanelOpen(index);
    
        if (!wasOpen && this.onBeforeOpen) {
          this.onBeforeOpen(index);
        }
    
        super.togglePanel(index);
    
        if (!wasOpen && this.onAfterOpen) {
          setTimeout(() => this.onAfterOpen!(index), 300); // After animation
        }
      }
    }
    <accordion-panel index="0" title="Custom Icon">
      <svg au-slot="icon" width="20" height="20">
        <!-- Custom icon -->
      </svg>
    
      Panel content here
    </accordion-panel>
    export class LazyAccordionPanel extends AccordionPanel {
      @bindable loadContent?: () => Promise<any>;
      content: any = null;
      loaded = false;
    
      async toggle() {
        super.toggle();
    
        if (this.isOpen && !this.loaded && this.loadContent) {
          this.content = await this.loadContent();
          this.loaded = true;
        }
      }
    }
    <!-- No parameters -->
    ${(() => 42)()}
    
    <!-- Single parameter (parentheses optional) -->
    ${items.map(item => item.name)}
    ${items.map((item) => item.name)}
    
    <!-- Multiple parameters -->
    ${items.reduce((sum, item) => sum + item.price, 0)}
    
    <!-- Rest parameters -->
    ${((...args) => args[0] + args[1] + args[2])(1, 2, 3)}
    
    <!-- Nested arrow functions -->
    ${((a => b => a + b)(1))(2)}
    <div repeat.for="item of items.filter(item => item.isActive)">
      ${item.name}
    </div>
    <!-- Text interpolation -->
    ${items.filter(item => item.active).length}
    
    <!-- Repeat bindings -->
    <div repeat.for="user of users.sort((a, b) => a.name.localeCompare(b.name))">
      ${user.name}
    </div>
    
    <!-- Event bindings -->
    <button click.trigger="() => doSomething()">Click</button>
    
    <!-- Attribute bindings -->
    <div my-attr.bind="value => transform(value)"></div>
    
    <!-- With binding behaviors and value converters -->
    <div repeat.for="item of items.filter(i => i.active) & debounce:500">
      ${item.name}
    </div>
    <div repeat.for="item of items.sort((a, b) => a - b) | take:10">
      ${item.name}
    </div>
    <!-- ❌ Block bodies with curly braces -->
    ${items.filter(item => { return item.active; })}
    
    <!-- ❌ Default parameters -->
    ${items.map((item = {}) => item.name)}
    
    <!-- ❌ Destructuring parameters -->
    ${items.map(([first]) => first)}
    ${items.map(({name}) => name)}
    <!-- Filtering -->
    <div repeat.for="item of items.filter(item => item.isVisible)">
      ${item.name}
    </div>
    
    <!-- Sorting numbers -->
    <div repeat.for="num of numbers.sort((a, b) => a - b)">
      ${num}
    </div>
    
    <!-- Mapping and joining -->
    ${items.map(item => item.name.toUpperCase()).join(', ')}
    
    <!-- Array search methods -->
    ${items.find(item => item.id === selectedId)?.name}
    ${items.findIndex(item => item.active)}
    ${items.indexOf(targetValue)}
    ${items.lastIndexOf(targetValue)}
    ${items.includes(searchValue)}
    
    <!-- Array access -->
    ${items.at(-1)} <!-- Last item -->
    
    <!-- Aggregation -->
    ${cartItems.reduce((total, item) => total + item.price, 0)}
    ${cartItems.reduceRight((acc, item) => acc + item.value)}
    
    <!-- Array tests -->
    ${items.every(item => item.valid)}
    ${items.some(item => item.hasError)}
    
    <!-- Array transformation -->
    ${nested.flat()}
    ${items.flatMap(item => item.tags)}
    ${items.slice(0, 5)}
    <div repeat.for="product of products
      .filter(p => p.inStock && p.category === currentCategory)
      .sort((a, b) => b.rating - a.rating)
      .slice(0, 10)">
      ${product.name} - ${product.rating}⭐
    </div>
    <!-- Changes to item.visible will trigger re-evaluation -->
    <div repeat.for="item of items.filter(item => item.visible)">
      ${item.name}
    </div>
    <!-- Simple event handlers -->
    <button click.trigger="() => count++">Increment</button>
    
    <!-- Passing event data -->
    <input input.trigger="event => search(event.target.value)">
    
    <!-- Multiple parameters -->
    <button click.trigger="event => deleteItem(event, item.id)">Delete</button>
    <!-- Pass functions to custom attributes -->
    <div validate.bind="value => value.length > 3">
      <input value.bind="inputValue">
    </div>
    <!-- Array transformations -->
    <p>Tags: ${tags.map(tag => tag.toUpperCase()).join(', ')}</p>
    
    <!-- Calculations -->
    <p>Total: $${items.reduce((sum, item) => sum + item.price, 0).toFixed(2)}</p>
    
    <!-- Conditional text -->
    <p>Status: ${items.every(item => item.completed) ? 'All Done!' : 'In Progress'}</p>
    
    <!-- String operations -->
    <p>Names: ${users.map(u => u.name).join(' and ')}</p>
    <!-- Access view model properties -->
    ${items.filter(item => item.userId === $this.currentUserId).length}
    
    <!-- Access parent scope in nested contexts -->
    <div with.bind="childData">
      ${items.find(item => item.id === $parent.selectedId)?.name}
    </div>
    
    <!-- Complex scope navigation -->
    <div with.bind="{level: 1}">
      <div with.bind="{level: 2}">
        <div with.bind="{level: 3}">
          <!-- Access different scope levels -->
          ${(level => `Current: ${level}, Parent: ${$parent.level}, Root: ${$parent.$parent.level}`)($this.level)}
        </div>
      </div>
    </div>
    <!-- Flatten nested hierarchies -->
    <div repeat.for="item of items.flatMap(x => 
      [x].concat(x.children.flatMap(y => [y].concat(y.children))))">
      ${item.name}
    </div>
    
    <!-- Access parent variables in nested operations -->
    <div repeat.for="item of items.flatMap(x => 
      x.children.flatMap(y => ([x, y].concat(y.children))))">
      ${item.name}
    </div>
    
    <!-- Complex hierarchical flattening with metadata -->
    <div repeat.for="item of categories.flatMap(category => 
      category.products
        .filter(p => p.active)
        .map(product => ({ ...product, categoryName: category.name })))">
      ${item.name} (${item.categoryName})
    </div>
    <input value.bind="searchQuery" placeholder="Search products...">
    <input value.bind="minPrice" type="number" placeholder="Min price">
    <input value.bind="maxPrice" type="number" placeholder="Max price">
    
    <div repeat.for="product of products
      .filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
      .filter(p => p.price >= (minPrice || 0))
      .filter(p => p.price <= (maxPrice || 999999))
      .sort((a, b) => b.rating - a.rating)">
      ${product.name} - $${product.price} (${product.rating}⭐)
    </div>
    <!-- Simple IIFE -->
    ${(a => a)(42)}
    
    <!-- Nested arrow functions -->
    ${(a => b => a + b)(1)(2)}
    
    <!-- Rest parameters -->
    ${((...args) => args[0] + args[1] + args[2])(1, 2, 3)}
    
    <!-- Complex object creation with property access -->
    ${(((e) => ({ a: e.value }))({ value: 'test' })).a}
    
    <!-- Multi-step calculations -->
    ${((price, tax) => (price * (1 + tax)).toFixed(2))(100, 0.08)}
    <!-- Will update when item.status or item.priority changes -->
    <div repeat.for="item of items.filter(item => 
      item.status === 'active' && item.priority > 3)">
      ${item.name}
    </div>
    <!-- These will automatically update when arrays change -->
    ${items.map(item => item.name)}           <!-- ✅ Observes array changes -->
    ${items.filter(item => item.active)}      <!-- ✅ Observes array changes -->
    ${items.reduce((sum, item) => sum + item.price, 0)}  <!-- ✅ Observes array changes -->
    <!-- ✅ Works perfectly for text interpolation -->
    ${items.sort((a, b) => a - b)}
    
    <!-- ⚠️ Use slice() first for repeat.for to avoid mutation issues -->
    <div repeat.for="item of items.slice().sort((a, b) => a.order - b.order)">
      ${item.name}
    </div>
    
    <!-- ⚠️ Direct sort in repeat.for can cause issues due to array mutation -->
    <!-- This pattern is skipped in framework tests due to flush queue complications -->
    <div repeat.for="item of items.sort((a, b) => a.order - b.order)">
      ${item.name} <!-- Can cause problems when items array is mutated -->
    </div>
    <!-- ❌ Problematic: sort mutates original array -->
    <div repeat.for="item of items.sort((a, b) => a.name.localeCompare(b.name))">
    
    <!-- ✅ Better: use slice() to avoid mutation -->
    <div repeat.for="item of items.slice().sort((a, b) => a.name.localeCompare(b.name))">
    <!-- ❌ Too complex for templates -->
    ${items.filter(item => {
      const category = categories.find(c => c.id === item.categoryId);
      return category && category.active && item.stock > 0;
    })}
    
    <!-- ✅ Move complex logic to view model -->
    ${getAvailableItems(items, categories)}
  • Filter projected elements by CSS selector

  • React when the projected content changes

  • The @slotted decorator handles all of this automatically, creating a reactive property that updates whenever the slotted content changes.

    Basic Usage

    Usage:

    Filtering with CSS Selectors

    Use a CSS selector to filter which slotted elements are tracked:

    Usage:

    Targeting Specific Slots

    When your component has multiple named slots, you can target specific slots:

    Usage:

    Watching All Slots

    Use '*' as the slot name to watch all slots simultaneously:

    Change Callbacks

    The @slotted decorator automatically looks for a callback method following the naming convention {propertyName}Changed:

    Custom Callback Names

    You can specify a custom callback method name:

    Advanced Configuration

    The @slotted decorator accepts a configuration object for fine-grained control:

    Configuration Options

    Property
    Type
    Default
    Description

    query

    string

    '*'

    CSS selector to filter slotted elements. Use '*' to match all elements, '$all' to include text nodes

    slotName

    string

    'default'

    Name of the slot to watch. Use '*' to watch all slots

    callback

    PropertyKey

    '{property}Changed'

    Querying All Nodes Including Text Nodes

    By default, @slotted only tracks element nodes. To include text nodes, use the special query '$all':

    Complex Selectors

    The query parameter accepts any valid CSS selector:

    Complete Example: Dynamic Tab Component

    Here's a comprehensive example showing how to build a tab component using @slotted:

    Usage:

    Subscribing to Changes Programmatically

    The property decorated with @slotted has a special getObserver() method that returns a subscriber collection:

    Lifecycle and Timing

    The @slotted decorator integrates with Aurelia's lifecycle:

    • Activation: The watcher starts observing during the binding lifecycle

    • Deactivation: The watcher stops observing during the unbinding lifecycle

    • Initial Callback: The change callback is invoked after bound with the initial slotted elements

    • Updates: The callback is invoked whenever slotted content changes (elements added, removed, or reordered)

    Comparison with @children Decorator

    Both @slotted and @children decorators watch for changes in child elements, but they serve different purposes:

    Feature
    @slotted
    @children

    Purpose

    Watch slotted content projected into <au-slot>

    Watch direct child elements of the host

    Use with

    Shadow DOM or <au-slot> components

    Any component

    Filters by

    CSS selector + slot name

    CSS selector only

    Tracks

    Content from parent component

    Direct children only

    Use @slotted when:

    • You're using <au-slot> for content projection

    • You need to track which content was projected into which slot

    • You want to filter projected content by selector

    Use @children when:

    • You need to observe the direct children of your component's host element

    • You're not using slots

    • You need access to child component view models (via the filter and map options)

    Important Notes

    • The @slotted decorator only works with <au-slot>, not native <slot> elements

    • The decorated property becomes read-only; attempting to set it manually has no effect

    • Changes to the slotted content are detected via MutationObserver, so deep changes within slotted elements aren't automatically detected

    • The query selector is evaluated against each slotted node; complex selectors may impact performance with many slotted elements

    See Also

    • Slotted Content - Overview of slots in Aurelia

    • @children Decorator - Alternative for watching child elements

    • Custom Elements - Building custom elements

    • Lifecycle Hooks - Component lifecycle integration

    Cheat Sheet

    Quick reference guide for Aurelia 2 templating syntax and common patterns.

    Data Binding

    Binding Modes

    Syntax
    Direction
    Use Case

    Common Bindings

    String Interpolation

    Event Binding

    Basic Events

    Event Modifiers

    Binding Behaviors

    Conditional Rendering

    if.bind vs show.bind

    switch.bind

    List Rendering

    Basic Syntax

    Contextual Properties

    Property
    Type
    Description

    Advanced Collection Types

    Value Converters

    Syntax

    Common Built-in Patterns

    Template References

    Template Variables

    Class & Style Binding

    Promises in Templates

    Custom Attributes

    Component Import & Usage

    Quick Decision Trees

    When to use if vs show?

    Which binding mode?

    When to use keys in repeat.for?

    Common Patterns

    Loading States

    Form Validation Display

    Computed Display Values

    Dynamic CSS Classes

    Performance Tips

    1. Use appropriate binding modes: .one-way for display-only data

    2. Add keys to repeat.for: Enables efficient DOM reuse

    3. Use show.bind for frequent toggles: Avoids DOM manipulation overhead

    4. Use if.bind for infrequent changes

    Accessibility Reminders

    Related Documentation

    From Vue to Aurelia

    Vue developers: Love Vue's simplicity? Aurelia takes it further with better performance, stronger TypeScript support, and zero magic.

    Vue developer? You already appreciate simple, intuitive frameworks. Aurelia takes that philosophy even further with better performance, stronger TypeScript support, and standards-based architecture.

    Why Vue Developers Love Aurelia

    🎯 Vue's Simplicity + Better Performance

    Result: Same clean code style, but with direct DOM updates and no proxy overhead.

    ✨ Better TypeScript Integration

    🚀 Standards-Based Architecture

    Aurelia's approach: Build on web standards instead of creating new syntax.

    Your Vue Knowledge Transfers Perfectly

    Template Syntax Comparison

    Vue
    Aurelia
    Benefit

    Component Structure

    Reactivity Comparison

    Component Communication

    Migration Path: Vue → Aurelia

    1. Quick Start (5 minutes)

    2. Convert Your First Vue Component (10 minutes)

    Let's convert a typical Vue component:

    3. Experience the Differences

    • No ref() wrappers - plain JavaScript properties

    • No .value everywhere - direct property access

    • Better TypeScript - no generic complications

    • Automatic CSS loading - matching CSS files load automatically

    What You'll Gain Moving from Vue

    📈 Performance Improvements

    • Direct DOM updates instead of virtual DOM reconciliation

    • Smaller runtime - no proxy reactivity overhead

    • Better tree shaking - more efficient bundling

    • Faster startup - less framework initialization code

    🧹 Cleaner Development Experience

    • No composition API complexity - just class properties and methods

    • Better TypeScript support - built for TypeScript from day one

    • Simpler testing - no special test utilities needed

    • Standards-based - closer to web platform APIs

    🚀 Enhanced Capabilities

    • Built-in dependency injection - no need for provide/inject complexity

    • Powerful templating - lambda expressions and better binding

    • Shadow DOM support - true component encapsulation

    • Better routing - type-safe, more powerful navigation

    Vue vs Aurelia: Feature Comparison

    Feature
    Vue 3
    Aurelia 2
    Winner

    Ready to Experience the Upgrade?

    Next Steps:

    1. - Build a real app in 15 minutes

    2. - Master component patterns

    3. - Advanced templating

    4. - Powerful DI system

    Questions? Join our where developers discuss framework experiences and best practices.

    Ready to take the next step? and experience web development the way it should be.

    Spread Binding (.spread)

    The .spread binding command allows you to bind multiple properties from an object to a custom element's bindable properties or to an HTML element's attributes in a single, concise expression. This is particularly useful when you have an object with multiple properties that match the bindable properties of a custom element or attributes of an HTML element.

    Overview

    Instead of binding each property individually:

    You can use .spread to bind all matching properties at once:

    Basic Usage with Custom Elements

    The .spread binding is most powerful when used with custom elements that have multiple bindable properties:

    Result: The user-card component receives all matching properties from currentUser. Properties like name, email, avatarUrl, and role are automatically bound to their respective bindable properties.

    How It Works

    The .spread binding:

    1. Evaluates the expression to get an object

    2. Identifies matching properties between the object and the target's bindable properties (for custom elements) or valid attributes (for HTML elements)

    3. Creates individual bindings for each matching property

    4. Updates dynamically when the source object changes

    Property Matching

    For custom elements, only properties that are declared as @bindable are bound:

    For HTML elements, all standard HTML attributes can be bound:

    Dynamic Object Updates

    When the source object changes, the bindings update automatically:

    Combining Spread with Individual Bindings

    You can mix .spread bindings with individual property bindings. Individual bindings take precedence:

    Result: The user-card receives name and role from userDefaults, but email is bound to adminEmail specifically.

    Spreading HTML Element Attributes

    The .spread binding works with regular HTML elements too:

    Spreading to Multiple Custom Elements

    You can use the same object with different custom elements:

    Each component receives only the properties it defines as @bindable.

    Strict Mode

    By default, .spread operates in non-strict mode, which means it silently ignores properties that don't match bindable properties. In development builds, warnings may be logged when spreading non-object values.

    Performance Considerations

    Efficient Updates

    The .spread binding uses caching to minimize unnecessary work:

    • Binding cache: Bindings for each property are created once and reused

    • Scope cache: Binding scopes are cached per object instance

    • Smart updates: Only properties that exist in both the source object and target are bound

    When to Use Spread

    Use .spread when:

    • You have an object with multiple properties that map to bindable properties

    • You're working with data from APIs or state management

    • You want to reduce template verbosity

    • The source object structure matches the target's bindable properties

    Avoid .spread when:

    • You only need to bind one or two properties (use individual bindings)

    • You need fine-grained control over each binding's mode (.two-way, .one-time, etc.)

    • The source object has many properties that don't match the target (creates unnecessary overhead)

    Complete Example: Dynamic Form

    Here's a comprehensive example showing how to build dynamic forms with .spread:

    Spreading with Repeaters

    Combine .spread with repeat.for to dynamically generate components from data:

    Nested Spread Bindings

    The .spread binding can be nested to handle captured attributes in parent-child custom element scenarios:

    The spread binding will automatically handle captured attributes and pass them through the component hierarchy. This is particularly useful when building wrapper components.

    Integration with State Management

    Spread bindings work naturally with state management solutions:

    Debugging Spread Bindings

    In development mode, you can inspect which properties were bound:

    If properties aren't binding as expected:

    1. Verify @bindable decorators: Ensure target properties are decorated with @bindable

    2. Check property names: Property names must match exactly (case-sensitive)

    3. Inspect the source object: Log the object being spread to verify it contains expected properties

    Comparison with Other Approaches

    Traditional Individual Bindings

    Pros: Clear, explicit, supports different binding modes per property Cons: Verbose, repetitive, harder to maintain

    Spread Binding

    Pros: Concise, reduces repetition, easy to maintain Cons: Less explicit, all properties use .to-view binding mode

    Passing Whole Object as Bindable

    Pros: Very concise, passes all data Cons: Requires component to accept object, tighter coupling

    Best Practices

    1. Use descriptive names: Name the spread binding to indicate what it spreads (e.g., user.spread, config.spread, props.spread)

    2. Document expected properties: In your custom element, document which properties are expected from a spread binding

    1. Provide defaults: Use default values for optional properties

    1. Validate in lifecycle hooks: Validate required properties in binding() or bound() hooks

    See Also

    • - Defining bindable properties on custom elements

    • - Building custom elements

    • - Standard attribute binding syntax

    • - Binding to component properties

    Attribute transferring

    Forward bindings from a custom element to its inner template using Aurelia's spread operators.

    Attribute transferring lets a custom element pass bindings it receives down to elements inside its own template. It keeps component APIs small while still exposing the flexibility callers need.

    As an application grows, the components inside it also grow. Something that starts simple like the following component

    with the template

    can quickly grow out of hand with a number of needs for configuration: aria, type, min, max, pattern, tooltip, validation etc...

    After a while, the FormInput component above will become more and more like a relayer that simply passes bindings through. The number of @bindable properties increases and maintenance becomes tedious:

    And the usage of such element may look like this

    to be repeated like this inside:

    Coordinating all of those bindings isn't difficult, just repetitive. Attribute transferring, conceptually similar to JavaScript spread syntax, reduces the template to:

    This moves the bindings declared on <form-input> onto the <input> element inside the component.

    Aurelia Spread Operators Overview

    Aurelia provides several spread operators for different use cases:

    Operator
    Purpose
    Example

    Each operator serves a specific purpose in component composition and data flow.

    Usage

    To transfer attributes and bindings from a custom element, there are two steps:

    • Set capture to true on a custom element via @customElement decorator:

    This tells the template compiler to capture bindings and attributes (with some exceptions) for later reuse.

    • Spread the captured attributes onto an element:

    Avoid chaining attribute transfer across many component layers. Deep “prop drilling” is difficult to follow and can become a maintenance burden. Keep transfers to at most one or two levels.

    How it works

    What attributes are captured

    Everything except template controller and custom element bindables are captured. For the following example:

    View model:

    Usage:

    What are captured:

    • value.bind="extraComment"

    • class="form-control"

    • style="background: var(--theme-purple)"

    How will attributes be applied in ...$attrs

    Attributes that are spread onto an element will be compiled as if it was declared on that element.

    This means .bind command will work as expected when it's transferred from some element onto some element that uses .two-way for .bind.

    It also means that spreading onto a custom element will also work: if a captured attribute is targeting a bindable property of the applied custom element. An example:

    if value is a bindable property of my-input, the end result will be a binding that connects the message property of the corresponding app.html view model with <my-input> view model value property. Binding mode is also preserved like normal attributes.

    Advanced Spread Patterns

    Mixed Binding Patterns

    You can combine multiple spread operators and explicit bindings on the same element:

    Binding Priority (last wins):

    1. ...$bindables / ...expression (first)

    2. ...$attrs (second)

    3. Explicit bindings (last, highest priority)

    Note: According to the existing documentation, ...$attrs will always result in bindings after ...$bindables/$bindables.spread/...expression, regardless of their order in the template.

    Complex Member Access

    Spread operators support complex expressions:

    Conditional Spreading

    You can conditionally spread attributes based on expressions:

    Automatic Expression Inference

    Aurelia can automatically infer property names in certain binding scenarios:

    Shorthand Binding Syntax

    Inference Rules

    • Property name must match the attribute name exactly

    • Only works with simple property access (no expressions)

    • Works with all binding commands (.bind, .two-way, .one-way, etc.)

    Performance Considerations

    Binding Creation Optimization

    Spread operators include several performance optimizations:

    One-time Change Detection

    Spread operations are optimized to prevent unnecessary binding updates:

    Memory Usage Guidelines

    • Spread operators create bindings for each property accessed

    • Large objects with many properties create many bindings

    • Consider using specific bindable properties for frequently changing data

    • Use spreading primarily for configuration and setup data

    Error Handling & Edge Cases

    Null and Undefined Handling

    Spread operators handle null and undefined values gracefully:

    Invalid Expressions

    Type Safety with TypeScript

    TypeScript provides compile-time validation for spread operations:

    Advanced Capture Patterns

    Capture Filtering

    Filter which attributes are captured using a function:

    Multi-level Capture Guidelines

    Best Practice: Limit capture levels to 2-3 maximum to maintain code clarity and avoid prop-drilling anti-patterns.

    Template Controller Compatibility

    Spread operators work with template controllers:

    Integration Examples

    Component Composition

    Usage:

    Working with Third-party Components

    Dynamic Component Creation

    Common Patterns and Best Practices

    1. Configuration Objects

    2. Conditional Properties

    3. Proxy Objects for Transformation

    4. Default Values with Spreading

    This comprehensive documentation now covers all the advanced patterns and edge cases developers might encounter when working with Aurelia's spread operators.

    Component basics

    Components are the building blocks of Aurelia applications. This guide covers creating, configuring, and using components effectively.

    Components are the core building blocks of Aurelia applications. Each component typically consists of:

    • A TypeScript class (view model)

    • An HTML template (view)

    • Optional CSS styling

    import { slotted } from '@aurelia/runtime-html';
    
    export class TabContainer {
      // Watch all elements in the default slot
      @slotted() tabs: Element[];
    
      tabsChanged(newTabs: Element[], oldTabs: Element[]) {
        console.log('Tabs changed:', newTabs);
      }
    }
    <!-- tab-container.html -->
    <div class="tab-container">
      <au-slot></au-slot>
    </div>
    <tab-container>
      <div class="tab">Tab 1</div>
      <div class="tab">Tab 2</div>
      <div class="tab">Tab 3</div>
    </tab-container>
    import { slotted } from '@aurelia/runtime-html';
    
    export class Accordion {
      // Only watch elements with class 'accordion-item'
      @slotted('.accordion-item') items: Element[];
    
      itemsChanged(newItems: Element[], oldItems: Element[]) {
        console.log(`Accordion now has ${newItems.length} items`);
      }
    }
    <!-- accordion.html -->
    <div class="accordion">
      <au-slot></au-slot>
    </div>
    <accordion>
      <div class="accordion-item">Item 1</div>
      <div class="accordion-item">Item 2</div>
      <div>This won't be tracked</div>
      <div class="accordion-item">Item 3</div>
    </accordion>
    import { slotted } from '@aurelia/runtime-html';
    
    export class Dashboard {
      // Watch elements in the 'header' slot
      @slotted('*', 'header') headerItems: Element[];
    
      // Watch elements in the 'sidebar' slot
      @slotted('*', 'sidebar') sidebarItems: Element[];
    
      // Watch only buttons in the 'footer' slot
      @slotted('button', 'footer') footerButtons: Element[];
    }
    <!-- dashboard.html -->
    <div class="dashboard">
      <header>
        <au-slot name="header"></au-slot>
      </header>
    
      <aside>
        <au-slot name="sidebar"></au-slot>
      </aside>
    
      <main>
        <au-slot></au-slot> <!-- default slot -->
      </main>
    
      <footer>
        <au-slot name="footer"></au-slot>
      </footer>
    </div>
    <dashboard>
      <h1 au-slot="header">Dashboard Title</h1>
      <nav au-slot="sidebar">Sidebar Nav</nav>
      <p>Main content</p>
      <button au-slot="footer">Save</button>
      <button au-slot="footer">Cancel</button>
    </dashboard>
    import { slotted } from '@aurelia/runtime-html';
    
    export class MultiSlotComponent {
      // Watch all div elements across all slots
      @slotted('div', '*') allDivs: Element[];
    
      allDivsChanged(newDivs: Element[], oldDivs: Element[]) {
        console.log(`Total div elements across all slots: ${newDivs.length}`);
      }
    }
    import { slotted } from '@aurelia/runtime-html';
    
    export class CardList {
      @slotted('.card') cards: Element[];
    
      // This method is automatically called when cards change
      cardsChanged(newCards: Element[], oldCards: Element[]) {
        console.log(`Cards changed from ${oldCards.length} to ${newCards.length}`);
        this.updateCardIndexes();
      }
    
      private updateCardIndexes() {
        this.cards.forEach((card, index) => {
          card.setAttribute('data-index', String(index));
        });
      }
    }
    import { slotted } from '@aurelia/runtime-html';
    
    export class Gallery {
      @slotted({
        query: 'img',
        callback: 'handleImageChange'
      }) images: Element[];
    
      handleImageChange(newImages: Element[], oldImages: Element[]) {
        console.log('Images changed:', newImages);
      }
    }
    import { slotted } from '@aurelia/runtime-html';
    
    export class AdvancedComponent {
      @slotted({
        query: '.special-item',      // CSS selector to filter elements
        slotName: 'content',          // Name of the slot to watch
        callback: 'onItemsChanged'    // Custom callback method name
      }) specialItems: Element[];
    
      onItemsChanged(newItems: Element[], oldItems: Element[]) {
        console.log('Special items updated');
      }
    }
    import { slotted } from '@aurelia/runtime-html';
    
    export class TextAwareComponent {
      // Track all nodes including text nodes
      @slotted('$all') allNodes: Node[];
    
      allNodesChanged(newNodes: Node[], oldNodes: Node[]) {
        const textContent = newNodes
          .filter(node => node.nodeType === Node.TEXT_NODE)
          .map(node => node.textContent?.trim())
          .filter(Boolean)
          .join(' ');
    
        console.log('Text content:', textContent);
      }
    }
    import { slotted } from '@aurelia/runtime-html';
    
    export class ComplexSelectors {
      // Only direct children with specific class
      @slotted('> .direct-child') directChildren: Element[];
    
      // Elements with specific data attribute
      @slotted('[data-type="widget"]') widgets: Element[];
    
      // Multiple selectors
      @slotted('button, a, input') interactiveElements: Element[];
    
      // Pseudo-selectors
      @slotted(':not(.excluded)') includedElements: Element[];
    }
    // tab-panel.ts
    import { slotted } from '@aurelia/runtime-html';
    
    export class TabPanel {
      @slotted('.tab-header') tabHeaders: Element[];
      @slotted('.tab-content') tabContents: Element[];
    
      private activeIndex: number = 0;
    
      tabHeadersChanged(newHeaders: Element[]) {
        this.setupTabs();
      }
    
      tabContentsChanged(newContents: Element[]) {
        this.setupTabs();
      }
    
      private setupTabs() {
        if (this.tabHeaders.length === 0 || this.tabContents.length === 0) return;
    
        // Setup click handlers on headers
        this.tabHeaders.forEach((header, index) => {
          header.addEventListener('click', () => this.activateTab(index));
          header.setAttribute('role', 'tab');
          header.setAttribute('tabindex', index === this.activeIndex ? '0' : '-1');
        });
    
        // Setup content panels
        this.tabContents.forEach((content, index) => {
          content.setAttribute('role', 'tabpanel');
        });
    
        this.activateTab(this.activeIndex);
      }
    
      private activateTab(index: number) {
        if (index < 0 || index >= this.tabHeaders.length) return;
    
        this.activeIndex = index;
    
        // Update headers
        this.tabHeaders.forEach((header, i) => {
          header.classList.toggle('active', i === index);
          header.setAttribute('aria-selected', String(i === index));
          header.setAttribute('tabindex', i === index ? '0' : '-1');
        });
    
        // Update content
        this.tabContents.forEach((content, i) => {
          content.classList.toggle('active', i === index);
          content.setAttribute('aria-hidden', String(i !== index));
        });
      }
    }
    <!-- tab-panel.html -->
    <div class="tab-panel" role="tablist">
      <au-slot></au-slot>
    </div>
    <tab-panel>
      <div class="tab-header">Profile</div>
      <div class="tab-content">
        <h2>User Profile</h2>
        <p>Profile information goes here...</p>
      </div>
    
      <div class="tab-header">Settings</div>
      <div class="tab-content">
        <h2>Settings</h2>
        <p>User settings go here...</p>
      </div>
    
      <div class="tab-header">Messages</div>
      <div class="tab-content">
        <h2>Messages</h2>
        <p>User messages go here...</p>
      </div>
    </tab-panel>
    import { slotted } from '@aurelia/runtime-html';
    import { ICustomElementController } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    export class ObservableSlots {
      private controller = resolve(ICustomElementController);
    
      @slotted('.item') items: Element[];
    
      bound() {
        // Get the observer for the slotted property
        const observer = (this.items as any).getObserver?.();
    
        if (observer) {
          observer.subscribe({
            handleSlotChange: (nodes: Node[]) => {
              console.log('Items changed via subscription:', nodes);
            }
          });
        }
      }
    }
    import { slotted } from '@aurelia/runtime-html';
    
    export class LifecycleExample {
      @slotted('.item') items: Element[];
    
      binding() {
        console.log('Component binding - watcher will start soon');
      }
    
      bound() {
        console.log('Component bound - initial items:', this.items);
      }
    
      itemsChanged(newItems: Element[]) {
        console.log('Items changed:', newItems);
        // This will be called:
        // 1. After bound() with initial elements
        // 2. Whenever slotted content changes
      }
    
      unbinding() {
        console.log('Component unbinding - watcher will stop');
      }
    }
    <user-card
      name.bind="user.name"
      email.bind="user.email"
      avatar.bind="user.avatarUrl"
      role.bind="user.role">
    </user-card>
    <user-card user.spread="user"></user-card>
    export class FormInput {
      @bindable label
      @bindable value
    }
    <label>${label}
      <input value.bind="value">
    </label>
    export class FormInput {
      @bindable label
      @bindable value
      @bindable type
      @bindable tooltip
      @bindable arias
      @bindable etc
    }
    <form-input
      label.bind="label"
      value.bind="message"
      tooltip.bind="Did you know Aurelia syntax comes from an idea of an Angular community member? We greatly appreciate Angular and its community for this."
      validation.bind="...">
    <label>${label}
      <input value.bind tooltip.bind validation.bind min.bind max.bind>
    </label>

    Name of the callback method to invoke when slotted content changes

    Best for

    Content projection scenarios

    Observing component's immediate children

    $even

    boolean

    True for even indices

    $odd

    boolean

    True for odd indices

    $length

    number

    Total number of items

    $parent

    object

    Parent binding context

    $previous

    any

    Previous item (when contextual enabled)

    : Saves memory and resources
  • Debounce/throttle rapid events: Prevents excessive handler calls

  • Keep expressions simple: Move complex logic to view model

  • Use value converters: Separate formatting from view model logic

  • Event Binding
  • Form Inputs

  • Class & Style Binding

  • .bind

    Auto (two-way for form elements, one-way otherwise)

    Default choice for most scenarios

    .one-way / .to-view

    View Model → View

    Display-only data (performance)

    .two-way

    View Model ↔ View

    Form inputs requiring sync

    .from-view

    View → View Model

    Capture user input only

    .one-time

    View Model → View (once)

    Static data that never changes

    Feature

    if.bind

    show.bind

    DOM Manipulation

    Adds/removes from DOM

    Shows/hides (display: none)

    Performance

    Better for infrequent changes

    Better for frequent toggles

    Resources

    Cleans up events/bindings

    Keeps everything in memory

    Use When

    Content rarely changes

    Content toggles frequently

    $index

    number

    Zero-based index (0, 1, 2...)

    $first

    boolean

    True for first item

    $last

    boolean

    True for last item

    $middle

    boolean

    True for middle items

    Template Syntax Overview
    Conditional Rendering
    List Rendering
    Value Converters

    :class="{ active: isActive }"

    active.class="isActive"

    Cleaner conditional classes

    Bundle Size

    Small

    Smaller

    Aurelia

    Template Syntax

    Custom

    Standards-based

    Aurelia

    State Management

    Pinia/Vuex

    Built-in DI

    Aurelia

    Component Communication

    Props/Emits

    Bindable/Callable

    Tie

    Ecosystem Size

    Large

    Focused

    Vue

    v-model="value"

    value.bind="value"

    Two-way binding works the same

    v-if="condition"

    if.bind="condition"

    Same conditional logic

    v-for="item in items"

    repeat.for="item of items"

    Same iteration, better performance

    @click="handler"

    click.trigger="handler"

    Same event handling

    Learning Curve

    Easy

    Easy

    Tie

    TypeScript Support

    Good

    Excellent

    Aurelia

    Performance

    Good

    Better

    Complete Getting Started Guide
    Component Guide
    Templates Deep Dive
    Dependency Injection
    Discord community
    Start building with Aurelia

    Aurelia

    Watch for warnings
    : Development mode logs warnings for non-object values
    Bindable Properties
    Custom Elements
    Attribute Binding
    Property Binding
    tooltip="Hello, ${tooltip}" What are not captured:
  • if.bind="needsComment" (if is a template controller)

  • label.bind="label" (label is a bindable property)

  • Case-sensitive: firstName.bind infers firstName, not firstname

    ...$attrs

    Spread captured attributes from parent element

    <input ...$attrs>

    ...$bindables

    Spread object properties to bindable properties

    <my-component ...$bindables="user">

    ...expression

    Shorthand for bindable spreading

    <my-component ...user>

    Component Naming

    Component names must include a hyphen (e.g., user-card, nav-menu) to comply with Web Components standards. Use a consistent prefix like app- or your organization's initials for better organization.

    Creating Your First Component

    The simplest way to create a component is with convention-based files:

    export class UserCard {
      name = 'John Doe';
      email = '[email protected]';
    }
    <div class="user-card">
      <h3>${name}</h3>
      <p>${email}</p>
    </div>

    Aurelia automatically pairs user-card.ts with user-card.html by convention, creating a <user-card> element you can use in templates.

    Component Configuration

    Use the @customElement decorator for explicit configuration:

    For simple naming, use the shorthand syntax:

    Configuration Options

    Key @customElement options:

    Template Configuration:

    Importing external HTML templates with bundlers

    When a component imports an .html file, the bundler must deliver that file as a plain string. Otherwise tools such as Vite, Webpack, and Parcel try to parse the file as an entry point and emit errors like [vite:build-html] Unable to parse HTML; parse5 error code unexpected-character-in-unquoted-attribute-value or "template" is not exported by src/components/product-name-search.html.

    Configure your bundler using the option that best matches your stack:

    • Vite / esbuild (default Aurelia starter), Parcel 2, Rollup + @rollup/plugin-string – append ?raw to the import so the bundler treats the file as text:

      Add a matching declaration so TypeScript understands these imports (the query string can be reused for other text assets):

    • Webpack 5 – mark .html files as asset/source (or keep using raw-loader). After that you can import without a query parameter:

    • Other bundlers – use the equivalent “treat this file as a string” hook (e.g., SystemJS text plugin).

    Once the bundler understands .html files as text, both npm start and npm run build can reuse the same component source without inline templates. Keep the import pattern consistent across the project so contributors immediately know which loader configuration applies.

    Dependencies:

    Alternative Creation Methods

    Static Configuration:

    Programmatic (mainly for testing):

    HTML-Only Components

    Create simple components with just HTML:

    Usage:

    Viewless Components

    Components that handle DOM manipulation through third-party libraries:

    Using Components

    Global Registration (in main.ts):

    Local Import (in templates):

    Containerless Components

    Render component content without wrapper tags:

    Or configure inline:

    Use Sparingly

    Containerless components lose their wrapper element, which can complicate styling, testing, and third-party library integration.

    Component Lifecycle

    Components follow a predictable lifecycle. Implement only the hooks you need:

    See Component Lifecycles for comprehensive lifecycle documentation.

    Bindable Properties

    Components accept data through bindable properties:

    See Bindable Properties for complete configuration options.

    Advanced Features

    Shadow DOM

    Enable Shadow DOM for complete style and DOM encapsulation:

    Shadow DOM is useful for:

    • Complete style isolation (styles won't leak in or out)

    • Creating reusable components with predictable styling

    • Using native <slot> elements for content projection

    • Building design systems and component libraries

    See the Shadow DOM guide for detailed configuration, styling patterns, and best practices.

    Template Processing

    Transform markup before compilation:

    Enhancing Existing DOM

    Apply Aurelia to existing elements:

    Reactive Properties

    Watch for property changes:

    Child Element Observation

    Component Configuration

    Attribute Capture:

    Aliases:

    Best Practices

    Component Design

    • Single Responsibility: Each component should have one clear purpose

    • Type Safety: Use interfaces for complex data structures

    • Composition: Favor composition over inheritance

    Performance

    • Use attached() for DOM-dependent initialization

    • Clean up subscriptions in detaching()

    • Prefer @watch over polling for reactive updates

    • Consider Shadow DOM for style isolation

    Testing

    • Mock dependencies properly

    • Test lifecycle hooks and bindable properties

    • Write tests for error scenarios

    See Testing Components for detailed guidance.


    Components form the foundation of Aurelia applications. Start with simple convention-based components and add complexity as needed. The framework's flexibility allows you to adopt patterns that fit your project's requirements while maintaining clean, maintainable code.

    <!-- Text & Attributes -->
    <div title.bind="tooltip">${message}</div>
    <img src.bind="imageUrl" alt.bind="altText">
    
    <!-- Form Inputs -->
    <input value.bind="name">
    <input value.two-way="email">
    <textarea value.bind="comments"></textarea>
    
    <!-- Boolean Attributes -->
    <button disabled.bind="isLoading">Submit</button>
    <input required.bind="isRequired">
    
    <!-- Checkboxes -->
    <input type="checkbox" checked.bind="isActive">
    <input type="checkbox" model.bind="item.id" checked.bind="selectedIds">
    
    <!-- Radio Buttons -->
    <input type="radio" model.bind="option1" checked.bind="selectedOption">
    <input type="radio" model.bind="option2" checked.bind="selectedOption">
    
    <!-- Select -->
    <select value.bind="selectedValue">
      <option repeat.for="opt of options" value.bind="opt.id">${opt.name}</option>
    </select>
    <!-- Simple -->
    <p>${firstName} ${lastName}</p>
    
    <!-- Expressions -->
    <p>${count * 2}</p>
    <p>${isActive ? 'Active' : 'Inactive'}</p>
    
    <!-- Optional Chaining -->
    <p>${user?.profile?.name ?? 'Guest'}</p>
    <!-- Click Events -->
    <button click.trigger="save()">Save</button>
    <button click.capture="handleCapture()">Capture Phase</button>
    
    <!-- Form Events -->
    <form submit.trigger="handleSubmit($event)">
    <input input.trigger="onInput($event)">
    <input change.trigger="onChange()">
    
    <!-- Keyboard Events -->
    <input keydown.trigger="onKeyDown($event)">
    <input keyup.trigger="onKeyUp($event)">
    
    <!-- Mouse Events -->
    <div mouseover.trigger="onHover()">
    <div mouseout.trigger="onLeave()">
    <!-- Keyboard Modifiers -->
    <input keydown.trigger:ctrl="onCtrlKey()">
    <input keydown.trigger:enter="onEnter()">
    <input keydown.trigger:ctrl+enter="onCtrlEnter()">
    
    <!-- Mouse Button Modifiers -->
    <button click.trigger:left="onLeftClick()">
    <button click.trigger:middle="onMiddleClick()">
    <button click.trigger:right="onRightClick()">
    
    <!-- Event Control -->
    <a click.trigger:prevent="navigate()">Link</a>
    <div click.trigger:stop="handleClick()">Stop Propagation</div>
    <div click.trigger:self="handleSelfClick()">Only Direct Clicks</div>
    <!-- Throttle (max once per interval) -->
    <input input.trigger="search($event.target.value) & throttle:300">
    
    <!-- Debounce (wait until user stops) -->
    <input input.trigger="search($event.target.value) & debounce:500">
    <!-- if.bind - Removes from DOM -->
    <div if.bind="isLoggedIn">Welcome back!</div>
    <div else>Please log in</div>
    
    <!-- show.bind - CSS display control -->
    <div show.bind="isVisible">Toggled content</div>
    
    <!-- if with caching control -->
    <expensive-component if="value.bind: showComponent; cache: false"></expensive-component>
    <!-- Basic Switch -->
    <template switch.bind="status">
      <span case="pending">Waiting...</span>
      <span case="approved">Approved!</span>
      <span case="rejected">Rejected</span>
      <span default-case>Unknown</span>
    </template>
    
    <!-- Multiple Cases -->
    <template switch.bind="role">
      <admin-panel case.bind="['admin', 'superadmin']"></admin-panel>
      <user-panel case="user"></user-panel>
      <guest-panel default-case></guest-panel>
    </template>
    
    <!-- Fall-through -->
    <template switch.bind="level">
      <span case="high" fall-through.bind="true">High priority</span>
      <span case="medium">Medium priority</span>
      <span default-case>Low priority</span>
    </template>
    <!-- Simple Array -->
    <ul>
      <li repeat.for="item of items">${item.name}</li>
    </ul>
    
    <!-- With Keys (recommended for dynamic lists) -->
    <div repeat.for="user of users; key: id">
      ${user.name}
    </div>
    
    <!-- With Index -->
    <div repeat.for="item of items">
      ${$index + 1}. ${item.name}
    </div>
    
    <!-- Number Range -->
    <div repeat.for="i of 5">Item ${i}</div>
    <!-- Using Contextual Properties -->
    <div repeat.for="item of items">
      <span class="${$even ? 'even' : 'odd'}">
        ${$index + 1} of ${$length}: ${item.name}
      </span>
      <span if.bind="$first">👑 First!</span>
      <span if.bind="$last">🏁 Last!</span>
    </div>
    
    <!-- Nested Repeats with $parent -->
    <div repeat.for="category of categories">
      <h2>${category.name}</h2>
      <div repeat.for="item of category.items">
        ${item.name} in ${$parent.category.name}
      </div>
    </div>
    <!-- Sets -->
    <div repeat.for="tag of selectedTags">
      ${tag}
    </div>
    
    <!-- Maps -->
    <div repeat.for="[key, value] of configMap">
      ${key}: ${value}
    </div>
    
    <!-- Destructuring -->
    <div repeat.for="{ id, name, email } of users">
      ${name} (${email})
    </div>
    <!-- Basic -->
    <p>${price | currency}</p>
    
    <!-- With Parameters -->
    <p>${date | dateFormat:'MM/DD/YYYY'}</p>
    <p>${text | truncate:50:true}</p>
    
    <!-- Chaining -->
    <p>${input | sanitize | capitalize | truncate:100}</p>
    
    <!-- In Bindings -->
    <input value.bind="searchTerm | normalize">
    // Create a value converter
    import { valueConverter } from 'aurelia';
    
    @valueConverter('currency')
    export class CurrencyValueConverter {
      toView(value: number, currencyCode = 'USD'): string {
        return new Intl.NumberFormat('en-US', {
          style: 'currency',
          currency: currencyCode
        }).format(value);
      }
    }
    <!-- Element Reference -->
    <input ref="searchInput" value.bind="query">
    <button click.trigger="searchInput.focus()">Focus Input</button>
    
    <!-- Component Reference -->
    <my-component ref="myComponent"></my-component>
    <button click.trigger="myComponent.refresh()">Refresh</button>
    
    <!-- View Model Reference -->
    <div ref="element" view-model.ref="viewModel"></div>
    <!-- let - Local Variables -->
    <let full-name.bind="firstName + ' ' + lastName"></let>
    <h1>Hello, ${fullName}</h1>
    
    <!-- with - Scope Binding -->
    <div with.bind="user">
      <p>${firstName} ${lastName}</p>
      <p>${email}</p>
    </div>
    
    <!-- Multiple Variables -->
    <let greeting.bind="'Hello'"></let>
    <let name.bind="user.name"></let>
    <p>${greeting}, ${name}!</p>
    <!-- Class Binding -->
    <div class.bind="isActive ? 'active' : 'inactive'"></div>
    <div class.bind="cssClasses"></div>
    
    <!-- Style Binding -->
    <div style.bind="{ color: textColor, 'font-size': fontSize + 'px' }"></div>
    <div style.background-color.bind="bgColor"></div>
    <div style.width.bind="width + 'px'"></div>
    <div promise.bind="fetchData()">
      <span pending>Loading...</span>
      <span then="data">
        Loaded: ${data.title}
      </span>
      <span catch="error">
        Error: ${error.message}
      </span>
    </div>
    <!-- Using Custom Attributes -->
    <div my-attribute="value"></div>
    <div my-attribute.bind="dynamicValue"></div>
    
    <!-- With Multiple Parameters -->
    <div tooltip="text.bind: tooltipText; position: top; delay: 300"></div>
    <!-- Import -->
    <import from="./my-component"></import>
    <import from="./utils/helpers" as="helpers"></import>
    
    <!-- Usage -->
    <my-component title.bind="pageTitle" onSave.call="handleSave($event)"></my-component>
    
    <!-- Inline Component -->
    <template as-custom-element="inline-component">
      <h1>${title}</h1>
    </template>
    
    <inline-component title="Hello"></inline-component>
    Need to toggle visibility?
    ├─ Toggles frequently (e.g., dropdown, tab content)
    │  └─ Use show.bind (faster, preserves state)
    └─ Toggles infrequently (e.g., admin panel, authenticated content)
       └─ Use if.bind (saves memory, cleans up resources)
    Binding to form input?
    ├─ YES → Use .bind (auto two-way)
    └─ NO  → Displaying data only?
             ├─ YES → Use .one-way (better performance)
             └─ NO  → Need to capture user changes?
                      ├─ YES → Use .two-way
                      └─ NO  → Static data?
                               └─ Use .one-time
    Using repeat.for with dynamic list?
    ├─ List items can be added/removed/reordered?
    │  └─ YES → Always use keys (key.bind or key:)
    └─ List is static or append-only?
       └─ Keys optional (but recommended)
    <div if.bind="isLoading">Loading...</div>
    <div else-if.bind="error">Error: ${error.message}</div>
    <div else-if.bind="items.length === 0">No items found</div>
    <div else>
      <div repeat.for="item of items; key: id">${item.name}</div>
    </div>
    <input value.bind="email" class="${errors.email ? 'invalid' : ''}">
    <span if.bind="errors.email" class="error">${errors.email}</span>
    <let total.bind="items.reduce((sum, item) => sum + item.price, 0)"></let>
    <p>Total: ${total | currency}</p>
    <div class="card ${isActive ? 'active' : ''} ${isHighlighted ? 'highlight' : ''}">
      Content
    </div>
    <!-- Labels for Form Inputs -->
    <label for="email">Email:</label>
    <input id="email" value.bind="email">
    
    <!-- ARIA Attributes -->
    <button
      aria-label.bind="buttonLabel & attr"
      aria-busy.bind="isLoading & attr"
      disabled.bind="isLoading">
      ${isLoading ? 'Loading...' : 'Submit'}
    </button>
    
    <!-- Role Attributes -->
    <div role="alert" if.bind="errorMessage">
      ${errorMessage}
    </div>
    <!-- Vue: Reactivity with Proxy overhead -->
    <template>
      <div>
        <input v-model="searchQuery" placeholder="Search users...">
        <div v-if="loading">Loading...</div>
        <user-card 
          v-for="user in filteredUsers" 
          :key="user.id"
          :user="user"
          @edit="handleEdit"
        />
      </div>
    </template>
    
    <script setup lang="ts">
    import { ref, computed, watch } from 'vue'
    
    const searchQuery = ref('')
    const loading = ref(false)
    const users = ref<User[]>([])
    
    const filteredUsers = computed(() => 
      users.value.filter(user => 
        user.name.toLowerCase().includes(searchQuery.value.toLowerCase())
      )
    )
    
    watch(searchQuery, async (newQuery) => {
      if (newQuery.length > 2) {
        loading.value = true
        // Search logic
        loading.value = false
      }
    })
    </script>
    // Aurelia: Same simplicity, better performance
    export class UserSearch {
      searchQuery = '';
      loading = false;
      users: User[] = [];
    
      // Computed properties work automatically - no wrapper needed
      get filteredUsers() {
        return this.users.filter(user => 
          user.name.toLowerCase().includes(this.searchQuery.toLowerCase())
        );
      }
    
      // Watching is clean and intuitive
      @watch('searchQuery')
      async onSearchChange(newQuery: string) {
        if (newQuery.length > 2) {
          this.loading = true;
          // Search logic
          this.loading = false;
        }
      }
    }
    <!-- Aurelia template: Clean HTML, no special directives -->
    <div>
      <input value.bind="searchQuery & debounce:300" placeholder="Search users...">
      <div if.bind="loading">Loading...</div>
      <user-card repeat.for="user of filteredUsers" 
                 user.bind="user"
                 edit.call="handleEdit">
      </user-card>
    </div>
    <!-- Vue: TypeScript support is good but requires setup -->
    <script setup lang="ts">
    interface Props {
      user: User
      editable?: boolean
    }
    
    const props = withDefaults(defineProps<Props>(), {
      editable: false
    })
    
    const emit = defineEmits<{
      edit: [user: User]
      delete: [id: number]
    }>()
    </script>
    
    // Aurelia: TypeScript-first, no setup needed
    export class UserCard {
      @bindable user: User;
      @bindable editable = false;
      
      // Events are just methods - no emit setup
      edit() {
        // Automatically available as edit.call in templates
      }
      
      delete() {
        // Type-safe event handling
      }
    }
    <!-- Vue: Custom template syntax -->
    <template>
      <div :class="{ active: isActive, loading: isLoading }">
        <slot name="header">
          <h2>{{ title }}</h2>
        </slot>
        <div v-show="expanded">
          <slot>Default content</slot>
        </div>
      </div>
    </template>
    
    <!-- Aurelia: Closer to web standards -->
    <div class="card" active.class="isActive" loading.class="isLoading">
      <au-slot name="header">
        <h2>${title}</h2>
      </au-slot>
      <div show.bind="expanded">
        <au-slot>Default content</au-slot>
      </div>
    </div>
    <!-- Vue Single File Component -->
    <template>
      <div class="my-component">
        <h1>{{ message }}</h1>
        <button @click="updateMessage">Update</button>
      </div>
    </template>
    
    <script setup lang="ts">
    import { ref } from 'vue'
    
    const message = ref('Hello Vue!')
    
    const updateMessage = () => {
      message.value = 'Updated!'
    }
    </script>
    
    <style scoped>
    .my-component {
      padding: 20px;
      border: 1px solid #ccc;
    }
    </style>
    // Aurelia: Similar structure, separate files (or inline)
    export class MyComponent {
      message = 'Hello Aurelia!';
      
      updateMessage() {
        this.message = 'Updated!';
      }
    }
    <!-- my-component.html -->
    <div class="my-component">
      <h1>${message}</h1>
      <button click.trigger="updateMessage()">Update</button>
    </div>
    /* my-component.css - automatically loaded! */
    .my-component {
      padding: 20px;
      border: 1px solid #ccc;
    }
    <!-- Vue: Composition API -->
    <script setup>
    const count = ref(0)
    const doubled = computed(() => count.value * 2)
    
    watch(count, (newValue) => {
      console.log(`Count changed to ${newValue}`)
    })
    </script>
    
    // Aurelia: Plain JavaScript/TypeScript
    export class Counter {
      count = 0;
      
      // Computed properties are just getters
      get doubled() {
        return this.count * 2;
      }
      
      // Watching is explicit and clear
      @watch('count')
      countChanged(newValue: number) {
        console.log(`Count changed to ${newValue}`);
      }
    }
    <!-- Vue: Props and Emits -->
    <script setup>
    interface Props {
      items: Item[]
    }
    
    const props = defineProps<Props>()
    const emit = defineEmits<{
      itemSelected: [item: Item]
    }>()
    
    const selectItem = (item: Item) => {
      emit('itemSelected', item)
    }
    </script>
    
    // Aurelia: Bindable properties and callable methods
    export class ItemList {
      @bindable items: Item[];
      
      // Just call this method from parent template
      selectItem(item: Item) {
        // Parent can bind to this with select-item.call="handleSelection(item)"
      }
    }
    npx makes aurelia my-aurelia-app
    cd my-aurelia-app
    npm run dev
    <!-- Vue Todo Component -->
    <template>
      <div class="todo-app">
        <input 
          v-model="newTodo" 
          @keyup.enter="addTodo"
          placeholder="Add a todo..."
        >
        <ul>
          <li 
            v-for="todo in todos" 
            :key="todo.id"
            :class="{ completed: todo.completed }"
          >
            <input 
              type="checkbox" 
              v-model="todo.completed"
            >
            <span>{{ todo.text }}</span>
            <button @click="removeTodo(todo.id)">Remove</button>
          </li>
        </ul>
      </div>
    </template>
    
    <script setup lang="ts">
    import { ref } from 'vue'
    
    interface Todo {
      id: number
      text: string
      completed: boolean
    }
    
    const newTodo = ref('')
    const todos = ref<Todo[]>([])
    let nextId = 1
    
    const addTodo = () => {
      if (newTodo.value.trim()) {
        todos.value.push({
          id: nextId++,
          text: newTodo.value.trim(),
          completed: false
        })
        newTodo.value = ''
      }
    }
    
    const removeTodo = (id: number) => {
      todos.value = todos.value.filter(todo => todo.id !== id)
    }
    </script>
    // Aurelia equivalent - cleaner and more intuitive
    export class TodoApp {
      newTodo = '';
      todos: Todo[] = [];
      private nextId = 1;
    
      addTodo() {
        if (this.newTodo.trim()) {
          this.todos.push({
            id: this.nextId++,
            text: this.newTodo.trim(),
            completed: false
          });
          this.newTodo = '';
        }
      }
    
      removeTodo(id: number) {
        this.todos = this.todos.filter(todo => todo.id !== id);
      }
      
      onEnterKey(event: KeyboardEvent) {
        if (event.key === 'Enter') {
          this.addTodo();
        }
      }
    }
    
    interface Todo {
      id: number;
      text: string;
      completed: boolean;
    }
    <!-- todo-app.html -->
    <div class="todo-app">
      <input 
        value.bind="newTodo" 
        keydown.trigger="onEnterKey($event)"
        placeholder="Add a todo..."
      >
      <ul>
        <li repeat.for="todo of todos" completed.class="todo.completed">
          <input type="checkbox" checked.bind="todo.completed">
          <span>${todo.text}</span>
          <button click.trigger="removeTodo(todo.id)">Remove</button>
        </li>
      </ul>
    </div>
    # Start your Aurelia journey
    npx makes aurelia my-vue-to-aurelia-app
    cd my-vue-to-aurelia-app
    npm run dev
    // user-card.ts
    import { bindable } from 'aurelia';
    
    export class UserCard {
      @bindable name: string;
      @bindable email: string;
      @bindable avatarUrl: string;
      @bindable role: string;
    }
    <!-- my-app.html -->
    <user-card user.spread="currentUser"></user-card>
    // my-app.ts
    export class MyApp {
      currentUser = {
        name: 'Jane Doe',
        email: '[email protected]',
        avatarUrl: 'https://example.com/avatar.jpg',
        role: 'Administrator'
      };
    }
    export class ProductCard {
      @bindable name: string;     // Will be bound if exists in spread object
      @bindable price: number;    // Will be bound if exists in spread object
      description: string;        // Won't be bound (not @bindable)
    }
    <product-card data.spread="product"></product-card>
    export class MyApp {
      product = {
        name: 'Laptop',
        price: 999,
        description: 'A powerful laptop',  // This won't be bound (not @bindable)
        category: 'Electronics'             // This won't be bound (not @bindable)
      };
    }
    <input attrs.spread="inputConfig">
    export class MyApp {
      inputConfig = {
        type: 'email',
        placeholder: 'Enter your email',
        required: true,
        maxlength: 100
      };
    }
    import { resolve } from '@aurelia/kernel';
    
    export class DynamicProfile {
      user = {
        name: 'John',
        email: '[email protected]',
        role: 'User'
      };
    
      upgradeToAdmin() {
        // This will automatically update the spread bindings
        this.user = {
          name: 'John',
          email: '[email protected]',
          role: 'Administrator'  // Changed
        };
      }
    
      updateName(newName: string) {
        // This will also update the spread bindings
        this.user = {
          ...this.user,
          name: newName
        };
      }
    }
    <user-card profile.spread="user"></user-card>
    <button click.trigger="upgradeToAdmin()">Upgrade to Admin</button>
    <button click.trigger="updateName('Jane')">Change Name</button>
    export class MyApp {
      userDefaults = {
        name: 'Guest',
        email: '[email protected]',
        role: 'Visitor'
      };
    
      adminEmail = '[email protected]';
    }
    <!-- Spread provides defaults, but email is overridden -->
    <user-card
      defaults.spread="userDefaults"
      email.bind="adminEmail">
    </user-card>
    export class FormBuilder {
      textInputConfig = {
        type: 'text',
        placeholder: 'Enter text',
        class: 'form-control',
        required: true,
        minlength: 3,
        maxlength: 50
      };
    
      emailInputConfig = {
        type: 'email',
        placeholder: 'Enter email',
        class: 'form-control',
        required: true
      };
    }
    <form>
      <input config.spread="textInputConfig">
      <input config.spread="emailInputConfig">
    </form>
    export class Dashboard {
      cardData = {
        title: 'Sales Report',
        subtitle: 'Q4 2024',
        value: '$1.2M',
        change: '+15%',
        icon: 'trending-up'
      };
    }
    <info-card data.spread="cardData"></info-card>
    <metric-widget data.spread="cardData"></metric-widget>
    <summary-panel data.spread="cardData"></summary-panel>
    export class MyApp {
      // This will work but only matching properties are bound
      userData = {
        name: 'Alice',
        email: '[email protected]',
        unknownProp: 'ignored'  // Silently ignored if not @bindable
      };
    
      // This will log a warning in dev mode
      invalidData = null;
    }
    <user-card data.spread="userData"></user-card>
    <user-card data.spread="invalidData"></user-card> <!-- Warning in dev -->
    // form-field.ts
    import { bindable } from 'aurelia';
    
    export class FormField {
      @bindable label: string;
      @bindable type: string = 'text';
      @bindable value: string;
      @bindable placeholder: string;
      @bindable required: boolean = false;
      @bindable disabled: boolean = false;
      @bindable error: string;
    }
    <!-- form-field.html -->
    <div class="form-field">
      <label if.bind="label">${label}</label>
      <input
        type.bind="type"
        value.bind="value"
        placeholder.bind="placeholder"
        required.bind="required"
        disabled.bind="disabled">
      <span class="error" if.bind="error">${error}</span>
    </div>
    // registration-form.ts
    export class RegistrationForm {
      fields = {
        username: {
          label: 'Username',
          type: 'text',
          placeholder: 'Enter username',
          required: true,
          value: ''
        },
        email: {
          label: 'Email Address',
          type: 'email',
          placeholder: 'Enter email',
          required: true,
          value: ''
        },
        password: {
          label: 'Password',
          type: 'password',
          placeholder: 'Enter password',
          required: true,
          value: ''
        },
        bio: {
          label: 'Biography',
          type: 'textarea',
          placeholder: 'Tell us about yourself',
          required: false,
          value: ''
        }
      };
    
      submit() {
        const formData = Object.keys(this.fields).reduce((acc, key) => {
          acc[key] = this.fields[key].value;
          return acc;
        }, {} as Record<string, string>);
    
        console.log('Form submitted:', formData);
      }
    }
    <!-- registration-form.html -->
    <form>
      <form-field field.spread="fields.username"></form-field>
      <form-field field.spread="fields.email"></form-field>
      <form-field field.spread="fields.password"></form-field>
      <form-field field.spread="fields.bio"></form-field>
    
      <button type="button" click.trigger="submit()">Submit</button>
    </form>
    export class DataGrid {
      columns = [
        { name: 'id', label: 'ID', sortable: true, width: 60 },
        { name: 'name', label: 'Name', sortable: true, width: 200 },
        { name: 'email', label: 'Email', sortable: false, width: 250 },
        { name: 'role', label: 'Role', sortable: true, width: 120 }
      ];
    }
    <table>
      <thead>
        <tr>
          <table-header
            repeat.for="column of columns"
            config.spread="column">
          </table-header>
        </tr>
      </thead>
    </table>
    <!-- parent-component.html -->
    <child-component outer.spread="..."></child-component>
    import { Store } from '@aurelia/store-v1';
    import { connectTo } from '@aurelia/store-v1';
    
    @connectTo()
    export class UserProfile {
      state: State;
    
      // User object from store
      get user() {
        return this.state.currentUser;
      }
    }
    <!-- Spread the user from the store -->
    <user-details data.spread="user"></user-details>
    <user-preferences settings.spread="user.preferences"></user-preferences>
    export class DebugComponent {
      @bindable name: string;
      @bindable email: string;
      @bindable role: string;
    
      binding() {
        // Check what was bound
        console.log('Name:', this.name);
        console.log('Email:', this.email);
        console.log('Role:', this.role);
      }
    }
    <!-- Verbose but explicit -->
    <user-card
      name.bind="user.name"
      email.bind="user.email"
      role.bind="user.role">
    </user-card>
    <!-- Concise and maintainable -->
    <user-card data.spread="user"></user-card>
    <!-- Pass entire object -->
    <user-card user.bind="user"></user-card>
    /**
     * User card component
     *
     * @spread user - Expects: name, email, avatarUrl, role
     */
    export class UserCard {
      @bindable name: string;
      @bindable email: string;
      @bindable avatarUrl: string;
      @bindable role: string;
    }
    export class ConfigurableCard {
      @bindable title: string = 'Untitled';
      @bindable size: string = 'medium';
      @bindable color: string = 'blue';
    }
    export class UserCard {
      @bindable name: string;
      @bindable email: string;
    
      binding() {
        if (!this.name || !this.email) {
          throw new Error('UserCard requires name and email properties');
        }
      }
    }
    <label>${label}
      <input ...$attrs>
    </label>
    @customElement({
      ...,
      capture: true
    })
    <input ...$attrs>
    export class FormInput {
      @bindable label
    }
    <form-input if.bind="needsComment" label.bind="label" value.bind="extraComment" class="form-control" style="background: var(--theme-purple)" tooltip="Hello, ${tooltip}">
    app.html
    <input-field value.bind="message">
    
    input-field.html
    <my-input ...$attrs>
    <!-- Spread bindables, then attributes, then explicit bindings -->
    <input-field ...user ...$attrs id.bind="fieldId" class="form-control">
    <!-- The explicit value.bind will override any value from spreading -->
    <input ...$attrs value.bind="explicitValue">
    <!-- Deep property access -->
    <user-card ...user.profile.details>
    <user-card ...user.addresses[0]>
    
    <!-- Method calls and computed properties -->
    <user-card ...user.getDetails()>
    <user-card ...user.details | processUser>
    
    <!-- For complex expressions, use the full syntax -->
    <user-card ...$bindables="user.addresses.find(addr => addr.primary)">
    <!-- Only spread if user exists -->
    <user-card ...$bindables="user || {}">
    
    <!-- Spread different objects based on condition -->
    <user-card ...$bindables="isAdmin ? adminUser : regularUser">
    
    <!-- Combine with template controllers -->
    <user-card if.bind="user" ...user>
    <!-- These are equivalent -->
    <input value.bind="value">
    <input value.bind>  <!-- Auto-infers 'value' property -->
    
    <!-- Works with different binding commands -->
    <input value.two-way="value">
    <input value.two-way>  <!-- Auto-infers 'value' property -->
    
    <!-- Attribute binding -->
    <div textcontent.bind="textcontent">
    <div textcontent.bind>  <!-- Auto-infers 'textcontent' property -->
    
    <!-- Custom attributes -->
    <div tooltip.bind="tooltip">
    <div tooltip.bind>  <!-- Auto-infers 'tooltip' property -->
    // Aurelia optimizes repeated spread operations
    class UserCard {
      @bindable user = { name: 'John', age: 30 };
      
      updateUser() {
        // If the same object reference is returned, bindings aren't recreated
        this.user = this.user; // No rebinding
        
        // New object reference triggers binding recreation
        this.user = { ...this.user, age: 31 }; // Rebinding occurs
      }
    }
    <!-- Bindings are created once and reused when possible -->
    <user-card ...user>
    <!-- Safe spreading - handles null/undefined gracefully -->
    <user-card ...user>           <!-- Safe even if user is null/undefined -->
    <user-card ...$bindables="user || {}">  <!-- Explicit fallback -->
    
    <!-- Member access on null/undefined -->
    <user-card ...user?.profile>  <!-- Safe with optional chaining -->
    <!-- These will be handled gracefully -->
    <user-card ...undefined>      <!-- No bindings created -->
    <user-card ...nonExistentVar> <!-- No bindings created -->
    <user-card ...user.invalid>   <!-- No bindings created -->
    interface User {
      name: string;
      email: string;
      age: number;
    }
    
    export class UserCard {
      @bindable name: string;
      @bindable email: string;
      // age is not a bindable, so it won't be bound even if present in the object
    }
    
    const user: User = { name: 'John', email: '[email protected]', age: 30 };
    <!-- Only name and email will be bound based on component's @bindable properties -->
    <user-card ...user>
    @customElement({
      name: 'secure-input',
      template: '<input ...$attrs>',
      capture: attr => !attr.startsWith('on') // Exclude event handlers
    })
    export class SecureInput {
      @bindable value: string;
    }
    @customElement({
      name: 'styled-input',
      template: '<input ...$attrs>',
      capture: attr => ['class', 'style', 'disabled'].includes(attr) // Only style-related
    })
    export class StyledInput {
      @bindable value: string;
    }
    <!-- Level 1: App uses form-group -->
    <form-group title="User Info" ...validation>
      <!-- Level 2: form-group uses input-field -->
      <input-field label="Email" ...validation>
        <!-- Level 3: input-field uses input -->
        <input ...$attrs>
      </input-field>
    </form-group>
    <!-- Template controllers are not captured -->
    <input-field if.bind="showField" ...fieldProps>
    
    <!-- Multiple template controllers -->
    <input-field if.bind="showField" repeat.for="field of fields" ...field>
    // Base input component
    export class BaseInput {
      @bindable value: string;
      @bindable placeholder: string;
      @bindable disabled: boolean;
    }
    
    // Specialized email input
    @customElement({
      name: 'email-input',
      template: '<base-input type="email" ...$attrs>',
      capture: true
    })
    export class EmailInput {}
    
    // Form field wrapper
    @customElement({
      name: 'form-field',
      template: `
        <div class="form-field">
          <label if.bind="label">\${label}</label>
          <div class="input-wrapper">
            <div class="content-replaceable" replaceable part="input">
              <input ...$attrs>
            </div>
          </div>
          <div class="error" if.bind="error">\${error}</div>
        </div>
      `,
      capture: true
    })
    export class FormField {
      @bindable label: string;
      @bindable error: string;
    }
    <!-- Complex composition -->
    <form-field label="Email Address" error.bind="emailError">
      <email-input au-slot="input" value.bind="email" placeholder="Enter email">
    </form-field>
    // Wrapper for third-party component
    @customElement({
      name: 'material-input',
      template: '<mat-input ...$attrs>',
      capture: attr => !attr.startsWith('au-') // Exclude Aurelia-specific attributes
    })
    export class MaterialInput {
      @bindable value: string;
    }
    export class DynamicForm {
      @bindable fieldConfigs: FieldConfig[];
      
      createField(config: FieldConfig) {
        return {
          component: config.component,
          props: config.props
        };
      }
    }
    <div repeat.for="config of fieldConfigs">
      <compose 
        view-model.bind="config.component"
        ...$bindables="config.props">
      </compose>
    </div>
    // Good: Use spreading for configuration
    interface ButtonConfig {
      variant: 'primary' | 'secondary';
      size: 'small' | 'medium' | 'large';
      icon?: string;
    }
    
    const submitConfig: ButtonConfig = {
      variant: 'primary',
      size: 'medium',
      icon: 'save'
    };
    <custom-button ...submitConfig>Submit</custom-button>
    // Good: Build objects conditionally
    const inputProps = {
      value: userInput,
      ...(isRequired && { required: true }),
      ...(hasError && { 'aria-invalid': true }),
      ...(isDisabled && { disabled: true })
    };
    <input ...$bindables="inputProps">
    // Good: Transform data before spreading
    const transformedUser = {
      displayName: user.fullName,
      email: user.contactInfo.email,
      isActive: user.status === 'active'
    };
    <user-card ...transformedUser>
    // Good: Provide defaults
    const defaultFieldProps = {
      size: 'medium',
      variant: 'outline'
    };
    
    const fieldProps = {
      ...defaultFieldProps,
      ...customProps
    };
    <form-field ...$bindables="fieldProps">
    import template from './product-name-search.html?raw';
    declare module '*.html?raw' {
      const content: string;
      export default content;
    }
    // webpack.config.cjs
    module.exports = {
      module: {
        rules: [
          { test: /\.html$/i, type: 'asset/source' },
        ],
      },
    };
    import template from './product-name-search.html';
    declare module '*.html' {
      const content: string;
      export default content;
    }
    import { customElement } from 'aurelia';
    
    @customElement({
      name: 'user-card',
      template: `
        <div class="user-card">
          <h3>\${name}</h3>
          <p>\${email}</p>
        </div>
      `
    })
    export class UserCard {
      name = 'John Doe';
      email = '[email protected]';
    }
    @customElement('user-card')
    export class UserCard {
      // Component logic
    }
    import template from './custom-template.html?raw';
    
    @customElement({
      name: 'data-widget',
      template, // External file
    })
    export class DataWidget {}
    
    @customElement({
      name: 'inline-widget',
      template: '<div>Inline template</div>',
    })
    export class InlineWidget {}
    
    @customElement({
      name: 'viewless-widget',
      template: null,
    })
    export class ViewlessWidget {}
    import { ChildComponent } from './child-component';
    
    @customElement({
      name: 'parent-widget',
      dependencies: [ChildComponent] // Available without <import>
    })
    export class UserCard {
      static $au = {
        type: 'custom-element',
        name: 'user-card'
      };
    }
    import { CustomElement } from '@aurelia/runtime-html';
    
    const MyComponent = CustomElement.define({
      name: 'test-component',
      template: '<span>\${message}</span>'
    });
    status-badge.html
    <bindable name="status"></bindable>
    <bindable name="message"></bindable>
    
    <span class="badge badge-\${status}">\${message}</span>
    <import from="./status-badge.html"></import>
    
    <status-badge status="success" message="Complete"></status-badge>
    import { bindable, customElement } from 'aurelia';
    import * as nprogress from 'nprogress';
    
    @customElement({
      name: 'progress-indicator',
      template: null
    })
    export class ProgressIndicator {
      @bindable loading = false;
    
      loadingChanged(newValue: boolean) {
        newValue ? nprogress.start() : nprogress.done();
      }
    }
    import Aurelia from 'aurelia';
    import { UserCard } from './components/user-card';
    
    Aurelia
      .register(UserCard)
      .app(MyApp)
      .start();
    <import from="./user-card"></import>
    <!-- or with alias -->
    <import from="./user-card" as="profile-card"></import>
    
    <user-card user.bind="currentUser"></user-card>
    <profile-card user.bind="selectedUser"></profile-card>
    import { customElement, containerless } from 'aurelia';
    
    @customElement({ name: 'list-wrapper' })
    @containerless
    export class ListWrapper {
      // Component logic
    }
    @customElement({
      name: 'list-wrapper',
      containerless: true
    })
    export class ListWrapper {}
    export class UserProfile {
      constructor() {
        // Component instantiation
      }
    
      binding() {
        // Before bindings are processed
      }
    
      bound() {
        // After bindings are set
      }
    
      attached() {
        // Component is in the DOM
      }
    
      detaching() {
        // Before removal from DOM
      }
    }
    import { bindable, BindingMode } from 'aurelia';
    
    export class UserCard {
      @bindable user: User;
      @bindable isActive: boolean = false;
      @bindable({ mode: BindingMode.twoWay }) selectedId: string;
    
      userChanged(newUser: User, oldUser: User) {
        // Called when user property changes
      }
    }
    <user-card 
      user.bind="currentUser" 
      is-active.bind="userIsActive"
      selected-id.two-way="selectedUserId">
    </user-card>
    import { customElement, useShadowDOM, shadowCSS } from 'aurelia';
    
    @customElement({
      name: 'isolated-widget',
      template: '<div class="widget"><slot></slot></div>',
      dependencies: [
        shadowCSS(`
          .widget {
            border: 1px solid var(--widget-border, #ddd);
            padding: 16px;
          }
        `)
      ]
    })
    @useShadowDOM({ mode: 'open' })
    export class IsolatedWidget {
      // Styles and DOM are fully encapsulated from outside
    }
    import { customElement, processContent, INode } from 'aurelia';
    
    @customElement({ name: 'card-grid' })
    export class CardGrid {
      @processContent()
      static processContent(node: INode) {
        // Transform <card> elements into proper markup
        const cards = node.querySelectorAll('card');
        cards.forEach(card => {
          card.classList.add('card-item');
          // Additional transformations...
        });
      }
    }
    import { resolve, Aurelia } from 'aurelia';
    
    export class DynamicContent {
      private readonly au = resolve(Aurelia);
    
      async enhanceContent() {
        const element = document.getElementById('server-rendered');
        await this.au.enhance({
          host: element,
          component: { data: this.dynamicData }
        });
      }
    }
    import { watch, bindable } from 'aurelia';
    
    export class ChartWidget {
      @bindable data: ChartData[];
      @bindable config: ChartConfig;
    
      @watch('data')
      @watch('config') 
      onDataChange(newValue: any, oldValue: any, propertyName: string) {
        this.updateChart();
      }
    }
    import { children, slotted } from 'aurelia';
    
    export class TabContainer {
      @children('tab-item') tabItems: TabItem[];
      @slotted('tab-panel') panels: TabPanel[];
    
      tabItemsChanged(newItems: TabItem[]) {
        this.syncTabs();
      }
    }
    import { capture, customElement } from 'aurelia';
    
    @customElement({ name: 'flex-wrapper' })
    @capture() // Captures all unrecognized attributes
    export class FlexWrapper {}
    import { customElement } from 'aurelia';
    
    @customElement({
      name: 'primary-button',
      aliases: ['btn-primary', 'p-btn']
    })
    export class PrimaryButton {}
    import { bindable, resolve } from 'aurelia';
    import { ILogger } from '@aurelia/kernel';
    
    interface User {
      id: string;
      name: string;
      email: string;
    }
    
    export class UserProfile {
      @bindable user: User;
      private readonly logger = resolve(ILogger);
      
      attached() {
        this.logger.info('Profile loaded', { userId: this.user.id });
      }
    }

    Notification System

    A complete notification system with auto-dismiss, multiple types, animations, and queue management.

    Features Demonstrated

    • Dependency Injection - Singleton service pattern

    • Event Aggregator - Global notification triggering

    • Animations - CSS transitions for enter/leave

    • Timers - Auto-dismiss with setTimeout

    • Array manipulation - Add/remove notifications

    • Dynamic CSS classes - Type-based styling

    • Conditional rendering - Show/hide based on array length

    Code

    Service (notification-service.ts)

    Component (notification-container.ts)

    Template (notification-container.html)

    Styles (notification-container.css)

    Registration (main.ts)

    Usage in Root Component (my-app.html)

    Usage in Any Component

    How It Works

    Singleton Service Pattern

    The INotificationService is registered as a singleton, so the same instance is shared across the entire application. Any component can inject it and trigger notifications.

    Auto-Dismiss Timer

    When a notification is added with duration > 0, a timer is created that automatically dismisses it after the specified time. The timer is stored in a Map so it can be cleared if the user manually dismisses the notification.

    Reactive Array

    The notifications array is a reactive property. When notifications are added or removed, Aurelia's binding system automatically updates the DOM.

    Progress Bar Animation

    The progress bar uses a computed property (getProgressWidth) that calculates the percentage remaining based on elapsed time. This creates a smooth countdown animation.

    Variations

    Stacking vs Replacing

    Current implementation stacks notifications. For "replacing" behavior (only show one at a time):

    Position Options

    Make position configurable:

    Action Buttons

    Add action buttons to notifications:

    Pause on Hover

    Pause the auto-dismiss timer when hovering:

    Related

    • - Singleton services

    • - Alternative global communication

    • - if.bind documentation

    • - repeat.for

    From Angular to Aurelia

    Angular developers: Keep the best parts (DI, TypeScript, CLI) while eliminating the complexity and improving performance.

    Angular developer? You'll feel right at home with Aurelia. Keep everything you love—dependency injection, TypeScript, powerful CLI—while eliminating boilerplate, improving performance, and simplifying your development experience.

    Why Angular Developers Choose Aurelia

    Dropdown Menu

    Build a fully-featured dropdown menu component with keyboard navigation and accessibility

    Learn to build a production-ready dropdown menu with keyboard navigation, accessibility, and click-outside detection. This component is perfect for navigation menus, context menus, and action lists.

    What We're Building

    A dropdown menu that supports:

    • Click to toggle open/close

    documentation
    Dependency Injection
    Event Aggregator
    Conditional Rendering
    List Rendering

    Keyboard navigation (Arrow keys, Enter, Escape)

  • Click outside to close

  • Accessible with ARIA attributes

  • Customizable trigger and content

  • Positioning options

  • Component Code

    dropdown-menu.ts

    dropdown-menu.html

    dropdown-menu.css

    Usage Examples

    Basic Dropdown

    With Custom Trigger

    Programmatic Control

    Disabled State

    Testing

    Test your dropdown component:

    Accessibility Features

    This dropdown implements WCAG 2.1 guidelines:

    • ✅ Keyboard Navigation: Full keyboard support with arrow keys

    • ✅ ARIA Attributes: Proper role, aria-haspopup, aria-expanded, aria-hidden

    • ✅ Focus Management: Focuses first item when opened, returns focus to trigger when closed

    • ✅ Escape to Close: Standard Escape key behavior

    • ✅ Screen Reader Support: Announces menu state and items

    Enhancements

    1. Add Icons to Menu Items

    2. Add Submenus

    Nest another dropdown-menu inside:

    3. Add Search/Filter

    4. Add Positioning Intelligence

    Use a library like Floating UI to automatically position the menu to avoid viewport overflow:

    Best Practices

    1. Always Clean Up: Remove event listeners in detaching() to prevent memory leaks

    2. Focus Management: Return focus to trigger when closing for better UX

    3. Debounce: For search/filter, debounce input to avoid excessive filtering

    4. Accessibility: Test with keyboard only and screen readers

    5. Portal Rendering: For complex layouts, render menu in a portal to avoid z-index issues

    Summary

    You've built a fully-featured dropdown menu with:

    • ✅ Click and keyboard interactions

    • ✅ Accessibility built-in

    • ✅ Click-outside detection

    • ✅ Customizable trigger and content

    • ✅ Comprehensive tests

    This dropdown is production-ready and can be extended with search, submenus, and intelligent positioning!

    // src/services/notification-service.ts
    import { DI } from '@aurelia/kernel';
    
    export interface Notification {
      id: string;
      type: 'success' | 'error' | 'warning' | 'info';
      title: string;
      message: string;
      duration: number; // milliseconds, 0 = no auto-dismiss
      dismissible: boolean;
      timestamp: Date;
      expiresAt?: number;
      remaining?: number;
    }
    
    export const INotificationService = DI.createInterface<INotificationService>(
      'INotificationService',
      x => x.singleton(NotificationService)
    );
    
    export interface INotificationService {
      readonly notifications: Notification[];
      show(options: Partial<Notification>): string;
      success(title: string, message: string, duration?: number): string;
      error(title: string, message: string, duration?: number): string;
      warning(title: string, message: string, duration?: number): string;
      info(title: string, message: string, duration?: number): string;
      dismiss(id: string): void;
      clear(): void;
    }
    
    class NotificationService implements INotificationService {
      notifications: Notification[] = [];
      private nextId = 1;
      private timers = new Map<string, number>();
      private progressTimers = new Map<string, number>();
    
      show(options: Partial<Notification>): string {
        const notification: Notification = {
          id: `notification-${this.nextId++}`,
          type: options.type || 'info',
          title: options.title || '',
          message: options.message || '',
          duration: options.duration !== undefined ? options.duration : 5000,
          dismissible: options.dismissible !== undefined ? options.dismissible : true,
          timestamp: new Date(),
          remaining: options.duration ?? 5000,
          expiresAt: options.duration ? Date.now() + options.duration : undefined
        };
    
        // Add to beginning of array (newest first)
        this.notifications.unshift(notification);
    
        // Auto-dismiss if duration > 0
        if (notification.duration > 0) {
          const timer = window.setTimeout(() => {
            this.dismiss(notification.id);
          }, notification.duration);
    
          this.timers.set(notification.id, timer);
    
          const progress = window.setInterval(() => {
            if (!notification.expiresAt) return;
            const remaining = Math.max(notification.expiresAt - Date.now(), 0);
            notification.remaining = remaining;
            if (remaining <= 0) {
              window.clearInterval(progress);
              this.progressTimers.delete(notification.id);
            }
          }, 100);
          this.progressTimers.set(notification.id, progress);
        }
    
        return notification.id;
      }
    
      success(title: string, message: string, duration = 5000): string {
        return this.show({ type: 'success', title, message, duration });
      }
    
      error(title: string, message: string, duration = 0): string {
        // Errors don't auto-dismiss by default
        return this.show({ type: 'error', title, message, duration });
      }
    
      warning(title: string, message: string, duration = 7000): string {
        return this.show({ type: 'warning', title, message, duration });
      }
    
      info(title: string, message: string, duration = 5000): string {
        return this.show({ type: 'info', title, message, duration });
      }
    
      dismiss(id: string): void {
        // Clear timer if exists
        const timer = this.timers.get(id);
        if (timer) {
          clearTimeout(timer);
          this.timers.delete(id);
        }
        const progress = this.progressTimers.get(id);
        if (progress) {
          clearInterval(progress);
          this.progressTimers.delete(id);
        }
    
        // Remove notification
        const index = this.notifications.findIndex(n => n.id === id);
        if (index !== -1) {
          this.notifications.splice(index, 1);
        }
      }
    
      clear(): void {
        // Clear all timers
        this.timers.forEach(timer => clearTimeout(timer));
        this.timers.clear();
        this.progressTimers.forEach(interval => clearInterval(interval));
        this.progressTimers.clear();
    
        // Clear all notifications
        this.notifications = [];
      }
    }
    // src/components/notification-container.ts
    import { resolve } from '@aurelia/kernel';
    import { INotificationService } from '../services/notification-service';
    
    export class NotificationContainer {
      private notificationService = resolve(INotificationService);
    
      get notifications() {
        return this.notificationService.notifications;
      }
    
      dismiss(id: string) {
        this.notificationService.dismiss(id);
      }
    
      getIcon(type: string): string {
        switch (type) {
          case 'success': return '✓';
          case 'error': return '✕';
          case 'warning': return '⚠';
          case 'info': return 'ⓘ';
          default: return '';
        }
      }
    
      getProgressWidth(notification: any): number {
        if (notification.duration === 0) return 0;
        const remaining = notification.remaining ?? notification.duration;
        return Math.max((remaining / notification.duration) * 100, 0);
      }
    }
    <!-- src/components/notification-container.html -->
    <div class="notification-container">
      <div
        repeat.for="notification of notifications"
        class="notification notification-${notification.type}">
    
          <div class="notification-icon">
            ${getIcon(notification.type)}
          </div>
    
          <div class="notification-content">
            <div class="notification-title">${notification.title}</div>
            <div class="notification-message">${notification.message}</div>
    
            <!-- Progress bar for auto-dismiss -->
            <div
              if.bind="notification.duration > 0"
              class="notification-progress">
              <div
                class="notification-progress-bar"
                style.width.bind="getProgressWidth(notification) + '%'">
              </div>
            </div>
          </div>
    
          <button
            if.bind="notification.dismissible"
            type="button"
            click.trigger="dismiss(notification.id)"
            class="notification-close"
            aria-label="Dismiss notification">
            ×
          </button>
        </div>
      </div>
    .notification-container {
      position: fixed;
      top: 1rem;
      right: 1rem;
      z-index: 9999;
      display: flex;
      flex-direction: column;
      gap: 0.75rem;
      max-width: 400px;
      width: calc(100% - 2rem);
    }
    
    .notification {
      display: flex;
      align-items: flex-start;
      gap: 0.75rem;
      padding: 1rem;
      border-radius: 8px;
      background: white;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
      animation: slideIn 0.3s ease-out;
      position: relative;
      overflow: hidden;
    }
    
    @keyframes slideIn {
      from {
        transform: translateX(100%);
        opacity: 0;
      }
      to {
        transform: translateX(0);
        opacity: 1;
      }
    }
    
    .notification-icon {
      width: 24px;
      height: 24px;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      font-weight: bold;
      font-size: 16px;
      flex-shrink: 0;
    }
    
    .notification-success {
      border-left: 4px solid #4caf50;
    }
    
    .notification-success .notification-icon {
      background-color: #4caf50;
      color: white;
    }
    
    .notification-error {
      border-left: 4px solid #f44336;
    }
    
    .notification-error .notification-icon {
      background-color: #f44336;
      color: white;
    }
    
    .notification-warning {
      border-left: 4px solid #ff9800;
    }
    
    .notification-warning .notification-icon {
      background-color: #ff9800;
      color: white;
    }
    
    .notification-info {
      border-left: 4px solid #2196f3;
    }
    
    .notification-info .notification-icon {
      background-color: #2196f3;
      color: white;
    }
    
    .notification-content {
      flex-grow: 1;
    }
    
    .notification-title {
      font-weight: 600;
      margin-bottom: 0.25rem;
      color: #333;
    }
    
    .notification-message {
      font-size: 0.875rem;
      color: #666;
      line-height: 1.4;
    }
    
    .notification-close {
      background: none;
      border: none;
      font-size: 1.5rem;
      cursor: pointer;
      color: #999;
      padding: 0;
      width: 24px;
      height: 24px;
      line-height: 1;
      flex-shrink: 0;
    }
    
    .notification-close:hover {
      color: #333;
    }
    
    .notification-progress {
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      height: 4px;
      background-color: rgba(0, 0, 0, 0.1);
      overflow: hidden;
    }
    
    .notification-progress-bar {
      height: 100%;
      background-color: currentColor;
      transition: width 0.1s linear;
    }
    
    .notification-success .notification-progress-bar {
      background-color: #4caf50;
    }
    
    .notification-error .notification-progress-bar {
      background-color: #f44336;
    }
    
    .notification-warning .notification-progress-bar {
      background-color: #ff9800;
    }
    
    .notification-info .notification-progress-bar {
      background-color: #2196f3;
    }
    
    /* Responsive */
    @media (max-width: 640px) {
      .notification-container {
        top: auto;
        bottom: 0;
        left: 0;
        right: 0;
        max-width: 100%;
        width: 100%;
        border-radius: 0;
      }
    
      .notification {
        border-radius: 0;
        border-left: none;
        border-top: 4px solid;
      }
    }
    // src/main.ts
    import Aurelia from 'aurelia';
    import { NotificationContainer } from './components/notification-container';
    import { INotificationService } from './services/notification-service';
    
    Aurelia
      .register(NotificationContainer, INotificationService)
      .app(component)
      .start();
    <!-- src/my-app.html -->
    <notification-container></notification-container>
    
    <!-- Your app content -->
    <au-viewport></au-viewport>
    // src/pages/dashboard.ts
    import { resolve } from '@aurelia/kernel';
    import { INotificationService } from '../services/notification-service';
    
    export class Dashboard {
      private notifications = resolve(INotificationService);
    
      async saveData() {
        try {
          await this.apiClient.save(this.data);
    
          this.notifications.success(
            'Saved!',
            'Your changes have been saved successfully.'
          );
        } catch (error) {
          this.notifications.error(
            'Error',
            'Failed to save changes. Please try again.',
            0 // Don't auto-dismiss errors
          );
        }
      }
    
      showWarning() {
        this.notifications.warning(
          'Low Storage',
          'You are running low on storage space.',
          7000
        );
      }
    
      showInfo() {
        this.notifications.info(
          'Tip',
          'You can use keyboard shortcuts to navigate faster.'
        );
      }
    }
    show(options: Partial<Notification>): string {
      // Clear existing notifications of the same type
      this.notifications = this.notifications.filter(n => n.type !== options.type);
    
      // ... rest of implementation
    }
    <div class="notification-container notification-container-${position}">
    .notification-container-top-right { top: 1rem; right: 1rem; }
    .notification-container-top-left { top: 1rem; left: 1rem; }
    .notification-container-bottom-right { bottom: 1rem; right: 1rem; }
    .notification-container-bottom-left { bottom: 1rem; left: 1rem; }
    export interface NotificationAction {
      label: string;
      callback: () => void | Promise<void>;
    }
    
    export interface Notification {
      // ... existing properties
      actions?: NotificationAction[];
    }
    <div if.bind="notification.actions" class="notification-actions">
      <button
        repeat.for="action of notification.actions"
        type="button"
        click.trigger="action.callback()"
        class="btn btn-small">
        ${action.label}
      </button>
    </div>
    pauseTimer(id: string) {
      const timer = this.timers.get(id);
      if (timer) {
        clearTimeout(timer);
      }
    }
    
    resumeTimer(notification: Notification) {
      if (notification.duration > 0) {
        const elapsed = Date.now() - notification.timestamp.getTime();
        const remaining = Math.max(0, notification.duration - elapsed);
    
        const timer = setTimeout(() => {
          this.dismiss(notification.id);
        }, remaining);
    
        this.timers.set(notification.id, timer);
      }
    }
    <div
      mouseover.trigger="notifications.pauseTimer(notification.id)"
      mouseout.trigger="notifications.resumeTimer(notification)">
      <!-- notification content -->
    </div>
    import { bindable, IEventAggregator } from 'aurelia';
    import { resolve } from '@aurelia/kernel';
    import { queueTask } from '@aurelia/runtime';
    import { IPlatform } from '@aurelia/runtime-html';
    
    export class DropdownMenu {
      @bindable open = false;
      @bindable position: 'left' | 'right' = 'left';
      @bindable disabled = false;
    
      private platform = resolve(IPlatform);
      private element?: HTMLElement;
      private triggerButton?: HTMLButtonElement;
      private menuElement?: HTMLElement;
      private clickOutsideHandler?: (e: MouseEvent) => void;
    
      binding() {
        this.setupClickOutsideHandler();
      }
    
      attaching(initiator: HTMLElement) {
        this.element = initiator;
        this.triggerButton = this.element.querySelector('[data-dropdown-trigger]') as HTMLButtonElement;
        this.menuElement = this.element.querySelector('[data-dropdown-menu]') as HTMLElement;
      }
    
      detaching() {
        this.removeClickOutsideListener();
      }
    
      toggle() {
        if (this.disabled) return;
    
        this.open = !this.open;
    
        if (this.open) {
          this.addClickOutsideListener();
          this.focusFirstItem();
        } else {
          this.removeClickOutsideListener();
        }
      }
    
      close() {
        if (this.open) {
          this.open = false;
          this.removeClickOutsideListener();
          this.triggerButton?.focus();
        }
      }
    
      handleKeyDown(event: KeyboardEvent) {
        if (this.disabled) return;
    
        const { key } = event;
    
        // Toggle on Enter or Space when trigger is focused
        if ((key === 'Enter' || key === ' ') && document.activeElement === this.triggerButton) {
          event.preventDefault();
          this.toggle();
          return;
        }
    
        // Close on Escape
        if (key === 'Escape' && this.open) {
          event.preventDefault();
          this.close();
          return;
        }
    
        // Arrow navigation when menu is open
        if (this.open && (key === 'ArrowDown' || key === 'ArrowUp')) {
          event.preventDefault();
          this.navigateItems(key === 'ArrowDown' ? 1 : -1);
          return;
        }
    
        // Activate item on Enter when focused
        if (key === 'Enter' && this.open && document.activeElement?.hasAttribute('role')) {
          event.preventDefault();
          (document.activeElement as HTMLElement).click();
        }
      }
    
      private navigateItems(direction: 1 | -1) {
        if (!this.menuElement) return;
    
        const items = Array.from(this.menuElement.querySelectorAll('[role="menuitem"]')) as HTMLElement[];
        if (items.length === 0) return;
    
        const currentIndex = items.findIndex(item => item === document.activeElement);
        let nextIndex: number;
    
        if (currentIndex === -1) {
          // No item focused, focus first or last based on direction
          nextIndex = direction === 1 ? 0 : items.length - 1;
        } else {
          // Move to next/previous item, wrapping around
          nextIndex = (currentIndex + direction + items.length) % items.length;
        }
    
        items[nextIndex]?.focus();
      }
    
      private focusFirstItem() {
        // Use tasksSettled to ensure DOM is updated
        queueTask(() => {
          const firstItem = this.menuElement?.querySelector('[role="menuitem"]') as HTMLElement;
          firstItem?.focus();
        });
      }
    
      private setupClickOutsideHandler() {
        this.clickOutsideHandler = (event: MouseEvent) => {
          const target = event.target as Node;
          if (this.element && !this.element.contains(target)) {
            this.close();
          }
        };
      }
    
      private addClickOutsideListener() {
        if (this.clickOutsideHandler) {
          // Use timeout to avoid immediate close from the same click that opened it
          setTimeout(() => {
            document.addEventListener('click', this.clickOutsideHandler!, true);
          }, 0);
        }
      }
    
      private removeClickOutsideListener() {
        if (this.clickOutsideHandler) {
          document.removeEventListener('click', this.clickOutsideHandler, true);
        }
      }
    
      /**
       * Call this when an item is selected to close the menu
       */
      handleItemClick() {
        this.close();
      }
    }
    <div
      class="dropdown \${open ? 'dropdown--open' : ''} dropdown--\${position}"
      keydown.trigger="handleKeyDown($event)"
      ref="dropdownElement">
    
      <!-- Trigger slot -->
      <button
        type="button"
        class="dropdown__trigger"
        click.trigger="toggle()"
        aria-haspopup="true"
        aria-expanded.bind="open"
        disabled.bind="disabled"
        data-dropdown-trigger>
        <au-slot name="trigger">
          <span>Menu</span>
          <svg class="dropdown__icon" width="12" height="12" viewBox="0 0 12 12">
            <path d="M6 9L1 4h10z" fill="currentColor"/>
          </svg>
        </au-slot>
      </button>
    
      <!-- Menu content -->
      <div
        class="dropdown__menu"
        role="menu"
        aria-hidden.bind="!open"
        data-dropdown-menu
        if.bind="open">
        <au-slot>
          <div role="menuitem" tabindex="0" click.trigger="handleItemClick()">Menu Item 1</div>
          <div role="menuitem" tabindex="0" click.trigger="handleItemClick()">Menu Item 2</div>
          <div role="menuitem" tabindex="0" click.trigger="handleItemClick()">Menu Item 3</div>
        </au-slot>
      </div>
    </div>
    .dropdown {
      position: relative;
      display: inline-block;
    }
    
    .dropdown__trigger {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 8px 16px;
      background: white;
      border: 1px solid #d1d5db;
      border-radius: 6px;
      font-size: 14px;
      cursor: pointer;
      transition: all 0.2s;
    }
    
    .dropdown__trigger:hover:not(:disabled) {
      background: #f9fafb;
      border-color: #9ca3af;
    }
    
    .dropdown__trigger:focus {
      outline: 2px solid #3b82f6;
      outline-offset: 2px;
    }
    
    .dropdown__trigger:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
    
    .dropdown__icon {
      transition: transform 0.2s;
    }
    
    .dropdown--open .dropdown__icon {
      transform: rotate(180deg);
    }
    
    .dropdown__menu {
      position: absolute;
      top: calc(100% + 4px);
      min-width: 200px;
      background: white;
      border: 1px solid #e5e7eb;
      border-radius: 6px;
      box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
                  0 4px 6px -2px rgba(0, 0, 0, 0.05);
      padding: 4px;
      z-index: 1000;
      animation: dropdown-slide-in 0.15s ease-out;
    }
    
    .dropdown--left .dropdown__menu {
      left: 0;
    }
    
    .dropdown--right .dropdown__menu {
      right: 0;
    }
    
    @keyframes dropdown-slide-in {
      from {
        opacity: 0;
        transform: translateY(-8px);
      }
      to {
        opacity: 1;
        transform: translateY(0);
      }
    }
    
    .dropdown__menu [role="menuitem"] {
      padding: 8px 12px;
      border-radius: 4px;
      cursor: pointer;
      transition: background 0.15s;
      outline: none;
    }
    
    .dropdown__menu [role="menuitem"]:hover,
    .dropdown__menu [role="menuitem"]:focus {
      background: #f3f4f6;
    }
    
    .dropdown__menu [role="menuitem"]:active {
      background: #e5e7eb;
    }
    
    /* Divider */
    .dropdown__divider {
      height: 1px;
      background: #e5e7eb;
      margin: 4px 0;
    }
    <dropdown-menu>
      <div au-slot="trigger">
        Actions
      </div>
    
      <div role="menuitem" tabindex="0">Edit</div>
      <div role="menuitem" tabindex="0">Duplicate</div>
      <div class="dropdown__divider"></div>
      <div role="menuitem" tabindex="0">Delete</div>
    </dropdown-menu>
    <dropdown-menu position="right">
      <button au-slot="trigger" class="icon-button">
        <svg><!-- Settings icon --></svg>
      </button>
    
      <div role="menuitem" tabindex="0" click.trigger="openSettings()">
        Settings
      </div>
      <div role="menuitem" tabindex="0" click.trigger="viewProfile()">
        Profile
      </div>
      <div role="menuitem" tabindex="0" click.trigger="logout()">
        Logout
      </div>
    </dropdown-menu>
    // your-component.ts
    import { DropdownMenu } from './dropdown-menu';
    
    export class YourComponent {
      dropdownOpen = false;
    
      openDropdown() {
        this.dropdownOpen = true;
      }
    
      closeDropdown() {
        this.dropdownOpen = false;
      }
    }
    <!-- your-component.html -->
    <dropdown-menu open.bind="dropdownOpen">
      <div role="menuitem" tabindex="0" click.trigger="performAction()">
        Action
      </div>
    </dropdown-menu>
    
    <button click.trigger="openDropdown()">Open Menu</button>
    <dropdown-menu disabled.bind="isProcessing">
      <div au-slot="trigger">
        Actions \${isProcessing ? '(Processing...)' : ''}
      </div>
    
      <div role="menuitem" tabindex="0">Action 1</div>
      <div role="menuitem" tabindex="0">Action 2</div>
    </dropdown-menu>
    import { createFixture } from '@aurelia/testing';
    import { DropdownMenu } from './dropdown-menu';
    
    describe('DropdownMenu', () => {
      it('toggles open/close on trigger click', async () => {
        const { component, trigger, queryBy, stop } = await createFixture
          .html`<dropdown-menu></dropdown-menu>`
          .deps(DropdownMenu)
          .build()
          .started;
    
        expect(component.open).toBe(false);
        expect(queryBy('[data-dropdown-menu]')).toBeNull();
    
        // Click trigger to open
        trigger.click('[data-dropdown-trigger]');
        expect(component.open).toBe(true);
    
        // Click trigger to close
        trigger.click('[data-dropdown-trigger]');
        expect(component.open).toBe(false);
    
        await stop(true);
      });
    
      it('closes when clicking outside', async () => {
        const { component, trigger, stop } = await createFixture
          .html`
            <div>
              <dropdown-menu></dropdown-menu>
              <button id="outside">Outside</button>
            </div>
          `
          .deps(DropdownMenu)
          .build()
          .started;
    
        // Open the dropdown
        trigger.click('[data-dropdown-trigger]');
        expect(component.open).toBe(true);
    
        // Click outside
        trigger.click('#outside');
    
        // Wait for click handler
        await new Promise(resolve => setTimeout(resolve, 10));
    
        expect(component.open).toBe(false);
    
        await stop(true);
      });
    
      it('closes on Escape key', async () => {
        const { component, trigger, getBy, stop } = await createFixture
          .html`<dropdown-menu></dropdown-menu>`
          .deps(DropdownMenu)
          .build()
          .started;
    
        // Open the dropdown
        trigger.click('[data-dropdown-trigger]');
        expect(component.open).toBe(true);
    
        // Press Escape
        trigger.keydown(getBy('.dropdown'), { key: 'Escape' });
        expect(component.open).toBe(false);
    
        await stop(true);
      });
    
      it('navigates items with arrow keys', async () => {
        const { trigger, getBy, getAllBy, stop } = await createFixture
          .html`
            <dropdown-menu>
              <div role="menuitem" tabindex="0">Item 1</div>
              <div role="menuitem" tabindex="0">Item 2</div>
              <div role="menuitem" tabindex="0">Item 3</div>
            </dropdown-menu>
          `
          .deps(DropdownMenu)
          .build()
          .started;
    
        // Open the dropdown
        trigger.click('[data-dropdown-trigger]');
    
        const dropdown = getBy('.dropdown');
        const items = getAllBy('[role="menuitem"]');
    
        // First item should be focused
        await new Promise(resolve => setTimeout(resolve, 10));
        expect(document.activeElement).toBe(items[0]);
    
        // Arrow down to second item
        trigger.keydown(dropdown, { key: 'ArrowDown' });
        expect(document.activeElement).toBe(items[1]);
    
        // Arrow up back to first
        trigger.keydown(dropdown, { key: 'ArrowUp' });
        expect(document.activeElement).toBe(items[0]);
    
        await stop(true);
      });
    
      it('does not open when disabled', async () => {
        const { component, trigger, stop } = await createFixture
          .html`<dropdown-menu disabled.bind="true"></dropdown-menu>`
          .deps(DropdownMenu)
          .build()
          .started;
    
        trigger.click('[data-dropdown-trigger]');
        expect(component.open).toBe(false);
    
        await stop(true);
      });
    });
    <div role="menuitem" tabindex="0" class="menu-item">
      <svg class="menu-item__icon"><!-- Icon --></svg>
      <span>Edit</span>
    </div>
    <dropdown-menu>
      <div role="menuitem" tabindex="0">Item 1</div>
    
      <dropdown-menu position="right">
        <div au-slot="trigger" role="menuitem" tabindex="0">
          More Actions →
        </div>
        <div role="menuitem" tabindex="0">Sub Item 1</div>
        <div role="menuitem" tabindex="0">Sub Item 2</div>
      </dropdown-menu>
    </dropdown-menu>
    export class SearchableDropdown {
      @bindable items: any[] = [];
      searchTerm = '';
    
      get filteredItems() {
        return this.items.filter(item =>
          item.label.toLowerCase().includes(this.searchTerm.toLowerCase())
        );
      }
    }
    import { computePosition, flip, shift } from '@floating-ui/dom';
    
    async positionMenu() {
      const { x, y } = await computePosition(this.triggerButton!, this.menuElement!, {
        middleware: [flip(), shift({ padding: 8 })]
      });
    
      Object.assign(this.menuElement!.style, {
        left: `${x}px`,
        top: `${y}px`
      });
    }
    🎯 All the Power, None of the Complexity

    Result: 70% less code with the same functionality and better performance.

    🚀 Dependency Injection Without the Complexity

    ✨ Better TypeScript Integration

    Your Angular Knowledge Transfers

    Template Syntax Translation

    Angular
    Aurelia
    Benefit

    [property]="value"

    property.bind="value"

    Same one-way binding

    [(ngModel)]="value"

    value.bind="value"

    Simpler two-way binding

    (click)="handler()"

    click.trigger="handler()"

    Same event handling

    *ngIf="condition"

    if.bind="condition"

    Cleaner conditional syntax

    Component Architecture

    Services and DI Comparison

    Migration Benefits for Angular Developers

    📈 Performance Gains

    • No Zone.js overhead - direct DOM updates instead of change detection

    • Smaller bundle sizes - less framework code, better tree shaking

    • Faster startup - no complex bootstrap process

    • Better runtime performance - efficient batched updates

    🧹 Development Experience Improvements

    • Less boilerplate - no modules, less ceremony

    • Simpler testing - no TestBed setup complexity

    • Better debugging - inspect actual DOM, not framework abstractions

    • Cleaner templates - HTML that looks like HTML

    🚀 Modern Development Features

    • Built-in hot reload - better development experience

    • Automatic CSS loading - no need to import stylesheets

    • Shadow DOM support - true component encapsulation

    • Standards-based - closer to web platform APIs

    Quick Migration Path

    1. Set Up Your Aurelia Environment (5 minutes)

    2. Convert Your First Angular Component (15 minutes)

    Take any Angular component and follow this pattern:

    3. Experience the Improvements

    • No change detection cycles - updates happen directly

    • No modules to configure - components work immediately

    • Better TypeScript support - everything is typed by default

    • Cleaner templates - HTML without framework-specific syntax

    Angular vs Aurelia: Feature Comparison

    Feature
    Angular
    Aurelia
    Winner

    TypeScript Support

    Excellent

    Excellent

    Tie

    Dependency Injection

    Powerful but complex

    Powerful and simple

    Aurelia

    Performance

    Good with OnPush

    Better by default

    What Angular Concepts Work in Aurelia

    ✅ Dependency Injection - Even more powerful and simpler ✅ TypeScript - First-class support, better integration ✅ Component Architecture - Same concepts, cleaner implementation ✅ Services - Same patterns, less boilerplate ✅ Routing - More powerful, type-safe navigation ✅ Testing - Simpler setup, same testing patterns ✅ CLI Tools - Full-featured CLI for scaffolding and building

    Ready for a Better Angular Experience?

    Next Steps:

    1. Complete Getting Started Guide - Build a real app in 15 minutes

    2. Dependency Injection Guide - Master Aurelia's DI system

    3. Router Guide - Type-safe navigation

    4. Testing Guide - Test your applications

    Questions? Join our Discord community where developers discuss enterprise framework experiences and architectural decisions.

    Ready to experience Angular without the complexity? Start building with Aurelia today.

    Advanced custom attributes

    Advanced patterns for building custom attributes in Aurelia 2, including template controllers, complex bindings, and performance optimization.

    This guide covers advanced patterns for building custom attributes in Aurelia 2, focusing on template controllers, complex binding scenarios, and performance optimization techniques.

    Template Controllers

    Template controllers are custom attributes that control the rendering of their associated template. They're the mechanism behind built-in attributes like if, repeat, with, and switch.

    Basic Template Controller Structure

    All template controllers follow this pattern:

    Usage:

    Real-World Example: Visibility Controller

    A practical template controller that shows/hides content based on user permissions:

    Usage:

    Advanced Template Controller: Loading States

    A template controller that manages loading states with caching:

    Usage:

    Complex Two-Way Binding Attributes

    Bi-directional Data Synchronization

    Create attributes that can both read and write data:

    Usage:

    Multi-Value Binding

    Handle multiple bindable properties with complex interactions:

    Usage:

    Performance Optimization Patterns

    Lazy Initialization

    Defer expensive operations until needed:

    Batch Updates

    Minimize DOM operations by batching updates:

    Error Handling in Custom Attributes

    Graceful Degradation

    Handle errors gracefully without breaking the application:

    Validation and Sanitization

    Validate inputs before applying them:

    Testing Custom Attributes

    Unit Testing Template Controllers

    Best Practices

    1. Resource Management

    Always clean up resources in detached():

    2. Performance Considerations

    • Use requestAnimationFrame for DOM updates

    • Batch operations when possible

    • Avoid frequent DOM queries

    3. Error Handling

    • Validate inputs before applying changes

    • Provide fallback behaviors

    • Log errors for debugging

    4. Type Safety

    • Use TypeScript interfaces for bindable properties

    • Implement proper type guards for runtime validation

    These patterns provide a solid foundation for building robust, performant custom attributes that integrate well with Aurelia's architecture while handling edge cases gracefully.

    Shadow DOM

    Learn how to use Shadow DOM in Aurelia components for style encapsulation and native web component features.

    Shadow DOM provides native browser encapsulation for your components, isolating styles and DOM structure. Aurelia makes it easy to enable Shadow DOM for any custom element.

    Enabling Shadow DOM

    Using the @useShadowDOM Decorator

    The simplest way to enable Shadow DOM is with the @useShadowDOM decorator:

    By default, this creates a shadow root with mode: 'open'.

    Configuring Shadow DOM Mode

    Shadow DOM supports two modes: open and closed.

    Open mode (default) allows external JavaScript to access the shadow root:

    Closed mode prevents external access to the shadow root:

    Using the Configuration Object

    You can also configure Shadow DOM using the @customElement decorator's configuration object:

    Or using a static property:

    Styling Shadow DOM Components

    Shadow DOM provides complete CSS isolation. Styles defined outside the component won't affect elements inside, and styles inside won't leak out.

    Component-Local Styles

    Use the shadowCSS helper to register styles for your component:

    Using Constructable Stylesheets

    For better performance and reusability, you can pass CSSStyleSheet instances:

    Global Shared Styles

    Configure styles that apply to all Shadow DOM components in your application:

    Global styles are applied first, followed by component-local styles.

    Styling from Outside: CSS Custom Properties

    The only way to style Shadow DOM components from outside is using CSS custom properties (CSS variables):

    Shadow DOM and Slots

    Native <slot> elements require Shadow DOM. Attempting to use <slot> without Shadow DOM will throw a compilation error.

    Basic Slot Usage

    Usage:

    Named Slots

    Usage:

    Fallback Content

    Slots can have default content when nothing is projected:

    Listening to Slot Changes

    React to changes in slotted content:

    For more advanced slot usage, including the @children decorator and component view model retrieval, see the .

    Constraints and Limitations

    Cannot Combine with @containerless

    Shadow DOM requires a host element to attach to. You cannot use both @useShadowDOM and @containerless on the same component:

    Error: Invalid combination: cannot combine the containerless custom element option with Shadow DOM.

    Native Slots Require Shadow DOM

    Using <slot> elements without enabling Shadow DOM will cause a compilation error:

    Error: Template compilation error: detected a usage of "<slot>" element without specifying shadow DOM options in element: broken-component

    Solution: Either enable Shadow DOM or use <au-slot> instead:

    Choosing Between Shadow DOM and Light DOM

    Use Shadow DOM When:

    • Style isolation is critical: You need to prevent external styles from affecting your component

    • Building reusable components: Your component will be used in different contexts and needs predictable styling

    • Using native web component features: You need features like <slot>, CSS :host selector, or ::part

    Use Light DOM (no Shadow DOM) When:

    • Easy styling is important: Parent components or application styles should easily affect the component

    • Working with global styles: Your component should inherit application-wide styles

    • SEO is a concern: Search engines can more easily index light DOM content

    • Using

    Practical Examples

    Themed Button Component

    Card with Multiple Slots

    Component with Dynamic Styles

    Best Practices

    1. Use CSS Custom Properties for Theming

    Allow users to customize your components through CSS variables with sensible defaults:

    2. Provide Fallback Content for Slots

    Give users a good default experience even when they don't provide slot content:

    3. Namespace Your CSS Variables

    Prevent naming conflicts by prefixing your component's CSS variables:

    4. Consider Performance with Constructable Stylesheets

    For components that may be instantiated many times, use CSSStyleSheet objects instead of strings:

    5. Use Open Mode Unless You Have a Reason Not To

    Closed mode prevents useful debugging and testing. Use open mode by default:

    6. Document Your CSS Custom Properties

    If your component supports theming, document the available CSS variables:

    Additional Resources

    • - Deep dive into slots, @children, and @slotted decorators

    • - Using Aurelia components as web components

    • - Complete API documentation including Shadow DOM options

    Styling components

    Master the art of dynamic styling in Aurelia 2. Learn everything from basic class toggling to advanced CSS custom properties, plus component styling strategies that will make your apps both beautiful

    Dynamic styling is a fundamental aspect of modern web applications, and Aurelia 2 provides powerful, flexible mechanisms for binding CSS classes and styles to your elements. Whether you need to toggle an active state, implement a theming system, or create responsive layouts, Aurelia's binding system makes these tasks straightforward and maintainable.

    This comprehensive guide covers everything from basic class toggling to advanced styling techniques, giving you the knowledge and tools to implement any styling requirement in your Aurelia 2 applications.

    Basic Class Binding

    The most common use case for dynamic styling is conditionally applying CSS classes based on component state.

    Search Autocomplete

    A complete autocomplete/typeahead search component with keyboard navigation, highlighting, and debouncing.

    Features Demonstrated

    • Two-way data binding - Search input

    // Angular: Heavy ceremony and boilerplate
    import { Component, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angular/core';
    import { Subject } from 'rxjs';
    import { takeUntil, debounceTime, distinctUntilChanged } from 'rxjs/operators';
    
    @Component({
      selector: 'app-user-search',
      template: `
        <div>
          <input 
            [value]="searchQuery" 
            (input)="onSearchInput($event)"
            placeholder="Search users..."
          >
          <div *ngIf="loading">Loading...</div>
          <app-user-card 
            *ngFor="let user of filteredUsers; trackBy: trackByUserId"
            [user]="user"
            (userEdit)="onUserEdit($event)"
          ></app-user-card>
        </div>
      `
    })
    export class UserSearchComponent implements OnInit, OnDestroy {
      @Input() users: User[] = [];
      @Output() userEdit = new EventEmitter<User>();
      
      searchQuery = '';
      filteredUsers: User[] = [];
      loading = false;
      
      private destroy$ = new Subject<void>();
      private searchSubject = new Subject<string>();
    
      ngOnInit() {
        this.searchSubject.pipe(
          debounceTime(300),
          distinctUntilChanged(),
          takeUntil(this.destroy$)
        ).subscribe(query => this.performSearch(query));
      }
    
      ngOnDestroy() {
        this.destroy$.next();
        this.destroy$.complete();
      }
    
      onSearchInput(event: Event) {
        const target = event.target as HTMLInputElement;
        this.searchQuery = target.value;
        this.searchSubject.next(this.searchQuery);
      }
    
      trackByUserId(index: number, user: User): number {
        return user.id;
      }
    
      private async performSearch(query: string) {
        if (query.length > 2) {
          this.loading = true;
          // Search logic
          this.loading = false;
        } else {
          this.filteredUsers = [];
        }
      }
    
      onUserEdit(user: User) {
        this.userEdit.emit(user);
      }
    }
    
    // Aurelia: Clean, intuitive code
    export class UserSearch {
      @bindable users: User[];
      @bindable userEdit: (user: User) => void;
      
      searchQuery = '';
      loading = false;
    
      // Computed properties work automatically
      get filteredUsers() {
        if (this.searchQuery.length < 3) return [];
        return this.users.filter(user => 
          user.name.toLowerCase().includes(this.searchQuery.toLowerCase())
        );
      }
    
      // Simple debounced search
      @watch('searchQuery')
      async searchChanged(newQuery: string) {
        if (newQuery.length > 2) {
          this.loading = true;
          // Search logic  
          this.loading = false;
        }
      }
    }
    <!-- Aurelia template: Clean HTML -->
    <div>
      <input value.bind="searchQuery & debounce:300" placeholder="Search users...">
      <div if.bind="loading">Loading...</div>
      <user-card repeat.for="user of filteredUsers" 
                 user.bind="user"
                 user-edit.call="userEdit(user)">
      </user-card>
    </div>
    // Angular: Complex DI with decorators and modules
    import { Injectable, Inject } from '@angular/core';
    import { HttpClient } from '@angular/common/http';
    
    @Injectable({
      providedIn: 'root'
    })
    export class UserService {
      constructor(
        private http: HttpClient,
        @Inject('API_URL') private apiUrl: string
      ) {}
    }
    
    @NgModule({
      providers: [
        { provide: 'API_URL', useValue: 'https://api.example.com' },
        { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
      ]
    })
    export class AppModule {}
    
    // Aurelia: Simple, powerful DI
    export const IUserService = DI.createInterface<IUserService>(
      'IUserService',
      x => x.singleton(UserService)
    );
    
    export class UserService {
      private http = resolve(IHttpClient);
      private config = resolve(IApiConfig);
      
      // That's it - no modules, no complex setup
    }
    
    // Use anywhere
    export class UserList {
      private userService = resolve(IUserService);
      
      // Clean, type-safe injection
    }
    // Angular: Lots of ceremony for type safety
    interface User {
      id: number;
      name: string;
      email: string;
    }
    
    @Component({
      selector: 'app-user-detail',
      template: `
        <div *ngIf="user">
          <h2>{{ user.name }}</h2>
          <p>{{ user.email }}</p>
          <button (click)="editUser()">Edit</button>
        </div>
      `
    })
    export class UserDetailComponent {
      @Input() user: User | null = null;
      @Output() edit = new EventEmitter<User>();
    
      editUser() {
        if (this.user) {
          this.edit.emit(this.user);
        }
      }
    }
    
    // Aurelia: TypeScript-first design
    export class UserDetail {
      @bindable user: User | null = null;
      @bindable edit: (user: User) => void;
    
      editUser() {
        if (this.user) {
          this.edit(this.user);
        }
      }
    }
    <!-- Aurelia template with automatic type checking -->
    <div if.bind="user">
      <h2>${user.name}</h2>
      <p>${user.email}</p>
      <button click.trigger="editUser()">Edit</button>
    </div>
    // Angular Component
    @Component({
      selector: 'app-todo-list',
      template: `
        <div class="todo-app">
          <input 
            [(ngModel)]="newTodo" 
            (keyup.enter)="addTodo()"
            placeholder="Add todo..."
          >
          <ul>
            <li *ngFor="let todo of todos; trackBy: trackByTodoId"
                [class.completed]="todo.completed">
              <input 
                type="checkbox" 
                [(ngModel)]="todo.completed"
              >
              <span>{{ todo.text }}</span>
              <button (click)="deleteTodo(todo.id)">Delete</button>
            </li>
          </ul>
        </div>
      `,
      styleUrls: ['./todo-list.component.css']
    })
    export class TodoListComponent {
      @Input() todos: Todo[] = [];
      @Output() todoAdded = new EventEmitter<Todo>();
      @Output() todoDeleted = new EventEmitter<number>();
      
      newTodo = '';
      private nextId = 1;
    
      addTodo() {
        if (this.newTodo.trim()) {
          const todo: Todo = {
            id: this.nextId++,
            text: this.newTodo.trim(),
            completed: false
          };
          this.todoAdded.emit(todo);
          this.newTodo = '';
        }
      }
    
      deleteTodo(id: number) {
        this.todoDeleted.emit(id);
      }
    
      trackByTodoId(index: number, todo: Todo): number {
        return todo.id;
      }
    }
    
    // Aurelia Component - much cleaner
    export class TodoList {
      @bindable todos: Todo[] = [];
      @bindable todoAdded: (todo: Todo) => void;
      @bindable todoDeleted: (id: number) => void;
      
      newTodo = '';
      private nextId = 1;
    
      addTodo() {
        if (this.newTodo.trim()) {
          const todo: Todo = {
            id: this.nextId++,
            text: this.newTodo.trim(),
            completed: false
          };
          this.todoAdded(todo);
          this.newTodo = '';
        }
      }
    
      deleteTodo(id: number) {
        this.todoDeleted(id);
      }
      
      onEnterKey(event: KeyboardEvent) {
        if (event.key === 'Enter') {
          this.addTodo();
        }
      }
    }
    <!-- todo-list.html - clean, readable -->
    <div class="todo-app">
      <input 
        value.bind="newTodo" 
        keydown.trigger="onEnterKey($event)"
        placeholder="Add todo..."
      >
      <ul>
        <li repeat.for="todo of todos" completed.class="todo.completed">
          <input type="checkbox" checked.bind="todo.completed">
          <span>${todo.text}</span>
          <button click.trigger="deleteTodo(todo.id)">Delete</button>
        </li>
      </ul>
    </div>
    // Angular Service
    @Injectable({
      providedIn: 'root'
    })
    export class DataService {
      constructor(
        private http: HttpClient,
        @Inject('API_CONFIG') private config: ApiConfig
      ) {}
    
      async getUsers(): Promise<User[]> {
        return this.http.get<User[]>(`${this.config.baseUrl}/users`).toPromise();
      }
    }
    
    // Aurelia Service - cleaner and more flexible
    export const IDataService = DI.createInterface<IDataService>(
      'IDataService', 
      x => x.singleton(DataService)
    );
    
    export class DataService {
      private http = resolve(IHttpClient);
      private config = resolve(IApiConfig);
    
      async getUsers(): Promise<User[]> {
        return this.http.get(`${this.config.baseUrl}/users`);
      }
    }
    npx makes aurelia my-aurelia-app
    cd my-aurelia-app
    npm run dev
    // Angular
    @Component({
      selector: 'app-user-profile',
      template: `
        <div class="profile" [class.editing]="isEditing">
          <h2>{{ user.name }}</h2>
          <p>{{ user.email }}</p>
          <button *ngIf="!isEditing" (click)="startEdit()">Edit</button>
          <div *ngIf="isEditing">
            <input [(ngModel)]="editName" placeholder="Name">
            <input [(ngModel)]="editEmail" placeholder="Email">
            <button (click)="saveChanges()">Save</button>
            <button (click)="cancelEdit()">Cancel</button>
          </div>
        </div>
      `
    })
    export class UserProfileComponent {
      @Input() user: User;
      @Output() userUpdated = new EventEmitter<User>();
      
      isEditing = false;
      editName = '';
      editEmail = '';
    
      startEdit() {
        this.isEditing = true;
        this.editName = this.user.name;
        this.editEmail = this.user.email;
      }
    
      saveChanges() {
        const updatedUser = { ...this.user, name: this.editName, email: this.editEmail };
        this.userUpdated.emit(updatedUser);
        this.isEditing = false;
      }
    
      cancelEdit() {
        this.isEditing = false;
      }
    }
    
    // Aurelia - same functionality, cleaner code
    export class UserProfile {
      @bindable user: User;
      @bindable userUpdated: (user: User) => void;
      
      isEditing = false;
      editName = '';
      editEmail = '';
    
      startEdit() {
        this.isEditing = true;
        this.editName = this.user.name;
        this.editEmail = this.user.email;
      }
    
      saveChanges() {
        const updatedUser = { ...this.user, name: this.editName, email: this.editEmail };
        this.userUpdated(updatedUser);
        this.isEditing = false;
      }
    
      cancelEdit() {
        this.isEditing = false;
      }
    }
    <!-- user-profile.html -->
    <div class="profile" editing.class="isEditing">
      <h2>${user.name}</h2>
      <p>${user.email}</p>
      <button if.bind="!isEditing" click.trigger="startEdit()">Edit</button>
      <div if.bind="isEditing">
        <input value.bind="editName" placeholder="Name">
        <input value.bind="editEmail" placeholder="Email">
        <button click.trigger="saveChanges()">Save</button>
        <button click.trigger="cancelEdit()">Cancel</button>
      </div>
    </div>
    # Start your Aurelia journey
    npx makes aurelia my-angular-to-aurelia-app
    cd my-angular-to-aurelia-app
    npm run dev

    *ngFor="let item of items"

    repeat.for="item of items"

    Same iteration, better performance

    [class.active]="isActive"

    active.class="isActive"

    More intuitive class binding

    Aurelia

    Learning Curve

    Steep

    Gentle

    Aurelia

    Bundle Size

    Large

    Smaller

    Aurelia

    CLI Tools

    Excellent

    Excellent

    Tie

    Enterprise Features

    Comprehensive

    Comprehensive

    Tie

    Ecosystem

    Huge

    Focused

    Angular

    Standards Compliance

    Good

    Excellent

    Aurelia

    Creating a design system: Components should maintain consistent appearance regardless of environment

    <au-slot>
    : You need Aurelia's slot features like
    $host
    scope access
    Slotted Content documentation
    Slotted Content Documentation
    Web Components Documentation
    CustomElement API Reference
    Single Class Binding: The .class Syntax

    The .class binding is the foundation of dynamic styling in Aurelia. The syntax is straightforward:

    How it works: The syntax is className.class="booleanExpression". When the expression is truthy, the class is added. When it's falsy, the class is removed.

    Note: You can use any valid CSS class name, including ones with special characters like my-awesome-class.class="isAwesome" or Unicode characters like ✓.class="isComplete".

    Multiple Classes: Comma-Separated Syntax

    When you need to toggle multiple related classes together, you can use comma-separated class names:

    Important: No spaces around the commas! The parser expects class1,class2,class3, not class1, class2, class3.

    Style Binding

    Aurelia provides multiple approaches for binding CSS styles, from individual properties to complex style objects.

    Single Style Properties

    To bind individual CSS properties dynamically, use the .style syntax:

    Alternative Style Syntax

    Aurelia supports two equivalent syntaxes for style binding:

    Use whichever feels more natural to you. Some developers prefer the first syntax because it reads like "set the background-color style to myColor", while others prefer the second because it's more similar to traditional CSS.

    CSS Custom Properties

    Aurelia fully supports CSS custom properties (CSS variables), enabling powerful theming capabilities:

    Vendor Prefixes

    Aurelia supports vendor-prefixed CSS properties for cross-browser compatibility:

    The !important Declaration

    Aurelia automatically handles the !important CSS declaration when included in style values:

    Advanced Class Binding Techniques

    Advanced class binding techniques provide greater flexibility for complex styling scenarios.

    String-Based Class Binding

    For scenarios requiring more flexibility than boolean toggling, you can bind class strings directly:

    When to use what:

    • .class syntax: When you need boolean toggling of specific classes

    • class.bind: When you need to build class strings dynamically

    • Template interpolation: When you want to mix static and dynamic classes

    Advanced Style Binding

    Advanced style binding techniques enable sophisticated styling patterns and better code organization.

    Object-Based Style Binding

    For complex styling scenarios, bind an entire style object:

    String Interpolation

    Combine static and dynamic styles using template interpolation:

    Computed Style Properties

    Create dynamic styles based on component state:

    Component Styling Strategies

    Beyond template bindings, Aurelia provides several approaches for styling components themselves.

    Convention-Based Styling

    Aurelia automatically imports stylesheets that match your component names:

    This means you can focus on writing CSS without worrying about imports:

    Shadow DOM

    For complete style isolation, use Shadow DOM:

    Shadow DOM Configuration Options:

    Shadow DOM Special Selectors

    Shadow DOM provides special CSS selectors for enhanced styling control:

    Global Shared Styles in Shadow DOM

    To share styles across Shadow DOM components, configure shared styles in your application:

    CSS Modules

    CSS Modules provide an alternative to Shadow DOM for scoped styling:

    Real-World Examples and Patterns

    The following examples demonstrate practical applications of class and style binding techniques in common scenarios.

    Responsive Design with Dynamic Classes

    Theme System with CSS Variables

    Loading States with Animations

    Complex Form Validation Styling

    Performance Tips and Best Practices

    Do's and Don'ts

    ✅ DO:

    • Use .class for simple boolean toggling

    • Use CSS custom properties for theming

    • Prefer computed getters for complex style calculations

    • Use Shadow DOM for true component isolation

    • Cache complex style objects when possible

    ❌ DON'T:

    • Inline complex style calculations in templates

    • Use string concatenation for class names when .class will do

    • Forget about CSS specificity when using !important

    • Mix too many styling approaches in one component

    Performance Optimization

    Troubleshooting Common Issues

    "My styles aren't updating!"

    Problem: Styles don't change when data changes. Solution: Make sure you're using proper binding syntax and that your properties are observable.

    "My CSS classes have weird names!"

    Problem: Using CSS Modules and seeing transformed class names. Solution: This is expected behavior! CSS Modules transform class names to ensure uniqueness.

    "Shadow DOM is blocking my global styles!"

    Problem: Global CSS frameworks aren't working inside Shadow DOM components. Solution: Configure shared styles in your app startup.

    Migration and Compatibility

    Coming from Aurelia 1?

    The syntax is mostly the same, with some improvements:

    Browser Support

    All binding features work in modern browsers. For older browsers:

    • Shadow DOM requires a polyfill for older browsers

    • CSS Modules work everywhere (they're processed at build time)

    Summary

    This guide has covered the complete range of class and style binding capabilities in Aurelia 2. Key takeaways include:

    1. Basic class binding - Use .class syntax for simple boolean toggling

    2. Multiple class binding - Leverage comma-separated syntax for related classes

    3. Style property binding - Apply individual CSS properties with .style syntax

    4. Advanced techniques - Implement complex styling with objects, interpolation, and CSS variables

    5. Component styling - Choose appropriate encapsulation strategies for your use case

    These techniques provide the foundation for building maintainable, dynamic user interfaces that respond effectively to application state changes.


    Additional Resources: For more information on binding syntax, see the template syntax guide. To understand when styles are applied, refer to the component lifecycles documentation.

    import { customAttribute, ICustomAttributeController, IViewFactory, IRenderLocation, ISyntheticView } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute({
      name: 'my-controller',
      isTemplateController: true,
      bindables: ['value']
    })
    export class MyController {
      public readonly $controller!: ICustomAttributeController<this>;
      private readonly factory = resolve(IViewFactory);
      private readonly location = resolve(IRenderLocation);
      private view?: ISyntheticView;
    
      public value: unknown;
    
      public valueChanged(newValue: unknown): void {
        this.updateView(newValue);
      }
    
      private updateView(show: boolean): void {
        if (show && !this.view) {
          this.view = this.factory.create().setLocation(this.location);
          this.view.activate(this.view, this.$controller, this.$controller.scope);
        } else if (!show && this.view) {
          this.view.deactivate(this.view, this.$controller);
          this.view = undefined;
        }
      }
    
      public attaching(): void {
        if (this.value) {
          this.updateView(true);
        }
      }
    
      public detaching(): void {
        if (this.view) {
          this.view.deactivate(this.view, this.$controller);
          this.view = undefined;
        }
      }
    }
    <div my-controller.bind="condition">
      This content is conditionally rendered
    </div>
    import { customAttribute, ICustomAttributeController, IViewFactory, IRenderLocation, ISyntheticView } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    interface IPermissionService {
      hasPermission(permission: string): boolean;
      hasAnyPermission(permissions: string[]): boolean;
    }
    
    @customAttribute({
      name: 'show-if-permitted',
      isTemplateController: true,
      bindables: ['permission', 'anyOf']
    })
    export class ShowIfPermitted {
      public readonly $controller!: ICustomAttributeController<this>;
      private readonly factory = resolve(IViewFactory);
      private readonly location = resolve(IRenderLocation);
      private readonly permissionService = resolve(IPermissionService);
      private view?: ISyntheticView;
    
      public permission?: string;
      public anyOf?: string[];
    
      public permissionChanged(): void {
        this.updateVisibility();
      }
    
      public anyOfChanged(): void {
        this.updateVisibility();
      }
    
      private updateVisibility(): void {
        const hasPermission = this.permission 
          ? this.permissionService.hasPermission(this.permission)
          : this.anyOf 
            ? this.permissionService.hasAnyPermission(this.anyOf)
            : false;
    
        if (hasPermission && !this.view) {
          this.view = this.factory.create().setLocation(this.location);
          this.view.activate(this.view, this.$controller, this.$controller.scope);
        } else if (!hasPermission && this.view) {
          this.view.deactivate(this.view, this.$controller);
          this.view = undefined;
        }
      }
    
      public attaching(): void {
        this.updateVisibility();
      }
    
      public detaching(): void {
        if (this.view) {
          this.view.deactivate(this.view, this.$controller);
          this.view = undefined;
        }
      }
    }
    <div show-if-permitted.bind="'admin'">
      Admin-only content
    </div>
    
    <div show-if-permitted any-of.bind="['user', 'moderator']">
      User or moderator content
    </div>
    import { customAttribute, ICustomAttributeController, IViewFactory, IRenderLocation, ISyntheticView } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute({
      name: 'loading-state',
      isTemplateController: true,
      bindables: ['isLoading', 'cache']
    })
    export class LoadingState {
      public readonly $controller!: ICustomAttributeController<this>;
      private readonly factory = resolve(IViewFactory);
      private readonly location = resolve(IRenderLocation);
      private view?: ISyntheticView;
      private cachedView?: ISyntheticView;
    
      public isLoading: boolean = false;
      public cache: boolean = true;
    
      public isLoadingChanged(newValue: boolean): void {
        this.updateView(newValue);
      }
    
      private updateView(isLoading: boolean): void {
        if (!isLoading && !this.view) {
          // Show content
          if (this.cache && this.cachedView) {
            this.view = this.cachedView;
          } else {
            this.view = this.factory.create().setLocation(this.location);
            if (this.cache) {
              this.cachedView = this.view;
            }
          }
          this.view.activate(this.view, this.$controller, this.$controller.scope);
        } else if (isLoading && this.view) {
          // Hide content
          this.view.deactivate(this.view, this.$controller);
          if (!this.cache) {
            this.view = undefined;
          } else {
            this.view = undefined; // Keep cached view
          }
        }
      }
    
      public attaching(): void {
        this.updateView(this.isLoading);
      }
    
      public detaching(): void {
        if (this.view) {
          this.view.deactivate(this.view, this.$controller);
          this.view = undefined;
        }
        if (this.cachedView) {
          this.cachedView = undefined;
        }
      }
    }
    <div loading-state.bind="isLoading" cache.bind="true">
      <p>This content is hidden while loading</p>
    </div>
    import { customAttribute, INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute({
      name: 'auto-save',
      bindables: ['value', 'debounce']
    })
    export class AutoSave {
      private element = resolve(INode) as HTMLInputElement;
      private debounceTimer?: number;
    
      public value: string = '';
      public debounce: number = 500;
    
      public valueChanged(newValue: string): void {
        if (this.element.value !== newValue) {
          this.element.value = newValue;
        }
      }
    
      public attached(): void {
        this.element.addEventListener('input', this.handleInput);
        this.element.addEventListener('blur', this.handleBlur);
      }
    
      public detached(): void {
        this.element.removeEventListener('input', this.handleInput);
        this.element.removeEventListener('blur', this.handleBlur);
        this.clearTimer();
      }
    
      private handleInput = (): void => {
        this.clearTimer();
        this.debounceTimer = window.setTimeout(() => {
          this.updateValue();
        }, this.debounce);
      };
    
      private handleBlur = (): void => {
        this.clearTimer();
        this.updateValue();
      };
    
      private updateValue(): void {
        const newValue = this.element.value;
        if (this.value !== newValue) {
          this.value = newValue;
        }
      }
    
      private clearTimer(): void {
        if (this.debounceTimer) {
          clearTimeout(this.debounceTimer);
          this.debounceTimer = undefined;
        }
      }
    }
    <input auto-save.bind="document.title" debounce.bind="1000">
    import { customAttribute, INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute({
      name: 'slider-range',
      bindables: ['min', 'max', 'step', 'value']
    })
    export class SliderRange {
      private element = resolve(INode) as HTMLInputElement;
    
      public min: number = 0;
      public max: number = 100;
      public step: number = 1;
      public value: number = 0;
    
      public minChanged(newValue: number): void {
        this.element.min = String(newValue);
        this.validateValue();
      }
    
      public maxChanged(newValue: number): void {
        this.element.max = String(newValue);
        this.validateValue();
      }
    
      public stepChanged(newValue: number): void {
        this.element.step = String(newValue);
      }
    
      public valueChanged(newValue: number): void {
        const validValue = this.clampValue(newValue);
        if (this.element.value !== String(validValue)) {
          this.element.value = String(validValue);
        }
      }
    
      public attached(): void {
        this.element.type = 'range';
        this.element.addEventListener('input', this.handleInput);
        this.element.addEventListener('change', this.handleChange);
        this.updateElement();
      }
    
      public detached(): void {
        this.element.removeEventListener('input', this.handleInput);
        this.element.removeEventListener('change', this.handleChange);
      }
    
      private handleInput = (): void => {
        this.value = Number(this.element.value);
      };
    
      private handleChange = (): void => {
        this.value = Number(this.element.value);
      };
    
      private validateValue(): void {
        const clampedValue = this.clampValue(this.value);
        if (clampedValue !== this.value) {
          this.value = clampedValue;
        }
      }
    
      private clampValue(value: number): number {
        return Math.max(this.min, Math.min(this.max, value));
      }
    
      private updateElement(): void {
        this.element.min = String(this.min);
        this.element.max = String(this.max);
        this.element.step = String(this.step);
        this.element.value = String(this.clampValue(this.value));
      }
    }
    <input slider-range min.bind="0" max.bind="100" step.bind="5" value.bind="currentValue">
    import { customAttribute, INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute({
      name: 'lazy-load',
      bindables: ['src', 'placeholder']
    })
    export class LazyLoad {
      private element = resolve(INode) as HTMLImageElement;
      private observer?: IntersectionObserver;
    
      public src: string = '';
      public placeholder: string = '';
    
      public attached(): void {
        this.element.src = this.placeholder;
        this.setupIntersectionObserver();
      }
    
      public detached(): void {
        if (this.observer) {
          this.observer.disconnect();
          this.observer = undefined;
        }
      }
    
      private setupIntersectionObserver(): void {
        if (!('IntersectionObserver' in window)) {
          // Fallback for older browsers
          this.loadImage();
          return;
        }
    
        this.observer = new IntersectionObserver(
          (entries) => {
            entries.forEach(entry => {
              if (entry.isIntersecting) {
                this.loadImage();
                this.observer?.disconnect();
              }
            });
          },
          { threshold: 0.1 }
        );
    
        this.observer.observe(this.element);
      }
    
      private loadImage(): void {
        if (this.src) {
          this.element.src = this.src;
        }
      }
    }
    import { customAttribute, INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute({
      name: 'batch-class',
      bindables: ['classes']
    })
    export class BatchClass {
      private element = resolve(INode) as HTMLElement;
      private scheduledUpdate = false;
      private appliedClasses = new Set<string>();
    
      public classes: Record<string, boolean> = {};
    
      public classesChanged(): void {
        this.scheduleUpdate();
      }
    
      private scheduleUpdate(): void {
        if (!this.scheduledUpdate) {
          this.scheduledUpdate = true;
          requestAnimationFrame(() => {
            this.updateClasses();
            this.scheduledUpdate = false;
          });
        }
      }
    
      private updateClasses(): void {
        const newClasses = new Set<string>();
        
        // Collect classes that should be applied
        for (const [className, shouldApply] of Object.entries(this.classes)) {
          if (shouldApply) {
            newClasses.add(className);
          }
        }
    
        // Remove classes that are no longer needed
        for (const className of this.appliedClasses) {
          if (!newClasses.has(className)) {
            this.element.classList.remove(className);
          }
        }
    
        // Add new classes
        for (const className of newClasses) {
          if (!this.appliedClasses.has(className)) {
            this.element.classList.add(className);
          }
        }
    
        this.appliedClasses = newClasses;
      }
    }
    import { customAttribute, INode } from '@aurelia/runtime-html';
    import { resolve, ILogger } from '@aurelia/kernel';
    
    @customAttribute({
      name: 'safe-transform',
      bindables: ['transform', 'fallback']
    })
    export class SafeTransform {
      private element = resolve(INode) as HTMLElement;
      private logger = resolve(ILogger);
    
      public transform: string = '';
      public fallback: string = '';
    
      public transformChanged(newValue: string): void {
        this.applyTransform(newValue);
      }
    
      private applyTransform(transform: string): void {
        try {
          this.element.style.transform = transform;
        } catch (error) {
          this.logger.warn(`Invalid transform "${transform}":`, error);
          this.element.style.transform = this.fallback;
        }
      }
    
      public attached(): void {
        this.applyTransform(this.transform);
      }
    }
    import { customAttribute, INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute({
      name: 'safe-html',
      bindables: ['content', 'allowedTags']
    })
    export class SafeHtml {
      private element = resolve(INode) as HTMLElement;
    
      public content: string = '';
      public allowedTags: string[] = ['b', 'i', 'em', 'strong', 'p', 'br'];
    
      public contentChanged(newValue: string): void {
        this.updateContent(newValue);
      }
    
      private updateContent(content: string): void {
        const sanitized = this.sanitizeHtml(content);
        this.element.innerHTML = sanitized;
      }
    
      private sanitizeHtml(html: string): string {
        // Simple sanitization - in production, use a proper library like DOMPurify
        const div = document.createElement('div');
        div.innerHTML = html;
    
        // Remove all elements not in allowed tags
        const elements = div.querySelectorAll('*');
        for (let i = elements.length - 1; i >= 0; i--) {
          const element = elements[i];
          if (!this.allowedTags.includes(element.tagName.toLowerCase())) {
            element.remove();
          }
        }
    
        return div.innerHTML;
      }
    }
    import { TestContext } from '@aurelia/testing';
    import { MyController } from './my-controller';
    
    describe('MyController', () => {
      let ctx: TestContext;
    
      beforeEach(() => {
        ctx = TestContext.create();
      });
    
      afterEach(() => {
        ctx.dispose();
      });
    
      it('should show content when value is true', async () => {
        const { component, startPromise, tearDown } = ctx.createFixture(
          `<div my-controller.bind="showContent">Content</div>`,
          class {
            showContent = true;
          }
        );
    
        await startPromise;
    
        expect(component.textContent).toContain('Content');
    
        await tearDown();
      });
    
      it('should hide content when value is false', async () => {
        const { component, startPromise, tearDown } = ctx.createFixture(
          `<div my-controller.bind="showContent">Content</div>`,
          class {
            showContent = false;
          }
        );
    
        await startPromise;
    
        expect(component.textContent).not.toContain('Content');
    
        await tearDown();
      });
    });
    public detached(): void {
      if (this.observer) {
        this.observer.disconnect();
      }
      if (this.subscription) {
        this.subscription.dispose();
      }
    }
    import { customElement, useShadowDOM } from 'aurelia';
    
    @customElement('my-card')
    @useShadowDOM()
    export class MyCard {
      message = 'Hello from Shadow DOM';
    }
    @customElement('open-element')
    @useShadowDOM({ mode: 'open' })
    export class OpenElement {
      // External code can access: element.shadowRoot
    }
    @customElement('closed-element')
    @useShadowDOM({ mode: 'closed' })
    export class ClosedElement {
      // External code cannot access shadowRoot
      // element.shadowRoot returns null
    }
    import { customElement } from 'aurelia';
    
    @customElement({
      name: 'my-element',
      shadowOptions: { mode: 'open' }
    })
    export class MyElement {}
    export class MyElement {
      static shadowOptions = { mode: 'open' };
    }
    import { customElement, useShadowDOM, shadowCSS } from 'aurelia';
    
    @customElement({
      name: 'styled-card',
      template: `
        <div class="card">
          <h2 class="title">\${title}</h2>
          <div class="content">
            <slot></slot>
          </div>
        </div>
      `,
      dependencies: [
        shadowCSS(`
          .card {
            border: 1px solid #ddd;
            border-radius: 8px;
            padding: 16px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
          }
          .title {
            margin: 0 0 12px 0;
            color: #333;
          }
          .content {
            color: #666;
          }
        `)
      ]
    })
    @useShadowDOM()
    export class StyledCard {
      title = 'Card Title';
    }
    // Create a reusable stylesheet
    const cardStyles = new CSSStyleSheet();
    cardStyles.replaceSync(`
      .card {
        border: 1px solid #ddd;
        padding: 16px;
      }
    `);
    
    @customElement({
      name: 'optimized-card',
      template: '<div class="card"><slot></slot></div>',
      dependencies: [shadowCSS(cardStyles)]
    })
    @useShadowDOM()
    export class OptimizedCard {}
    import Aurelia from 'aurelia';
    import { StyleConfiguration } from '@aurelia/runtime-html';
    
    Aurelia
      .register(
        StyleConfiguration.shadowDOM({
          sharedStyles: [
            `
              * {
                box-sizing: border-box;
              }
              :host {
                display: block;
              }
            `
          ]
        })
      )
      .app(component)
      .start();
    import { customElement, useShadowDOM, shadowCSS } from 'aurelia';
    
    @customElement({
      name: 'my-button',
      template: '<button><slot></slot></button>',
      dependencies: [
        shadowCSS(`
          button {
            background: var(--button-bg, #007bff);
            color: var(--button-color, white);
            border: none;
            padding: 8px 16px;
            border-radius: var(--button-radius, 4px);
            cursor: pointer;
          }
          button:hover {
            background: var(--button-hover-bg, #0056b3);
          }
        `)
      ]
    })
    @useShadowDOM()
    export class MyButton {}
    <style>
      /* Theme the button from outside */
      .danger {
        --button-bg: #dc3545;
        --button-hover-bg: #c82333;
        --button-radius: 8px;
      }
    </style>
    
    <my-button>Default Button</my-button>
    <my-button class="danger">Danger Button</my-button>
    import { customElement, useShadowDOM } from 'aurelia';
    
    @customElement({
      name: 'modal-dialog',
      template: `
        <div class="modal-overlay">
          <div class="modal-content">
            <slot></slot>
          </div>
        </div>
      `
    })
    @useShadowDOM()
    export class ModalDialog {}
    <modal-dialog>
      <h2>Modal Title</h2>
      <p>Modal content goes here</p>
    </modal-dialog>
    @customElement({
      name: 'card-layout',
      template: `
        <div class="card">
          <header class="card-header">
            <slot name="header"></slot>
          </header>
          <div class="card-body">
            <slot></slot>
          </div>
          <footer class="card-footer">
            <slot name="footer"></slot>
          </footer>
        </div>
      `
    })
    @useShadowDOM()
    export class CardLayout {}
    <card-layout>
      <span slot="header">Card Header</span>
      <p>Main content goes in the default slot</p>
      <div slot="footer">
        <button>Action</button>
      </div>
    </card-layout>
    @customElement({
      name: 'greeting-card',
      template: `
        <div class="greeting">
          <slot>Hello, Guest!</slot>
        </div>
      `
    })
    @useShadowDOM()
    export class GreetingCard {}
    <!-- Uses fallback -->
    <greeting-card></greeting-card>
    <!-- Output: Hello, Guest! -->
    
    <!-- Overrides fallback -->
    <greeting-card>Hello, John!</greeting-card>
    <!-- Output: Hello, John! -->
    <div class="list">
      <slot slotchange.trigger="handleSlotChange($event)"></slot>
    </div>
    import { customElement, useShadowDOM } from 'aurelia';
    
    @customElement('my-list')
    @useShadowDOM()
    export class MyList {
      handleSlotChange(event: Event) {
        const slot = event.target as HTMLSlotElement;
        const assignedNodes = slot.assignedNodes();
        console.log('Slot changed, node count:', assignedNodes.length);
      }
    }
    // ❌ This will throw an error at runtime
    @customElement('invalid-component')
    @useShadowDOM()
    @containerless()
    export class InvalidComponent {}
    // ❌ This will throw a compilation error
    @customElement({
      name: 'broken-component',
      template: '<div><slot></slot></div>'
      // Missing shadowOptions!
    })
    export class BrokenComponent {}
    // ✅ Option 1: Enable Shadow DOM
    @customElement({
      name: 'fixed-component',
      template: '<div><slot></slot></div>'
    })
    @useShadowDOM()
    export class FixedComponent {}
    
    // ✅ Option 2: Use <au-slot> without Shadow DOM
    @customElement({
      name: 'alternative-component',
      template: '<div><au-slot></au-slot></div>'
    })
    export class AlternativeComponent {}
    import { customElement, useShadowDOM, shadowCSS, bindable } from 'aurelia';
    
    @customElement({
      name: 'theme-button',
      template: `
        <button class="btn \${variant}">
          <slot></slot>
        </button>
      `,
      dependencies: [
        shadowCSS(`
          .btn {
            padding: var(--btn-padding, 10px 20px);
            border: none;
            border-radius: var(--btn-radius, 4px);
            font-size: var(--btn-font-size, 16px);
            cursor: pointer;
            transition: all 0.2s;
          }
          .btn.primary {
            background: var(--primary-bg, #007bff);
            color: var(--primary-color, white);
          }
          .btn.primary:hover {
            background: var(--primary-hover, #0056b3);
          }
          .btn.secondary {
            background: var(--secondary-bg, #6c757d);
            color: var(--secondary-color, white);
          }
          .btn.secondary:hover {
            background: var(--secondary-hover, #545b62);
          }
        `)
      ]
    })
    @useShadowDOM()
    export class ThemeButton {
      @bindable variant: 'primary' | 'secondary' = 'primary';
    }
    <style>
      .custom-theme {
        --primary-bg: #28a745;
        --primary-hover: #218838;
        --btn-radius: 20px;
      }
    </style>
    
    <theme-button variant="primary">Default Primary</theme-button>
    <theme-button variant="secondary">Default Secondary</theme-button>
    
    <div class="custom-theme">
      <theme-button variant="primary">Custom Themed</theme-button>
    </div>
    import { customElement, useShadowDOM, shadowCSS, bindable } from 'aurelia';
    
    @customElement({
      name: 'info-card',
      template: `
        <div class="card \${expanded ? 'expanded' : ''}">
          <header class="card-header" click.trigger="toggle()">
            <slot name="header">Untitled Card</slot>
            <span class="toggle">\${expanded ? '−' : '+'}</span>
          </header>
          <div class="card-body" if.bind="expanded">
            <slot></slot>
          </div>
          <footer class="card-footer" if.bind="expanded">
            <slot name="footer"></slot>
          </footer>
        </div>
      `,
      dependencies: [
        shadowCSS(`
          .card {
            border: 1px solid #ddd;
            border-radius: 8px;
            overflow: hidden;
            margin-bottom: 16px;
          }
          .card-header {
            background: #f8f9fa;
            padding: 16px;
            cursor: pointer;
            display: flex;
            justify-content: space-between;
            align-items: center;
            user-select: none;
          }
          .card-header:hover {
            background: #e9ecef;
          }
          .toggle {
            font-size: 24px;
            font-weight: bold;
          }
          .card-body {
            padding: 16px;
          }
          .card-footer {
            background: #f8f9fa;
            padding: 12px 16px;
            border-top: 1px solid #ddd;
          }
        `)
      ]
    })
    @useShadowDOM()
    export class InfoCard {
      @bindable expanded = false;
    
      toggle() {
        this.expanded = !this.expanded;
      }
    }
    <info-card expanded.bind="true">
      <strong slot="header">User Information</strong>
    
      <div>
        <p><strong>Name:</strong> John Doe</p>
        <p><strong>Email:</strong> [email protected]</p>
        <p><strong>Role:</strong> Developer</p>
      </div>
    
      <div slot="footer">
        <button>Edit</button>
        <button>Delete</button>
      </div>
    </info-card>
    
    <info-card>
      <span slot="header">System Status</span>
      <p>All systems operational</p>
    </info-card>
    import { customElement, useShadowDOM, shadowCSS, bindable, resolve } from 'aurelia';
    import { INode } from '@aurelia/runtime-html';
    
    @customElement({
      name: 'progress-bar',
      template: `
        <div class="progress-container">
          <div class="progress-bar" css="width: \${percentage}%"></div>
          <span class="progress-text">\${percentage}%</span>
        </div>
      `,
      dependencies: [
        shadowCSS(`
          .progress-container {
            position: relative;
            width: 100%;
            height: 30px;
            background: #e9ecef;
            border-radius: 15px;
            overflow: hidden;
          }
          .progress-bar {
            height: 100%;
            background: var(--progress-color, #007bff);
            transition: width 0.3s ease;
          }
          .progress-text {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            font-weight: bold;
            color: #333;
          }
        `)
      ]
    })
    @useShadowDOM()
    export class ProgressBar {
      @bindable percentage = 0;
    
      private host = resolve(INode);
    
      percentageChanged(newValue: number) {
        // Change color based on progress
        const color = newValue < 30 ? '#dc3545' :
                      newValue < 70 ? '#ffc107' :
                      '#28a745';
        this.host.style.setProperty('--progress-color', color);
      }
    }
    <progress-bar percentage.bind="25"></progress-bar>
    <progress-bar percentage.bind="50"></progress-bar>
    <progress-bar percentage.bind="90"></progress-bar>
    shadowCSS(`
      .component {
        color: var(--component-color, #333);
        background: var(--component-bg, white);
        padding: var(--component-padding, 16px);
      }
    `)
    <slot name="header">
      <h2>Default Header</h2>
    </slot>
    shadowCSS(`
      .card {
        background: var(--my-card-bg, white);
        border: 1px solid var(--my-card-border, #ddd);
      }
    `)
    // Create once, reuse many times
    const sharedStyles = new CSSStyleSheet();
    sharedStyles.replaceSync(`/* styles */`);
    
    dependencies: [shadowCSS(sharedStyles)]
    @useShadowDOM() // defaults to open mode
    /**
     * CSS Variables:
     * --card-bg: Background color (default: white)
     * --card-border: Border color (default: #ddd)
     * --card-padding: Internal padding (default: 16px)
     */
    @customElement('themable-card')
    @useShadowDOM()
    export class ThemableCard {}
    <button submit.class="isFormValid">Submit Form</button>
    <div loading.class="isLoading">Content here...</div>
    <nav-item active.class="isCurrentPage">Home</nav-item>
    export class MyComponent {
      isFormValid = false;
      isLoading = true;
      isCurrentPage = false;
    
      // When isFormValid becomes true, the 'submit' class gets added
      // When isLoading is false, the 'loading' class gets removed
    }
    <div alert,alert-danger,fade-in,shake.class="hasError">
      Error message content
    </div>
    export class ErrorComponent {
      hasError = false;
    
      triggerError() {
        this.hasError = true; // All four classes get added at once!
      }
    
      clearError() {
        this.hasError = false; // All four classes get removed together
      }
    }
    <div background-color.style="themeColor">Themed content</div>
    <progress width.style="progressPercentage + '%'">Loading...</progress>
    <aside opacity.style="sidebarVisible ? '1' : '0.3'">Sidebar</aside>
    export class ThemedComponent {
      themeColor = '#3498db';
      progressPercentage = 75;
      sidebarVisible = true;
    }
    <!-- These do exactly the same thing! -->
    <div background-color.style="myColor"></div>
    <div style.background-color="myColor"></div>
    
    <!-- Works with any CSS property -->
    <div font-size.style="textSize"></div>
    <div style.font-size="textSize"></div>
    <div --primary-color.style="brandColor">
      <p style="color: var(--primary-color)">Branded text!</p>
    </div>
    
    <!-- Or with the alternative syntax -->
    <div style.--primary-color="brandColor">
      <p style="color: var(--primary-color)">Same result!</p>
    </div>
    export class ThemeManager {
      brandColor = '#e74c3c';
    
      switchToDarkMode() {
        this.brandColor = '#34495e';
      }
    }
    <div -webkit-user-select.style="userSelectValue">Non-selectable content</div>
    <div style.-webkit-user-select="userSelectValue">Alternative syntax</div>
    export class ImportantComponent {
      criticalColor = 'red!important';
    
      // Aurelia automatically:
      // 1. Strips the !important from the value
      // 2. Sets the CSS property priority correctly
      // 3. Applies the style with proper priority
    }
    <div class.bind="dynamicClasses">Content with dynamic classes</div>
    <div class="base-class ${additionalClasses}">Mixed static and dynamic</div>
    export class FlexibleComponent {
      dynamicClasses = 'btn btn-primary active';
      additionalClasses = 'fade-in hover-effect';
    
      updateClasses() {
        this.dynamicClasses = `btn btn-${this.isSuccess ? 'success' : 'danger'}`;
      }
    }
    <div style.bind="cardStyles">Beautifully styled card</div>
    export class StylishComponent {
      cardStyles = {
        backgroundColor: '#ffffff',
        border: '1px solid #e1e1e1',
        borderRadius: '8px',
        padding: '16px',
        boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
      };
    
      switchToNightMode() {
        this.cardStyles = {
          ...this.cardStyles,
          backgroundColor: '#2d3748',
          color: '#ffffff',
          borderColor: '#4a5568'
        };
      }
    }
    <div style="padding: 16px; background: ${bgColor}; transform: scale(${scale})">
      Combined static and dynamic styles
    </div>
    export class HybridComponent {
      bgColor = 'linear-gradient(45deg, #3498db, #2ecc71)';
      scale = 1.0;
    
      animateIn() {
        this.scale = 1.1;
      }
    }
    export class ComputedStyleComponent {
      progress = 0.7;
      theme = 'light';
    
      get progressBarStyles() {
        return {
          width: `${this.progress * 100}%`,
          backgroundColor: this.theme === 'dark' ? '#3498db' : '#2ecc71',
          transition: 'all 0.3s ease'
        };
      }
    }
    <div class="progress-container">
      <div class="progress-bar" style.bind="progressBarStyles"></div>
    </div>
    my-awesome-component.ts    (component logic)
    my-awesome-component.html  (template)
    my-awesome-component.css   (styles - automatically imported!)
    /* my-awesome-component.css */
    :host {
      display: block;
      padding: 16px;
    }
    
    .content {
      background: linear-gradient(45deg, #3498db, #2ecc71);
      border-radius: 8px;
    }
    import { useShadowDOM } from 'aurelia';
    
    @useShadowDOM()
    export class IsolatedComponent {
      // Styles are completely encapsulated
    }
    // Open mode (default) - JavaScript can access shadowRoot
    @useShadowDOM({ mode: 'open' })
    export class OpenComponent { }
    
    // Closed mode - shadowRoot is not accessible
    @useShadowDOM({ mode: 'closed' })
    export class ClosedComponent { }
    
    // Disable Shadow DOM for a specific component
    @useShadowDOM(false)
    export class NoShadowComponent { }
    /* Style the component host element */
    :host {
      display: block;
      border: 1px solid #e1e1e1;
    }
    
    /* Style the host when it has a specific class */
    :host(.active) {
      background-color: #f8f9fa;
    }
    
    /* Style the host based on ancestor context */
    :host-context(.dark-theme) {
      background-color: #2d3748;
      color: #ffffff;
    }
    
    /* Style slotted content */
    ::slotted(.special-content) {
      font-weight: bold;
      color: #3498db;
    }
    // main.ts
    import Aurelia, { StyleConfiguration } from 'aurelia';
    import { MyApp } from './my-app';
    import bootstrap from 'bootstrap/dist/css/bootstrap.css';
    import customTheme from './theme.css';
    
    Aurelia
      .register(StyleConfiguration.shadowDOM({
        sharedStyles: [bootstrap, customTheme]
      }))
      .app(MyApp)
      .start();
    /* my-component.module.css */
    .title {
      font-size: 24px;
      color: #333;
    }
    
    .button {
      composes: title; /* Inherit styles from title */
      background-color: #3498db;
      padding: 8px 16px;
    }
    <!-- Webpack transforms class names to unique identifiers -->
    <h1 class="title">My Title</h1>
    <button class="button">Click Me</button>
    export class ResponsiveComponent {
      screenSize = 'desktop';
    
      get responsiveClasses() {
        return {
          'mobile-layout': this.screenSize === 'mobile',
          'tablet-layout': this.screenSize === 'tablet',
          'desktop-layout': this.screenSize === 'desktop'
        };
      }
    
      @listener('resize', window)
      updateScreenSize() {
        const width = window.innerWidth;
        if (width < 768) {
          this.screenSize = 'mobile';
        } else if (width < 1024) {
          this.screenSize = 'tablet';
        } else {
          this.screenSize = 'desktop';
        }
      }
    }
    <div class.bind="responsiveClasses">
      <header class="header ${screenSize === 'mobile' ? 'mobile-header' : ''}">
        <!-- Responsive header -->
      </header>
    </div>
    export class ThemeManager {
      currentTheme = 'light';
    
      get themeVariables() {
        const themes = {
          light: {
            '--primary-color': '#3498db',
            '--background-color': '#ffffff',
            '--text-color': '#333333'
          },
          dark: {
            '--primary-color': '#2ecc71',
            '--background-color': '#2d3748',
            '--text-color': '#ffffff'
          }
        };
    
        return themes[this.currentTheme];
      }
    
      toggleTheme() {
        this.currentTheme = this.currentTheme === 'light' ? 'dark' : 'light';
      }
    }
    <div style.bind="themeVariables" class="theme-container">
      <button
        style="background: var(--primary-color); color: var(--text-color)"
        click.trigger="toggleTheme()">
        Toggle Theme
      </button>
    </div>
    export class LoadingComponent {
      isLoading = false;
      loadingProgress = 0;
    
      async loadData() {
        this.isLoading = true;
        this.loadingProgress = 0;
    
        // Simulate loading with progress
        const interval = setInterval(() => {
          this.loadingProgress += 10;
          if (this.loadingProgress >= 100) {
            clearInterval(interval);
            this.isLoading = false;
          }
        }, 100);
      }
    
      get progressBarStyle() {
        return {
          width: `${this.loadingProgress}%`,
          transition: 'width 0.1s ease'
        };
      }
    }
    }
    <div loading.class="isLoading">
      <div class="progress-container" show.bind="isLoading">
        <div class="progress-bar" style.bind="progressBarStyle"></div>
      </div>
    
      <div class="content" hide.bind="isLoading">
        <!-- Your actual content -->
      </div>
    </div>
    export class ValidationForm {
      email = '';
      password = '';
    
      get emailValidation() {
        return {
          isEmpty: !this.email,
          isInvalid: this.email && !this.isValidEmail(this.email),
          isValid: this.email && this.isValidEmail(this.email)
        };
      }
    
      get passwordValidation() {
        return {
          isEmpty: !this.password,
          isTooShort: this.password && this.password.length < 8,
          isValid: this.password && this.password.length >= 8
        };
      }
    
      isValidEmail(email: string): boolean {
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
      }
    }
    <form>
      <div class="field">
        <input
          type="email"
          value.bind="email"
          empty.class="emailValidation.isEmpty"
          invalid.class="emailValidation.isInvalid"
          valid.class="emailValidation.isValid">
    
        <span
          class="error-message"
          show.bind="emailValidation.isInvalid">
          Please enter a valid email
        </span>
    
        <span
          class="success-indicator"
          show.bind="emailValidation.isValid">
          ✓
        </span>
      </div>
    </form>
    export class OptimizedComponent {
      private _cachedStyles: any = null;
      private _lastTheme: string = '';
    
      // Cache expensive style calculations
      get expensiveStyles() {
        if (this._cachedStyles && this._lastTheme === this.currentTheme) {
          return this._cachedStyles;
        }
    
        this._cachedStyles = this.calculateComplexStyles();
        this._lastTheme = this.currentTheme;
        return this._cachedStyles;
      }
    
      private calculateComplexStyles() {
        // Your expensive calculations here
        return { /* styles */ };
      }
    }
    // ❌ This won't trigger updates
    export class BadComponent {
      styles = { color: 'red' };
    
      changeColor() {
        this.styles.color = 'blue'; // Mutation won't be detected
      }
    }
    
    // ✅ This will work
    export class GoodComponent {
      styles = { color: 'red' };
    
      changeColor() {
        this.styles = { ...this.styles, color: 'blue' }; // New object
      }
    }
    <!-- Aurelia 1 & 2 (still works) -->
    <div class.bind="myClasses"></div>
    
    <!-- Aurelia 2 (new!) -->
    <div loading,spinner,active.class="isLoading"></div>
    Debouncing - Optimize API calls
  • Computed properties - Filtered results

  • Keyboard navigation - Arrow keys, Enter, Escape

  • Focus management - Keep track of selected item

  • Click outside - Close dropdown

  • Custom attributes - Auto-focus

  • Template references - Access DOM elements

  • Conditional rendering - Loading states, empty states

  • Code

    Component (search-autocomplete.ts)

    Template (search-autocomplete.html)

    Styles (search-autocomplete.css)

    Usage Example

    How It Works

    Debouncing

    The queryChanged callback uses setTimeout to debounce API calls. When the user types, previous timers are cleared, so only the final query triggers a search after the specified delay.

    Keyboard Navigation

    The component handles arrow keys to navigate results, Enter to select, and Escape to close. The selected index tracks which item is highlighted, and scrollIntoView ensures it's visible.

    Click Outside

    A global click listener detects clicks outside the component and closes the dropdown. The listener is added in attached() and cleaned up in detaching().

    Highlighting Matches

    The highlightMatch method uses regex to wrap matching text in <mark> tags. The result is bound with innerhtml.bind to render the HTML.

    Security Note

    Be careful with innerhtml.bind. In this case it's safe because we're only highlighting text we control. For user-generated content, use the sanitize value converter.

    Accessibility

    • role="combobox" on input

    • role="listbox" on dropdown

    • role="option" on results

    • aria-expanded indicates dropdown state

    • aria-activedescendant points to selected item

    • Keyboard navigation follows ARIA practices

    Variations

    Recent Searches

    Store and show recent searches when input is focused but empty:

    Grouped Results

    Group results by category:

    Infinite Scroll

    Load more results as user scrolls:

    Related

    • Event Binding - Keyboard events

    • Conditional Rendering - if.bind documentation

    • Template References - ref attribute

    • - Component inputs

    • - sanitize for HTML binding

    Product Catalog

    A complete product catalog featuring real-time search, category filtering, sorting, and responsive design. This recipe demonstrates how to build a performant, user-friendly product browsing experience.

    Features Demonstrated

    • Two-way data binding - Search input with instant updates

    • Computed properties - Filtered product list based on search and filters

    • repeat.for with keys - Efficient list rendering

    • Event handling - Sort buttons, filter checkboxes

    • Conditional rendering - Empty states, loading states

    • Value converters - Currency formatting

    • CSS class binding - Active filters, selected sort order

    • Debouncing - Optimize search performance

    Code

    View Model (product-catalog.ts)

    Template (product-catalog.html)

    Styles (product-catalog.css)

    How It Works

    1. Search with Debouncing

    The search input uses debouncing to avoid excessive filtering operations:

    This waits 300ms after the user stops typing before updating searchQuery, which triggers the filteredProducts computed property.

    2. Reactive Filtering

    The filteredProducts getter automatically recalculates when any filter changes:

    3. Multiple Checkbox Selection

    Category filters use array binding:

    Aurelia automatically adds/removes items from the selectedCategories array.

    4. Efficient List Rendering

    Using key: id tells Aurelia to track products by ID, enabling efficient DOM updates when sorting or filtering:

    5. Dynamic CSS Classes

    The active sort button and out-of-stock cards use class binding:

    Variations

    Add Price Range Filter

    Add to Cart Functionality

    Persist Filters in URL

    Use the router to save filter state:

    Related

    Event binding

    Event binding in Aurelia 2 offers a streamlined approach to managing DOM events directly within your templates. By declaratively attaching event listeners in your view templates, you can effortlessly respond to user interactions like clicks, keystrokes, form submissions, and more. This guide explores the intricacies of event binding in Aurelia 2, providing detailed explanations and practical examples to deepen your understanding and effective utilization of this feature.

    Understanding Event Binding

    Aurelia 2 simplifies the connection between DOM events and your view model methods. It employs a clear and concise syntax, enabling you to specify the event type and the corresponding method to be invoked in your view model when that event occurs.

    Event Binding Syntax

    The general syntax for event binding in Aurelia 2 follows this pattern:

    • <element>: The HTML element to which you are attaching the event listener.

    • event: The name of the DOM event you wish to listen for (e.g., click, input, mouseover).

    Event Binding Commands: .trigger and .capture

    Aurelia 2 primarily offers two commands for event binding, each controlling the event listening phase:

    1. .trigger: This command attaches an event listener that reacts to events during the bubbling phase. This is the most frequently used and generally recommended command for event binding as it aligns with typical event handling patterns in web applications. Events are first captured by the deepest element and then propagate upwards through the DOM tree.

    2. .capture: This command listens for events during the capturing phase. Capturing is the less common phase where events propagate downwards from the window to the target element. .capture is typically used in specific scenarios, such as when you need to intercept an event before it reaches child elements, potentially preventing default behaviors or further propagation.

    The .delegate command from Aurelia 1 has been removed in Aurelia 2. If you need to migrate from Aurelia 1 code that uses .delegate, you can use the @aurelia/compat-v1 package, or simply replace .delegate with .trigger in most cases, as .trigger in Aurelia 2 efficiently handles event bubbling for dynamic content.

    Example: Click Event Binding using .trigger

    To bind a click event on a button to a method named handleClick in your view model, you would use:

    When a user clicks the "Click Me" button, Aurelia will execute the handleClick method defined in your associated view model.

    Shorthand syntax for events (@event)

    To make it easier for teams migrating from Vue or other frameworks, Aurelia also understands the @event="handler" shorthand. The compiler converts it to the equivalent event.trigger binding, including modifiers after a colon.

    Use whichever style you prefer—the generated instructions are the same. If you need capturing semantics, use the explicit event.capture syntax because the shorthand only targets the bubbling (.trigger) command.

    Passing Event Data to Handlers

    Often, you need access to the event object or want to pass additional data to your event handler method. Aurelia provides a straightforward way to do this.

    To pass the DOM event object itself to your handler, use the $event special variable:

    In your view model, the handleClick method would accept the event object as a parameter:

    You can also pass custom arguments along with the event:

    Common DOM Events

    Aurelia 2 supports binding to all standard DOM events. Here are some frequently used events in web development:

    click

    The click event is triggered when a pointing device button (typically a mouse button) is both pressed and released while the pointer is inside the element. It is commonly used for buttons, links, and interactive elements.

    input

    The input event fires when the value of an <input>, <textarea>, or <select> element has been changed. It's useful for real-time validation or dynamic updates based on user input.

    change

    The change event is fired when the value of an element has been changed and the element loses focus. This is often used for <input>, <select>, and <textarea> elements when you want to react after the user has finished making changes.

    mouseover and mouseout

    The mouseover event occurs when the mouse pointer is moved onto an element, and mouseout occurs when it is moved off of an element. These are useful for hover effects and interactive UI elements.

    keydown, keyup, and keypress

    These keyboard events are triggered when a key is pressed down, released, or pressed and released, respectively. keydown and keyup are generally preferred for capturing special keys like arrows, Ctrl, Shift, etc., while keypress is more suited for character input.

    Controlling Event Propagation

    In DOM event handling, events can "bubble" up the DOM tree (from the target element up to the document) or "capture" down (from the document to the target element). Sometimes you need to control this propagation. Within your event handler methods, you can use methods of the event object to manage propagation:

    • event.stopPropagation(): Prevents the event from further bubbling up the DOM tree to parent elements.

    • event.preventDefault(): Prevents the default action associated with the event (if it's cancelable), without stopping event propagation. For example, preventDefault on a click event of a link (<a>) would stop the browser from navigating to the link's href.

    Advanced Event Binding Techniques

    Aurelia 2 provides capabilities beyond basic event binding, allowing for performance optimization and handling specific scenarios.

    Throttling and Debouncing Event Handlers

    For events that fire rapidly and repeatedly, such as mousemove, scroll, or input, calling an event handler function on every event can be performance-intensive. Aurelia's binding behaviors offer throttle and debounce to limit the rate at which your handler is invoked.

    Throttling: Ensures a function is called at most once in a specified time interval.

    In this example, trackMouse will be executed at most every 50 milliseconds, even if mousemove events are firing more frequently.

    Debouncing: Delays the execution of a function until after a certain amount of time has passed since the last time the event was triggered. Useful for autocomplete or search features to avoid making API calls on every keystroke.

    Here, searchQuery will be called 300ms after the user stops typing, reducing the number of search requests.

    Performance Considerations

    When using throttling and debouncing, consider these performance best practices:

    • Choose appropriate delays: Too short delays may not provide performance benefits, while too long delays can make the UI feel unresponsive.

    • Monitor handler complexity: Ensure that even throttled/debounced handlers are optimized for performance.

    • Use signals for immediate updates: When you need to force immediate execution of a throttled/debounced handler (e.g., on form submission), use signals:

    Custom Events

    Aurelia 2 fully supports custom events, which are essential when working with custom elements or integrating third-party libraries that dispatch their own events.

    In this scenario, data-loaded is a custom event emitted by <my-custom-element>. handleDataLoaded in the parent view model will be invoked when this custom event is dispatched.

    Event Binding Examples and Use Cases

    To solidify your understanding, let's explore practical examples showcasing different event binding scenarios in Aurelia 2.

    Self-Delegating Events with .self

    The self binding behavior ensures that an event handler is only triggered if the event originated directly from the element to which the listener is attached, and not from any of its child elements (due to event bubbling).

    In this setup, divClicked() will only be executed if the click originates directly on the <div> element. Clicks on the <button> (a child element) will trigger buttonClicked() but will not bubble up to trigger divClicked() due to the & self behavior.

    Checkbox change Event and Two-Way Binding

    Combine event binding with two-way binding for interactive form elements like checkboxes.

    Here, checked.bind="isAgreed" keeps the isAgreed property in sync with the checkbox state (two-way binding). change.trigger="agreementChanged()" additionally allows you to execute custom logic when the checkbox state changes.

    Handling Keyboard Events for Specific Keys

    React to specific key presses within input fields.

    This example shows how to check event.key to handle specific keys like "Enter" and "Escape".

    Event Delegation for Dynamic Lists

    Event delegation is a powerful technique for efficiently handling events on dynamically generated lists. Attach a single event listener to the parent <ul> or <div> instead of individual listeners to each list item using .trigger.

    The listItemClicked handler attached to the <ul> will be triggered for clicks on any <li> within it due to event bubbling. We check event.target to ensure the click originated from an <li> and extract the data-item-id. This approach provides efficient event handling for dynamic lists without requiring individual listeners on each item.

    Custom Event Communication Between Components

    Parent components can listen for and react to custom events dispatched by child custom elements.

    Custom Element (Child):

    Parent Component (Parent):

    When the button in <my-button> is clicked, it dispatches a custom event button-clicked. The parent component listens for this event using button-clicked.trigger and executes handleButtonClick, receiving event details in $event.detail.

    Autocomplete with Debounced Input

    Implement autocomplete functionality with debouncing to reduce API calls during typing.

    The autocomplete method will be called 500ms after the last input event. This delay allows users to finish typing before triggering the (simulated) autocomplete API call, improving performance.

    Event Modifiers: Enhancing Event Handling

    Event modifiers provide a declarative way to apply conditions or actions to event bindings directly in your templates. Event modifiers are appended to the event name after a colon:

    Aurelia provides built-in modifiers for common event handling scenarios, and you can extend them with custom mappings.

    Modifier
    Works with
    Description

    Modifiers are additive: @click:ctrl+enter.prevent checks modifier keys first and only then calls your handler (after canceling the DOM default). If a modifier check fails (for example, the required key is not pressed) the handler simply does not run.

    Mouse and Keyboard Event Modifiers

    Aurelia has built-in support for modifiers related to mouse buttons and keyboard keys.

    Example: ctrl Key Modifier

    Execute onCtrlClick() only when the button is clicked and the Ctrl key is pressed.

    Example: ctrl+enter Key Combination

    Execute send() only when the Enter key is pressed while the Ctrl key is also held down. Modifiers can be combined using +.

    prevent and stop Modifiers

    Declaratively call event.preventDefault() and event.stopPropagation() using modifiers.

    Example: prevent and stop Modifiers

    Call validate() when the button is clicked, and also prevent the default button behavior and stop event propagation.

    Mouse Button Modifiers: left, middle, right

    Handle clicks based on specific mouse buttons.

    Example: middle Mouse Button Modifier

    Execute newTab() only when the button is clicked with the middle mouse button.

    Keyboard Key Mappings and Custom Modifiers

    You can use character codes as modifiers for keyboard events. For example, 75 is the char code for uppercase 'K'.

    Example: Ctrl + K using Char Code Modifier

    Execute openSearchDialog() when Ctrl + K is pressed in the textarea.

    While using char codes works, it can be less readable. You can create custom key mappings to use more descriptive modifier names. For example, map upper_k to the key code for 'K'.

    Custom Key Mapping Setup (in your main application file, e.g., main.ts):

    Now you can use :upper_k as a modifier:

    This makes your template more readable as :ctrl+upper_k is more self-explanatory than :ctrl+75.

    Aurelia provides default key mappings for lowercase letters 'a' through 'z' (both as key codes and letter names). For uppercase letters, only key code mappings are provided by default (e.g., :65 for 'A'). You can extend these mappings as shown above to create more semantic modifier names.

    Extending modifier handling

    The runtime registers EventModifier, IModifiedEventHandlerCreator, and a set of default creators (mouse, keyboard, generic) inside EventModifierRegistration. If you need custom semantics—gestures, wheel direction checks, or application-specific shortcuts—add your own creator and register it with the container:

    After registration you can bind to @wheel:vertical.invert="onScroll($event)". Returning false from the handler vetoes the event (your view-model method will not be called), while returning true allows the binding to proceed.

    Common Pitfalls and Troubleshooting

    Event Handler Issues

    1. Event not firing: Verify that the event name is correct and the element supports that event type.

    2. Handler not found: Ensure the method exists in your view model and is properly spelled.

    3. Context issues: Remember that event handlers execute in the context of the view model, so this refers to the view model instance.

    Performance Issues

    1. Frequent event handlers: Use throttling or debouncing for events that fire rapidly (e.g., mousemove, scroll, input).

    2. Complex handlers: Keep event handlers lightweight. Move heavy processing to separate methods called asynchronously.

    3. Memory leaks: Aurelia automatically manages event listener cleanup, but be cautious with manual event listeners in your handlers.

    Binding Behavior Conflicts

    1. Multiple rate limiters: You cannot apply both throttle and debounce to the same binding (Error AUR9996).

    2. Duplicate behaviors: Avoid applying the same binding behavior multiple times (Error AUR0102).

    3. Behavior order: When chaining behaviors, order matters: event.trigger="handler() & behavior1 & behavior2".

    Event Modifier Issues

    1. Incorrect syntax: Modifiers must be placed after the command: click.trigger:ctrl not click:ctrl.trigger.

    2. Unsupported modifiers: Verify that the modifier is supported for the event type.

    3. Custom modifiers: Ensure custom key mappings are registered before use.

    Debugging Tips

    1. Console logging: Add console.log statements to verify event firing:

    2. Browser dev tools: Use the browser's event listener inspection to verify bindings are attached.

    3. Event object inspection: Log the entire event object to understand available properties:

    Conclusion

    Event binding in Aurelia 2 is a powerful and intuitive mechanism for creating interactive web applications. By mastering the syntax, commands, event modifiers, and advanced techniques like throttling and custom events, you can effectively handle user interactions and build dynamic, responsive user interfaces. Leverage the .trigger command for typical scenarios and .capture when you need to intercept events during the capturing phase. With these tools and patterns, you can craft a seamless and engaging user experience in your Aurelia 2 applications.

    Intermediate Tutorial

    Take your Aurelia skills to the next level by building a feature-rich todo application. This tutorial covers component composition, filtering, local storage, and real-world patterns.

    What You'll Learn

    • Creating multiple components and composing them

    Attribute binding

    Attribute binding in Aurelia is a powerful feature that allows you to dynamically bind data from your view model to any native HTML attribute within your templates. This enables real-time updates to element attributes such as classes, styles, src, alt, and other standard HTML attributes, enhancing the interactivity and responsiveness of your applications.

    Basic Binding Syntax

    The fundamental syntax for binding to attributes in Aurelia is simple and intuitive:

    Modifying template parsing with AttributePattern

    Aurelia's attribute pattern system allows you to create custom template syntax extensions that can emulate other framework syntaxes like Angular or Vue, or define entirely new patterns for your specific needs. This powerful extensibility feature integrates directly with Aurelia's template compiler and binding engine.

    Architecture Overview

    The attribute pattern system consists of several core components:

    • AttributePatternDefinition: Defines pattern structure with

    Collections (Checkboxes, Radios, Select)

    Learn how to work with checkboxes, radio buttons, select elements, and advanced collection patterns in Aurelia forms.

    Overview

    Aurelia provides sophisticated support for collection-based form controls, going beyond simple arrays to support Sets, Maps, and custom collection types with optimal performance.

    // src/components/search-autocomplete.ts
    import { bindable, INode, IPlatform } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    export interface SearchResult {
      id: string | number;
      title: string;
      description?: string;
      image?: string;
      category?: string;
    }
    
    export class SearchAutocomplete {
      @bindable placeholder = 'Search...';
      @bindable minLength = 2;
      @bindable debounceMs = 300;
      @bindable maxResults = 10;
      @bindable onSelect: (result: SearchResult) => void;
      @bindable onSearch: (query: string) => Promise<SearchResult[]>;
    
      private query = '';
      private results: SearchResult[] = [];
      private isOpen = false;
      private isLoading = false;
      private selectedIndex = -1;
      private searchTimeout: any = null;
    
      private inputElement?: HTMLInputElement;
      private dropdownElement?: HTMLElement;
      private clickOutsideListener?: (e: MouseEvent) => void;
      private readonly platform = resolve(IPlatform);
      private readonly element = resolve(INode);
    
      attached() {
        // Listen for clicks outside to close dropdown
        this.clickOutsideListener = (e: MouseEvent) => {
          if (!this.element.contains(e.target as Node)) {
            this.close();
          }
        };
    
        this.platform.document?.addEventListener('click', this.clickOutsideListener);
      }
    
      detaching() {
        // Clean up event listener
        if (this.clickOutsideListener) {
          this.platform.document?.removeEventListener('click', this.clickOutsideListener);
        }
    
        // Clean up timeout
        if (this.searchTimeout) {
          clearTimeout(this.searchTimeout);
        }
      }
    
      private async performSearch() {
        if (!this.query || this.query.length < this.minLength) {
          this.results = [];
          this.isOpen = false;
          return;
        }
    
        this.isLoading = true;
        this.isOpen = true;
    
        try {
          if (this.onSearch) {
            // Use custom search function
            this.results = await this.onSearch(this.query);
          } else {
            // Use default search (for demo purposes)
            this.results = await this.defaultSearch(this.query);
          }
    
          // Limit results
          this.results = this.results.slice(0, this.maxResults);
    
          // Reset selection
          this.selectedIndex = -1;
        } catch (error) {
          console.error('Search failed:', error);
          this.results = [];
        } finally {
          this.isLoading = false;
        }
      }
    
      // Default search implementation (replace with real API)
      private async defaultSearch(query: string): Promise<SearchResult[]> {
        // Simulate API delay
        await new Promise(resolve => setTimeout(resolve, 500));
    
        const mockData: SearchResult[] = [
          { id: 1, title: 'Getting Started with Aurelia', category: 'Tutorial' },
          { id: 2, title: 'Advanced Routing', category: 'Guide' },
          { id: 3, title: 'Dependency Injection', category: 'Concept' },
          { id: 4, title: 'Template Syntax', category: 'Reference' },
          { id: 5, title: 'Validation Plugin', category: 'Plugin' },
        ];
    
        return mockData.filter(item =>
          item.title.toLowerCase().includes(query.toLowerCase()) ||
          item.category?.toLowerCase().includes(query.toLowerCase())
        );
      }
    
      queryChanged(newValue: string, oldValue: string) {
        // Clear existing timeout
        if (this.searchTimeout) {
          clearTimeout(this.searchTimeout);
        }
    
        // Debounce the search
        this.searchTimeout = setTimeout(() => {
          this.performSearch();
        }, this.debounceMs);
      }
    
      handleKeydown(event: KeyboardEvent) {
        if (!this.isOpen || this.results.length === 0) {
          return;
        }
    
        switch (event.key) {
          case 'ArrowDown':
            event.preventDefault();
            this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
            this.scrollToSelected();
            break;
    
          case 'ArrowUp':
            event.preventDefault();
            this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
            this.scrollToSelected();
            break;
    
          case 'Enter':
            event.preventDefault();
            if (this.selectedIndex >= 0) {
              this.selectResult(this.results[this.selectedIndex]);
            }
            break;
    
          case 'Escape':
            event.preventDefault();
            this.close();
            break;
        }
      }
    
      private scrollToSelected() {
        if (!this.dropdownElement || this.selectedIndex < 0) {
          return;
        }
    
        const selectedElement = this.dropdownElement.querySelector(
          `.autocomplete-item[data-index="${this.selectedIndex}"]`
        ) as HTMLElement;
    
        if (selectedElement) {
          selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
        }
      }
    
      selectResult(result: SearchResult) {
        if (this.onSelect) {
          this.onSelect(result);
        }
    
        // Set input to selected title
        this.query = result.title;
    
        // Close dropdown
        this.close();
      }
    
      close() {
        this.isOpen = false;
        this.selectedIndex = -1;
      }
    
      highlightMatch(text: string, query: string): string {
        if (!query) return text;
    
        const regex = new RegExp(`(${this.escapeRegex(query)})`, 'gi');
        return text.replace(regex, '<mark>$1</mark>');
      }
    
      private escapeRegex(str: string): string {
        return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      }
    
      get showEmpty(): boolean {
        return this.isOpen &&
          !this.isLoading &&
          this.query.length >= this.minLength &&
          this.results.length === 0;
      }
    }
    <!-- src/components/search-autocomplete.html -->
    <div class="autocomplete">
      <!-- Search input -->
      <div class="autocomplete-input-wrapper">
          <input
            ref="inputElement"
            type="text"
            value.bind="query"
            keydown.trigger="handleKeydown($event)"
            placeholder.bind="placeholder"
            autocomplete="off"
            role="combobox"
            aria-autocomplete="list"
            aria-expanded.bind="isOpen"
            aria-controls="autocomplete-dropdown"
            aria-activedescendant.bind="selectedIndex >= 0 ? `result-${selectedIndex}` : undefined"
            class="autocomplete-input">
    
          <!-- Loading spinner -->
          <div if.bind="isLoading" class="autocomplete-spinner">
            <span class="spinner"></span>
          </div>
    
          <!-- Clear button -->
          <button
            if.bind="query && !isLoading"
            type="button"
            click.trigger="query = ''; close()"
            class="autocomplete-clear"
            aria-label="Clear search">
            ×
          </button>
        </div>
    
        <!-- Dropdown -->
        <div
          if.bind="isOpen"
          ref="dropdownElement"
          id="autocomplete-dropdown"
          role="listbox"
          class="autocomplete-dropdown">
    
          <!-- Results -->
          <div
            repeat.for="result of results"
            data-index.bind="$index"
            id="result-${$index}"
            role="option"
            aria-selected.bind="selectedIndex === $index"
            click.trigger="selectResult(result)"
            class="autocomplete-item ${selectedIndex === $index ? 'selected' : ''}">
    
            <!-- Image (if provided) -->
            <img
              if.bind="result.image"
              src.bind="result.image"
              alt=""
              class="autocomplete-item-image">
    
            <div class="autocomplete-item-content">
              <div
                class="autocomplete-item-title"
                innerhtml.bind="highlightMatch(result.title, query)"></div>
    
              <div
                if.bind="result.description"
                class="autocomplete-item-description">
                ${result.description}
              </div>
    
              <div
                if.bind="result.category"
                class="autocomplete-item-category">
                ${result.category}
              </div>
            </div>
          </div>
    
          <!-- Empty state -->
          <div if.bind="showEmpty" class="autocomplete-empty">
            No results found for "${query}"
          </div>
        </div>
      </div>
    .autocomplete {
      position: relative;
      width: 100%;
    }
    
    .autocomplete-input-wrapper {
      position: relative;
      display: flex;
      align-items: center;
    }
    
    .autocomplete-input {
      width: 100%;
      padding: 0.75rem 3rem 0.75rem 1rem;
      border: 2px solid #e0e0e0;
      border-radius: 8px;
      font-size: 1rem;
      outline: none;
      transition: border-color 0.2s;
    }
    
    .autocomplete-input:focus {
      border-color: #2196f3;
    }
    
    .autocomplete-spinner {
      position: absolute;
      right: 1rem;
      display: flex;
      align-items: center;
    }
    
    .spinner {
      width: 16px;
      height: 16px;
      border: 2px solid #e0e0e0;
      border-top-color: #2196f3;
      border-radius: 50%;
      animation: spin 0.6s linear infinite;
    }
    
    @keyframes spin {
      to { transform: rotate(360deg); }
    }
    
    .autocomplete-clear {
      position: absolute;
      right: 0.75rem;
      background: none;
      border: none;
      font-size: 1.5rem;
      cursor: pointer;
      color: #999;
      padding: 0;
      width: 24px;
      height: 24px;
      line-height: 1;
      border-radius: 50%;
      transition: background-color 0.2s;
    }
    
    .autocomplete-clear:hover {
      background-color: #f5f5f5;
      color: #333;
    }
    
    .autocomplete-dropdown {
      position: absolute;
      top: calc(100% + 0.5rem);
      left: 0;
      right: 0;
      max-height: 400px;
      overflow-y: auto;
      background: white;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
      z-index: 1000;
      animation: fadeIn 0.2s ease-out;
    }
    
    @keyframes fadeIn {
      from {
        opacity: 0;
        transform: translateY(-10px);
      }
      to {
        opacity: 1;
        transform: translateY(0);
      }
    }
    
    .autocomplete-item {
      display: flex;
      align-items: flex-start;
      gap: 0.75rem;
      padding: 0.75rem 1rem;
      cursor: pointer;
      transition: background-color 0.15s;
      border-bottom: 1px solid #f5f5f5;
    }
    
    .autocomplete-item:last-child {
      border-bottom: none;
    }
    
    .autocomplete-item:hover,
    .autocomplete-item.selected {
      background-color: #f5f5f5;
    }
    
    .autocomplete-item-image {
      width: 40px;
      height: 40px;
      border-radius: 4px;
      object-fit: cover;
      flex-shrink: 0;
    }
    
    .autocomplete-item-content {
      flex-grow: 1;
      min-width: 0;
    }
    
    .autocomplete-item-title {
      font-weight: 500;
      color: #333;
      margin-bottom: 0.25rem;
    }
    
    .autocomplete-item-title mark {
      background-color: #ffeb3b;
      padding: 0 2px;
      border-radius: 2px;
    }
    
    .autocomplete-item-description {
      font-size: 0.875rem;
      color: #666;
      margin-bottom: 0.25rem;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    
    .autocomplete-item-category {
      font-size: 0.75rem;
      color: #999;
      text-transform: uppercase;
      letter-spacing: 0.5px;
    }
    
    .autocomplete-empty {
      padding: 2rem 1rem;
      text-align: center;
      color: #999;
    }
    // src/pages/search-page.ts
    import { IRouter } from '@aurelia/router';
    import { resolve } from '@aurelia/kernel';
    
    export class SearchPage {
      private readonly router = resolve(IRouter);
    
      async searchProducts(query: string) {
        const response = await fetch(`/api/products/search?q=${encodeURIComponent(query)}`);
        return response.json();
      }
    
      handleSelect(result: any) {
        console.log('Selected:', result);
        this.router.load(`products/${result.id}`);
      }
    }
    <!-- src/pages/search-page.html -->
    <div class="search-page">
      <h1>Search Products</h1>
    
      <search-autocomplete
        placeholder="Search for products..."
        min-length.bind="2"
        debounce-ms.bind="300"
        max-results.bind="10"
        on-search.bind="searchProducts"
        on-select.bind="handleSelect">
      </search-autocomplete>
    </div>
    private recentSearches: string[] = [];
    
    attached() {
      // Load from localStorage
      const stored = localStorage.getItem('recent-searches');
      if (stored) {
        this.recentSearches = JSON.parse(stored);
      }
    }
    
    selectResult(result: SearchResult) {
      // Save to recent searches
      this.recentSearches = [
        result.title,
        ...this.recentSearches.filter(s => s !== result.title)
      ].slice(0, 5);
    
      localStorage.setItem('recent-searches', JSON.stringify(this.recentSearches));
    
      // ... rest of implementation
    }
    get groupedResults() {
      const groups = new Map<string, SearchResult[]>();
    
      this.results.forEach(result => {
        const category = result.category || 'Other';
        if (!groups.has(category)) {
          groups.set(category, []);
        }
        groups.get(category)!.push(result);
      });
    
      return Array.from(groups.entries());
    }
    <div repeat.for="[category, items] of groupedResults">
      <div class="autocomplete-group-header">${category}</div>
      <div repeat.for="item of items" class="autocomplete-item">
        <!-- item content -->
      </div>
    </div>
    handleScroll(event: Event) {
      const element = event.target as HTMLElement;
      const bottom = element.scrollHeight - element.scrollTop === element.clientHeight;
    
      if (bottom && !this.isLoading && this.hasMoreResults) {
        this.loadMore();
      }
    }
    Bindable Properties
    Value Converters
    Value Converters
    Shopping Cart Recipe
    Data Table Recipe
    List Rendering Guide
    Conditional Rendering
    .command: The binding command that instructs Aurelia how to handle the event. Common commands are .trigger and .capture.
  • methodName: The name of the method in your view model that will be executed when the event is dispatched.

  • argument1, argument2, ...: Optional arguments that you can pass to the methodName.

  • left, middle, right

    Mouse events

    Filters mouse buttons.

    prevent

    Any event

    Calls event.preventDefault() before running your handler.

    stop

    Any event

    Calls event.stopPropagation() before running your handler.

    ctrl, alt, shift, meta

    Keyboard/Mouse events

    Ensures the corresponding meta key is pressed. Multiple keys can be chained (:ctrl+enter).

    Named keys (enter, escape, tab, a, ArrowUp, etc.)

    Keyboard events

    Only invokes your handler when the pressed key matches.

    Checkboxes

    Boolean Checkboxes

    The simplest checkbox pattern binds to boolean properties:

    Key Points:

    • Use checked.bind for boolean checkboxes

    • Works with any boolean property

    • Great for independent on/off toggles

    Array-Based Multi-Select

    For multi-select scenarios, bind arrays to checkbox groups using model.bind:

    How It Works:

    1. model.bind tells Aurelia what value to add to the array

    2. checked.bind points to the array that holds selected values

    3. Aurelia automatically adds/removes values when checkboxes are toggled

    Use Cases:

    • Multi-select forms (select multiple skills, interests, tags)

    • Batch operations (select multiple items for deletion)

    • Filter selections (select multiple categories to filter by)

    Set-Based Collections

    For high-performance scenarios with frequent additions/removals, use Set collections:

    Why Use Sets:

    • O(1) lookup performance with .has()

    • Efficient for large collections

    • Natural for unique value storage

    • Better performance for frequent add/remove operations

    Map-Based Collections

    For complex key-value selections, Maps provide the most flexibility:

    When to Use Maps:

    • Nested selection scenarios (resource → actions)

    • Complex key-value relationships

    • Grouped permissions or settings

    • Multi-dimensional selections

    Radio Buttons

    Radio buttons are for single-selection from multiple options.

    Basic Radio Buttons

    Radio Buttons with Objects

    Key Points:

    • Use same name attribute for all radios in a group

    • model.bind defines the value when selected

    • checked.bind holds the currently selected value

    • Use matcher.bind for complex object comparison

    Select Elements

    Basic Select

    Select with Objects

    Select with Optgroups

    Multi-Select

    Performance Considerations

    Choose the right collection type for your use case:

    Collection Type
    Best For
    Performance

    Array

    General purpose, small-medium collections

    Good

    Set

    Frequent additions/removals, uniqueness

    Excellent (O(1) lookups)

    Map

    Key-value pairs, nested selections

    Excellent (O(1) lookups)

    Performance Tips:

    • Use Set for large collections with frequent changes

    • Implement efficient matcher functions for object comparison

    • Avoid creating new objects in templates—use computed properties

    • Consider virtualization for very large checkbox/radio lists

    Matchers Explained

    Matchers tell Aurelia how to compare values:

    When to use matchers:

    • Binding objects to checkboxes/radios

    • Working with Sets containing objects

    • Need custom equality logic

    • Comparing by properties other than reference

    Common Patterns

    Select All / Deselect All

    Conditional Options

    Related

    • Form Basics - Basic form inputs

    • Validation - Validate form inputs

    • Form Examples - Complete examples

    • List Rendering - Using repeat.for

    interface Product {
      id: number;
      name: string;
      description: string;
      price: number;
      category: string;
      image: string;
      inStock: boolean;
      rating: number;
    }
    
    type SortOption = 'name' | 'price-low' | 'price-high' | 'rating';
    
    export class ProductCatalog {
      // Data
      products: Product[] = [
        {
          id: 1,
          name: 'Wireless Headphones',
          description: 'Premium noise-canceling headphones with 30-hour battery',
          price: 299.99,
          category: 'Audio',
          image: '/images/headphones.jpg',
          inStock: true,
          rating: 4.5
        },
        {
          id: 2,
          name: 'Smart Watch',
          description: 'Fitness tracking with heart rate monitor and GPS',
          price: 399.99,
          category: 'Wearables',
          image: '/images/smartwatch.jpg',
          inStock: true,
          rating: 4.2
        },
        {
          id: 3,
          name: 'Laptop Stand',
          description: 'Ergonomic aluminum stand for better posture',
          price: 49.99,
          category: 'Accessories',
          image: '/images/stand.jpg',
          inStock: false,
          rating: 4.8
        },
        {
          id: 4,
          name: 'Mechanical Keyboard',
          description: 'RGB backlit with customizable switches',
          price: 159.99,
          category: 'Accessories',
          image: '/images/keyboard.jpg',
          inStock: true,
          rating: 4.6
        },
        {
          id: 5,
          name: 'USB-C Hub',
          description: '7-in-1 adapter with 4K HDMI and SD card reader',
          price: 79.99,
          category: 'Accessories',
          image: '/images/hub.jpg',
          inStock: true,
          rating: 4.3
        },
        {
          id: 6,
          name: 'Wireless Earbuds',
          description: 'True wireless with active noise cancellation',
          price: 199.99,
          category: 'Audio',
          image: '/images/earbuds.jpg',
          inStock: true,
          rating: 4.4
        }
      ];
    
      // Filter state
      searchQuery = '';
      selectedCategories: string[] = [];
      sortBy: SortOption = 'name';
      showOutOfStock = true;
    
      // Computed property for unique categories
      get categories(): string[] {
        return [...new Set(this.products.map(p => p.category))].sort();
      }
    
      // Computed property for filtered and sorted products
      get filteredProducts(): Product[] {
        let filtered = this.products;
    
        // Filter by search query
        if (this.searchQuery.trim()) {
          const query = this.searchQuery.toLowerCase();
          filtered = filtered.filter(p =>
            p.name.toLowerCase().includes(query) ||
            p.description.toLowerCase().includes(query)
          );
        }
    
        // Filter by selected categories
        if (this.selectedCategories.length > 0) {
          filtered = filtered.filter(p =>
            this.selectedCategories.includes(p.category)
          );
        }
    
        // Filter out of stock if needed
        if (!this.showOutOfStock) {
          filtered = filtered.filter(p => p.inStock);
        }
    
        // Sort products
        return this.sortProducts(filtered);
      }
    
      get hasActiveFilters(): boolean {
        return this.searchQuery.trim() !== '' ||
               this.selectedCategories.length > 0 ||
               !this.showOutOfStock;
      }
    
      private sortProducts(products: Product[]): Product[] {
        const sorted = [...products];
    
        switch (this.sortBy) {
          case 'name':
            return sorted.sort((a, b) => a.name.localeCompare(b.name));
          case 'price-low':
            return sorted.sort((a, b) => a.price - b.price);
          case 'price-high':
            return sorted.sort((a, b) => b.price - a.price);
          case 'rating':
            return sorted.sort((a, b) => b.rating - a.rating);
          default:
            return sorted;
        }
      }
    
      clearFilters() {
        this.searchQuery = '';
        this.selectedCategories = [];
        this.showOutOfStock = true;
      }
    
      setSortOrder(sortOption: SortOption) {
        this.sortBy = sortOption;
      }
    }
    <div class="product-catalog">
      <!-- Header -->
      <header class="catalog-header">
        <h1>Product Catalog</h1>
        <p class="result-count">
          Showing ${filteredProducts.length} of ${products.length} products
        </p>
      </header>
    
      <!-- Search and Filters -->
      <div class="filters-section">
        <!-- Search Bar -->
        <div class="search-box">
          <input
            type="search"
            value.bind="searchQuery & debounce:300"
            placeholder="Search products..."
            class="search-input">
          <span class="search-icon">🔍</span>
        </div>
    
        <!-- Category Filters -->
        <div class="filter-group">
          <h3>Categories</h3>
          <label repeat.for="category of categories" class="filter-option">
            <input
              type="checkbox"
              model.bind="category"
              checked.bind="selectedCategories">
            ${category}
          </label>
        </div>
    
        <!-- Availability Filter -->
        <div class="filter-group">
          <label class="filter-option">
            <input type="checkbox" checked.bind="showOutOfStock">
            Show out of stock items
          </label>
        </div>
    
        <!-- Clear Filters -->
        <button
          if.bind="hasActiveFilters"
          click.trigger="clearFilters()"
          class="clear-filters-btn">
          Clear All Filters
        </button>
      </div>
    
      <!-- Sort Options -->
      <div class="sort-section">
        <label>Sort by:</label>
        <button
          click.trigger="setSortOrder('name')"
          class="sort-btn ${sortBy === 'name' ? 'active' : ''}">
          Name
        </button>
        <button
          click.trigger="setSortOrder('price-low')"
          class="sort-btn ${sortBy === 'price-low' ? 'active' : ''}">
          Price: Low to High
        </button>
        <button
          click.trigger="setSortOrder('price-high')"
          class="sort-btn ${sortBy === 'price-high' ? 'active' : ''}">
          Price: High to Low
        </button>
        <button
          click.trigger="setSortOrder('rating')"
          class="sort-btn ${sortBy === 'rating' ? 'active' : ''}">
          Rating
        </button>
      </div>
    
      <!-- Product Grid -->
      <div class="product-grid" if.bind="filteredProducts.length > 0">
        <div
          repeat.for="product of filteredProducts; key: id"
          class="product-card ${product.inStock ? '' : 'out-of-stock'}">
    
          <!-- Product Image -->
          <div class="product-image">
            <img src.bind="product.image" alt.bind="product.name">
            <span if.bind="!product.inStock" class="stock-badge">Out of Stock</span>
          </div>
    
          <!-- Product Info -->
          <div class="product-info">
            <h3 class="product-name">${product.name}</h3>
            <p class="product-description">${product.description}</p>
    
            <!-- Rating -->
            <div class="product-rating">
              <span repeat.for="star of 5" class="star ${star < product.rating ? 'filled' : ''}">
                ★
              </span>
              <span class="rating-value">${product.rating}</span>
            </div>
    
            <!-- Price and Actions -->
            <div class="product-footer">
              <span class="product-price">${product.price | currency:'USD'}</span>
              <button
                class="add-to-cart-btn"
                disabled.bind="!product.inStock"
                click.trigger="addToCart(product)">
                ${product.inStock ? 'Add to Cart' : 'Unavailable'}
              </button>
            </div>
          </div>
        </div>
      </div>
    
      <!-- Empty State -->
      <div if.bind="filteredProducts.length === 0" class="empty-state">
        <p class="empty-icon">📦</p>
        <h2>No products found</h2>
        <p>Try adjusting your search or filters</p>
        <button click.trigger="clearFilters()" class="btn-primary">
          Clear Filters
        </button>
      </div>
    </div>
    .product-catalog {
      max-width: 1200px;
      margin: 0 auto;
      padding: 2rem;
    }
    
    .catalog-header {
      margin-bottom: 2rem;
    }
    
    .result-count {
      color: #666;
      margin-top: 0.5rem;
    }
    
    .filters-section {
      background: #f5f5f5;
      padding: 1.5rem;
      border-radius: 8px;
      margin-bottom: 2rem;
    }
    
    .search-box {
      position: relative;
      margin-bottom: 1.5rem;
    }
    
    .search-input {
      width: 100%;
      padding: 0.75rem 2.5rem 0.75rem 1rem;
      border: 1px solid #ddd;
      border-radius: 4px;
      font-size: 1rem;
    }
    
    .search-icon {
      position: absolute;
      right: 1rem;
      top: 50%;
      transform: translateY(-50%);
      pointer-events: none;
    }
    
    .filter-group {
      margin-bottom: 1rem;
    }
    
    .filter-group h3 {
      font-size: 0.9rem;
      font-weight: 600;
      margin-bottom: 0.5rem;
      text-transform: uppercase;
      color: #333;
    }
    
    .filter-option {
      display: block;
      margin-bottom: 0.5rem;
      cursor: pointer;
    }
    
    .filter-option input {
      margin-right: 0.5rem;
    }
    
    .clear-filters-btn {
      background: #fff;
      border: 1px solid #ddd;
      padding: 0.5rem 1rem;
      border-radius: 4px;
      cursor: pointer;
      font-size: 0.9rem;
    }
    
    .clear-filters-btn:hover {
      background: #f0f0f0;
    }
    
    .sort-section {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      margin-bottom: 2rem;
      flex-wrap: wrap;
    }
    
    .sort-btn {
      padding: 0.5rem 1rem;
      border: 1px solid #ddd;
      background: #fff;
      border-radius: 4px;
      cursor: pointer;
      transition: all 0.2s;
    }
    
    .sort-btn:hover {
      border-color: #007bff;
    }
    
    .sort-btn.active {
      background: #007bff;
      color: white;
      border-color: #007bff;
    }
    
    .product-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
      gap: 1.5rem;
    }
    
    .product-card {
      background: white;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      overflow: hidden;
      transition: transform 0.2s, box-shadow 0.2s;
    }
    
    .product-card:hover {
      transform: translateY(-4px);
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
    }
    
    .product-card.out-of-stock {
      opacity: 0.6;
    }
    
    .product-image {
      position: relative;
      height: 200px;
      background: #f5f5f5;
      overflow: hidden;
    }
    
    .product-image img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
    
    .stock-badge {
      position: absolute;
      top: 0.5rem;
      right: 0.5rem;
      background: #dc3545;
      color: white;
      padding: 0.25rem 0.5rem;
      border-radius: 4px;
      font-size: 0.75rem;
      font-weight: 600;
    }
    
    .product-info {
      padding: 1rem;
    }
    
    .product-name {
      font-size: 1.1rem;
      margin: 0 0 0.5rem 0;
      color: #333;
    }
    
    .product-description {
      color: #666;
      font-size: 0.9rem;
      margin-bottom: 0.75rem;
      line-height: 1.4;
    }
    
    .product-rating {
      display: flex;
      align-items: center;
      gap: 0.25rem;
      margin-bottom: 1rem;
    }
    
    .star {
      color: #ddd;
      font-size: 1rem;
    }
    
    .star.filled {
      color: #ffc107;
    }
    
    .rating-value {
      margin-left: 0.25rem;
      color: #666;
      font-size: 0.9rem;
    }
    
    .product-footer {
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    
    .product-price {
      font-size: 1.25rem;
      font-weight: 600;
      color: #007bff;
    }
    
    .add-to-cart-btn {
      padding: 0.5rem 1rem;
      background: #28a745;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-weight: 600;
      transition: background 0.2s;
    }
    
    .add-to-cart-btn:hover:not(:disabled) {
      background: #218838;
    }
    
    .add-to-cart-btn:disabled {
      background: #6c757d;
      cursor: not-allowed;
    }
    
    .empty-state {
      text-align: center;
      padding: 4rem 2rem;
    }
    
    .empty-icon {
      font-size: 4rem;
      margin-bottom: 1rem;
    }
    
    .empty-state h2 {
      color: #333;
      margin-bottom: 0.5rem;
    }
    
    .empty-state p {
      color: #666;
      margin-bottom: 1.5rem;
    }
    
    .btn-primary {
      padding: 0.75rem 1.5rem;
      background: #007bff;
      color: white;
      border: none;
      border-radius: 4px;
      font-size: 1rem;
      cursor: pointer;
      transition: background 0.2s;
    }
    
    .btn-primary:hover {
      background: #0056b3;
    }
    
    @media (max-width: 768px) {
      .product-grid {
        grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
      }
    
      .sort-section {
        font-size: 0.9rem;
      }
    
      .sort-btn {
        padding: 0.4rem 0.8rem;
        font-size: 0.85rem;
      }
    }
    <input value.bind="searchQuery & debounce:300">
    get filteredProducts(): Product[] {
      // Filters are applied in sequence
      // Search → Categories → Stock availability → Sort
    }
    <input type="checkbox" model.bind="category" checked.bind="selectedCategories">
    <div repeat.for="product of filteredProducts; key: id">
    <button class="sort-btn ${sortBy === 'name' ? 'active' : ''}">
    <div class="product-card ${product.inStock ? '' : 'out-of-stock'}">
    minPrice = 0;
    maxPrice = 500;
    
    get filteredProducts(): Product[] {
      // ... existing filters
      filtered = filtered.filter(p =>
        p.price >= this.minPrice && p.price <= this.maxPrice
      );
      // ... sort
    }
    <div class="filter-group">
      <h3>Price Range</h3>
      <input type="range" min="0" max="500" value.bind="minPrice">
      <input type="range" min="0" max="500" value.bind="maxPrice">
      <p>${minPrice | currency} - ${maxPrice | currency}</p>
    </div>
    cart: Product[] = [];
    
    addToCart(product: Product) {
      this.cart.push(product);
      // Show notification
      console.log(`Added ${product.name} to cart`);
    }
    import { resolve } from 'aurelia';
    import { IRouter } from '@aurelia/router';
    
    export class ProductCatalog {
      private readonly router = resolve(IRouter);
    
      searchQueryChanged() {
        this.router.load({
          query: { search: this.searchQuery }
        });
      }
    }
    <element event.command="methodName(argument1, argument2, ...)">
    <button click.trigger="handleClick()">Click Me</button>
    <!-- These two lines are identical -->
    <button click.trigger="save()">Save</button>
    <button @click="save()">Save</button>
    
    <!-- Modifiers work the same way -->
    <button @click:ctrl+enter="send()">Send (Ctrl + Enter)</button>
    <button click.trigger="handleClick($event)">Click Me</button>
    export class MyViewModel {
      handleClick(event: MouseEvent) {
        console.log('Button clicked!', event);
        // Access event properties like event.target, event.clientX, etc.
      }
    }
    <button click.trigger="removeItem(item.id, $event)">Remove Item</button>
    export class MyViewModel {
      removeItem(itemId: number, event: MouseEvent) {
        console.log(`Removing item with ID: ${itemId}`, event);
        // Logic to remove the item
      }
    }
    <button click.trigger="submitForm()">Submit</button>
    <a href="#" click.trigger="openModal()">Learn More</a>
    <input type="text" input.trigger="updateSearchQuery($event.target.value)" placeholder="Search..." />
    <select change.trigger="selectTheme($event.target.value)">
      <option value="light">Light Theme</option>
      <option value="dark">Dark Theme</option>
    </select>
    <div mouseover.trigger="highlight()" mouseout.trigger="unhighlight()">Hover Me</div>
    <input type="text" keydown.trigger="handleKeyDown($event)" />
    <div mousemove.trigger="trackMouse($event) & throttle:50">Move mouse here</div>
    <input type="text" input.trigger="searchQuery($event.target.value) & debounce:300" placeholder="Search" />
    <input input.trigger="search($event.target.value) & debounce:300:'immediate'">
    <button click.trigger="signaler.dispatchSignal('immediate')">Search Now</button>
    <my-custom-element data-loaded.trigger="handleDataLoaded($event)"></my-custom-element>
    <div click.trigger="divClicked() & self">
      <p>Clicking here will trigger divClicked</p>
      <button click.trigger="buttonClicked()">Clicking button will NOT trigger divClicked</button>
    </div>
    <input type="checkbox" checked.bind="isAgreed" change.trigger="agreementChanged()" id="agreementCheckbox">
    <label for="agreementCheckbox">I agree to the terms</label>
    export class MyViewModel {
      isAgreed = false;
    
      agreementChanged() {
        console.log('Agreement status changed:', this.isAgreed);
        // Perform actions based on checkbox state
      }
    }
    <input type="text" keydown.trigger="handleKeyDown($event)" placeholder="Type here">
    export class MyViewModel {
      handleKeyDown(event: KeyboardEvent) {
        if (event.key === 'Enter') {
          console.log('Enter key pressed!');
          // Perform action on Enter key press (e.g., submit form)
          event.preventDefault(); // Prevent default form submission if inside a form
        } else if (event.key === 'Escape') {
          console.log('Escape key pressed!');
          // Handle Escape key press (e.g., clear input)
        }
        // ... handle other keys as needed
      }
    }
    <ul click.trigger="listItemClicked($event)">
      <li repeat.for="item of items" data-item-id="${item.id}">${item.name}</li>
    </ul>
    export class MyViewModel {
      items = [{ id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }];
    
      listItemClicked(event: Event) {
        const target = event.target as HTMLElement;
        if (target.tagName === 'LI') {
          const itemId = target.dataset.itemId;
          console.log(`List item clicked, ID: ${itemId}`);
          // Logic to handle click on list item with ID itemId
        }
      }
    }
    import { bindable, customElement, resolve } from 'aurelia';
    import { INode } from '@aurelia/runtime-html';
    
    @customElement({ name: 'my-button', template: `<button click.trigger="handleClick()">\${label}</button>` })
    export class MyButton {
      private element = resolve(INode) as HTMLElement;
      @bindable label = 'Click Me';
    
      handleClick() {
        this.element.dispatchEvent(new CustomEvent('button-clicked', {
          bubbles: true, // Allow event to bubble up
          detail: { message: 'Button with label "' + this.label + '" was clicked' }
        }));
      }
    }
    <my-button label="Action Button" button-clicked.trigger="handleButtonClick($event)"></my-button>
    export class ParentViewModel {
      handleButtonClick(event: CustomEvent) {
        console.log('Custom event "button-clicked" received:', event.detail.message);
        // Handle the custom event
      }
    }
    <input type="text" input.trigger="autocomplete($event.target.value) & debounce:500" placeholder="Start typing..." />
    <ul if.bind="suggestions.length">
      <li repeat.for="suggestion of suggestions">${suggestion}</li>
    </ul>
    export class MyViewModel {
      searchQuery = '';
      suggestions = [];
    
      autocomplete(query: string) {
        this.searchQuery = query;
        if (query.length > 2) {
          // Simulate API call for suggestions (replace with actual API call)
          setTimeout(() => {
            this.suggestions = [`${query} suggestion 1`, `${query} suggestion 2`, `${query} suggestion 3`];
          }, 300);
        } else {
          this.suggestions = [];
        }
      }
    }
    <element event.trigger[:modifier]="methodName()">
    <!-- Submit only on Ctrl + Enter, prevent default form submission -->
    <textarea @keydown:ctrl+enter.prevent="submitDraft()"></textarea>
    
    <!-- Ignore bubbling clicks; only fire when the element itself is clicked -->
    <button click.trigger="destroy()" @click:left.stop.prevent></button>
    
    <!-- When using dot syntax, the command still comes first -->
    <div scroll.trigger="syncScroll($event)" @scroll.prevent></div>
    <button click.trigger:ctrl="onCtrlClick()">Ctrl + Click</button>
    <textarea keydown.trigger:ctrl+enter="send()"></textarea>
    <button click.trigger:stop:prevent="validate()">Validate</button>
    <button click.trigger:middle="newTab()">Open in New Tab (Middle Click)</button>
    <textarea keydown.trigger:ctrl+75="openSearchDialog()"></textarea>
    import Aurelia, { AppTask, IKeyMapping } from 'aurelia';
    
    Aurelia.register(
      AppTask.creating(IKeyMapping, mapping => {
        mapping.keys.upper_k = 'K'; // Map 'upper_k' to 'K'
      })
    );
    <textarea keydown.trigger:ctrl+upper_k="openSearchDialog()"></textarea>
    import { EventModifierRegistration, IModifiedEventHandlerCreator } from '@aurelia/runtime-html';
    import { Registration } from '@aurelia/kernel';
    
    class WheelModifier implements IModifiedEventHandlerCreator {
      public readonly type = 'wheel';
      public getHandler(modifier: string) {
        const parts = modifier.split('.');
        return (event: WheelEvent) => {
          if (parts.includes('vertical') && Math.abs(event.deltaY) <= Math.abs(event.deltaX)) {
            return false; // Ignore horizontal scrolls
          }
          if (parts.includes('invert')) {
            event.deltaY *= -1;
          }
          return true;
        };
      }
    }
    
    Aurelia.register(
      EventModifierRegistration,
      Registration.singleton(IModifiedEventHandlerCreator, WheelModifier)
    );
    handleClick(event: MouseEvent) {
      console.log('Click handler called', event);
      // Your logic here
    }
    handleEvent(event: Event) {
      console.log('Event details:', {
        type: event.type,
        target: event.target,
        currentTarget: event.currentTarget
      });
    }
    export class PreferencesForm {
      emailNotifications = false;
      smsNotifications = true;
      pushNotifications = false;
    
      get hasValidNotificationPrefs(): boolean {
        return this.emailNotifications || this.smsNotifications || this.pushNotifications;
      }
    }
    <form>
      <fieldset>
        <legend>Notification Preferences</legend>
        <label>
          <input type="checkbox" checked.bind="emailNotifications" />
          Email notifications
        </label>
        <label>
          <input type="checkbox" checked.bind="smsNotifications" />
          SMS notifications
        </label>
        <label>
          <input type="checkbox" checked.bind="pushNotifications" />
          Push notifications
        </label>
      </fieldset>
    
      <div if.bind="!hasValidNotificationPrefs" class="warning">
        Please select at least one notification method.
      </div>
    </form>
    interface Product {
      id: number;
      name: string;
      category: string;
      price: number;
    }
    
    export class ProductSelectionForm {
      products: Product[] = [
        { id: 1, name: "Gaming Mouse", category: "Peripherals", price: 89.99 },
        { id: 2, name: "Mechanical Keyboard", category: "Peripherals", price: 159.99 },
        { id: 3, name: "4K Monitor", category: "Display", price: 399.99 },
        { id: 4, name: "Graphics Card", category: "Components", price: 599.99 }
      ];
    
      // Array of selected product IDs
      selectedProductIds: number[] = [];
    
      // Array of selected product objects
      selectedProducts: Product[] = [];
    
      get totalValue(): number {
        return this.selectedProducts.reduce((sum, product) => sum + product.price, 0);
      }
    }
    <form>
      <h3>Select Products</h3>
    
      <!-- ID-based selection -->
      <div class="product-grid">
        <div repeat.for="product of products" class="product-card">
          <label>
            <input type="checkbox"
                   model.bind="product.id"
                   checked.bind="selectedProductIds" />
            <strong>${product.name}</strong>
            <span class="category">${product.category}</span>
            <span class="price">$${product.price}</span>
          </label>
        </div>
      </div>
    
      <!-- Object-based selection (more flexible) -->
      <h4>Or select complete product objects:</h4>
      <div class="product-list">
        <label repeat.for="product of products" class="product-item">
          <input type="checkbox"
                 model.bind="product"
                 checked.bind="selectedProducts" />
          ${product.name} - $${product.price}
        </label>
      </div>
    
      <div class="summary" if.bind="selectedProducts.length">
        <h4>Selected Items (${selectedProducts.length})</h4>
        <ul>
          <li repeat.for="product of selectedProducts">
            ${product.name} - $${product.price}
          </li>
        </ul>
        <strong>Total: $${totalValue}</strong>
      </div>
    </form>
    export class TagSelectionForm {
      availableTags = [
        { id: 'frontend', name: 'Frontend Development', color: '#blue' },
        { id: 'backend', name: 'Backend Development', color: '#green' },
        { id: 'database', name: 'Database Design', color: '#orange' },
        { id: 'devops', name: 'DevOps', color: '#purple' },
        { id: 'mobile', name: 'Mobile Development', color: '#red' }
      ];
    
      // Set-based selection for O(1) lookups
      selectedTags: Set<string> = new Set(['frontend', 'database']);
    
      // Custom matcher for Set operations
      tagMatcher = (a: any, b: any) => {
        if (typeof a === 'string' && typeof b === 'object') return a === b.id;
        if (typeof b === 'string' && typeof a === 'object') return b === a.id;
        return a === b;
      };
    
      get selectedTagList() {
        return this.availableTags.filter(tag => this.selectedTags.has(tag.id));
      }
    
      toggleTag(tagId: string) {
        if (this.selectedTags.has(tagId)) {
          this.selectedTags.delete(tagId);
        } else {
          this.selectedTags.add(tagId);
        }
      }
    }
    <form>
      <h3>Select Your Skills</h3>
      <div class="tag-container">
        <label repeat.for="tag of availableTags"
               class="tag-label">
          <input type="checkbox"
                 model.bind="tag.id"
                 checked.bind="selectedTags"
                 matcher.bind="tagMatcher" />
          <span class="tag-text">${tag.name}</span>
        </label>
      </div>
    
      <div if.bind="selectedTags.size > 0" class="selected-tags">
        <h4>Selected Skills (${selectedTags.size})</h4>
        <div class="tag-chips">
          <span repeat.for="tag of selectedTagList" class="tag-chip">
            ${tag.name}
            <button type="button"
                    click.trigger="toggleTag(tag.id)"
                    class="remove-tag">×</button>
          </span>
        </div>
      </div>
    </form>
    interface Permission {
      resource: string;
      actions: string[];
      description: string;
    }
    
    export class PermissionForm {
      permissions: Permission[] = [
        {
          resource: 'users',
          actions: ['create', 'read', 'update', 'delete'],
          description: 'User management operations'
        },
        {
          resource: 'posts',
          actions: ['create', 'read', 'update', 'delete', 'publish'],
          description: 'Content management operations'
        },
        {
          resource: 'settings',
          actions: ['read', 'update'],
          description: 'System configuration'
        }
      ];
    
      // Map: resource -> Set<action>
      selectedPermissions: Map<string, Set<string>> = new Map();
    
      constructor() {
        // Initialize with default permissions
        this.selectedPermissions.set('users', new Set(['read']));
        this.selectedPermissions.set('posts', new Set(['read', 'create']));
      }
    
      hasPermission(resource: string, action: string): boolean {
        return this.selectedPermissions.get(resource)?.has(action) ?? false;
      }
    
      togglePermission(resource: string, action: string) {
        if (!this.selectedPermissions.has(resource)) {
          this.selectedPermissions.set(resource, new Set());
        }
    
        const resourcePerms = this.selectedPermissions.get(resource)!;
        if (resourcePerms.has(action)) {
          resourcePerms.delete(action);
        } else {
          resourcePerms.add(action);
        }
      }
    
      get permissionSummary() {
        const summary: Array<{ resource: string; actions: string[] }> = [];
        this.selectedPermissions.forEach((actions, resource) => {
          if (actions.size > 0) {
            summary.push({ resource, actions: Array.from(actions) });
          }
        });
        return summary;
      }
    }
    <form>
      <h3>Configure Permissions</h3>
      <div class="permission-matrix">
        <div repeat.for="permission of permissions" class="permission-group">
          <h4>${permission.resource}</h4>
          <p class="description">${permission.description}</p>
          <div class="action-checkboxes">
            <label repeat.for="action of permission.actions" class="action-label">
              <input type="checkbox"
                     checked.bind="hasPermission(permission.resource, action)"
                     change.trigger="togglePermission(permission.resource, action)" />
              ${action}
            </label>
          </div>
        </div>
      </div>
    
      <div if.bind="permissionSummary.length > 0" class="permission-summary">
        <h4>Selected Permissions</h4>
        <ul>
          <li repeat.for="perm of permissionSummary">
            <strong>${perm.resource}</strong>: ${perm.actions.join(', ')}
          </li>
        </ul>
      </div>
    </form>
    export class ShippingForm {
      shippingMethods = ['Standard', 'Express', 'Overnight'];
      selectedMethod = 'Standard';
    }
    <fieldset>
      <legend>Shipping Method</legend>
      <label repeat.for="method of shippingMethods">
        <input type="radio"
               name="shipping"
               model.bind="method"
               checked.bind="selectedMethod" />
        ${method}
      </label>
    </fieldset>
    
    <p>Selected: ${selectedMethod}</p>
    interface PaymentMethod {
      id: string;
      type: 'credit' | 'debit' | 'paypal' | 'crypto';
      name: string;
      fee: number;
      processingTime: string;
      requiresVerification: boolean;
    }
    
    export class PaymentSelectionForm {
      paymentMethods: PaymentMethod[] = [
        {
          id: 'cc-visa',
          type: 'credit',
          name: 'Visa Credit Card',
          fee: 0,
          processingTime: 'Instant',
          requiresVerification: false
        },
        {
          id: 'pp-account',
          type: 'paypal',
          name: 'PayPal Account',
          fee: 2.50,
          processingTime: '1-2 business days',
          requiresVerification: true
        },
        {
          id: 'btc-wallet',
          type: 'crypto',
          name: 'Bitcoin Wallet',
          fee: 0.0001,
          processingTime: '10-60 minutes',
          requiresVerification: true
        }
      ];
    
      selectedPaymentMethod: PaymentMethod | null = null;
    
      // Custom matcher for complex object comparison
      paymentMethodMatcher = (a: PaymentMethod, b: PaymentMethod) => {
        return a?.id === b?.id;
      };
    
      get totalFee(): number {
        return this.selectedPaymentMethod?.fee || 0;
      }
    
      get requiresUserVerification(): boolean {
        return this.selectedPaymentMethod?.requiresVerification || false;
      }
    }
    <form class="payment-selection-form">
      <h3>Select Payment Method</h3>
    
      <div class="payment-options">
        <div repeat.for="method of paymentMethods" class="payment-option">
          <label class="payment-card"
                 class.bind="{ 'selected': selectedPaymentMethod?.id === method.id }">
            <input type="radio"
                   name="paymentMethod"
                   model.bind="method"
                   checked.bind="selectedPaymentMethod"
                   matcher.bind="paymentMethodMatcher" />
    
            <div class="payment-info">
              <div class="payment-header">
                <span class="payment-name">${method.name}</span>
                <span class="payment-type badge">${method.type}</span>
              </div>
    
              <div class="payment-details">
                <div class="processing-time">⏱️ ${method.processingTime}</div>
                <div class="fee-info">
                  💵 ${method.fee === 0 ? 'No fees' : '$' + method.fee.toFixed(2)}
                </div>
                <div if.bind="method.requiresVerification" class="verification-required">
                  🛡️ Verification required
                </div>
              </div>
            </div>
          </label>
        </div>
      </div>
    
      <!-- Selection Summary -->
      <div if.bind="selectedPaymentMethod" class="selection-summary">
        <h4>Payment Summary</h4>
        <p>Method: ${selectedPaymentMethod.name}</p>
        <p>Processing: ${selectedPaymentMethod.processingTime}</p>
        <p>Fee: ${totalFee === 0 ? 'Free' : '$' + totalFee.toFixed(2)}</p>
        <div if.bind="requiresUserVerification" class="warning">
          ⚠️ This payment method requires account verification
        </div>
      </div>
    </form>
    export class CountryForm {
      countries = ['USA', 'Canada', 'Mexico', 'UK', 'France', 'Germany'];
      selectedCountry = 'USA';
    }
    <select value.bind="selectedCountry">
      <option repeat.for="country of countries" value.bind="country">
        ${country}
      </option>
    </select>
    interface Country {
      code: string;
      name: string;
      region: string;
    }
    
    export class AdvancedCountryForm {
      countries: Country[] = [
        { code: 'US', name: 'United States', region: 'North America' },
        { code: 'CA', name: 'Canada', region: 'North America' },
        { code: 'MX', name: 'Mexico', region: 'North America' },
        { code: 'UK', name: 'United Kingdom', region: 'Europe' },
        { code: 'FR', name: 'France', region: 'Europe' },
        { code: 'DE', name: 'Germany', region: 'Europe' }
      ];
    
      selectedCountry: Country | null = null;
    
      // Custom matcher
      countryMatcher = (a: Country, b: Country) => a?.code === b?.code;
    }
    <!-- Using model.bind for objects -->
    <select value.bind="selectedCountry" matcher.bind="countryMatcher">
      <option model.bind="null">-- Select Country --</option>
      <option repeat.for="country of countries" model.bind="country">
        ${country.name}
      </option>
    </select>
    
    <p if.bind="selectedCountry">
      Selected: ${selectedCountry.name} (${selectedCountry.region})
    </p>
    <select value.bind="selectedCountry" matcher.bind="countryMatcher">
      <option model.bind="null">-- Select Country --</option>
      <optgroup label="North America">
        <option repeat.for="country of countries | filter:isNorthAmerica"
                model.bind="country">
          ${country.name}
        </option>
      </optgroup>
      <optgroup label="Europe">
        <option repeat.for="country of countries | filter:isEurope"
                model.bind="country">
          ${country.name}
        </option>
      </optgroup>
    </select>
    export class MultiSelectForm {
      availableSkills = ['JavaScript', 'TypeScript', 'Python', 'Java', 'C#', 'Go'];
      selectedSkills: string[] = ['JavaScript', 'TypeScript'];
    }
    <select multiple value.bind="selectedSkills">
      <option repeat.for="skill of availableSkills" value.bind="skill">
        ${skill}
      </option>
    </select>
    
    <div if.bind="selectedSkills.length">
      <h4>Selected Skills (${selectedSkills.length})</h4>
      <ul>
        <li repeat.for="skill of selectedSkills">${skill}</li>
      </ul>
    </div>
    // Simple matcher for objects with id property
    simpleMatcher = (a, b) => a?.id === b?.id;
    
    // Type-safe matcher
    typedMatcher = (a: Product, b: Product) => a?.id === b?.id;
    
    // Complex matcher with multiple criteria
    complexMatcher = (a, b) => {
      if (!a || !b) return false;
      return a.id === b.id && a.version === b.version;
    };
    
    // Mixed type matcher (for Sets with objects)
    mixedMatcher = (a: any, b: any) => {
      if (typeof a === 'string' && typeof b === 'object') return a === b.id;
      if (typeof b === 'string' && typeof a === 'object') return b === a.id;
      return a === b;
    };
    export class BulkSelectionForm {
      items = [/* array of items */];
      selectedItems: any[] = [];
    
      get allSelected(): boolean {
        return this.selectedItems.length === this.items.length;
      }
    
      get someSelected(): boolean {
        return this.selectedItems.length > 0 && !this.allSelected;
      }
    
      toggleAll() {
        if (this.allSelected) {
          this.selectedItems = [];
        } else {
          this.selectedItems = [...this.items];
        }
      }
    }
    <label>
      <input type="checkbox"
             checked.bind="allSelected"
             click.trigger="toggleAll()"
             indeterminate.bind="someSelected" />
      Select All
    </label>
    
    <label repeat.for="item of items">
      <input type="checkbox"
             model.bind="item"
             checked.bind="selectedItems" />
      ${item.name}
    </label>
    <select value.bind="selectedOption">
      <option repeat.for="option of options"
              model.bind="option"
              disabled.bind="option.disabled">
        ${option.name}
        ${option.disabled ? '(unavailable)' : ''}
      </option>
    </select>
    Component communication with bindable properties
  • Advanced list rendering and filtering

  • Form handling with validation

  • Local storage persistence

  • Computed properties and reactive updates

  • Template patterns for real apps

  • Prerequisites

    • Completed the Hello World Tutorial

    • Basic understanding of Templates

    • Familiarity with TypeScript

    The App We're Building

    A todo application with:

    • ✅ Add, complete, and delete tasks

    • 🏷️ Categorize tasks (Work, Personal, Shopping)

    • 🔍 Filter by category and completion status

    • 💾 Auto-save to local storage

    • 📊 Task statistics

    Step 1: Project Setup

    Step 2: Data Models

    Create src/models.ts:

    Step 3: Storage Service

    Create src/storage-service.ts:

    The service is automatically registered as a singleton via DI.createInterface.

    Step 4: Main App Component

    Update src/my-app.ts:

    Step 5: Create Todo Form Component

    Create src/todo-form.ts:

    Create src/todo-form.html:

    Step 6: Create Todo Item Component

    Create src/todo-item.ts:

    Create src/todo-item.html:

    Step 7: Main App Template

    Update src/my-app.html:

    Step 8: Styling

    Update src/my-app.css:

    What You've Learned

    • Component Composition - Created reusable TodoForm and TodoItem components

    • Component Communication - Used @bindable and .call for parent-child communication

    • Dependency Injection - Created and injected StorageService

    • Computed Properties - Implemented filtered lists and statistics

    • List Rendering - Used repeat.for with keys for efficient updates

    • Conditional Rendering - Showed/hid elements based on state

    • Form Handling - Built forms with validation and submission

    • Local Storage - Persisted data across sessions

    • Template Patterns - Applied real-world templating techniques

    Next Steps

    Enhance your app with:

    • Drag-and-drop reordering

    • Edit mode for todos

    • Due dates and reminders

    • Search functionality

    • Dark mode toggle

    • Export/import todos

    Related Documentation

    • Templates Overview

    • Component Basics

    • Dependency Injection

    • Form Handling

    attribute-name.bind="value": The binding declaration.

    • attribute-name: The target HTML attribute you want to bind to.

    • .bind: The binding command indicating a two-way binding by default.

    • value: The expression or property from the view model to bind.

    You can bind to virtually any attribute listed in the HTML Attributes Reference.

    Example: Binding the title Attribute

    Result: The div will have a title attribute with the value "This is a tooltip". Hovering over the div will display the tooltip.

    When using an empty expression in a binding, such as attribute-name.bind or attribute-name.bind="", Aurelia automatically infers the expression based on the camelCase version of the target attribute. For example, attribute-name.bind="" is equivalent to attribute-name.bind="attributeName". This behavior applies to other binding commands as well:

    • .one-time

    • .to-view

    • .from-view

    • .two-way

    • .attr

    Binding Techniques and Syntax

    Aurelia provides multiple methods for attribute binding, each tailored for specific use cases and offering different levels of data flow control.

    1. Interpolation Binding

    Interpolation allows embedding dynamic values directly within strings. This is useful for concatenating strings with dynamic data.

    Example: Binding the id Attribute Using Interpolation

    Result: The h1 element will have an id attribute set to "main-heading".

    2. Keyword Binding

    Aurelia supports several binding keywords that define the direction and frequency of data flow between the view model and the view:

    • .one-time: Updates the view from the view model only once. Subsequent changes in the view model do not affect the view.

    • .to-view / .one-way: Continuously updates the view from the view model.

    • .from-view: Updates the view model based on changes in the view.

    • .two-way: Establishes a two-way data flow, keeping both the view and view model in sync.

    • .bind: Automatically determines the appropriate binding mode. Defaults to .two-way for form elements (e.g., input, textarea) and .to-view for most other elements.

    Examples of Keyword Binding

    Result: The input fields and links will reflect the bound properties with varying degrees of reactivity based on the binding keyword used.

    3. Vue-style shorthand for .bind

    If you are used to Vue or other template syntaxes, Aurelia ships with an attribute pattern that treats a leading colon as an alias for .bind. This allows you to write more compact markup without giving up any functionality.

    The shorthand always creates a property binding (the same as attribute.bind). If you need a different binding mode, fall back to the explicit syntax (value.two-way, value.one-time, etc.). Event shorthands that start with @ are handled separately in the event binding guide.

    3. Binding to Images

    Binding image attributes such as src and alt ensures that images update dynamically based on the view model data.

    Example: Dynamic Image Binding

    Result: The img element will display the image from the specified src and use the provided alt text.

    4. Disabling Elements

    Dynamically enabling or disabling form elements enhances user interaction and form validation.

    Example: Binding the disabled Attribute

    Result: The Submit button starts as disabled, and the input field is enabled. Calling toggleButton() or toggleInput() will toggle their disabled states.

    5. Binding innerHTML and textContent

    Choose between innerHTML for rendering HTML content and textContent for rendering plain text to control how content is displayed within elements.

    Example: Rendering HTML vs. Text

    Result:

    • The first div will render the bold text as HTML.

    • The second div will display the HTML tags as plain text.

    Advanced Binding Techniques

    Explore more sophisticated binding scenarios to handle complex data interactions and ensure seamless attribute management.

    0. Treating existing markup as a custom element with as-element

    When outside systems dictate the tag name you must render (for example, a CMS that only allows <div> and <section>), you can still hydrate one of your custom elements by adding as-element="component-name" to any real DOM node. The compiler will instantiate component-name for that element while leaving the original tag in place.

    • Use as-element when you want the behavior of a custom element but must keep the original tag name for semantic or styling reasons.

    • Unlike <template as-custom-element="...">, this does not create a local element definition; it simply aliases an existing element instance.

    • The attribute can appear anywhere inside your markup except on the root <template> surrogate (putting it there triggers AUR0702).

    This makes as-element a handy compatibility feature when integrating Aurelia components into environments with strict HTML requirements.

    1. How Attribute Binding Works

    Aurelia employs a mapping function to translate view model properties to corresponding HTML attributes. This typically involves converting kebab-case attribute names to camelCase property names. However, not all properties directly map to attributes, especially custom or non-standard attributes.

    Example: Automatic Mapping

    Result: The input element's value attribute is bound to the userName property. Changes in userName update the input value and vice versa.

    Property vs. Attribute Targeting

    By default, Aurelia bindings target DOM properties rather than HTML attributes. This distinction is important because:

    • Properties are JavaScript object properties on DOM elements (e.g., element.value)

    • Attributes are the HTML markup attributes (e.g., <input value="...">)

    For most standard HTML attributes, this works seamlessly because browsers synchronize property and attribute values. However, for custom attributes or when you specifically need attribute targeting, use the .attr binding command or the attr binding behavior.

    2. Using the .attr Binding Command

    When automatic mapping fails or when dealing with non-standard attributes, use the .attr binding command to ensure proper attribute binding.

    Example: Binding a Custom Attribute

    Result: The input element will have a my-custom-attr attribute set to "Custom Attribute Value".

    3. The attr Binding Behavior

    The attr binding behavior is a powerful feature that forces any property binding to target the HTML attribute instead of the DOM property. This is especially useful for:

    • Custom attributes that don't have corresponding DOM properties

    • Data attributes (data-*)

    • ARIA attributes

    • SVG attributes

    • Cases where you need to ensure attribute-specific behavior

    Example: Using the attr Binding Behavior

    Result: All bindings will target their respective HTML attributes directly, ensuring proper DOM attribute manipulation.

    The attr binding behavior can only be used with property bindings (.bind, .one-way, .two-way, .to-view, .from-view). It cannot be used with event bindings (.trigger, .capture) or reference bindings (.ref).

    4. Attribute Mapping and Custom Elements

    Aurelia includes a built-in attribute mapper that handles common HTML attribute-to-property mappings automatically. For example:

    • maxlength → maxLength

    • readonly → readOnly

    • tabindex → tabIndex

    • contenteditable → contentEditable

    You can extend this mapping for custom elements or third-party components:

    5. Special Attribute Handling

    Aurelia provides specialized handling for certain attributes:

    Class Attributes

    Style Attributes

    Practical Use Cases

    To better illustrate attribute bindings, here are several practical scenarios showcasing different binding techniques.

    1. Dynamic Class Binding

    Example: Toggling CSS Classes

    Result: The div will have the class active when isActive is true and inactive when false. Calling toggleStatus() toggles the class.

    2. Styling Elements Dynamically

    Example: Binding Inline Styles

    Result: The div's background color reflects the current value of bgColor. Invoking changeColor('coral') will update the background to coral.

    3. Conditional Attribute Rendering

    Example: Conditionally Setting the required Attribute

    Result: The input field will be required based on the isEmailRequired property. Toggling this property will add or remove the required attribute.

    Notes on Syntax

    While attribute binding in Aurelia is versatile and robust, there are certain syntactical nuances and limitations to be aware of to prevent unexpected behavior.

    1. Expression Syntax Restrictions

      • No Chaining with ; or ,: Expressions within ${} cannot be chained using semicolons ; or commas ,. Each interpolation expression should represent a single, complete expression.

      • Restricted Primitives and Operators: Certain JavaScript primitives and operators cannot be used within interpolation expressions. These include:

        • Boolean

        • String

        • instanceof

      • Usage of Pipe |: The pipe character | is reserved exclusively for Aurelia's value converters within bindings and cannot be used as a bitwise operator.

    2. Attribute Targeting Syntax

      The presence of both .bind and .attr syntaxes can be confusing. Here's why both exist:

      • Property vs. Attribute Binding: .bind targets the DOM property, which is suitable for standard attributes that have corresponding DOM properties. However, for custom or non-standard attributes that do not have direct property mappings, .attr is necessary to bind directly to the attribute itself.

    3. Choosing Between Interpolation and Keyword Binding

      Both interpolation and keyword binding can achieve similar outcomes. The choice between them often comes down to preference and specific use case requirements.

      • Performance and Features: There is no significant performance difference between the two. Both are equally efficient and offer similar capabilities.

      • Readability and Maintainability: Interpolation can be more readable for simple string concatenations, while keyword bindings offer more explicit control for complex bindings.

    For complex transformations or formatting, consider using Aurelia's value converters instead of embedding extensive logic within interpolation expressions. This practice enhances code maintainability and separation of concerns.

    Example: Using Value Converters for Formatting

    Binding with a Value Converter

    Result: Displays the totalPrice formatted as currency, e.g., "$199.99".

    Troubleshooting and Common Issues

    1. Binding Behavior Errors

    Error: AUR9994 - Invalid Binding Type for 'attr' Binding Behavior

    This error occurs when the attr binding behavior is used with non-property bindings:

    2. Null and Undefined Values

    When bound properties are null or undefined, attributes are removed from the DOM:

    3. Custom Attribute Conflicts

    When using .attr binding command, Aurelia bypasses custom attribute detection:

    4. Boolean Attribute Handling

    HTML boolean attributes (like disabled, checked, readonly) have special handling:

    5. SVG Attributes

    SVG attributes may require the attr binding behavior for proper functionality:

    Performance Considerations

    1. Binding Mode Selection

    Choose the appropriate binding mode for optimal performance:

    • Use .one-time for static values that never change

    • Use .to-view for display-only data

    • Use .two-way only when bidirectional synchronization is needed

    • Use .from-view for write-only scenarios

    2. Expression Complexity

    Keep binding expressions simple for better performance:

    3. Computed Properties

    Use computed properties with proper dependencies for efficient updates:

    Best Practices

    1. Consistent Naming

    Use consistent naming conventions for attributes and properties:

    2. Type Safety

    Leverage TypeScript for better development experience:

    3. Error Boundaries

    Handle potential errors in binding expressions:

    4. Accessibility

    Ensure proper accessibility attributes:

    Summary

    Attribute binding in Aurelia offers a flexible and powerful means to synchronize data between your view model and the DOM. By understanding and utilizing the various binding commands and techniques, you can create dynamic, responsive, and maintainable user interfaces. Always consider the specific needs of your project when choosing between different binding strategies, and leverage Aurelia's features to their fullest to enhance your application's interactivity and user experience.

    Key takeaways:

    • Use the appropriate binding mode for your use case

    • Understand the difference between property and attribute targeting

    • Leverage the attr binding behavior for custom attributes

    • Handle null/undefined values gracefully

    • Consider performance implications of complex expressions

    • Follow accessibility best practices

    • Use TypeScript for better development experience

    pattern
    and
    symbols
  • AttrSyntax: The parsed result containing binding information

  • SyntaxInterpreter: A finite state machine that efficiently parses attribute names

  • AttributeParser: Manages pattern registration and result caching

  • Pattern Priority System: Resolves conflicts when multiple patterns match

  • When to reach for attribute patterns

    Create an attribute pattern when the attribute name itself needs to convey extra meaning. Typical use cases include:

    • Porting syntaxes from other frameworks ([(value)], @click, :value, #ref).

    • Building DSLs where symbols separate intent (for example, data-track.click.once).

    • Collapsing multiple instructions into one attribute, such as emit:save or listen:customer.updated.

    If you simply need value.bind to default to two-way binding, prefer the attribute mapper. If you want to change how an attribute behaves after it has been parsed, reach for a binding command instead. Attribute patterns run before the mapper and binding commands, so they are ideal for inventing new syntaxes.

    Basic Pattern Definition

    AttributePatternDefinition Interface

    The PART Keyword

    PART in patterns represents dynamic segments that can match any characters except those defined in symbols. Think of PART as a flexible placeholder equivalent to the regex ([^symbols]+).

    Symbols Behavior

    The symbols property defines characters that:

    • Act as separators between pattern segments

    • Are excluded from PART matching

    • Can be used for readability and structure

    Example:

    • foo@bar → parts: ['foo', 'bar'] (with symbols)

    • Without symbols → parts: ['foo@', 'bar'] (without symbols)

    Pattern Class Structure

    Basic Pattern Class

    Note: AttrSyntax must be imported from @aurelia/template-compiler, not from the main aurelia package, as it's not currently re-exported there.

    Pattern Method Signature

    Each pattern method must:

    1. Have the exact same name as the pattern string

    2. Accept three required parameters:

      • rawName: string - Original attribute name (e.g., "[(value)]")

      • rawValue: string - Attribute value (e.g., "message")

      • parts: readonly string[] - Extracted PART values (e.g., ["value"])

    3. Return an AttrSyntax instance

    AttrSyntax Constructor

    The AttrSyntax class has the following constructor signature:

    AttrSyntax Parameters Explained

    Parameter
    Description
    Example

    rawName

    Original attribute name from template

    "[(value)]"

    rawValue

    Original attribute value

    "message"

    target

    The target property, element, or identifier

    "value"

    command

    Binding command type

    Common Binding Commands

    • 'bind' - One-way to view binding

    • 'to-view' - Explicit one-way to view

    • 'from-view' - One-way from view

    • 'two-way' - Two-way data binding

    • 'trigger' - Event binding

    • 'capture' - Event capture

    • 'ref' - Element/component reference

    • null - Custom or no specific command

    Pattern Registration

    Global Registration

    Register patterns globally at application startup:

    Local Registration

    Register patterns for specific components:

    Inline Pattern Definition

    For simple patterns, you can define them inline:

    Multiple Patterns per Class

    A single class can handle multiple related patterns:

    Pattern Priority System

    When multiple patterns could match the same attribute name, Aurelia uses a priority system:

    1. Static segments (exact text matches) have highest priority

    2. Dynamic segments (PART) have medium priority

    3. Symbol segments have lower priority

    Example Priority Resolution:

    Advanced Pattern Examples

    Event Modifiers

    Static Patterns (No PART)

    Complex Multi-PART Patterns

    Built-in Pattern Examples

    Aurelia includes several built-in patterns you can reference:

    Dot-Separated Patterns

    Shorthand Binding Patterns

    Framework Syntax Examples

    Angular-Style Patterns

    Vue-Style Patterns

    Performance Considerations

    Caching System

    The attribute parser maintains an internal cache of parsed interpretations. Once an attribute name is parsed, the result is cached for subsequent uses, improving template compilation performance.

    Pattern Optimization

    • Order Matters: More specific patterns should be defined first when possible

    • Symbol Selection: Choose symbols that don't conflict with common attribute patterns

    • Minimal Patterns: Avoid overly complex patterns that could match unintended attributes

    Registration Timing

    Patterns must be registered before template compilation begins. Late registration after the application starts may not take effect for already-compiled templates.

    Debugging and Error Handling

    Common Pattern Errors

    1. Missing Method: Pattern method name doesn't match pattern string exactly

    2. Wrong Signature: Method signature doesn't match required parameters

    3. Symbol Conflicts: Pattern symbols conflict with other registered patterns

    4. Registration Timing: Patterns registered after compilation begins

    Debugging Tips

    Pattern Testing

    Test your patterns with various attribute combinations:

    Integration with Template Compiler

    Attribute patterns integrate seamlessly with Aurelia's template compilation process:

    1. Template Analysis: The compiler scans for all attributes

    2. Pattern Matching: Each attribute name is tested against registered patterns

    3. Syntax Creation: Matching patterns create AttrSyntax objects

    4. Binding Generation: The compiler generates appropriate bindings based on the syntax

    5. Runtime Execution: Bindings execute during component lifecycle

    Working alongside binding commands and the attribute mapper

    • Attribute patterns decide the final target and command for an attribute. They are the only hook that can rewrite foo.bar.baz into whatever structure you need.

    • Binding commands use that parsed information to produce instructions. If your pattern returns command: 'permission', the binding command named permission will receive the attribute.

    • Attribute mapper only runs when command === 'bind'. If your pattern emits 'bind', the mapper can still remap value.bind to value.two-way or translate attribute names into DOM properties.

    Design patterns so they hand off clear targets and commands to the downstream pipeline. When in doubt, log the resulting AttrSyntax objects while authoring your pattern to confirm the values that later hooks will see.

    Best Practices

    Pattern Design

    1. Intuitive Syntax: Create patterns that feel natural to developers

    2. Consistent Naming: Follow consistent conventions across related patterns

    3. Clear Symbols: Use symbols that clearly separate pattern parts

    4. Avoid Conflicts: Test patterns against existing Aurelia syntax

    Registration Strategy

    1. Global vs Local: Use global registration for widely-used patterns, local for component-specific ones

    2. Bundle Size: Consider the impact of registering many patterns globally

    3. Tree Shaking: Local registration helps with tree shaking unused patterns

    Error Recovery

    1. Graceful Fallback: Design patterns to fail gracefully when they don't match

    2. Clear Errors: Provide meaningful error messages in pattern methods

    3. Validation: Validate pattern inputs and provide helpful feedback

    Complete Examples

    Custom Framework Integration

    Advanced Component System

    The attribute pattern system provides unlimited flexibility for creating custom template syntaxes that fit your team's needs or emulate familiar patterns from other frameworks, all while maintaining full integration with Aurelia's binding and compilation systems.

    Quick Reference Cheatsheet

    Here's a corrected cheatsheet with working examples:

    Next steps

    • Pair attribute patterns with the attribute mapper when you need to translate new syntaxes into existing DOM APIs.

    • Continue with Extending templating syntax to see how patterns, mappers, and observers work together end-to-end.

    • Explore custom binding commands whenever your pattern should hand off to bespoke runtime behavior instead of the default bind/two-way commands.

    Reactivity

    Aurelia's reactivity system automatically tracks changes to your data and updates the UI efficiently. Unlike frameworks that use virtual DOM, Aurelia observes your data directly and surgically updates only what has changed.

    When to Use Which Reactivity Feature?

    Aurelia offers several reactivity tools. Here's how to choose:

    Use simple properties (no decorator) when:

    • ✅ You only need UI updates - Properties bound in templates are automatically observed

    • ✅ Most common case - Just declare the property and bind it

    • ✅ Example: todos: Todo[] = [] with repeat.for="todo of todos"

    Use getters (computed) when:

    • ✅ Value depends on other properties and calculation is cheap

    • ✅ Automatic dependency tracking - no manual configuration needed

    • ✅ Example: get fullName() { return this.firstName + ' ' + this.lastName; }

    Use @computed decorator when:

    • ✅ Expensive calculations that should be cached

    • ✅ You want to explicitly control dependencies (not automatic)

    • ✅ Deep observation needed for nested objects

    • ✅ Example: Complex filtering, heavy aggregations

    Use @observable when:

    • ✅ You need to run code when a property changes (side effects)

    • ✅ You want the propertyChanged(newValue, oldValue) callback

    • ✅ Examples: Validation, analytics tracking, syncing data

    Use watch() when:

    • ✅ Complex expressions - watching multiple properties or nested values

    • ✅ Need more flexibility than @observable

    • ✅ Examples: @watch('user.address.city'), @watch(vm => vm.total > 100)

    Use manual observation when:

    • ✅ Building libraries or advanced features

    • ✅ Need fine-grained control over subscription lifecycle

    • ✅ Performance critical code requiring optimization

    Automatic Change Detection

    Aurelia automatically observes properties that are bound in your templates. No decorators or special setup required:

    The ${todos.length}, value.bind="filter", and repeat.for="todo of todos" create automatic observation - Aurelia tracks changes to these properties and updates the UI accordingly.

    Computed Properties

    Getter properties automatically become reactive when their dependencies change:

    Decorator computed

    For some reason, it's more preferrable to specify dependencies of a getter manually, rather than automatically tracked on read, you can use the decorator @computed to declare the dependencies, like the following example:

    You can also specify multiple properties as dependencies, like the following example:

    Basides the above basic usages, the computed decorator also supports a few more options, depending on the needs of an application.

    Flush timing with flush

    Like how you can specify flush mode of computed getter with @computed({ flush: 'sync' }), flush mode of @computed can also be done in a similar way, like the following example:

    Deep observation with deep

    Sometimes you also want to automatically observe all properties of an object recursively, regardless at what level, deep option on the @computed decorator can be used to achieve this goal, like the following example:

    Now whenever _cart.items[].price or _cart.items[].quantity (or whatever else properties on each element in the items array), or _cart.gst changes, the total is considered dirty.

    [!WARNING] deep observation doesn't observe non-existent properties, which means newly added properties won't trigger any changes notification. Replace the entire object instead.

    Deep Observation

    Aurelia can observe nested object changes:

    Array Observation

    Arrays are automatically observed for mutations:

    When You Need @observable

    The @observable decorator is only needed when you want to react to property changes in your view-model code (not just the template):

    Effect Observation

    Create side effects that run when observed data changes:

    Manual Observation Control

    For advanced scenarios, manually control observation:

    Performance Considerations

    Aurelia's observation is highly optimized:

    • Batched Updates: Multiple changes are batched into single DOM updates

    • Surgical Updates: Only changed elements are updated, not entire component trees

    • Smart Detection: Observes only bound properties, not entire objects

    • No Virtual DOM: Direct DOM manipulation eliminates virtual DOM overhead

    Common Reactivity Patterns

    Pattern: Form Validation with @observable

    Use case: Validate input as the user types, show errors immediately.

    Why this works: @observable triggers validation automatically as users type. The isValid getter recomputes whenever errors change, enabling/disabling the submit button reactively.

    Pattern: Computed Filtering and Sorting

    Use case: Filter and sort a list based on user input without re-fetching data.

    Why this works: The filteredProducts getter automatically recomputes when any dependency changes. No manual refresh needed - the UI stays in sync with filters.

    Pattern: Syncing Data with @watch

    Use case: Keep related data in sync, like saving to localStorage or syncing with a server.

    Why this works: @watch observes both content and title, automatically saving changes. The pattern prevents data loss and provides user feedback.

    Pattern: Dependent Computations

    Use case: Chain computed properties where one depends on another.

    Why this works: Computed properties automatically form a dependency chain. When subtotal changes, discount updates, which updates afterDiscount, then tax, and finally total. All cascade automatically.

    Pattern: Optimized List Updates with @computed

    Use case: Expensive computations on large lists that should only recalculate when necessary.

    Why this works: @computed with explicit dependencies prevents unnecessary recalculations. Changing individual data point properties won't trigger recalculation - only changes to array length, date range, or metric do.

    Best Practices

    Choose the Right Tool

    • ✅ Start simple - Use plain properties and getters first

    • ✅ Add @observable only when you need side effects

    • ✅ Use @computed for expensive operations, not simple getters

    Keep Computations Pure

    • ✅ Computed getters should have no side effects

    • ✅ Same inputs should always produce same outputs

    • ❌ Don't modify state inside getters

    • ❌ Don't make API calls in computed properties

    Optimize Performance

    • ✅ Use @computed with explicit dependencies for expensive calculations

    • ✅ Debounce rapid changes (user input, scroll events)

    • ✅ Batch related updates together

    • ❌ Don't create unnecessary watchers

    Handle Async Operations

    • ✅ Use @watch or propertyChanged callbacks for async side effects

    • ✅ Track loading states during async operations

    • ✅ Handle errors gracefully

    • ❌ Don't make computed properties async

    What's Next

    • Learn about in detail

    • Explore for advanced reactive patterns

    • Understand strategies

    Modal Dialog

    Build a flexible modal dialog component with backdrop, animations, and focus management

    Learn to build a production-ready modal dialog with proper focus management, backdrop click handling, animations, and accessibility. Perfect for confirmations, forms, and detailed content displays.

    What We're Building

    A modal dialog that supports:

    • Open/close with smooth animations

    Visual Diagrams

    Visual diagrams to help understand Aurelia's templating system. Every diagram below is rendered with GitBook-friendly Mermaid so it stays legible in dark and light modes.

    Data Binding Flow

    One-Way Binding (View Model → View)

    npx makes aurelia
    # Name: todo-app
    # Select TypeScript
    cd todo-app
    npm run dev
    export interface Todo {
      id: string;
      title: string;
      description: string;
      category: Category;
      completed: boolean;
      createdAt: Date;
    }
    
    export type Category = 'work' | 'personal' | 'shopping';
    
    export const CATEGORIES: Category[] = ['work', 'personal', 'shopping'];
    
    export const CATEGORY_LABELS: Record<Category, string> = {
      work: 'Work',
      personal: 'Personal',
      shopping: 'Shopping'
    };
    
    export const CATEGORY_COLORS: Record<Category, string> = {
      work: '#3b82f6',
      personal: '#10b981',
      shopping: '#f59e0b'
    };
    import { DI } from 'aurelia';
    
    export const IStorageService = DI.createInterface<IStorageService>(
      'IStorageService',
      x => x.singleton(StorageService)
    );
    
    export interface IStorageService extends StorageService {}
    
    export class StorageService {
      private readonly STORAGE_KEY = 'aurelia-todos';
    
      saveTodos(todos: any[]): void {
        try {
          localStorage.setItem(this.STORAGE_KEY, JSON.stringify(todos));
        } catch (error) {
          console.error('Failed to save todos:', error);
        }
      }
    
      loadTodos(): any[] {
        try {
          const data = localStorage.getItem(this.STORAGE_KEY);
          return data ? JSON.parse(data) : [];
        } catch (error) {
          console.error('Failed to load todos:', error);
          return [];
        }
      }
    
      clearTodos(): void {
        localStorage.removeItem(this.STORAGE_KEY);
      }
    }
    import { resolve } from 'aurelia';
    import { IStorageService } from './storage-service';
    import { Todo, Category, CATEGORIES } from './models';
    
    export class MyApp {
      private readonly storage = resolve(IStorageService);
    
      todos: Todo[] = [];
      filterCategory: Category | 'all' = 'all';
      filterCompleted: 'all' | 'active' | 'completed' = 'all';
    
      constructor() {
        this.loadTodos();
      }
    
      // Computed property for filtered todos
      get filteredTodos(): Todo[] {
        let filtered = this.todos;
    
        // Filter by category
        if (this.filterCategory !== 'all') {
          filtered = filtered.filter(todo => todo.category === this.filterCategory);
        }
    
        // Filter by completion status
        if (this.filterCompleted === 'active') {
          filtered = filtered.filter(todo => !todo.completed);
        } else if (this.filterCompleted === 'completed') {
          filtered = filtered.filter(todo => todo.completed);
        }
    
        return filtered;
      }
    
      // Statistics computed properties
      get totalTodos(): number {
        return this.todos.length;
      }
    
      get activeTodos(): number {
        return this.todos.filter(todo => !todo.completed).length;
      }
    
      get completedTodos(): number {
        return this.todos.filter(todo => todo.completed).length;
      }
    
      get categories(): (Category | 'all')[] {
        return ['all', ...CATEGORIES];
      }
    
      // Todo operations
      addTodo(todo: Omit<Todo, 'id' | 'createdAt'>): void {
        const newTodo: Todo = {
          ...todo,
          id: crypto.randomUUID(),
          createdAt: new Date()
        };
    
        this.todos.push(newTodo);
        this.saveTodos();
      }
    
      toggleTodo(todo: Todo): void {
        todo.completed = !todo.completed;
        this.saveTodos();
      }
    
      deleteTodo(todo: Todo): void {
        const index = this.todos.indexOf(todo);
        if (index > -1) {
          this.todos.splice(index, 1);
          this.saveTodos();
        }
      }
    
      clearCompleted(): void {
        this.todos = this.todos.filter(todo => !todo.completed);
        this.saveTodos();
      }
    
      // Persistence
      private saveTodos(): void {
        this.storage.saveTodos(this.todos);
      }
    
      private loadTodos(): void {
        const loaded = this.storage.loadTodos();
        this.todos = loaded.map(todo => ({
          ...todo,
          createdAt: new Date(todo.createdAt)
        }));
      }
    }
    import { bindable } from 'aurelia';
    import { Category, CATEGORIES, CATEGORY_LABELS } from './models';
    
    export class TodoForm {
      @bindable onSubmit?: (data: any) => void;
    
      formData = {
        title: '',
        description: '',
        category: 'work' as Category
      };
    
      categories = CATEGORIES;
      categoryLabels = CATEGORY_LABELS;
    
      get isValid(): boolean {
        return this.formData.title.trim().length > 0;
      }
    
      handleSubmit(): void {
        if (!this.isValid) return;
    
        this.onSubmit?.({
          title: this.formData.title.trim(),
          description: this.formData.description.trim(),
          category: this.formData.category,
          completed: false
        });
    
        this.resetForm();
      }
    
      resetForm(): void {
        this.formData = {
          title: '',
          description: '',
          category: 'work'
        };
      }
    }
    <div class="todo-form">
      <h2>Add New Todo</h2>
      <form submit.trigger="handleSubmit()">
        <div class="form-group">
          <label for="title">Title *</label>
          <input
            id="title"
            type="text"
            value.bind="formData.title"
            placeholder="Enter todo title"
            required />
        </div>
    
        <div class="form-group">
          <label for="description">Description</label>
          <textarea
            id="description"
            value.bind="formData.description"
            placeholder="Optional description"
            rows="3"></textarea>
        </div>
    
        <div class="form-group">
          <label for="category">Category</label>
          <select id="category" value.bind="formData.category">
            <option repeat.for="cat of categories" value.bind="cat">
              ${categoryLabels[cat]}
            </option>
          </select>
        </div>
    
        <button type="submit" disabled.bind="!isValid">
          Add Todo
        </button>
      </form>
    </div>
    import { bindable } from 'aurelia';
    import { Todo, CATEGORY_LABELS, CATEGORY_COLORS } from './models';
    
    export class TodoItem {
      @bindable todo!: Todo;
      @bindable onToggle?: (todo: Todo) => void;
      @bindable onDelete?: (todo: Todo) => void;
    
      categoryLabels = CATEGORY_LABELS;
      categoryColors = CATEGORY_COLORS;
    
      get categoryColor(): string {
        return this.categoryColors[this.todo.category];
      }
    
      handleToggle(): void {
        this.onToggle?.(this.todo);
      }
    
      handleDelete(): void {
        if (confirm(`Delete "${this.todo.title}"?`)) {
          this.onDelete?.(this.todo);
        }
      }
    
      get formattedDate(): string {
        return this.todo.createdAt.toLocaleDateString();
      }
    }
    <div class="todo-item ${todo.completed ? 'completed' : ''}">
      <div class="todo-content">
        <label class="todo-checkbox">
          <input
            type="checkbox"
            checked.bind="todo.completed"
            change.trigger="handleToggle()" />
          <span class="checkmark"></span>
        </label>
    
        <div class="todo-details">
          <h3 class="todo-title">${todo.title}</h3>
          <p if.bind="todo.description" class="todo-description">
            ${todo.description}
          </p>
          <div class="todo-meta">
            <span class="todo-category" style="background-color: ${categoryColor}">
              ${categoryLabels[todo.category]}
            </span>
            <span class="todo-date">${formattedDate}</span>
          </div>
        </div>
      </div>
    
      <button
        class="delete-btn"
        click.trigger="handleDelete()"
        title="Delete todo">
        ×
      </button>
    </div>
    <import from="./todo-form"></import>
    <import from="./todo-item"></import>
    
    <div class="app">
      <header class="app-header">
        <h1>📝 Aurelia Todo App</h1>
        <div class="stats">
          <span class="stat">Total: ${totalTodos}</span>
          <span class="stat">Active: ${activeTodos}</span>
          <span class="stat">Completed: ${completedTodos}</span>
        </div>
      </header>
    
      <main class="app-main">
        <div class="sidebar">
          <todo-form on-submit.call="addTodo($event)"></todo-form>
        </div>
    
        <div class="content">
          <!-- Filters -->
          <div class="filters">
            <div class="filter-group">
              <label>Category:</label>
              <select value.bind="filterCategory">
                <option value="all">All Categories</option>
                <option repeat.for="cat of categories" value.bind="cat">
                  ${cat === 'all' ? 'All' : cat}
                </option>
              </select>
            </div>
    
            <div class="filter-group">
              <label>Status:</label>
              <select value.bind="filterCompleted">
                <option value="all">All</option>
                <option value="active">Active</option>
                <option value="completed">Completed</option>
              </select>
            </div>
    
            <button
              if.bind="completedTodos > 0"
              click.trigger="clearCompleted()"
              class="clear-btn">
              Clear Completed
            </button>
          </div>
    
          <!-- Todo List -->
          <div class="todo-list">
            <div if.bind="filteredTodos.length === 0" class="empty-state">
              <p>No todos found!</p>
              <small if.bind="filterCategory !== 'all' || filterCompleted !== 'all'">
                Try changing your filters
              </small>
            </div>
    
            <todo-item
              repeat.for="todo of filteredTodos; key: id"
              todo.bind="todo"
              on-toggle.call="toggleTodo($event)"
              on-delete.call="deleteTodo($event)">
            </todo-item>
          </div>
        </div>
      </main>
    </div>
    * {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }
    
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      background: #f5f5f5;
      color: #333;
    }
    
    .app {
      max-width: 1200px;
      margin: 0 auto;
      padding: 2rem;
    }
    
    .app-header {
      background: white;
      padding: 2rem;
      border-radius: 8px;
      margin-bottom: 2rem;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }
    
    .app-header h1 {
      margin-bottom: 1rem;
    }
    
    .stats {
      display: flex;
      gap: 2rem;
    }
    
    .stat {
      font-size: 0.9rem;
      color: #666;
    }
    
    .app-main {
      display: grid;
      grid-template-columns: 350px 1fr;
      gap: 2rem;
    }
    
    /* Todo Form */
    .todo-form {
      background: white;
      padding: 1.5rem;
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }
    
    .todo-form h2 {
      font-size: 1.2rem;
      margin-bottom: 1rem;
    }
    
    .form-group {
      margin-bottom: 1rem;
    }
    
    .form-group label {
      display: block;
      margin-bottom: 0.5rem;
      font-weight: 500;
    }
    
    .form-group input,
    .form-group textarea,
    .form-group select {
      width: 100%;
      padding: 0.5rem;
      border: 1px solid #ddd;
      border-radius: 4px;
      font-size: 1rem;
    }
    
    button {
      padding: 0.75rem 1.5rem;
      background: #3b82f6;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 1rem;
    }
    
    button:hover:not(:disabled) {
      background: #2563eb;
    }
    
    button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
    
    /* Filters */
    .filters {
      background: white;
      padding: 1.5rem;
      border-radius: 8px;
      margin-bottom: 1rem;
      display: flex;
      gap: 1rem;
      align-items: flex-end;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }
    
    .filter-group {
      flex: 1;
    }
    
    .filter-group label {
      display: block;
      margin-bottom: 0.5rem;
      font-size: 0.9rem;
      font-weight: 500;
    }
    
    .filter-group select {
      width: 100%;
      padding: 0.5rem;
      border: 1px solid #ddd;
      border-radius: 4px;
    }
    
    .clear-btn {
      background: #ef4444;
    }
    
    .clear-btn:hover {
      background: #dc2626;
    }
    
    /* Todo List */
    .todo-list {
      display: flex;
      flex-direction: column;
      gap: 0.75rem;
    }
    
    .empty-state {
      background: white;
      padding: 3rem;
      border-radius: 8px;
      text-align: center;
      color: #999;
    }
    
    /* Todo Item */
    .todo-item {
      background: white;
      padding: 1rem;
      border-radius: 8px;
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
      transition: transform 0.2s;
    }
    
    .todo-item:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
    }
    
    .todo-item.completed {
      opacity: 0.6;
    }
    
    .todo-content {
      display: flex;
      gap: 1rem;
      flex: 1;
    }
    
    .todo-checkbox {
      cursor: pointer;
      position: relative;
    }
    
    .todo-checkbox input {
      cursor: pointer;
    }
    
    .todo-details {
      flex: 1;
    }
    
    .todo-title {
      font-size: 1.1rem;
      margin-bottom: 0.25rem;
    }
    
    .todo-item.completed .todo-title {
      text-decoration: line-through;
    }
    
    .todo-description {
      color: #666;
      font-size: 0.9rem;
      margin-bottom: 0.5rem;
    }
    
    .todo-meta {
      display: flex;
      gap: 1rem;
      align-items: center;
    }
    
    .todo-category {
      font-size: 0.75rem;
      padding: 0.25rem 0.5rem;
      border-radius: 4px;
      color: white;
      font-weight: 500;
    }
    
    .todo-date {
      font-size: 0.8rem;
      color: #999;
    }
    
    .delete-btn {
      background: transparent;
      color: #ef4444;
      border: 1px solid #ef4444;
      width: 32px;
      height: 32px;
      padding: 0;
      font-size: 1.5rem;
      line-height: 1;
    }
    
    .delete-btn:hover {
      background: #ef4444;
      color: white;
    }
    
    @media (max-width: 768px) {
      .app-main {
        grid-template-columns: 1fr;
      }
    
      .filters {
        flex-direction: column;
        align-items: stretch;
      }
    }
    <div attribute-name.bind="value"></div>
    <!-- my-app.html -->
    <div title.bind="tooltipText">Hover over me!</div>
    // my-app.ts
    export class MyApp {
      tooltipText = 'This is a tooltip';
    }
    <!-- my-app.html -->
    <div>
      <h1 id="${headingId}">Dynamic Heading</h1>
    </div>
    // my-app.ts
    export class MyApp {
      headingId = 'main-heading';
    }
    <!-- my-app.html -->
    <!-- Two-way binding: changes in input update 'firstName' and vice versa -->
    <input type="text" value.two-way="firstName" placeholder="First Name">
    
    <!-- One-way binding: changes in 'lastName' update the input, but not vice versa -->
    <input type="text" value.one-way="lastName" placeholder="Last Name">
    
    <!-- One-time binding: input value is set once from 'middleName' -->
    <input type="text" value.one-time="middleName" placeholder="Middle Name">
    
    <!-- Binding a link's href attribute using to-view -->
    <a href.to-view="profile.blogUrl">Blog</a>
    
    <!-- Binding a link's href attribute using one-time -->
    <a href.one-time="profile.twitterUrl">Twitter</a>
    
    <!-- Binding a link's href attribute using bind (auto mode) -->
    <a href.bind="profile.linkedInUrl">LinkedIn</a>
    // my-app.ts
    export class MyApp {
      firstName = 'John';
      lastName = 'Doe';
      middleName = 'A.';
      profile = {
        blogUrl: 'https://johnsblog.com',
        twitterUrl: 'https://twitter.com/johndoe',
        linkedInUrl: 'https://linkedin.com/in/johndoe'
      };
    }
    <!-- These two lines are equivalent -->
    <input value.bind="firstName">
    <input :value="firstName">
    
    <!-- Works with any attribute -->
    <img :src="profile.avatarUrl" :alt="profile.fullName">
    <!-- my-app.html -->
    <img src.bind="imageSrc" alt.bind="imageAlt" />
    // my-app.ts
    export class MyApp {
      imageSrc = 'https://example.com/image.jpg';
      imageAlt = 'Example Image';
    }
    <!-- my-app.html -->
    <button disabled.bind="isButtonDisabled">Submit</button>
    <input type="text" disabled.bind="isInputDisabled" placeholder="Enter text" />
    // my-app.ts
    export class MyApp {
      isButtonDisabled = true;
      isInputDisabled = false;
    
      toggleButton() {
        this.isButtonDisabled = !this.isButtonDisabled;
      }
    
      toggleInput() {
        this.isInputDisabled = !this.isInputDisabled;
      }
    }
    <!-- my-app.html -->
    <div innerhtml.bind="htmlContent"></div>
    <div textcontent.bind="plainText"></div>
    // my-app.ts
    export class MyApp {
      htmlContent = '<strong>This is bold text.</strong>';
      plainText = '<strong>This is not bold text.</strong>';
    }
    <!-- Render a section, but run it through the <page-card> custom element -->
    <section as-element="page-card" header.bind="title">
      <p>Projected slot content still works as usual.</p>
    </section>
    <!-- my-app.html -->
    <input value.bind="userName" />
    // my-app.ts
    export class MyApp {
      userName = 'JaneDoe';
    }
    <!-- my-app.html -->
    <input my-custom-attr.attr="customValue" />
    // my-app.ts
    export class MyApp {
      customValue = 'Custom Attribute Value';
    }
    <!-- my-app.html -->
    <input pattern.bind="patternProp & attr" />
    <div data-tooltip.bind="tooltipText & attr"></div>
    <svg>
      <circle cx.bind="centerX & attr" cy.bind="centerY & attr" r="50" />
    </svg>
    // my-app.ts
    export class MyApp {
      patternProp = '[A-Za-z]{3,}';
      tooltipText = 'This is a custom tooltip';
      centerX = 100;
      centerY = 100;
    }
    // main.ts
    import { Aurelia, AppTask, IAttrMapper } from '@aurelia/runtime-html';
    
    Aurelia
      .register(
        AppTask.creating(IAttrMapper, (attrMapper) => {
          attrMapper.useMapping({
            'CUSTOM-INPUT': {
              'max-length': 'maxLength',
              'min-length': 'minLength'
            }
          });
    
          attrMapper.useGlobalMapping({
            'custom-attr': 'customAttribute'
          });
    
          attrMapper.useTwoWay(
            (element, attr) => element.tagName === 'CUSTOM-INPUT' && attr === 'value'
          );
        })
      )
      .app(MyApp)
      .start();
    <!-- Single class binding -->
    <div class.bind="isActive && 'active'"></div>
    
    <!-- Multiple class binding -->
    <div class.bind="getClasses()"></div>
    <!-- Style property binding -->
    <div style.background-color.bind="bgColor"></div>
    <div style.font-size.bind="fontSize + 'px'"></div>
    
    <!-- Full style object binding -->
    <div style.bind="styleObject"></div>
    export class MyApp {
      bgColor = 'red';
      fontSize = 16;
      styleObject = {
        color: 'blue',
        'font-weight': 'bold',
        margin: '10px'
      };
    
      getClasses() {
        return this.isActive ? 'active highlight' : 'inactive';
      }
    }
    <!-- my-app.html -->
    <div class.bind="isActive ? 'active' : 'inactive'">Status</div>
    // my-app.ts
    export class MyApp {
      isActive = true;
    
      toggleStatus() {
        this.isActive = !this.isActive;
      }
    }
    <!-- my-app.html -->
    <div style.backgroundColor.bind="bgColor">Colored Box</div>
    // my-app.ts
    export class MyApp {
      bgColor = 'lightblue';
    
      changeColor(newColor: string) {
        this.bgColor = newColor;
      }
    }
    <!-- my-app.html -->
    <input type="email" required.bind="isEmailRequired" placeholder="Enter your email" />
    // my-app.ts
    export class MyApp {
      isEmailRequired = true;
    
      toggleEmailRequirement() {
        this.isEmailRequired = !this.isEmailRequired;
      }
    }
    <!-- my-app.html -->
    <span class="price">${totalPrice | currency}</span>
    // my-app.ts
    export class MyApp {
      totalPrice = 199.99;
    }
    // currency-value-converter.ts
    export class CurrencyValueConverter {
      toView(value: number) {
        return `$${value.toFixed(2)}`;
      }
    }
    <!-- ❌ Incorrect: Using attr with event binding -->
    <button click.trigger="save() & attr">Save</button>
    
    <!-- ✅ Correct: Remove attr from event binding -->
    <button click.trigger="save()">Save</button>
    
    <!-- ✅ Correct: Use attr with property binding -->
    <input value.bind="query & attr">
    export class MyApp {
      tooltipText: string | null = null; // Will remove title attribute
      isDisabled: boolean | undefined = undefined; // Will remove disabled attribute
    }
    <!-- These attributes will be removed when values are null/undefined -->
    <div title.bind="tooltipText">Content</div>
    <button disabled.bind="isDisabled">Click me</button>
    <!-- This will create a DOM attribute, NOT invoke a custom attribute -->
    <div my-custom.attr="value"></div>
    
    <!-- This will invoke the custom attribute -->
    <div my-custom.bind="value"></div>
    <!-- These all result in disabled="disabled" or no attribute -->
    <button disabled.bind="true">Always Disabled</button>
    <button disabled.bind="false">Never Disabled</button>
    <button disabled.bind="isDisabled">Conditionally Disabled</button>
    <svg>
      <!-- For SVG-specific attributes, use attr binding behavior -->
      <circle cx.bind="x & attr" cy.bind="y & attr" r.bind="radius & attr" />
      <text text-anchor.bind="anchor & attr">Label</text>
    </svg>
    <!-- ❌ Complex expression in template -->
    <div class.bind="items.filter(i => i.active).length > 0 ? 'has-active' : 'no-active'"></div>
    
    <!-- ✅ Move logic to view model -->
    <div class.bind="hasActiveItems ? 'has-active' : 'no-active'"></div>
    export class MyApp {
      get hasActiveItems() {
        return this.items.some(i => i.active);
      }
    }
    import { computed } from '@aurelia/runtime';
    
    export class MyApp {
      items = [];
    
      @computed({ dependencies: ['items.length'] })
      get itemCount() {
        return this.items.length;
      }
    }
    <!-- Use kebab-case for attributes -->
    <my-component data-id.bind="itemId" custom-prop.bind="value"></my-component>
    interface User {
      id: number;
      name: string;
      avatar?: string;
    }
    
    export class UserProfile {
      user: User = { id: 1, name: 'John' };
    
      get avatarUrl(): string {
        return this.user.avatar ?? '/default-avatar.png';
      }
    }
    <!-- Use optional chaining and fallbacks -->
    <img src.bind="user?.avatar ?? defaultAvatar" alt.bind="user?.name ?? 'Unknown User'">
    <!-- Include ARIA attributes for screen readers -->
    <button
      disabled.bind="isLoading"
      aria-busy.bind="isLoading & attr"
      aria-label.bind="buttonLabel & attr">
      ${isLoading ? 'Loading...' : 'Submit'}
    </button>
    export interface AttributePatternDefinition {
      pattern: string;   // Pattern with PART placeholders
      symbols: string;   // Characters treated as separators/delimiters
    }
    { pattern: 'foo@PART', symbols: '@' }
    import { attributePattern, AttrSyntax } from '@aurelia/template-compiler';
    
    @attributePattern({ pattern: '[(PART)]', symbols: '[()]' })
    export class AngularTwoWayBindingAttributePattern {
      public ['[(PART)]'](rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'two-way');
      }
    }
    export class AttrSyntax {
      public constructor(
        public rawName: string,      // Original attribute name
        public rawValue: string,     // Original attribute value
        public target: string,       // Target property/element
        public command: string | null, // Binding command
        public parts: readonly string[] | null = null // Additional parts for complex patterns
      ) {}
    }
    import { Aurelia } from 'aurelia';
    import { AngularTwoWayBindingAttributePattern } from './patterns/angular-patterns';
    
    Aurelia
      .register(AngularTwoWayBindingAttributePattern)
      .app(MyApp)
      .start();
    import { customElement } from '@aurelia/runtime-html';
    import { AngularTwoWayBindingAttributePattern } from './patterns/angular-patterns';
    
    @customElement({
      name: 'my-component',
      template: '<input [(value)]="message">',
      dependencies: [AngularTwoWayBindingAttributePattern]
    })
    export class MyComponent {
      public message = 'Hello World';
    }
    import { AttributePattern } from '@aurelia/template-compiler';
    
    @customElement({
      name: 'my-component',
      template: '<input !value="message">',
      dependencies: [
        // Define pattern inline and register directly
        (() => {
          @attributePattern({ pattern: '!PART', symbols: '!' })
          class InlineExclamationPattern {
            '!PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
              return new AttrSyntax(rawName, rawValue, parts[0], 'bind');
            }
          }
          return InlineExclamationPattern;
        })()
      ]
    })
    @attributePattern(
      { pattern: 'PART#PART', symbols: '#' }, // view-model#uploadVM
      { pattern: '#PART', symbols: '#' }      // #uploadInput
    )
    export class AngularSharpRefAttributePattern {
      public ['PART#PART'](rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, parts[1], parts[0], 'ref');
      }
    
      public ['#PART'](rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, parts[0], 'element', 'ref');
      }
    }
    // Given patterns: 'PART.PART', 'value.PART', 'PART.bind'
    // For attribute 'value.bind':
    // - 'value.PART' matches with 1 static + 1 dynamic = higher priority
    // - 'PART.bind' matches with 1 dynamic + 1 static = same priority
    // - 'PART.PART' matches with 2 dynamic = lower priority
    // Result: First pattern with highest static count wins
    @attributePattern(
      { pattern: 'PART.trigger:PART', symbols: '.:' },
      { pattern: 'PART.capture:PART', symbols: '.:' }
    )
    export class EventModifierPattern {
      public 'PART.trigger:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'trigger', parts);
      }
    
      public 'PART.capture:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'capture', parts);
      }
    }
    @attributePattern(
      { pattern: 'promise.resolve', symbols: '' },
      { pattern: 'promise.catch', symbols: '' },
      { pattern: 'ref', symbols: '' }
    )
    export class StaticPatterns {
      public 'promise.resolve'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, rawValue, 'promise-resolve');
      }
    
      public 'promise.catch'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, rawValue, 'promise-catch');
      }
    
      public 'ref'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, 'element', 'ref');
      }
    }
    @attributePattern({ pattern: 'PART.PART.PART', symbols: '.' })
    export class ThreePartPattern {
      public 'PART.PART.PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        // For something like 'user.address.street.bind'
        // parts = ['user', 'address', 'street', 'bind']
        const target = `${parts[0]}.${parts[1]}.${parts[2]}`;
        return new AttrSyntax(rawName, rawValue, target, parts[3]);
      }
    }
    // Built-in: handles 'value.bind', 'checked.two-way', etc.
    @attributePattern(
      { pattern: 'PART.PART', symbols: '.' },
      { pattern: 'PART.PART.PART', symbols: '.' }
    )
    export class DotSeparatedAttributePattern {
      public 'PART.PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], parts[1]);
      }
    
      public 'PART.PART.PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, `${parts[0]}.${parts[1]}`, parts[2]);
      }
    }
    // Built-in: handles ':value', '@click', etc.
    @attributePattern({ pattern: ':PART', symbols: ':' })
    export class ColonPrefixedBindAttributePattern {
      public ':PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'bind');
      }
    }
    
    @attributePattern(
      { pattern: '@PART', symbols: '@' },
      { pattern: '@PART:PART', symbols: '@:' }
    )
    export class AtPrefixedTriggerAttributePattern {
      public '@PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'trigger');
      }
    
      public '@PART:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'trigger', [parts[0], 'trigger', ...parts.slice(1)]);
      }
    }
    // Angular ref syntax: #myInput
    @attributePattern({ pattern: '#PART', symbols: '#' })
    export class AngularRefPattern {
      public '#PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, parts[0], 'element', 'ref');
      }
    }
    
    // Angular property binding: [value]
    @attributePattern({ pattern: '[PART]', symbols: '[]' })
    export class AngularPropertyBinding {
      public '[PART]'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'bind');
      }
    }
    
    // Angular event binding: (click)
    @attributePattern({ pattern: '(PART)', symbols: '()' })
    export class AngularEventBinding {
      public '(PART)'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'trigger');
      }
    }
    
    // Angular two-way binding: [(ngModel)]
    @attributePattern({ pattern: '[(PART)]', symbols: '[()]' })
    export class AngularTwoWayBinding {
      public '[(PART)]'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'two-way');
      }
    }
    // Vue property binding: :value
    @attributePattern({ pattern: ':PART', symbols: ':' })
    export class VuePropertyBinding {
      public ':PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'bind');
      }
    }
    
    // Vue event binding: @click
    @attributePattern({ pattern: '@PART', symbols: '@' })
    export class VueEventBinding {
      public '@PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'trigger');
      }
    }
    
    // Vue v-model directive
    @attributePattern({ pattern: 'v-model', symbols: '' })
    export class VueModelDirective {
      public 'v-model'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, 'value', 'two-way');
      }
    }
    // Enable debug logging to see pattern matching
    import { LoggerConfiguration, LogLevel } from '@aurelia/kernel';
    
    Aurelia
      .register(LoggerConfiguration.create({ level: LogLevel.debug }))
      .register(MyPatternClass)
      .app(MyApp)
      .start();
    // Testing patterns is typically done through the DI container
    import { DI } from '@aurelia/kernel';
    import { ISyntaxInterpreter, IAttributePattern } from '@aurelia/template-compiler';
    
    // Create a container and register your pattern
    const container = DI.createContainer();
    container.register(MyPatternClass);
    
    const interpreter = container.get(ISyntaxInterpreter);
    const attrPattern = container.get(IAttributePattern);
    
    // Test pattern interpretation
    const result = interpreter.interpret('[(value)]');
    if (result.pattern) {
      console.log('Pattern matched:', result.pattern);
      console.log('Parts:', result.parts);
    }
    // Complete React-like pattern system
    @attributePattern(
      { pattern: 'className', symbols: '' },
      { pattern: 'onClick', symbols: '' },
      { pattern: 'onChange', symbols: '' }
    )
    export class ReactLikePatterns {
      public 'className'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, 'class', 'bind');
      }
    
      public 'onClick'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, 'click', 'trigger');
      }
    
      public 'onChange'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, 'change', 'trigger');
      }
    }
    // Advanced pattern for component communication
    @attributePattern(
      { pattern: 'emit:PART', symbols: ':' },
      { pattern: 'listen:PART', symbols: ':' },
      { pattern: 'sync:PART', symbols: ':' }
    )
    export class ComponentCommunicationPatterns {
      public 'emit:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'emit-event');
      }
    
      public 'listen:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'listen-event');
      }
    
      public 'sync:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'sync-prop');
      }
    }
    // attr-patterns.ts
    
    import { attributePattern, AttrSyntax } from '@aurelia/template-compiler';
    
    // Angular-style patterns
    
    @attributePattern({ pattern: '#PART', symbols: '#' })
    export class AngularSharpRefAttributePattern {
      public '#PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, parts[0], 'element', 'ref');
      }
    }
    
    @attributePattern({ pattern: '[PART]', symbols: '[]' })
    export class AngularOneWayBindingAttributePattern {
      public '[PART]'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'bind');
      }
    }
    
    @attributePattern({ pattern: '(PART)', symbols: '()' })
    export class AngularEventBindingAttributePattern {
      public '(PART)'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'trigger');
      }
    }
    
    @attributePattern({ pattern: '[(PART)]', symbols: '[()]' })
    export class AngularTwoWayBindingAttributePattern {
      public '[(PART)]'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'two-way');
      }
    }
    
    // Vue-style patterns
    
    @attributePattern({ pattern: ':PART', symbols: ':' })
    export class VueOneWayBindingAttributePattern {
      public ':PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'bind');
      }
    }
    
    @attributePattern({ pattern: '@PART', symbols: '@' })
    export class VueEventBindingAttributePattern {
      public '@PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'trigger');
      }
    }
    
    @attributePattern({ pattern: 'v-model', symbols: '' })
    export class VueTwoWayBindingAttributePattern {
      public 'v-model'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, 'value', 'two-way');
      }
    }
    
    // Custom patterns
    
    @attributePattern({ pattern: '::PART', symbols: '::' })
    export class DoubleColonTwoWayBindingAttributePattern {
      public '::PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
        return new AttrSyntax(rawName, rawValue, parts[0], 'two-way');
      }
    }
    // main.ts
    
    import { Aurelia } from 'aurelia';
    import {
      AngularEventBindingAttributePattern,
      AngularOneWayBindingAttributePattern,
      AngularSharpRefAttributePattern,
      AngularTwoWayBindingAttributePattern,
      DoubleColonTwoWayBindingAttributePattern,
      VueEventBindingAttributePattern,
      VueOneWayBindingAttributePattern,
      VueTwoWayBindingAttributePattern
    } from './attr-patterns';
    
    Aurelia
      .register(
        AngularSharpRefAttributePattern,
        AngularOneWayBindingAttributePattern,
        AngularEventBindingAttributePattern,
        AngularTwoWayBindingAttributePattern,
        VueOneWayBindingAttributePattern,
        VueEventBindingAttributePattern,
        VueTwoWayBindingAttributePattern,
        DoubleColonTwoWayBindingAttributePattern
      )
      .app(MyApp)
      .start();

    "two-way", "bind", "trigger", "ref"

    parts

    Additional parts for complex patterns

    For event modifiers, extended syntax

    List Rendering

    typeof

  • Bitwise operators (except for the pipe | used with value converters)

  • Example: Binding id Using Property and Attribute

    Result:

    • Using .bind, Aurelia binds to the id property of the input element.

    • Using .attr, Aurelia binds directly to the id attribute in the DOM.

    ❌ Don't over-engineer - most scenarios don't need @watch or manual observation
    observing property changes
    effect observation
    watching data

    Backdrop click to close (optional)

  • Escape key to close

  • Focus trap (keyboard focus stays within modal)

  • Return focus to trigger when closed

  • Accessible with ARIA attributes

  • Portal rendering (renders outside parent context)

  • Scrollable content

  • Component Code

    modal-dialog.ts

    modal-dialog.html

    modal-dialog.css

    Usage Examples

    Basic Modal

    Confirmation Dialog

    Form Modal

    Full-Screen Modal

    Testing

    Accessibility Features

    This modal follows WCAG 2.1 guidelines:

    • ✅ Focus Trap: Tab key cycles through focusable elements within modal

    • ✅ Focus Management: Focuses first element when opened, returns focus when closed

    • ✅ Keyboard Support: Escape key closes modal

    • ✅ ARIA Attributes: role="dialog", aria-modal="true" for screen readers

    • ✅ Body Scroll Lock: Prevents scrolling background content

    Enhancements

    1. Add Transition Animations

    Use Aurelia's animation system for smoother transitions:

    2. Add Confirmation Before Close

    3. Add Modal Service

    Create a global modal service for programmatic modals:

    Best Practices

    1. Focus Management: Always return focus to the trigger element

    2. Body Scroll: Lock body scroll to prevent confusion

    3. Escape Key: Always allow Escape to close (unless critical action)

    4. Backdrop Click: Make it configurable, disable for forms with unsaved changes

    5. Portal Rendering: For complex apps, render modals in a portal at document root

    6. Stacking: Support multiple modals with z-index management

    Summary

    You've built a fully-featured modal dialog with:

    • ✅ Smooth animations

    • ✅ Focus trap and management

    • ✅ Keyboard support

    • ✅ Accessible markup

    • ✅ Multiple sizes

    • ✅ Customizable behavior

    This modal is production-ready and handles all common use cases!

    <!-- Property Binding -->
    <input id.bind="inputId" />
    
    <!-- Attribute Binding -->
    <input id.attr="inputId" />
    // my-app.ts
    export class MyApp {
      inputId = 'user-input';
    }
    export class TodoApp {
      todos: Todo[] = [];
      filter: string = 'all';
    
      addTodo(text: string) {
        // UI updates automatically when todos changes
        this.todos.push({ id: Date.now(), text, completed: false });
      }
    
      removeTodo(index: number) {
        // UI updates automatically
        this.todos.splice(index, 1);
      }
    }
    <div>
      <h2>Todos (${todos.length})</h2>
      <input value.bind="filter" placeholder="Filter todos">
      <ul>
        <li repeat.for="todo of todos" if.bind="shouldShow(todo)">
          ${todo.text}
          <button click.trigger="removeTodo($index)">Remove</button>
        </li>
      </ul>
    </div>
    export class ShoppingCart {
      items: CartItem[] = [];
      
      get total() {
        // This computed property automatically updates when items change
        return this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
      }
    
      get itemCount() {
        // Also reactive - updates when items array changes
        return this.items.length;
      }
    
      addItem(product: Product, quantity: number = 1) {
        // UI updates automatically for total, itemCount, and items display
        this.items.push({ ...product, quantity });
      }
    }
    <div class="cart">
      <h3>Cart (${itemCount} items)</h3>
      <div repeat.for="item of items" class="cart-item">
        <span>${item.name}</span>
        <span>$${item.price} x ${item.quantity}</span>
      </div>
      <div class="total">Total: $${total}</div>
    </div>
    import { computed } from 'aurelia';
    
    export class ShoppingCart {
      items: CartItem[] = [];
    
      // we only care when there's a change in the number of items
      // but not when the price or quantity of each item changes
      @computed('items.length')
      get total() {
        // This computed property automatically updates when items change
        return this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
      }
    
      // other code ...
    }
    import { computed } from 'aurelia';
    
    export class ShoppingCart {
      items: CartItem[] = [];
      gst = .1;
    
      // we only care when there's a change in the number of items
      // but not when the price or quantity of each item changes
      @computed('items.length', 'gst')
      get total() {
        // This computed property automatically updates when items change
        return this.items.reduce((sum, item) => sum + (item.price * this.tax * item.quantity), 0);
      }
    
      get tax() {
        return 1 + this.gst;
      }
    
      // other code ...
    }
    import { computed } from 'aurelia';
    
    export class ShoppingCart {
      items: CartItem[] = [];
      gst = .1;
    
      // we only care when there's a change in the number of items, or gst
      // but not when the price or quantity of each item changes
      @computed({
        deps: ['items.length', 'gst'],
        flush: 'sync'
      })
      get total() {
        // This computed property automatically updates when items change
        return this.items.reduce((sum, item) => sum + (item.price * this.tax * item.quantity), 0);
      }
    
      get tax() {
        return 1 + this.gst;
      }
    
      // other code ...
    }
    import { computed } from 'aurelia';
    
    export class ShoppingCart {
      _cart = {
        items = [],
        gst = .1,
      }
    
      // we care about any changes inside cart items, or gst
      @computed({
        deps: ['_cart'],
        deep: true,
      })
      get total() {
        // This computed property automatically updates when items change
        return this._cart.items.reduce((sum, item) => sum + (item.price * this._cart.gst * item.quantity), 0);
      }
    
      get tax() {
        return 1 + this.gst;
      }
    
      // other code ...
    }
    export class UserProfile {
      user = {
        name: 'John Doe',
        address: {
          street: '123 Main St',
          city: 'Anytown',
          country: 'USA'
        },
        preferences: {
          theme: 'dark',
          notifications: true
        }
      };
    
      updateAddress(newAddress: Partial<Address>) {
        // Nested property changes are automatically detected
        Object.assign(this.user.address, newAddress);
      }
    }
    export class TaskList {
      tasks: Task[] = [];
    
      addTask(task: Task) {
        this.tasks.push(task); // Automatically triggers UI update
      }
    
      completeTask(index: number) {
        this.tasks[index].completed = true; // Property change observed
      }
    
      removeTasks(indices: number[]) {
        // Multiple array changes batched into single UI update
        indices.sort((a, b) => b - a).forEach(index => {
          this.tasks.splice(index, 1);
        });
      }
    }
    import { observable } from 'aurelia';
    
    export class UserProfile {
      @observable userName: string = '';
    
      // This method is called whenever userName changes
      userNameChanged(newValue: string, oldValue: string) {
        console.log(`Username changed from ${oldValue} to ${newValue}`);
        this.validateUsername(newValue);
      }
    
      private validateUsername(name: string) {
        // Perform validation when username changes
      }
    }
    <!-- userName is still automatically observed for template updates -->
    <input value.bind="userName">
    <p>Hello, ${userName}!</p>
    import { watch } from 'aurelia';
    
    export class Analytics {
      currentPage: string = '/';
      user: User | null = null;
    
      constructor() {
        // Watch properties and react to changes
        watch(() => this.currentPage, (newPage) => {
          this.trackPageView(newPage);
        });
    
        watch(() => this.user, (newUser, oldUser) => {
          if (oldUser) this.trackUserLogout(oldUser);
          if (newUser) this.trackUserLogin(newUser);
        });
      }
    
      private trackPageView(page: string) {
        console.log(`Page view: ${page}`);
      }
    }
    import { resolve } from '@aurelia/kernel';
    import { IObserverLocator } from '@aurelia/runtime';
    
    export class AdvancedComponent {
      private observerLocator = resolve(IObserverLocator);
      data = { value: 0 };
    
      attached() {
        // Manually observe a property
        const observer = this.observerLocator.getObserver(this.data, 'value');
        observer.subscribe((newValue, oldValue) => {
          console.log(`Value changed: ${oldValue} -> ${newValue}`);
        });
      }
    }
    import { observable } from 'aurelia';
    
    export class RegistrationForm {
      @observable email: string = '';
      @observable password: string = '';
    
      emailError: string = '';
      passwordError: string = '';
    
      emailChanged(newValue: string) {
        // Run validation whenever email changes
        if (!newValue) {
          this.emailError = 'Email is required';
        } else if (!newValue.includes('@')) {
          this.emailError = 'Please enter a valid email';
        } else {
          this.emailError = '';
        }
      }
    
      passwordChanged(newValue: string) {
        if (newValue.length < 8) {
          this.passwordError = 'Password must be at least 8 characters';
        } else {
          this.passwordError = '';
        }
      }
    
      get isValid(): boolean {
        return !this.emailError && !this.passwordError && this.email && this.password;
      }
    }
    <form>
      <input value.bind="email" type="email" placeholder="Email">
      <span class="error" if.bind="emailError">${emailError}</span>
    
      <input value.bind="password" type="password" placeholder="Password">
      <span class="error" if.bind="passwordError">${passwordError}</span>
    
      <button disabled.bind="!isValid" click.trigger="submit()">Register</button>
    </form>
    import { observable } from 'aurelia';
    
    export class ProductCatalog {
      products: Product[] = [];
    
      @observable searchQuery: string = '';
      @observable sortBy: 'name' | 'price' = 'name';
      @observable maxPrice: number = 1000;
    
      get filteredProducts(): Product[] {
        // Automatically recomputes when searchQuery, maxPrice, or products change
        return this.products
          .filter(p =>
            p.name.toLowerCase().includes(this.searchQuery.toLowerCase()) &&
            p.price <= this.maxPrice
          )
          .sort((a, b) => {
            if (this.sortBy === 'price') {
              return a.price - b.price;
            }
            return a.name.localeCompare(b.name);
          });
      }
    
      get resultCount(): number {
        return this.filteredProducts.length;
      }
    
      async binding() {
        const response = await fetch('/api/products');
        this.products = await response.json();
      }
    }
    <div class="catalog">
      <input value.bind="searchQuery" placeholder="Search products...">
    
      <select value.bind="sortBy">
        <option value="name">Sort by Name</option>
        <option value="price">Sort by Price</option>
      </select>
    
      <input type="range" min="0" max="1000" value.bind="maxPrice">
      <span>Max: $${maxPrice}</span>
    
      <p>${resultCount} products found</p>
    
      <div repeat.for="product of filteredProducts" class="product-card">
        <h3>${product.name}</h3>
        <p>$${product.price}</p>
      </div>
    </div>
    import { watch, observable } from 'aurelia';
    import { resolve } from '@aurelia/kernel';
    import { ILogger } from '@aurelia/kernel';
    
    export class DraftEditor {
      private logger = resolve(ILogger);
    
      @observable content: string = '';
      @observable title: string = '';
    
      lastSaved: Date | null = null;
      isSaving: boolean = false;
    
      constructor() {
        // Load from localStorage on startup
        this.content = localStorage.getItem('draft-content') || '';
        this.title = localStorage.getItem('draft-title') || '';
      }
    
      // Watch for changes and auto-save
      @watch('content')
      @watch('title')
      async contentChanged() {
        if (this.isSaving) return;
    
        this.isSaving = true;
        try {
          // Save to localStorage immediately
          localStorage.setItem('draft-content', this.content);
          localStorage.setItem('draft-title', this.title);
    
          // Debounced server sync (implement as needed)
          await this.syncToServer();
    
          this.lastSaved = new Date();
          this.logger.debug('Draft saved');
        } finally {
          this.isSaving = false;
        }
      }
    
      private async syncToServer() {
        // Sync to server with debouncing
      }
    }
    <div class="editor">
      <input value.bind="title" placeholder="Title">
      <textarea value.bind="content" placeholder="Start writing..."></textarea>
    
      <div class="status">
        <span if.bind="isSaving">Saving...</span>
        <span if.bind="lastSaved && !isSaving">
          Saved at ${lastSaved.toLocaleTimeString()}
        </span>
      </div>
    </div>
    export class OrderSummary {
      items: OrderItem[] = [];
    
      @observable discountCode: string = '';
      @observable taxRate: number = 0.08;
    
      get subtotal(): number {
        return this.items.reduce((sum, item) =>
          sum + (item.price * item.quantity), 0
        );
      }
    
      get discount(): number {
        // Depends on subtotal and discountCode
        if (this.discountCode === 'SAVE10') {
          return this.subtotal * 0.1;
        }
        if (this.discountCode === 'SAVE20') {
          return this.subtotal * 0.2;
        }
        return 0;
      }
    
      get afterDiscount(): number {
        // Depends on subtotal and discount
        return this.subtotal - this.discount;
      }
    
      get tax(): number {
        // Depends on afterDiscount and taxRate
        return this.afterDiscount * this.taxRate;
      }
    
      get total(): number {
        // Final total depends on afterDiscount and tax
        return this.afterDiscount + this.tax;
      }
    }
    <div class="order-summary">
      <p>Subtotal: $${subtotal.toFixed(2)}</p>
    
      <input value.bind="discountCode" placeholder="Discount code">
      <p if.bind="discount > 0" class="discount">
        Discount: -$${discount.toFixed(2)}
      </p>
    
      <p>Tax (${(taxRate * 100).toFixed(0)}%): $${tax.toFixed(2)}</p>
    
      <p class="total">Total: $${total.toFixed(2)}</p>
    </div>
    import { computed } from 'aurelia';
    
    export class DataAnalytics {
      dataPoints: DataPoint[] = []; // Large array
    
      @observable dateRange: DateRange;
      @observable selectedMetric: string = 'sales';
    
      // Only recalculate when dependencies actually change
      @computed('dataPoints.length', 'dateRange', 'selectedMetric')
      get filteredData(): DataPoint[] {
        console.log('Filtering data (expensive)');
    
        return this.dataPoints.filter(point =>
          point.date >= this.dateRange.start &&
          point.date <= this.dateRange.end &&
          point.metric === this.selectedMetric
        );
      }
    
      @computed('filteredData.length')
      get statistics(): Statistics {
        console.log('Computing statistics (expensive)');
    
        const values = this.filteredData.map(d => d.value);
        return {
          mean: this.mean(values),
          median: this.median(values),
          stdDev: this.standardDeviation(values)
        };
      }
    
      // Heavy computation methods
      private mean(values: number[]): number { /* ... */ }
      private median(values: number[]): number { /* ... */ }
      private standardDeviation(values: number[]): number { /* ... */ }
    }
    import { bindable, IEventAggregator } from 'aurelia';
    import { resolve } from '@aurelia/kernel';
    import { queueTask } from '@aurelia/runtime';
    import { IPlatform } from '@aurelia/runtime-html';
    
    export class ModalDialog {
      @bindable open = false;
      @bindable closeOnBackdropClick = true;
      @bindable closeOnEscape = true;
      @bindable size: 'small' | 'medium' | 'large' | 'full' = 'medium';
    
      private platform = resolve(IPlatform);
      private element?: HTMLElement;
      private modalElement?: HTMLElement;
      private previousActiveElement?: HTMLElement;
      private focusableElements: HTMLElement[] = [];
    
      openChanged(newValue: boolean) {
        if (newValue) {
          this.onOpen();
        } else {
          this.onClose();
        }
      }
    
      attaching(initiator: HTMLElement) {
        this.element = initiator;
        this.modalElement = this.element.querySelector('[data-modal]') as HTMLElement;
      }
    
      detaching() {
        // Clean up if modal is still open
        if (this.open) {
          this.cleanupModal();
        }
      }
    
      closeModal() {
        this.open = false;
      }
    
      handleBackdropClick(event: MouseEvent) {
        // Only close if clicking the backdrop itself, not content inside
        if (this.closeOnBackdropClick && event.target === event.currentTarget) {
          this.closeModal();
        }
      }
    
      handleKeyDown(event: KeyboardEvent) {
        if (event.key === 'Escape' && this.closeOnEscape) {
          event.preventDefault();
          this.closeModal();
          return;
        }
    
        // Tab key focus trap
        if (event.key === 'Tab') {
          this.handleTabKey(event);
        }
      }
    
      private onOpen() {
        // Store currently focused element to return focus later
        this.previousActiveElement = document.activeElement as HTMLElement;
    
        // Prevent body scroll
        document.body.style.overflow = 'hidden';
    
        // Wait for DOM to render, then focus first element
        queueTask(() => {
          this.updateFocusableElements();
          this.focusFirstElement();
        });
      }
    
      private onClose() {
        this.cleanupModal();
      }
    
      private cleanupModal() {
        // Restore body scroll
        document.body.style.overflow = '';
    
        // Return focus to element that opened the modal
        if (this.previousActiveElement) {
          this.previousActiveElement.focus();
          this.previousActiveElement = undefined;
        }
      }
    
      private updateFocusableElements() {
        if (!this.modalElement) return;
    
        const focusableSelectors = [
          'a[href]',
          'button:not([disabled])',
          'textarea:not([disabled])',
          'input:not([disabled])',
          'select:not([disabled])',
          '[tabindex]:not([tabindex="-1"])'
        ].join(', ');
    
        this.focusableElements = Array.from(
          this.modalElement.querySelectorAll(focusableSelectors)
        ) as HTMLElement[];
      }
    
      private focusFirstElement() {
        const firstFocusable = this.focusableElements[0];
        if (firstFocusable) {
          firstFocusable.focus();
        }
      }
    
      private handleTabKey(event: KeyboardEvent) {
        if (this.focusableElements.length === 0) return;
    
        const firstElement = this.focusableElements[0];
        const lastElement = this.focusableElements[this.focusableElements.length - 1];
        const activeElement = document.activeElement as HTMLElement;
    
        if (event.shiftKey) {
          // Shift + Tab: Move backwards
          if (activeElement === firstElement) {
            event.preventDefault();
            lastElement.focus();
          }
        } else {
          // Tab: Move forwards
          if (activeElement === lastElement) {
            event.preventDefault();
            firstElement.focus();
          }
        }
      }
    }
    <div
      if.bind="open"
      class="modal modal--\${size}"
      role="dialog"
      aria-modal="true"
      keydown.trigger="handleKeyDown($event)"
      data-modal>
    
      <!-- Backdrop -->
      <div
        class="modal__backdrop"
        click.trigger="handleBackdropClick($event)">
    
        <!-- Content container -->
        <div class="modal__content" role="document">
    
          <!-- Header slot -->
          <div class="modal__header" if.bind="$slots.header">
            <au-slot name="header"></au-slot>
            <button
              type="button"
              class="modal__close"
              click.trigger="closeModal()"
              aria-label="Close modal">
              ×
            </button>
          </div>
    
          <!-- Body slot -->
          <div class="modal__body">
            <au-slot>
              <p>Modal content goes here</p>
            </au-slot>
          </div>
    
          <!-- Footer slot -->
          <div class="modal__footer" if.bind="$slots.footer">
            <au-slot name="footer"></au-slot>
          </div>
    
        </div>
      </div>
    </div>
    .modal {
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      z-index: 9999;
      display: flex;
      align-items: center;
      justify-content: center;
      animation: modal-fade-in 0.2s ease-out;
    }
    
    @keyframes modal-fade-in {
      from {
        opacity: 0;
      }
      to {
        opacity: 1;
      }
    }
    
    .modal__backdrop {
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      background: rgba(0, 0, 0, 0.5);
      backdrop-filter: blur(2px);
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 20px;
      overflow-y: auto;
    }
    
    .modal__content {
      position: relative;
      background: white;
      border-radius: 12px;
      box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
                  0 10px 10px -5px rgba(0, 0, 0, 0.04);
      max-height: 90vh;
      display: flex;
      flex-direction: column;
      animation: modal-slide-up 0.2s ease-out;
      margin: auto;
    }
    
    @keyframes modal-slide-up {
      from {
        opacity: 0;
        transform: translateY(20px) scale(0.95);
      }
      to {
        opacity: 1;
        transform: translateY(0) scale(1);
      }
    }
    
    /* Size variants */
    .modal--small .modal__content {
      width: 100%;
      max-width: 400px;
    }
    
    .modal--medium .modal__content {
      width: 100%;
      max-width: 600px;
    }
    
    .modal--large .modal__content {
      width: 100%;
      max-width: 900px;
    }
    
    .modal--full .modal__content {
      width: 100%;
      max-width: 95vw;
      max-height: 95vh;
    }
    
    .modal__header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 24px 24px 16px;
      border-bottom: 1px solid #e5e7eb;
    }
    
    .modal__header h2 {
      margin: 0;
      font-size: 20px;
      font-weight: 600;
      color: #111827;
    }
    
    .modal__close {
      background: none;
      border: none;
      font-size: 28px;
      line-height: 1;
      color: #6b7280;
      cursor: pointer;
      padding: 0;
      width: 32px;
      height: 32px;
      display: flex;
      align-items: center;
      justify-content: center;
      border-radius: 6px;
      transition: all 0.15s;
    }
    
    .modal__close:hover {
      background: #f3f4f6;
      color: #111827;
    }
    
    .modal__close:focus {
      outline: 2px solid #3b82f6;
      outline-offset: 2px;
    }
    
    .modal__body {
      padding: 24px;
      overflow-y: auto;
      flex: 1;
    }
    
    .modal__footer {
      padding: 16px 24px;
      border-top: 1px solid #e5e7eb;
      display: flex;
      gap: 12px;
      justify-content: flex-end;
    }
    
    .modal__footer button {
      padding: 8px 16px;
      border-radius: 6px;
      font-size: 14px;
      font-weight: 500;
      cursor: pointer;
      transition: all 0.15s;
    }
    
    .modal__footer button.btn-primary {
      background: #3b82f6;
      color: white;
      border: none;
    }
    
    .modal__footer button.btn-primary:hover {
      background: #2563eb;
    }
    
    .modal__footer button.btn-secondary {
      background: white;
      color: #374151;
      border: 1px solid #d1d5db;
    }
    
    .modal__footer button.btn-secondary:hover {
      background: #f9fafb;
    }
    // your-component.ts
    export class YourComponent {
      showModal = false;
    
      openModal() {
        this.showModal = true;
      }
    
      closeModal() {
        this.showModal = false;
      }
    }
    <!-- your-component.html -->
    <button click.trigger="openModal()">Open Modal</button>
    
    <modal-dialog open.bind="showModal">
      <h2 au-slot="header">Welcome!</h2>
    
      <p>This is the modal content. You can put anything here.</p>
    
      <div au-slot="footer">
        <button class="btn-secondary" click.trigger="closeModal()">Cancel</button>
        <button class="btn-primary" click.trigger="closeModal()">OK</button>
      </div>
    </modal-dialog>
    export class ConfirmDialog {
      showConfirm = false;
      confirmMessage = '';
    
      confirm(message: string): Promise<boolean> {
        this.confirmMessage = message;
        this.showConfirm = true;
    
        return new Promise(resolve => {
          this.resolveConfirm = resolve;
        });
      }
    
      handleConfirm(result: boolean) {
        this.showConfirm = false;
        if (this.resolveConfirm) {
          this.resolveConfirm(result);
        }
      }
    
      async deleteItem() {
        const confirmed = await this.confirm('Are you sure you want to delete this item?');
        if (confirmed) {
          // Delete the item
        }
      }
    
      private resolveConfirm?: (value: boolean) => void;
    }
    <modal-dialog open.bind="showConfirm" size="small">
      <h2 au-slot="header">Confirm Action</h2>
    
      <p>\${confirmMessage}</p>
    
      <div au-slot="footer">
        <button class="btn-secondary" click.trigger="handleConfirm(false)">
          Cancel
        </button>
        <button class="btn-primary" click.trigger="handleConfirm(true)">
          Confirm
        </button>
      </div>
    </modal-dialog>
    export class FormModal {
      showForm = false;
      formData = {
        name: '',
        email: '',
        message: ''
      };
    
      openForm() {
        this.showForm = true;
        this.resetForm();
      }
    
      closeForm() {
        this.showForm = false;
      }
    
      async submitForm() {
        // Validate and submit
        console.log('Submitting:', this.formData);
        this.closeForm();
      }
    
      resetForm() {
        this.formData = { name: '', email: '', message: '' };
      }
    }
    <modal-dialog open.bind="showForm" size="medium" close-on-backdrop-click.bind="false">
      <h2 au-slot="header">Contact Us</h2>
    
      <form>
        <div class="form-group">
          <label for="name">Name</label>
          <input id="name" type="text" value.bind="formData.name">
        </div>
    
        <div class="form-group">
          <label for="email">Email</label>
          <input id="email" type="email" value.bind="formData.email">
        </div>
    
        <div class="form-group">
          <label for="message">Message</label>
          <textarea id="message" rows="4" value.bind="formData.message"></textarea>
        </div>
      </form>
    
      <div au-slot="footer">
        <button class="btn-secondary" click.trigger="closeForm()">Cancel</button>
        <button class="btn-primary" click.trigger="submitForm()">Send Message</button>
      </div>
    </modal-dialog>
    <modal-dialog open.bind="showDetails" size="full">
      <h2 au-slot="header">Full Details</h2>
    
      <div class="content-grid">
        <!-- Large amount of content -->
      </div>
    
      <div au-slot="footer">
        <button class="btn-primary" click.trigger="showDetails = false">Close</button>
      </div>
    </modal-dialog>
    import { createFixture } from '@aurelia/testing';
    import { ModalDialog } from './modal-dialog';
    
    describe('ModalDialog', () => {
      it('opens and closes', async () => {
        const { component, queryBy, stop } = await createFixture
          .html`<modal-dialog open.bind="isOpen"></modal-dialog>`
          .component(class { isOpen = false; })
          .deps(ModalDialog)
          .build()
          .started;
    
        expect(queryBy('[data-modal]')).toBeNull();
    
        component.isOpen = true;
        await new Promise(resolve => setTimeout(resolve, 10));
    
        expect(queryBy('[data-modal]')).toBeTruthy();
    
        component.isOpen = false;
        await new Promise(resolve => setTimeout(resolve, 10));
    
        expect(queryBy('[data-modal]')).toBeNull();
    
        await stop(true);
      });
    
      it('closes on Escape key', async () => {
        const { component, trigger, getBy, stop } = await createFixture
          .html`<modal-dialog open.bind="isOpen"></modal-dialog>`
          .component(class { isOpen = true; })
          .deps(ModalDialog)
          .build()
          .started;
    
        await new Promise(resolve => setTimeout(resolve, 10));
    
        trigger.keydown(getBy('[data-modal]'), { key: 'Escape' });
    
        expect(component.isOpen).toBe(false);
    
        await stop(true);
      });
    
      it('closes on backdrop click when enabled', async () => {
        const { component, trigger, getBy, stop } = await createFixture
          .html`<modal-dialog open.bind="isOpen" close-on-backdrop-click.bind="true"></modal-dialog>`
          .component(class { isOpen = true; })
          .deps(ModalDialog)
          .build()
          .started;
    
        await new Promise(resolve => setTimeout(resolve, 10));
    
        trigger.click(getBy('.modal__backdrop'));
    
        expect(component.isOpen).toBe(false);
    
        await stop(true);
      });
    
      it('does not close on content click', async () => {
        const { component, trigger, getBy, stop } = await createFixture
          .html`<modal-dialog open.bind="isOpen"></modal-dialog>`
          .component(class { isOpen = true; })
          .deps(ModalDialog)
          .build()
          .started;
    
        await new Promise(resolve => setTimeout(resolve, 10));
    
        trigger.click(getBy('.modal__content'));
    
        expect(component.isOpen).toBe(true);
    
        await stop(true);
      });
    
      it('prevents body scroll when open', async () => {
        const { component, stop } = await createFixture
          .html`<modal-dialog open.bind="isOpen"></modal-dialog>`
          .component(class { isOpen = false; })
          .deps(ModalDialog)
          .build()
          .started;
    
        expect(document.body.style.overflow).toBe('');
    
        component.isOpen = true;
        await new Promise(resolve => setTimeout(resolve, 10));
    
        expect(document.body.style.overflow).toBe('hidden');
    
        component.isOpen = false;
        await new Promise(resolve => setTimeout(resolve, 10));
    
        expect(document.body.style.overflow).toBe('');
    
        await stop(true);
      });
    });
    import { animator } from '@aurelia/runtime-html';
    
    export class AnimatedModal {
      private animator = resolve(animator);
    
      async openModal() {
        this.open = true;
        await tasksSettled();
        await this.animator.enter(this.modalElement!);
      }
    
      async closeModal() {
        await this.animator.leave(this.modalElement!);
        this.open = false;
      }
    }
    export class UnsavedChangesModal {
      @bindable hasUnsavedChanges = false;
    
      async closeModal() {
        if (this.hasUnsavedChanges) {
          const confirmed = confirm('You have unsaved changes. Close anyway?');
          if (!confirmed) return;
        }
    
        this.open = false;
      }
    }
    // modal-service.ts
    import { IEventAggregator } from 'aurelia';
    import { resolve } from '@aurelia/kernel';
    
    export interface ModalConfig {
      title: string;
      message: string;
      buttons?: Array<{ label: string; action: () => void; primary?: boolean }>;
    }
    
    export class ModalService {
      private ea = resolve(IEventAggregator);
    
      alert(title: string, message: string) {
        return this.open({
          title,
          message,
          buttons: [{ label: 'OK', action: () => {}, primary: true }]
        });
      }
    
      confirm(title: string, message: string): Promise<boolean> {
        return new Promise(resolve => {
          this.open({
            title,
            message,
            buttons: [
              { label: 'Cancel', action: () => resolve(false) },
              { label: 'Confirm', action: () => resolve(true), primary: true }
            ]
          });
        });
      }
    
      private open(config: ModalConfig) {
        this.ea.publish('modal:open', config);
      }
    }
    Use one-way bindings for read-only flows or whenever the DOM only needs to reflect state.

    Two-Way Binding (View Model ↔ View)

    Two-way bindings keep inputs and view-model properties in sync. Typing "Alice" updates name, which in turn refreshes every binding that depends on it.

    From-View Binding (View → View Model)

    .from-view captures user input without pushing view-model changes back into the DOM—handy for debounced searches or analytics where the DOM already mirrors the value elsewhere.

    Binding Mode Decision Tree

    Conditional Rendering: if vs show

    if.bind – Adds/Removes from the DOM

    if.bind creates and disposes the DOM subtree. It frees memory and automatically detaches listeners any time the condition flips back to false.

    show.bind – CSS Display Toggle

    show.bind toggles display: none without touching the DOM tree. It is ideal for frequently toggled sections that should keep their internal state alive.

    Decision Matrix

    Capability

    if.bind

    show.bind

    DOM manipulation

    Create/destroy nodes

    Toggle CSS display

    Memory

    Released when hidden

    Always allocated

    Toggle speed

    Slightly slower

    Instant

    Event cleanup

    Automatic

    Handled manually if needed

    List Rendering with repeat.for

    Basic Flow

    With Keys for Efficient Updates

    By default, the repeat controller tracks scopes by the actual item reference. When you insert X in between existing objects ([A, B, C] → [A, X, B, C]), Aurelia reuses the same scopes for A, B, and C because their references are unchanged; only X produces a new view. The _scopeMap maintained inside packages/runtime-html/src/resources/template-controllers/repeat.ts (see _createScopes and _applyIndexMap) stores either the raw item reference or your explicit key, which is why Aurelia can diff without re-rendering.

    Provide a key only when you recreate objects between refreshes (for example, mapping API data into new literals) or when the list contains primitives. In those cases a property such as id gives Aurelia a stable identity to match.

    Contextual Properties

    Property
    Description
    Example Values (list of 3)

    $index

    Zero-based index

    0, 1, 2

    $first

    True only for the first item

    true, false, false

    $last

    True only for the last item

    false, false, true

    $even

    True when $index % 2 === 0

    Event Binding: Trigger vs Capture

    Bubbling Phase (.trigger)

    .trigger listens during the bubble phase as the event travels from the target back toward the window.

    Capturing Phase (.capture)

    .capture intercepts the event on its way down the DOM tree before child handlers run.

    Event Flow Complete Picture

    Value Converters Pipeline

    Converter Flow Detail

    Component Communication

    Parent → Child (Bindable Properties)

    Child → Parent (Callback Binding)

    Use .bind to pass a callback reference to the child now that the deprecated .call binding command is gone.

    Form Checkbox Collections

    When the user checks "Keyboard", Aurelia pushes 2 into selectedIds. Unchecking "Mouse" removes 1, keeping the array aligned with the checked boxes.

    Template Lifecycle

    Performance: Binding Modes Comparison

    Binding Mode
    Setup Cost
    Updates
    Memory Footprint
    Typical Use

    .one-time

    Set value once

    Never updates

    No observers hooked up

    Static text that never changes

    .one-way / .to-view

    Set value + observer

    Whenever property changes

    One source observer

    Displaying reactive state

    Internals note: the PropertyBinding.bind implementation wires observers based on the binding mode flags. .one-time evaluates the expression once without connecting, .one-way connects the source side so it can re-run when dependencies change, and .bind/.two-way also subscribes to the target observer (for example, an input element) so user input flows back to the view model. This mirrors the logic in packages/runtime-html/src/binding/property-binding.ts where toView, fromView, and oneTime determine which observers are created.

    Computed Properties Reactivity

    Aurelia re-runs getters whenever any accessed dependency (the array itself or a member property) mutates, then propagates the new value into the DOM.

    Related Documentation

    • Template Syntax Overview

    • Cheat Sheet

    • Conditional Rendering

    • List Rendering

    List Rendering

    Master list rendering in Aurelia with repeat.for. Learn efficient data binding, performance optimization, advanced patterns, and real-world techniques for dynamic collections including arrays, maps, s

    The repeat.for binding is Aurelia's powerful list rendering mechanism that creates highly optimized, reactive displays of collection data. It intelligently tracks changes, minimizes DOM updates, and provides rich contextual information for sophisticated data presentation.

    Core Concepts

    The repeat.for Binding

    repeat.for creates a template instance for each item in a collection, similar to a for...of loop but with intelligent DOM management:

    JavaScript Analogy:

    Change Detection and Updates

    Aurelia automatically observes collection changes and updates the DOM efficiently:

    Important: Use array mutating methods (push, pop, splice, reverse, sort) for automatic detection. Direct index assignment works but requires the array reference to change for detection.

    Performance Optimization with Keys

    Why Keys Matter

    Without keys, Aurelia recreates DOM elements when collections change. With keys, it reuses existing elements:

    Key Strategies

    Property-based keys (recommended):

    Literal property keys (more efficient):

    Expression-based keys (flexible but slower):

    When to Use Keys

    • Dynamic collections where items are added, removed, or reordered

    • Form inputs to preserve user input during updates

    • Stateful components to maintain component state

    • Large lists for performance optimization

    Avoid keys when:

    • Collection is static or append-only

    • Items are simple primitives without DOM state

    • Performance testing shows no benefit

    Contextual Properties

    Every repeat iteration provides rich contextual information:

    Complete Property Reference

    Property
    Type
    Description

    Nested Repeats and $parent

    Access parent contexts in nested structures:

    Accessing Previous Items with $previous

    The $previous contextual property provides access to the previous iteration's item, enabling powerful comparison and rendering patterns. It is a computed property available by default as part of repeat's contextual values. You can disable all contextual computed values (including $previous) using the contextual option.

    Basic usage:

    Key characteristics:

    • $previous is null for the first item

    • $previous is undefined when contextual is disabled

    • Computed property with minimal overhead when enabled (contextual is enabled by default)

    Section Headers and Dividers

    A common use case is rendering section headers only when data changes:

    Output:

    Comparison and Change Indicators

    Highlight changes from previous values:

    Combining with Keys

    $previous works seamlessly with keyed repeats:

    Conditional Contextual Properties

    Control contextual computed properties (including $previous) based on view model properties:

    Performance Considerations

    When contextual is disabled:

    • Zero memory overhead - $previous is not computed

    • Negligible CPU cost - single conditional check per item

    When contextual is enabled (default):

    • Computed on demand via contextual getter

    • Minimal CPU cost

    Best practices:

    • Keep contextual enabled unless you have a strong reason to disable it

    • If needed, disable per-instance with contextual: false or contextual.bind: someBoolean

    Data Types and Collections

    Arrays

    The most common and optimized collection type:

    Sets

    Useful for unique collections:

    Maps

    Perfect for key-value pairs:

    Number Ranges

    Generate sequences quickly:

    Advanced Patterns

    Destructuring Declarations

    Extract multiple values in the repeat declaration:

    Integration with Other Template Controllers

    Conditional rendering within repeats:

    Nested conditionals and repeats:

    Working with Async Data

    Handle loading states and async operations:

    Complex Object Iteration

    Use value converters for non-standard collections:

    Performance Best Practices

    Optimizing Large Lists

    Use keyed iteration:

    Consider virtual scrolling for very large lists:

    This requires using the virtual repeat plugin.

    Memory Management

    Avoid memory leaks in complex scenarios:

    Custom Collection Handlers

    Built-in Handlers

    Aurelia includes handlers for:

    • Arrays (Array, [])

    • Sets (Set)

    • Maps (Map)

    Creating Custom Handlers

    For specialized collections:

    Observable Collections

    Create reactive custom collections:

    Troubleshooting Common Issues

    Issue: Changes Not Reflecting

    Problem: Direct array index assignment doesn't trigger updates

    Solution: Use array methods or replace the array

    Issue: Form State Lost on Reorder

    Problem: Input values disappear when list is reordered

    Solution: Use stable keys

    Issue: Performance with Large Lists

    Problem: Slow rendering with 1000+ items

    Solutions:

    1. Use virtual scrolling for very large lists

    2. Implement pagination or infinite scroll

    3. Optimize templates - minimize complex expressions

    4. Use keys to enable DOM reuse

    Issue: Memory Leaks

    Problem: Components not disposing properly

    Solution: Clean up in lifecycle hooks

    Real-World Examples

    Dynamic Product Catalog

    Data Table with Sorting

    TypeScript Integration

    Type-Safe Repeats

    Shopping Cart

    A complete shopping cart implementation with add/remove items, quantity updates, and dynamic total calculations. Demonstrates reactive data management and user interaction patterns.

    Features Demonstrated

    • Array manipulation - Add, remove, update cart items

    • Lambda expressions - Complex calculations directly in templates using reduce, filter, etc.

    • Event handling - Button clicks, quantity changes

    • Conditional rendering - Empty cart state, checkout button

    • List rendering with keys - Efficient cart item updates

    • Two-way binding - Quantity inputs

    • Number formatting - Currency display

    • Component state management - Cart as a service

    Code

    View Model (shopping-cart.ts)

    Currency Value Converter (currency-value-converter.ts)

    Template (shopping-cart.html)

    Styles (shopping-cart.css)

    How It Works

    1. Lambda Expressions in Templates

    Instead of computed properties in the view model, calculations are done directly in the template using lambda expressions:

    Aurelia's lambda expressions support complex operations like reduce, filter, map, every, and some directly in templates. The template automatically tracks dependencies and recalculates when cartItems changes.

    2. Array Manipulation

    Using array methods ensures change detection:

    3. Benefits of Lambda Expressions

    Moving calculations to the template has several advantages:

    • Reduced boilerplate - No need for getter methods in the view model

    • Clear intent - Calculations are visible right where they're used

    • Single source of truth - The template directly expresses what data it needs

    • Automatic reactivity - Aurelia tracks all dependencies within lambda expressions

    This approach is particularly useful for derived data that's only needed in the view.

    4. Efficient List Updates

    Using key: id allows Aurelia to track items efficiently:

    When items are removed or reordered, Aurelia reuses DOM elements.

    5. Quantity Validation

    Multiple ways to update quantity with validation:

    6. Conditional Rendering

    Show different UI based on cart state:

    Lambda expressions work seamlessly with conditionals: if.bind="cartItems.length" or if.bind="!cartItems.length".

    7. Currency Formatting with Value Converter

    The currency value converter formats prices consistently:

    The converter uses Intl.NumberFormat for proper currency formatting including the currency symbol, decimal places, and thousands separators. This keeps formatting logic out of the view model.

    8. When to Use Lambda Expressions vs Computed Properties

    Use lambda expressions in templates when:

    • The calculation is only needed in the view

    • The logic is straightforward and readable inline

    • You want to reduce view model boilerplate

    Use computed properties in the view model when:

    • The calculation is complex and would make the template hard to read

    • The value is used in multiple places (template and view model logic)

    • You need to unit test the calculation logic

    • The calculation is expensive and you want explicit memoization

    For this shopping cart example, the calculations are simple arithmetic operations that are only displayed to the user, making lambda expressions a great fit. They eliminate boilerplate while keeping the template clear and maintainable.

    Variations

    Persist Cart to LocalStorage

    Add Discount Codes

    Cart as a Service

    Make the cart available throughout the app:

    Related

    <user-card user.bind="user"></user-card>
    <!-- Parent template -->
    <user-card user.bind="user" on-delete.bind="handleDelete"></user-card>
    
    // Child view-model
    import { bindable } from '@aurelia/runtime-html';
    
    export class UserCard {
      @bindable() public onDelete: (user: User) => void;
    
      deleteUser(): void {
        this.onDelete?.(this.user);
      }
    }
    items = [
      { price: 10, qty: 2 },
      { price: 20, qty: 1 }
    ];
    
    get total() {
      return this.items.reduce((sum, item) => sum + item.price * item.qty, 0);
    }

    Sortable/filterable lists

    $even

    boolean

    true for even indices (0, 2, 4...)

    $odd

    boolean

    true for odd indices (1, 3, 5...)

    $length

    number

    Total number of items

    $previous

    any

    null

    $parent

    object

    Parent binding context

    Works with all collection types (arrays, Maps, Sets, etc.)

  • Compatible with keyed repeats

  • Numbers (5 → creates range 0-4)

  • Array-like objects (NodeList, HTMLCollection, etc.)

  • Null/undefined (renders nothing)

  • $index

    number

    Zero-based index (0, 1, 2...)

    $first

    boolean

    true for the first item

    $last

    boolean

    true for the last item

    $middle

    boolean

    true for items that aren't first or last

    Value Converters
    Product Catalog Recipe
    Lambda Expressions Guide
    List Rendering Guide
    Computed Properties
    <ul>
      <li repeat.for="item of items">
        ${item.name}
      </li>
    </ul>
    for (let item of items) {
      // Aurelia creates DOM element for each item
      console.log(item.name);
    }
    export class MyComponent {
      items = [{ name: 'John' }, { name: 'Jane' }];
    
      addItem() {
        // Aurelia detects this change and updates DOM
        this.items.push({ name: 'Bob' });
      }
    
      updateFirst() {
        // This change is also detected
        this.items[0] = { name: 'Johnny' };
      }
    }
    <!-- Without keys: recreates all DOM on reorder -->
    <div repeat.for="user of users">
      <input value.bind="user.name">
    </div>
    
    <!-- With keys: preserves DOM and form state -->
    <div repeat.for="user of users; key.bind: user.id">
      <input value.bind="user.name">
    </div>
    <!-- Use stable, unique properties -->
    <li repeat.for="product of products; key.bind: product.id">
      ${product.name}
    </li>
    <!-- Avoids expression evaluation -->
    <li repeat.for="product of products; key: id">
      ${product.name}
    </li>
    <!-- For complex key logic -->
    <li repeat.for="item of items; key.bind: item.category + '-' + item.id">
      ${item.name}
    </li>
    <div repeat.for="item of items">
      <span class="index">Item ${$index + 1} of ${$length}</span>
      <span class="status">
        ${$first ? 'First' : $last ? 'Last' : $middle ? 'Middle' : ''}
      </span>
      <div class="item ${$even ? 'even' : 'odd'}">
        ${item.name}
      </div>
    </div>
    <div repeat.for="department of departments">
      <h2>${department.name}</h2>
      <div repeat.for="employee of department.employees">
        <span>
          Dept: ${$parent.department.name},
          Employee #${$index + 1}: ${employee.name}
        </span>
        <!-- Access root context -->
        <span>Company: ${$parent.$parent.companyName}</span>
      </div>
    </div>
    <!-- $previous is enabled by default (disable with contextual: false) -->
    <div repeat.for="item of items">
      <div class="item">
        ${item.name}
        <span if.bind="$previous !== null">
          (Previous: ${$previous.name})
        </span>
      </div>
    </div>
    export class ProductList {
      products = [
        { category: 'Electronics', name: 'Laptop' },
        { category: 'Electronics', name: 'Mouse' },
        { category: 'Books', name: 'JavaScript Guide' },
        { category: 'Books', name: 'TypeScript Handbook' }
      ];
    }
    <!-- Show category header only when it changes -->
    <div repeat.for="product of products">
      <h2 if.bind="product.category !== $previous?.category">
        ${product.category}
      </h2>
      <div class="product">${product.name}</div>
    </div>
    Electronics
      Laptop
      Mouse
    Books
      JavaScript Guide
      TypeScript Handbook
    export class StockTracker {
      prices = [
        { time: '09:00', price: 100 },
        { time: '09:01', price: 102 },
        { time: '09:02', price: 98 },
        { time: '09:03', price: 98 }
      ];
    }
    <table>
      <tr repeat.for="entry of prices">
        <td>${entry.time}</td>
        <td class="${entry.price > $previous?.price ? 'up' :
                      entry.price < $previous?.price ? 'down' : ''}">
          $${entry.price}
          <span if.bind="$previous && entry.price !== $previous.price">
            ${entry.price > $previous.price ? '↑' : '↓'}
          </span>
        </td>
      </tr>
    </table>
    <!-- Multiple iterator properties separated by semicolons -->
    <div repeat.for="item of items; key: id">
      <div class="item-${item.id}">
        ${item.name}
        <span if.bind="$previous">
          Changed from: ${$previous.name}
        </span>
      </div>
    </div>
    export class ConfigurableList {
      items = [...];
      showContextual = true; // Toggle contextual on/off
    }
    <!-- Enable/disable contextual based on component state -->
    <div repeat.for="item of items; contextual.bind: showContextual">
      <!-- $previous is only available when contextual is true -->
    </div>
    export class ProductList {
      products = [
        { id: 1, name: 'Laptop', price: 999 },
        { id: 2, name: 'Mouse', price: 25 }
      ];
    
      sortByPrice() {
        // Aurelia detects and updates DOM
        this.products.sort((a, b) => a.price - b.price);
      }
    }
    <div repeat.for="product of products; key.bind: product.id">
      <h3>${product.name}</h3>
      <span class="price">${product.price | currency}</span>
    </div>
    export class TagManager {
      selectedTags = new Set(['javascript', 'typescript']);
    
      toggleTag(tag: string) {
        if (this.selectedTags.has(tag)) {
          this.selectedTags.delete(tag);
        } else {
          this.selectedTags.add(tag);
        }
      }
    }
    <div repeat.for="tag of selectedTags">
      <span class="tag">${tag}</span>
    </div>
    export class LocalizationDemo {
      translations = new Map([
        ['en', 'Hello'],
        ['es', 'Hola'],
        ['fr', 'Bonjour']
      ]);
    }
    <!-- Destructure map entries -->
    <div repeat.for="[language, greeting] of translations">
      <strong>${language}:</strong> ${greeting}
    </div>
    
    <!-- Or access as entry object -->
    <div repeat.for="entry of translations">
      <strong>${entry[0]}:</strong> ${entry[1]}
    </div>
    <!-- Create pagination -->
    <nav>
      <a repeat.for="page of totalPages"
         href="/products?page=${page + 1}">
        ${page + 1}
      </a>
    </nav>
    
    <!-- Star ratings -->
    <div class="rating">
      <span repeat.for="star of 5"
            class="star ${star < rating ? 'filled' : ''}">
        ★
      </span>
    </div>
    export class OrderHistory {
      orders = [
        { id: 1, items: [{ name: 'Coffee', qty: 2 }] },
        { id: 2, items: [{ name: 'Tea', qty: 1 }] }
      ];
    }
    <!-- Destructure objects -->
    <div repeat.for="{ id, items } of orders">
      Order #${id}: ${items.length} items
    </div>
    
    <!-- Destructure arrays -->
    <div repeat.for="[index, value] of arrayOfPairs">
      ${index}: ${value}
    </div>
    <div repeat.for="user of users">
      <div if.bind="user.isActive">
        <strong>${user.name}</strong> - Active
      </div>
      <div else>
        <em>${user.name}</em> - Inactive
      </div>
    </div>
    <div repeat.for="category of categories">
      <h2>${category.name}</h2>
      <div if.bind="category.products.length > 0">
        <div repeat.for="product of category.products; key.bind: product.id">
          ${product.name}
        </div>
      </div>
      <p else>No products in this category</p>
    </div>
    export class AsyncDataExample {
      items: Item[] = [];
      isLoading = true;
      error: string | null = null;
    
      async attached() {
        try {
          this.items = await this.dataService.getItems();
        } catch (err) {
          this.error = err.message;
        } finally {
          this.isLoading = false;
        }
      }
    }
    <div if.bind="isLoading">
      <spinner></spinner> Loading...
    </div>
    
    <div else>
      <div if.bind="error">
        <div class="error">Error: ${error}</div>
      </div>
    
      <div else>
        <div if.bind="items.length === 0">
          <p>No items found</p>
        </div>
    
        <div else>
          <div repeat.for="item of items; key.bind: item.id">
            ${item.name}
          </div>
        </div>
      </div>
    </div>
    // Object keys converter
    export class KeysValueConverter {
      toView(obj: Record<string, any>): string[] {
        return obj ? Object.keys(obj) : [];
      }
    }
    
    // Object entries converter
    export class EntriesValueConverter {
      toView(obj: Record<string, any>): [string, any][] {
        return obj ? Object.entries(obj) : [];
      }
    }
    <!-- Iterate object keys -->
    <div repeat.for="key of settings | keys">
      <label>${key}:</label>
      <input value.bind="settings[key]">
    </div>
    
    <!-- Iterate object entries -->
    <div repeat.for="[key, value] of configuration | entries">
      <strong>${key}:</strong> ${value}
    </div>
    <!-- Enables efficient DOM reuse -->
    <div repeat.for="item of largeList; key.bind: item.id">
      ${item.name}
    </div>
    <!-- Use ui-virtualization for very large collecitons of items -->
    <div virtual-repeat.for="item of hugeList">
      ${item.name}
    </div>
    export class ListComponent {
      private subscription?: IDisposable;
    
      attached() {
        // Subscribe to external data changes
        this.subscription = this.dataService.changes.subscribe(
          items => this.items = items
        );
      }
    
      detaching() {
        // Clean up subscriptions
        this.subscription?.dispose();
      }
    }
    import { IRepeatableHandler, Registration } from 'aurelia';
    
    // Custom handler for immutable lists
    class ImmutableListHandler implements IRepeatableHandler {
      handles(value: unknown): boolean {
        return value && typeof value === 'object' && 'size' in value && 'get' in value;
      }
    
      iterate(value: any, func: (item: unknown, index: number) => void): void {
        for (let i = 0; i < value.size; i++) {
          func(value.get(i), i);
        }
      }
    }
    
    // Register the handler
    Aurelia.register(
      Registration.singleton(IRepeatableHandler, ImmutableListHandler)
    ).app(MyApp).start();
    import { CollectionObserver, ICollectionObserver } from '@aurelia/runtime';
    
    class ReactiveCustomCollection {
      private _items: any[] = [];
      private _observer?: ICollectionObserver;
    
      get items() { return this._items; }
    
      add(item: any) {
        this._items.push(item);
        this._observer?.handleCollectionChange(/* change details */);
      }
    
      // Implement observable pattern...
    }
    // This won't update the DOM
    this.items[0] = newItem;
    // These will update the DOM
    this.items.splice(0, 1, newItem);
    // or
    this.items = [...this.items.slice(0, 0), newItem, ...this.items.slice(1)];
    <!-- No keys = DOM recreation -->
    <div repeat.for="item of items">
      <input value.bind="item.name">
    </div>
    <!-- Keys preserve DOM elements -->
    <div repeat.for="item of items; key.bind: item.id">
      <input value.bind="item.name">
    </div>
    export class MyComponent {
      detaching() {
        // Dispose of subscriptions, timers, etc.
        this.cleanup();
      }
    }
    export class ProductCatalog {
      products: Product[] = [];
      filteredProducts: Product[] = [];
      searchTerm = '';
      selectedCategory = '';
    
      searchTermChanged() {
        this.filterProducts();
      }
    
      categoryChanged() {
        this.filterProducts();
      }
    
      private filterProducts() {
        this.filteredProducts = this.products.filter(product => {
          const matchesSearch = !this.searchTerm ||
            product.name.toLowerCase().includes(this.searchTerm.toLowerCase());
          const matchesCategory = !this.selectedCategory ||
            product.category === this.selectedCategory;
          return matchesSearch && matchesCategory;
        });
      }
    }
    <div class="filters">
      <input value.bind="searchTerm" placeholder="Search products...">
      <select value.bind="selectedCategory">
        <option value="">All Categories</option>
        <option repeat.for="category of categories"
                value.bind="category">${category}</option>
      </select>
    </div>
    
    <div class="product-grid">
      <div repeat.for="product of filteredProducts; key.bind: product.id"
           class="product-card">
        <img src.bind="product.image" alt.bind="product.name">
        <h3>${product.name}</h3>
        <p class="price">${product.price | currency}</p>
        <button click.trigger="addToCart(product)">Add to Cart</button>
      </div>
    </div>
    
    <div if.bind="filteredProducts.length === 0" class="no-results">
      No products found matching your criteria.
    </div>
    export class DataTable {
      data: TableRow[] = [];
      sortColumn = '';
      sortDirection: 'asc' | 'desc' = 'asc';
    
      sort(column: string) {
        if (this.sortColumn === column) {
          this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
        } else {
          this.sortColumn = column;
          this.sortDirection = 'asc';
        }
    
        this.data.sort((a, b) => {
          const aVal = a[column];
          const bVal = b[column];
          const modifier = this.sortDirection === 'asc' ? 1 : -1;
    
          return aVal < bVal ? -modifier : aVal > bVal ? modifier : 0;
        });
      }
    }
    <table class="data-table">
      <thead>
        <tr>
          <th repeat.for="column of columns"
              click.trigger="sort(column.key)"
              class="${sortColumn === column.key ? 'sorted ' + sortDirection : ''}">
            ${column.title}
            <span if.bind="sortColumn === column.key"
                  class="sort-indicator">
              ${sortDirection === 'asc' ? '↑' : '↓'}
            </span>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr repeat.for="row of data; key.bind: row.id">
          <td repeat.for="column of columns">
            ${row[column.key] | column.converter}
          </td>
        </tr>
      </tbody>
    </table>
    interface User {
      id: number;
      name: string;
      email: string;
      isActive: boolean;
    }
    
    export class UserList {
      users: User[] = [];
    
      // Type-safe filtering
      get activeUsers(): User[] {
        return this.users.filter(user => user.isActive);
      }
    
      // Type-safe operations
      toggleUserStatus(user: User): void {
        user.isActive = !user.isActive;
      }
    }
    <!-- TypeScript provides intellisense and type checking -->
    <div repeat.for="user of activeUsers; key.bind: user.id">
      <span>${user.name}</span> <!-- ✓ TypeScript knows user.name exists -->
      <span>${user.email}</span> <!-- ✓ Type safe -->
      <button click.trigger="toggleUserStatus(user)">
        ${user.isActive ? 'Deactivate' : 'Activate'}
      </button>
    </div>
    export interface CartItem {
      id: number;
      productId: number;
      name: string;
      price: number;
      quantity: number;
      image: string;
      maxQuantity: number;
    }
    
    export class ShoppingCart {
      cartItems: CartItem[] = [];
    
      // Add item to cart
      addToCart(product: { id: number; name: string; price: number; image: string; maxQuantity: number }) {
        const existingItem = this.cartItems.find(item => item.productId === product.id);
    
        if (existingItem) {
          // Increase quantity if item already in cart
          if (existingItem.quantity < existingItem.maxQuantity) {
            existingItem.quantity++;
          } else {
            alert(`Maximum quantity (${existingItem.maxQuantity}) reached for ${existingItem.name}`);
          }
        } else {
          // Add new item
          this.cartItems.push({
            id: Date.now(), // Simple ID generation
            productId: product.id,
            name: product.name,
            price: product.price,
            quantity: 1,
            image: product.image,
            maxQuantity: product.maxQuantity
          });
        }
      }
    
      // Update item quantity
      updateQuantity(item: CartItem, newQuantity: number) {
        if (newQuantity <= 0) {
          this.removeItem(item);
        } else if (newQuantity <= item.maxQuantity) {
          item.quantity = newQuantity;
        } else {
          item.quantity = item.maxQuantity;
          alert(`Maximum quantity is ${item.maxQuantity}`);
        }
      }
    
      // Increase quantity
      increaseQuantity(item: CartItem) {
        if (item.quantity < item.maxQuantity) {
          item.quantity++;
        } else {
          alert(`Maximum quantity (${item.maxQuantity}) reached`);
        }
      }
    
      // Decrease quantity
      decreaseQuantity(item: CartItem) {
        if (item.quantity > 1) {
          item.quantity--;
        } else {
          this.removeItem(item);
        }
      }
    
      // Remove item from cart
      removeItem(item: CartItem) {
        const index = this.cartItems.indexOf(item);
        if (index > -1) {
          this.cartItems.splice(index, 1);
        }
      }
    
      // Clear entire cart
      clearCart() {
        if (confirm('Are you sure you want to clear your cart?')) {
          this.cartItems = [];
        }
      }
    
      // Proceed to checkout
      checkout() {
        console.log('Proceeding to checkout with:', this.cartItems);
        alert('Proceeding to checkout...');
        // In a real app, navigate to checkout page or open checkout modal
      }
    }
    import { valueConverter } from 'aurelia';
    
    @valueConverter('currency')
    export class CurrencyValueConverter {
      toView(value: number, currencyCode = 'USD'): string {
        if (value == null) return '';
    
        return new Intl.NumberFormat('en-US', {
          style: 'currency',
          currency: currencyCode
        }).format(value);
      }
    }
    <import from="./currency-value-converter"></import>
    
    <div class="shopping-cart">
      <!-- Cart Header -->
      <header class="cart-header">
        <h1>
          Shopping Cart
          <span class="item-count" if.bind="cartItems.length">
            (${cartItems.reduce((sum, item) => sum + item.quantity, 0)} ${cartItems.reduce((sum, item) => sum + item.quantity, 0) === 1 ? 'item' : 'items'})
          </span>
        </h1>
        <button
          if.bind="cartItems.length"
          click.trigger="clearCart()"
          class="clear-btn">
          Clear Cart
        </button>
      </header>
    
      <!-- Empty Cart State -->
      <div if.bind="!cartItems.length" class="empty-cart">
        <div class="empty-icon">🛒</div>
        <h2>Your cart is empty</h2>
        <p>Add some products to get started!</p>
      </div>
    
      <!-- Cart Items -->
      <div else class="cart-content">
        <!-- Cart Items List -->
        <div class="cart-items">
          <div
            repeat.for="item of cartItems; key: id"
            class="cart-item">
    
            <!-- Product Image -->
            <div class="item-image">
              <img src.bind="item.image" alt.bind="item.name">
            </div>
    
            <!-- Product Details -->
            <div class="item-details">
              <h3 class="item-name">${item.name}</h3>
              <p class="item-price">${item.price | currency:'USD'} each</p>
    
              <!-- Quantity Controls -->
              <div class="quantity-controls">
                <button
                  click.trigger="decreaseQuantity(item)"
                  class="qty-btn"
                  title="Decrease quantity">
                  −
                </button>
    
                <input
                  type="number"
                  value.bind="item.quantity"
                  min="1"
                  max.bind="item.maxQuantity"
                  change.trigger="updateQuantity(item, item.quantity)"
                  class="qty-input">
    
                <button
                  click.trigger="increaseQuantity(item)"
                  class="qty-btn"
                  disabled.bind="item.quantity >= item.maxQuantity"
                  title="Increase quantity">
                  +
                </button>
    
                <span class="max-qty-label" if.bind="item.quantity >= item.maxQuantity">
                  (max)
                </span>
              </div>
            </div>
    
            <!-- Item Total and Remove -->
            <div class="item-actions">
              <div class="item-total">
                ${(item.price * item.quantity) | currency:'USD'}
              </div>
              <button
                click.trigger="removeItem(item)"
                class="remove-btn"
                title="Remove item">
                ×
              </button>
            </div>
          </div>
        </div>
    
        <!-- Cart Summary -->
        <div class="cart-summary">
          <h2>Order Summary</h2>
    
          <div class="summary-row">
            <span>Subtotal:</span>
            <span>${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) | currency:'USD'}</span>
          </div>
    
          <div class="summary-row">
            <span>Tax (8%):</span>
            <span>${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * 0.08 | currency:'USD'}</span>
          </div>
    
          <div class="summary-row">
            <span>Shipping:</span>
            <span>
              ${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) >= 50
                ? 'FREE'
                : (5.99 | currency:'USD')}
            </span>
          </div>
    
          <div if.bind="cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) < 50 && cartItems.length" class="shipping-notice">
            <small>💡 Add ${(50 - cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0)) | currency:'USD'} more for free shipping!</small>
          </div>
    
          <hr class="summary-divider">
    
          <div class="summary-row total-row">
            <strong>Total:</strong>
            <strong class="total-amount">
              ${(cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) +
                 cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * 0.08 +
                 (cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) >= 50 ? 0 : 5.99)) | currency:'USD'}
            </strong>
          </div>
    
          <button
            click.trigger="checkout()"
            disabled.bind="!cartItems.length"
            class="checkout-btn">
            Proceed to Checkout
          </button>
        </div>
      </div>
    </div>
    .shopping-cart {
      max-width: 1200px;
      margin: 0 auto;
      padding: 2rem;
    }
    
    .cart-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 2rem;
    }
    
    .cart-header h1 {
      font-size: 2rem;
      color: #333;
      margin: 0;
    }
    
    .item-count {
      font-size: 1.2rem;
      color: #666;
      font-weight: normal;
    }
    
    .clear-btn {
      padding: 0.5rem 1rem;
      background: #fff;
      border: 1px solid #dc3545;
      color: #dc3545;
      border-radius: 4px;
      cursor: pointer;
      transition: all 0.2s;
    }
    
    .clear-btn:hover {
      background: #dc3545;
      color: white;
    }
    
    .empty-cart {
      text-align: center;
      padding: 4rem 2rem;
    }
    
    .empty-icon {
      font-size: 5rem;
      margin-bottom: 1rem;
      opacity: 0.5;
    }
    
    .empty-cart h2 {
      color: #333;
      margin-bottom: 0.5rem;
    }
    
    .empty-cart p {
      color: #666;
    }
    
    .cart-content {
      display: grid;
      grid-template-columns: 2fr 1fr;
      gap: 2rem;
    }
    
    .cart-items {
      display: flex;
      flex-direction: column;
      gap: 1rem;
    }
    
    .cart-item {
      display: grid;
      grid-template-columns: 100px 1fr auto;
      gap: 1rem;
      padding: 1rem;
      background: white;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      transition: box-shadow 0.2s;
    }
    
    .cart-item:hover {
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    }
    
    .item-image {
      width: 100px;
      height: 100px;
      overflow: hidden;
      border-radius: 4px;
      background: #f5f5f5;
    }
    
    .item-image img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
    
    .item-details {
      display: flex;
      flex-direction: column;
      gap: 0.5rem;
    }
    
    .item-name {
      margin: 0;
      font-size: 1.1rem;
      color: #333;
    }
    
    .item-price {
      margin: 0;
      color: #666;
      font-size: 0.9rem;
    }
    
    .quantity-controls {
      display: flex;
      align-items: center;
      gap: 0.5rem;
    }
    
    .qty-btn {
      width: 32px;
      height: 32px;
      border: 1px solid #ddd;
      background: white;
      border-radius: 4px;
      cursor: pointer;
      font-size: 1.2rem;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: all 0.2s;
    }
    
    .qty-btn:hover:not(:disabled) {
      background: #f0f0f0;
      border-color: #007bff;
    }
    
    .qty-btn:disabled {
      opacity: 0.4;
      cursor: not-allowed;
    }
    
    .qty-input {
      width: 60px;
      height: 32px;
      border: 1px solid #ddd;
      border-radius: 4px;
      text-align: center;
      font-size: 1rem;
    }
    
    .max-qty-label {
      font-size: 0.85rem;
      color: #666;
    }
    
    .item-actions {
      display: flex;
      flex-direction: column;
      align-items: flex-end;
      gap: 0.5rem;
    }
    
    .item-total {
      font-size: 1.2rem;
      font-weight: 600;
      color: #007bff;
    }
    
    .remove-btn {
      width: 32px;
      height: 32px;
      border: 1px solid #dc3545;
      background: white;
      color: #dc3545;
      border-radius: 4px;
      cursor: pointer;
      font-size: 1.5rem;
      line-height: 1;
      transition: all 0.2s;
    }
    
    .remove-btn:hover {
      background: #dc3545;
      color: white;
    }
    
    .cart-summary {
      background: white;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      padding: 1.5rem;
      height: fit-content;
      position: sticky;
      top: 2rem;
    }
    
    .cart-summary h2 {
      margin: 0 0 1rem 0;
      font-size: 1.3rem;
      color: #333;
    }
    
    .summary-row {
      display: flex;
      justify-content: space-between;
      margin-bottom: 0.75rem;
      color: #666;
    }
    
    .shipping-notice {
      background: #e3f2fd;
      padding: 0.5rem;
      border-radius: 4px;
      margin: 0.5rem 0;
      text-align: center;
    }
    
    .shipping-notice small {
      color: #1976d2;
    }
    
    .summary-divider {
      border: none;
      border-top: 1px solid #e0e0e0;
      margin: 1rem 0;
    }
    
    .total-row {
      font-size: 1.2rem;
      color: #333;
      margin-bottom: 1.5rem;
    }
    
    .total-amount {
      color: #007bff;
      font-size: 1.5rem;
    }
    
    .checkout-btn {
      width: 100%;
      padding: 1rem;
      background: #28a745;
      color: white;
      border: none;
      border-radius: 4px;
      font-size: 1.1rem;
      font-weight: 600;
      cursor: pointer;
      transition: background 0.2s;
    }
    
    .checkout-btn:hover:not(:disabled) {
      background: #218838;
    }
    
    .checkout-btn:disabled {
      background: #6c757d;
      cursor: not-allowed;
      opacity: 0.6;
    }
    
    @media (max-width: 768px) {
      .cart-content {
        grid-template-columns: 1fr;
      }
    
      .cart-item {
        grid-template-columns: 80px 1fr;
        gap: 0.75rem;
      }
    
      .item-actions {
        grid-column: 1 / -1;
        flex-direction: row;
        justify-content: space-between;
        align-items: center;
      }
    
      .cart-summary {
        position: static;
      }
    }
    <!-- Item count using reduce -->
    ${cartItems.reduce((sum, item) => sum + item.quantity, 0)}
    
    <!-- Subtotal calculation -->
    ${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) | currency:'USD'}
    
    <!-- Conditional logic for free shipping -->
    ${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) >= 50 ? 'FREE' : (5.99 | currency:'USD')}
    // ✓ Aurelia detects this
    this.cartItems.splice(index, 1);
    
    // ✓ Aurelia detects this
    this.cartItems.push(newItem);
    
    // ✗ Aurelia won't detect this
    this.cartItems[index] = newItem; // Use splice instead
    <div repeat.for="item of cartItems; key: id">
    updateQuantity(item: CartItem, newQuantity: number) {
      if (newQuantity <= 0) {
        this.removeItem(item);
      } else if (newQuantity <= item.maxQuantity) {
        item.quantity = newQuantity;
      } else {
        item.quantity = item.maxQuantity;
        alert(`Maximum quantity is ${item.maxQuantity}`);
      }
    }
    <div if.bind="!cartItems.length" class="empty-cart">
      <!-- Empty state -->
    </div>
    
    <div else class="cart-content">
      <!-- Cart items and summary -->
    </div>
    ${product.price | currency:'USD'}
    export class ShoppingCart {
      cartItems: CartItem[] = [];
    
      constructor() {
        this.loadCart();
      }
    
      private loadCart() {
        const saved = localStorage.getItem('cart');
        if (saved) {
          this.cartItems = JSON.parse(saved);
        }
      }
    
      private saveCart() {
        localStorage.setItem('cart', JSON.stringify(this.cartItems));
      }
    
      addToCart(product: any) {
        // ... existing logic
        this.saveCart();
      }
    
      removeItem(item: CartItem) {
        // ... existing logic
        this.saveCart();
      }
    }
    export class ShoppingCart {
      discountCode = '';
      discountPercentage = 0;
    
      applyDiscount() {
        const codes: Record<string, number> = {
          'SAVE10': 10,
          'SAVE20': 20,
          'FREESHIP': 0 // Handle free shipping separately
        };
    
        if (codes[this.discountCode.toUpperCase()]) {
          this.discountPercentage = codes[this.discountCode.toUpperCase()];
        } else {
          alert('Invalid discount code');
        }
      }
    }
    <div class="discount-section">
      <input value.bind="discountCode" placeholder="Enter discount code">
      <button click.trigger="applyDiscount()">Apply</button>
    </div>
    
    <!-- In the summary, calculate discount using lambda -->
    <div class="summary-row" if.bind="discountPercentage > 0">
      <span>Discount (${discountPercentage}%):</span>
      <span>-${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * (discountPercentage / 100) | currency:'USD'}</span>
    </div>
    
    <!-- Update total to include discount -->
    <div class="summary-row total-row">
      <strong>Total:</strong>
      <strong class="total-amount">
        ${(cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * (1 - discountPercentage / 100) +
           cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * 0.08 +
           (cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) >= 50 ? 0 : 5.99)) | currency:'USD'}
      </strong>
    </div>
    // cart.service.ts
    import { DI } from 'aurelia';
    
    export const ICartService = DI.createInterface<ICartService>(
      'ICartService',
      x => x.singleton(CartService)
    );
    
    export interface ICartService extends CartService {}
    
    export class CartService {
      cartItems: CartItem[] = [];
    
      // ... all cart methods
    }
    
    // Use in components
    import { resolve } from 'aurelia';
    import { ICartService } from './cart.service';
    
    export class ProductList {
      private readonly cart = resolve(ICartService);
    
      addToCart(product: Product) {
        this.cart.addToCart(product);
      }
    }

    Component init

    Runs every attach

    Runs once

    Best for

    Rare toggles

    Frequent toggles

    true, false, true

    $odd

    True when $index % 2 === 1

    false, true, false

    $length

    Total length of the iterable

    3

    item

    Current iteration value

    'Apple', 'Banana', 'Cherry'

    .bind (.two-way)

    Bidirectional observers

    View ↔ ViewModel

    Source observer + DOM listener

    Form controls that read/write

    Event Binding
    Value Converters

    CustomElement API

    The CustomElement resource is a fundamental concept in Aurelia 2, providing the core functionality for creating encapsulated and reusable components. This comprehensive guide covers all aspects of the CustomElement API, including methods, decorators, and configuration options.

    Table of Contents

    Data Table

    A complete, production-ready data table with sorting, filtering, pagination, row selection, and responsive design.

    Features Demonstrated

    • Two-way data binding - Search input, filters, page size

    Quick Reference ("How Do I...")

    Navigate your Aurelia 2 application with confidence using this task-focused quick reference.

    Table of Contents

    • CustomElement.for

    • CustomElement.define

    • CustomElement.getDefinition

    • CustomElement.find

  • Metadata Methods

    • CustomElement.annotate

    • CustomElement.getAnnotation

  • Utility Methods

    • CustomElement.generateName

    • CustomElement.generateType

    • CustomElement.createInjectable

  • Decorators

    • @customElement

    • @useShadowDOM

    • @containerless

  • Definition Objects

    • PartialCustomElementDefinition

    • CustomElementDefinition

  • Core Methods

    CustomElement.for

    Retrieves the Aurelia controller associated with a DOM node. The controller provides access to the element's view model, lifecycle methods, and other properties.

    Method Signatures

    Parameters

    • node: Node - The DOM node for which to retrieve the controller

    • opts?: object - Optional configuration object with the following properties:

      • optional?: boolean - If true, returns null instead of throwing when no controller is found

      • searchParents?: boolean - If true, searches parent nodes (including containerless elements) for a controller

      • name?: string - If provided, only returns controllers for custom elements with this specific name

    Examples

    CustomElement.define

    Registers a class as a custom element in Aurelia. This method can be called directly or is used internally by the @customElement decorator.

    Method Signatures

    Parameters

    • nameOrDef: string | PartialCustomElementDefinition - Either the element name or a complete definition object

    • Type?: Constructable - The class containing the element's logic (optional when using definition object)

    Examples

    CustomElement.getDefinition

    Retrieves the CustomElementDefinition for a custom element class, providing access to all metadata about the element.

    Method Signature

    Parameters

    • Type: Constructable - The custom element class

    Return Value

    Returns a CustomElementDefinition object containing all metadata about the custom element.

    Example

    CustomElement.find

    Searches for a custom element definition by name within a specific container's registry.

    Method Signature

    Parameters

    • container: IContainer - The dependency injection container to search in

    • name: string - The name of the custom element to find

    Return Value

    Returns the CustomElementDefinition if found, or null if no element with the specified name is registered.

    Example

    CustomElement.isType

    Checks whether a given value is a custom element type (class decorated with @customElement or defined via CustomElement.define).

    Method Signature

    Parameters

    • value: any - The value to check

    Return Value

    Returns true if the value is a custom element type, false otherwise.

    Example

    Metadata Methods

    CustomElement.annotate

    Attaches metadata to a custom element class. This is typically used internally by decorators and the framework.

    Method Signature

    Parameters

    • Type: Constructable - The custom element class to annotate

    • prop: string - The property key for the annotation

    • value: any - The value to associate with the property

    Example

    CustomElement.getAnnotation

    Retrieves metadata that was previously attached to a custom element class.

    Method Signature

    Parameters

    • Type: Constructable - The custom element class

    • prop: string - The property key to retrieve

    Return Value

    Returns the annotation value, or undefined if not found.

    Example

    Utility Methods

    CustomElement.generateName

    Generates a unique name for a custom element, useful for anonymous or dynamically created elements.

    Method Signature

    Return Value

    A string representing a unique name (typically in the format unnamed-{number}).

    Example

    CustomElement.generateType

    Dynamically generates a CustomElementType with a given name and prototype properties.

    Method Signature

    Parameters

    • name: string - The name for the generated type

    • proto?: object - An optional object containing properties and methods to add to the prototype

    Return Value

    A CustomElementType that can be used with CustomElement.define.

    Example

    CustomElement.createInjectable

    Creates an InjectableToken for dependency injection scenarios.

    Method Signature

    Return Value

    An InterfaceSymbol that can be used as a dependency injection token.

    Example

    CustomElement.keyFrom

    Generates the registry key used internally to store and retrieve custom element definitions.

    Method Signature

    Parameters

    • name: string - The custom element name

    Return Value

    A string representing the internal registry key.

    Example

    Decorators

    @customElement Decorator

    The primary decorator for marking a class as a custom element.

    Syntax

    Examples

    @useShadowDOM Decorator

    Enables Shadow DOM for the custom element.

    Syntax

    Example

    @containerless Decorator

    Renders the custom element without its element container.

    Syntax

    Example

    @capture Decorator

    Enables capturing of all attributes and bindings that are not explicitly defined as bindables or template controllers.

    Syntax

    Example

    @processContent Decorator

    Defines a hook that processes the element's content before compilation.

    Syntax

    Example

    Definition Objects

    PartialCustomElementDefinition

    An object that describes a custom element's configuration. All properties are optional.

    Properties

    Example

    CustomElementDefinition

    The complete, resolved definition of a custom element (read-only).

    Key Properties

    Programmatic Resource Aliases

    PartialCustomElementDefinition.aliases is only one way to expose alternative names. For reusable libraries or bridge packages you often need to add aliases outside of the definition itself. The runtime provides two helpers to make that ergonomic.

    alias(...aliases) decorator

    Apply the decorator directly to any custom element, custom attribute, value converter, or binding behavior to append aliases to the resource metadata.

    The decorator merges with aliases declared via the definition object, so you can sprinkle default aliases in a base class and extend them in derived implementations without clobbering earlier metadata.

    registerAliases(...)

    When you need to attach aliases to an existing resource (for example, to keep backwards compatibility after a rename), call registerAliases during app startup.

    The resource argument identifies which registry to update. Pass CustomElement, CustomAttribute, ValueConverter, or BindingBehavior depending on the resource you are aliasing. Because aliases are registered against the supplied container you can scope them to individual feature modules or make them global by running the task in your root configuration.

    Best Practices

    1. Use Decorators Over Direct API Calls

    2. Type Your Controllers

    3. Handle Errors Gracefully

    4. Leverage Definition Objects for Complex Elements

    Core Methods
    Computed properties - Filtered, sorted, and paginated data
  • repeat.for with keys - Efficient list rendering with tracking

  • Event handling - Sort, filter, pagination clicks

  • Conditional rendering - Empty states, loading states

  • Value converters - Date and number formatting

  • CSS class binding - Active sort, selected rows

  • Debouncing - Optimize search performance

  • Code

    View Model (data-table.ts)

    Template (data-table.html)

    Styles (data-table.css)

    How It Works

    Filtering Pipeline

    Data flows through a pipeline:

    1. Raw data (allUsers) → all records

    2. Filtered (filteredUsers) → apply search and dropdown filters

    3. Sorted (sortedUsers) → apply column sorting

    4. Paginated (paginatedUsers) → slice for current page

    Each computed property builds on the previous one, keeping the logic clean and testable.

    Sorting

    Click column headers to sort. The first click sorts ascending, the second descending, and subsequent clicks toggle between the two. The active sort column is highlighted.

    Pagination

    Smart pagination shows up to 5 page numbers with ellipsis for gaps. Always shows first and last pages. Automatically adjusts when filters reduce total pages.

    Selection

    • Checkbox in header selects/deselects all rows on current page

    • Individual row checkboxes for granular selection

    • Selected rows track across pages

    • Delete selected button removes all selected users

    Performance

    • Debounced search (300ms) prevents excessive filtering

    • Keyed repeat ensures efficient DOM updates

    • Computed properties cache results until dependencies change

    Variations

    Server-Side Pagination

    For large datasets, move filtering/sorting to the server:

    Inline Editing

    Add edit mode for quick updates:

    Column Visibility Toggle

    Let users show/hide columns:

    Related

    • Product Catalog - Another filtering/sorting example

    • List Rendering - repeat.for documentation

    • Conditional Rendering - if.bind and show.bind

    • - Date/number formatting

  • Route Parameters

  • Route Protection

  • Lifecycle Hooks

  • Advanced Topics

  • Troubleshooting


  • Getting Started

    How do I install and configure the router?

    Full configuration options →

    How do I define routes?

    Configuring routes →

    How do I set up a viewport?

    Viewports documentation →

    How do I use hash-based routing instead of clean URLs?

    Hash vs PushState routing →


    Navigation

    How do I create navigation links?

    Navigation methods →

    How do I navigate programmatically?

    Using the Router API →

    How do I highlight the active link?

    Active CSS class →

    How do I navigate to parent routes from nested components?

    Ancestor navigation →

    How do I pass query parameters?

    Query parameters →

    How do I handle external links?

    Good news: External links work automatically! The router automatically ignores:

    Only use external attribute for edge cases:

    How it works: The router uses the URL constructor to check if a link is external. Any URL that can be parsed without a base (like https://, mailto:, etc.) is automatically treated as external.

    Bypassing the router →


    Route Parameters

    How do I define route parameters?

    Path and parameters →

    How do I access route parameters in my component?

    Lifecycle hooks →

    How do I get all parameters including from parent routes?

    Aggregate parameters → Route parameters guide →

    How do I constrain parameters with regex?

    Constrained parameters →


    Route Protection

    How do I protect routes (authentication)?

    Router hooks →

    How do I implement authorization (role-based access)?

    Router hooks example →

    How do I prevent navigation away from unsaved forms?

    canUnload hook →

    How do I redirect based on conditions?

    Redirect from canLoad →


    Lifecycle Hooks

    How do I load data before showing a component?

    loading hook →

    How do I run code after a component is fully loaded?

    loaded hook →

    When do lifecycle hooks run?

    Hook
    When
    Use For

    canLoad

    Before activation

    Guards, redirects, param validation

    loading

    After approval, before render

    Data fetching, state setup

    loaded

    After render

    Analytics, scroll, post-render effects

    canUnload

    Before deactivation

    Unsaved changes warnings

    Hook summary →

    What's the difference between component hooks and router hooks?

    • Component hooks (IRouteViewModel): Implemented on the component itself

    • Router hooks (@lifecycleHooks()): Shared across multiple components

    Router hooks vs component hooks →


    Advanced Topics

    How do I handle 404 / unknown routes?

    Fallback configuration →

    How do I create route aliases / redirects?

    Redirects →

    How do I work with multiple viewports (sibling routes)?

    Sibling viewports →

    How do I implement nested/child routes?

    Hierarchical routing → Child routing playbook →

    How do I lazy load routes?

    Using inline import() →

    How do I set/change the page title?

    Setting titles → | Customizing titles →

    How do I generate URLs without navigating?

    Path generation →

    How do I work with base paths (multi-tenant apps)?

    Base path configuration →

    How do I handle browser back/forward buttons?

    History strategy →

    How do I access the current route information?

    Current route →


    Troubleshooting

    My routes don't work with clean URLs (no hash)

    Problem: Getting 404 errors when refreshing or accessing routes directly

    Solution:

    1. Ensure <base href="/"> is in your HTML

    2. Configure server for SPA routing (return index.html for all routes)

    3. Or use hash routing: useUrlFragmentHash: true

    PushState configuration →

    External links are triggering the router (rare)

    Problem: External links somehow being handled by router

    This should NOT happen - the router automatically ignores external links like https://, mailto:, tel:, etc.

    If it's happening:

    1. Check your link format - is it truly external?

    2. You probably don't need the external attribute anymore

    3. Links with protocol (https://, mailto:) are automatically bypassed

    Only needed for edge cases:

    Bypassing href →

    Navigation isn't working from nested components

    Problem: Links to sibling routes not working

    Solution: Use ../ prefix for parent context

    Ancestor navigation →

    My lifecycle hooks aren't being called

    Problem: canLoad, loading, etc. not executing

    Solution: Implement the IRouteViewModel interface

    Lifecycle hooks →

    Route parameters aren't updating when navigating between same routes

    Problem: Navigating from /users/1 to /users/2 doesn't update component

    Solution: Configure transition plan

    Transition plans →

    How do I debug routing issues?

    Router events →


    Complete Documentation

    • Getting Started

    • Router Configuration

    • Configuring Routes

    • Child Routing Playbook

    Getting Started
    Navigation

    Router configuration

    Learn about configuring the Router.

    Bundler note: These examples import '.html' files as raw strings (showing '?raw' for Vite/esbuild). Configure your bundler as described in Importing external HTML templates with bundlers so the imports resolve to strings on Webpack, Parcel, etc.

    The router allows you to configure how it interprets and handles routing in your Aurelia applications. The customize method on the RouterConfiguration object can be used to configure router settings.

    Complete Configuration Reference

    The router accepts the following configuration options through RouterConfiguration.customize() (all map directly to RouterOptions except for basePath):

    Option
    Type
    Default
    Description

    Pass a partial options object—the router merges your values with the defaults so you only specify what changes. If you need to adjust settings later, resolve IRouterOptions from DI and mutate it at runtime. Unlike RouterConfiguration.customize in @aurelia/router-direct, this overload does not accept a callback—use app tasks or register your own startup code if you need to defer router.start().

    Choose between hash and pushState routing using useUrlFragmentHash

    If you do not provide any configuration value, the default is pushState routing. If you prefer hash-based routing to be used, you can enable this like so:

    By calling the customize method, you can supply a configuration object containing the property useUrlFragmentHash and supplying a boolean value. If you supply true this will enable hash mode. The default is false.

    If you are working with pushState routing, you will need a <base> element with href attribute (for more information, refer ) in the head of your document. The scaffolded application from the CLI includes this in the index.html file, but if you're starting from scratch or building within an existing application you need to be aware of this.

    PushState requires server-side support. This configuration is different depending on your server setup. For example, if you are using Webpack DevServer, you'll want to set the devServer.historyApiFallback option to true. If you are using ASP.NET Core, you'll want to call routes.MapSpaFallbackRoute in your startup code. See your preferred server technology's documentation for more information on how to allow 404s to be handled on the client with push state.

    Configuring basePath

    Configuring a base path is useful in many real-life scenarios. One such example is when you are hosting multiple smaller application under a single hosting service. In this case, you probably want the URLs to look like https://example.com/app1/view42 or https://example.com/app2/view21. In such cases, it is useful to specify a different value for every app.

    Run the following example to understand how the value defined in base#href is affecting the URLs.

    When you open the example in a new browser tab, you can note that the URL in the address bar looks the HOSTING_PREFIX/app/home or HOSTING_PREFIX/app/about. This is also true for the href values in the a tags. This happens because <base href="/app"> is used in the index.ejs (producing the index.html). In this case, the router is picking up the baseURI information and performing the routing accordingly.

    This needs bit more work when you are supporting multi-tenancy for your app. In this case, you might want the URLs look like https://example.com/tenant-foo/app1/view42 or https://example.com/tenant-bar/app2/view21. You cannot set the document.baseURI every time you start the app for a different tenant, as that value is static and readonly, read from the base#href value.

    With router you can support this by setting the basePath value differently for each tenant, while customizing the router configuration, at bootstrapping phase. Following is an example that implements the aforementioned URL convention. To better understand, open the the example in a new tab and check the URL in address bar when you switch tenants as well as the links in the a tags.

    The actual configuration takes place in the main.ts while customizing the router configuration in the following lines of code.

    There are also the following links, included in the my-app.html, to simulate tenant switch/selection.

    Note the a tags with . Note that when you switch to a tenant, the links in the a tags also now includes the tenant name; for example when we switch to tenant 'foo' the 'Home' link is changed to /foo/app/home from /app/home.

    Provide a custom location manager

    If your host does not behave like a normal browser history stack (for example, a native WebView, an Electron shell, or a sandbox that proxies URLs), override the router’s location manager. The router always resolves ILocationManager from DI and ships with a browser-based implementation. Register your own class that implements the same public surface (startListening, stopListening, handleEvent, pushState, replaceState, getPath, addBaseHref, removeBaseHref) before the router starts:

    Because RouterConfiguration registers BrowserLocationManager as a singleton, registering your custom implementation afterward replaces it everywhere. Match the method contracts from the linked file so the router keeps receiving normalized URLs and can keep raising au:router:location-change.

    Swap the URL parser

    RouterOptions stores an _urlParser instance that is derived from useUrlFragmentHash. Advanced apps can replace that parser before any navigation happens. The _urlParser field is marked readonly, so use Writable<T> from @aurelia/kernel when mutating:

    Every call to ViewportInstruction.toUrl, router.load, or router.generatePath now runs through your parser while still using the same API surface as the built-in implementation.

    Customizing title

    A buildTitle function can be used to customize the . For this example, we assume that we have the configured the routes as follows:

    With this route configuration in place, when we navigate to /home, the default-built title will be Home | Aurelia. We can use the following buildTitle function that will cause the title to be Aurelia - Home when users navigate to / or /home route.

    Check out the following live example. You might need to open the demo in a new tab to observe the title changes.

    Translating the title

    When localizing your app, you would also like to translate the title. Note that the router does not facilitate the translation by itself. However, there are enough hooks that can be leveraged to translate the title. To this end, we would use the in the route configuration to store the i18n key.

    As data is an object of type Record<string, unknown>, you are free to chose the property names inside the data object. Here we are using the i18n property to store the i18n key for individual routes.

    In the next step we make use of the buildTitle customization as well as a AppTask hook to subscribe to the locale change event.

    This customization in conjunction with the previously shown routing configuration will cause the title to be Aurelia - Startseite when user is navigated to / or /home route and the current locale is de. Here we are assuming that the i18n resource for the de locale contains the following.

    The following example demonstrate the title translation.

    Enable or disable the usage of the href custom attribute using useHref

    By default, the router will allow you to use both href as well as load for specifying routes. Where this can get you into trouble is external links, mailto: links and other types of links that do not route. A simple example looks like this:

    This seemingly innocent and common scenario by default will trigger the router and will cause an error.

    You have two options when it comes to working with external links. You can specify the link as external using the .

    Or, you can set useHref to false (default is true) and only ever use the load attribute for routes.

    Configure browser history strategy

    Using the historyStrategy configuration option it can be instructed, how the router should interact with the browser history object. This configuration option can take the following values: push, replace, and none.

    push

    This is the default strategy. In this mode, the router will interact with Browser history to push a new navigation state each time a new navigation is performed. This enables the end users to use the back and forward buttons of the browser to navigate back and forth in an application using the router.

    Check out the following example to see this in action.

    The main configuration can be found in the main.ts.

    To demonstrate the push behavior, there is a small piece of code in the my-app.ts that listens to router events to create informative text (the history property in the class) from the browser history object that is used in the view to display the information.

    As you click the Home and About links in the example, you can see that the new states are being pushed to the history, and thereby increasing the length of the history.

    replace

    This can be used to replace the current state in the history. Check out the following example to see this in action. Note that the following example is identical with the previous example, with the difference of using the replace-value as the history strategy.

    As you interact with this example, you can see that new states are replacing old states, and therefore, unlike the previous example, you don't observe any change in the length of the history.

    none

    Use this if you don't want the router to interact with the history at all. Check out the following example to see this in action. Note that the following example is identical with the previous example, with the difference of using the none-value as the history strategy.

    As you interact with this example, you can see that there is absolutely no change in the history information, indicating non-interaction with the history object.

    Override configured history strategy

    You can use the to override the configured history strategy for individual routing instructions.

    Return a dynamic history strategy

    RouterOptions.historyStrategy is declared as ValueOrFunc<HistoryStrategy>, so you can supply a function whenever you call RouterConfiguration.customize. That callback receives the ViewportInstructionTree for the pending transition, allowing you to branch on route metadata:

    The router invokes your function right before it pushes or replaces browser history inside router.load, so every navigation—declarative or programmatic—follows the same rule.

    Configure active class

    Using the activeClass option you can add a class name to the router configuration. This class name is used by the when the associated instruction is active. The default value for this option is null, which also means that the load custom attribute won't add any class proactively. Note that the router does not define any CSS class out-of-the-box. If you want to use this feature, make sure that you defines the class as well in your stylesheet.

    Disable navigation model generation

    If you're not using the navigation model feature for building menus, you can disable it to improve performance:

    This prevents the router from generating navigation model data, which can be useful in applications with many routes where you don't need the navigation model functionality.

    Error recovery configuration

    The restorePreviousRouteTreeOnError option controls what happens when navigation fails:

    With the default true setting, if navigation fails (due to guards returning false, component loading errors, etc.), the router will restore the previous working route. Setting this to false provides stricter error handling but requires your application to handle error states properly.

    Observing navigation state while configuring the router

    Beyond setting up routes, hash/push mode, or titles, you can optionally observe the active route and track query parameters. One way is to inject ICurrentRoute in any of your components. Another is to watch router events:

    This can help debug or log your router's runtime state. See the for an example usage.

    Treat query parameters as path parameters

    When the treatQueryAsParameters property in the router configuration is set to true, the router will treat query parameters as path parameters. The default value is false.

    This is a temporary option to help developers transitioning from router-direct to router. It will be removed in the future version.

    Advanced Configuration Scenarios

    Combining Multiple Options

    Most real-world applications will need to combine multiple configuration options:

    Environment-Specific Configuration

    You might want different configurations for different environments:

    Micro-frontend Configuration

    When building micro-frontends, you might need specific base path configurations:

    Single-Page Application Embedded in Existing Site

    When your Aurelia app is embedded within a larger traditional website:

    Common Configuration Patterns

    Mobile-Optimized Configuration

    Debug-Friendly Development Configuration

    Manual router lifecycle control

    The router instance exposes start(performInitialNavigation: boolean) and stop() so you can decide when navigation begins or ends. RouterConfiguration normally starts the router automatically, but embedded scenarios (micro-frontends, SSR hand-off, wizard-style shells) often need to defer the first navigation until data is ready:

    • Passing false prevents the router from immediately loading location.pathname. Call router.load() yourself once prerequisites are satisfied.

    • stop() unsubscribes the router from au:router:location-change events and stops listening to browser history so you can safely dispose of the hosting view.

    Because this matches the same methods used inside the framework, it stays aligned with the code linked above.

    Troubleshooting Configuration Issues

    Common Problems and Solutions

    Problem: Routes not working with useUrlFragmentHash: false

    Problem: External links being processed by router

    Problem: Navigation not updating browser history

    Binding behaviors

    Binding behaviors are a powerful category of view resources in Aurelia 2 that modify how bindings operate. Unlike value converters which transform data, binding behaviors have complete access to the binding instance throughout its entire lifecycle, allowing them to fundamentally alter binding behavior.

    Overview

    Binding behaviors enable you to:

    • Control timing - throttle, debounce, or trigger updates at specific intervals

    // Get controller for the current node
    CustomElement.for<T>(node: Node): ICustomElementController<T>
    
    // Get controller with optional flag (returns null if not found)
    CustomElement.for<T>(node: Node, opts: { optional: true }): ICustomElementController<T> | null
    
    // Search parent nodes for a controller
    CustomElement.for<T>(node: Node, opts: { searchParents: true }): ICustomElementController<T>
    
    // Get controller for a named custom element
    CustomElement.for<T>(node: Node, opts: { name: string }): ICustomElementController<T> | undefined
    
    // Get controller for a named custom element, searching parents
    CustomElement.for<T>(node: Node, opts: { name: string; searchParents: true }): ICustomElementController<T> | undefined
    import { CustomElement, ILogger } from 'aurelia';
    
    // Basic usage - get controller for current node
    const myElement = document.querySelector('.my-custom-element');
    try {
      const controller = CustomElement.for(myElement);
      // You can inject ILogger in your classes for proper logging
      this.logger?.info('View model:', controller.viewModel);
      this.logger?.info('Element state:', controller.state);
    } catch (error) {
      this.logger?.error('The provided node does not host a custom element.', error);
    }
    
    // Safe retrieval without throwing errors
    const optionalController = CustomElement.for(myElement, { optional: true });
    if (optionalController) {
      // Controller found and available for use
      optionalController.viewModel.someMethod();
    } else {
      // No controller found, handle gracefully
      this.logger?.info('Node is not a custom element');
    }
    
    // Search parent hierarchy for any custom element controller
    const someInnerElement = document.querySelector('.some-inner-element');
    const parentController = CustomElement.for(someInnerElement, { searchParents: true });
    // parentController is the closest controller up the DOM tree
    
    // Get controller for a specific named custom element
    const namedController = CustomElement.for(myElement, { name: 'my-custom-element' });
    if (namedController) {
      // Found a controller for the specific element type
    } else {
      // The node is not hosting the named custom element type
    }
    
    // Search parents for a specific named custom element
    const namedParentController = CustomElement.for(someInnerElement, {
      name: 'my-custom-element',
      searchParents: true
    });
    
    // Access view model properties and methods
    const controller = CustomElement.for(myElement);
    const viewModel = controller.viewModel;
    viewModel.myProperty = 'new value';
    viewModel.myMethod();
    
    // Access lifecycle state
    this.logger?.info('Current state:', controller.state);
    this.logger?.info('Is activated:', controller.isActive);
    // Define with name and class
    CustomElement.define<T>(name: string, Type: Constructable<T>): CustomElementType<T>
    
    // Define with definition object and class
    CustomElement.define<T>(def: PartialCustomElementDefinition, Type: Constructable<T>): CustomElementType<T>
    
    // Define with definition object only (generates type)
    CustomElement.define<T>(def: PartialCustomElementDefinition): CustomElementType<T>
    import { CustomElement } from 'aurelia';
    
    // Basic definition with name and class
    class MyCustomElement {
      public message = 'Hello, World!';
    
      public greet() {
        alert(this.message);
      }
    }
    
    CustomElement.define('my-custom-element', MyCustomElement);
    
    // Definition with complete configuration object
    const definition = {
      name: 'advanced-element',
      template: '<h1>${title}</h1><div class="content"><au-slot></au-slot></div>',
      bindables: ['title', 'size'],
      shadowOptions: { mode: 'open' },
      containerless: false,
      capture: true,
      dependencies: []
    };
    
    class AdvancedElement {
      public title = '';
      public size = 'medium';
    }
    
    CustomElement.define(definition, AdvancedElement);
    
    // Definition without explicit type (generates anonymous class)
    const simpleDefinition = {
      name: 'simple-element',
      template: '<p>${text}</p>',
      bindables: ['text']
    };
    
    const SimpleElementType = CustomElement.define(simpleDefinition);
    
    // Note: Using @customElement decorator is preferred over calling define directly
    @customElement('my-element')
    class MyElement {
      // This is equivalent to calling CustomElement.define('my-element', MyElement)
    }
    CustomElement.getDefinition<T>(Type: Constructable<T>): CustomElementDefinition<T>
    import { CustomElement, ILogger } from 'aurelia';
    
    @customElement({
      name: 'my-element',
      template: '${message}',
      bindables: ['message']
    })
    class MyElement {
      public message = '';
    
      constructor(private logger: ILogger) {}
    
      public logDefinitionInfo() {
        const definition = CustomElement.getDefinition(MyElement);
    
        this.logger.info('Element name:', definition.name); // 'my-element'
        this.logger.info('Template:', definition.template);
        this.logger.info('Bindables:', definition.bindables);
        this.logger.info('Is containerless:', definition.containerless);
        this.logger.info('Shadow options:', definition.shadowOptions);
        this.logger.info('Dependencies:', definition.dependencies);
        this.logger.info('Aliases:', definition.aliases);
        this.logger.info('Capture mode:', definition.capture);
      }
    }
    CustomElement.find(container: IContainer, name: string): CustomElementDefinition | null
    import { CustomElement, IContainer, ILogger } from 'aurelia';
    
    // In a custom service or component
    class MyService {
      constructor(private container: IContainer, private logger: ILogger) {}
    
      public checkElementExists(elementName: string): boolean {
        const definition = CustomElement.find(this.container, elementName);
        return definition !== null;
      }
    
      public getElementTemplate(elementName: string): string | null {
        const definition = CustomElement.find(this.container, elementName);
        return definition?.template as string || null;
      }
    }
    
    // Usage in template compiler or dynamic composition
    class SomeComponent {
      constructor(private logger: ILogger) {}
    
      public checkDynamicElement(container: IContainer) {
        const definition = CustomElement.find(container, 'my-dynamic-element');
        if (definition) {
          // Element is registered and available for use
          this.logger.info('Found element:', definition.name);
        } else {
          // Element not found in current container
          this.logger.warn('Element not registered');
        }
      }
    }
    CustomElement.isType<T>(value: T): value is CustomElementType<T>
    import { CustomElement, customElement, ILogger } from 'aurelia';
    
    @customElement('my-element')
    class MyElement {}
    
    class RegularClass {}
    
    // Service class that performs type checking
    class TypeCheckingService {
      constructor(private logger: ILogger) {}
    
      public demonstrateTypeChecking() {
        // Type checking
        this.logger.info('MyElement is custom element type:', CustomElement.isType(MyElement)); // true
        this.logger.info('RegularClass is custom element type:', CustomElement.isType(RegularClass)); // false
        this.logger.info('String is custom element type:', CustomElement.isType('string')); // false
        this.logger.info('Number is custom element type:', CustomElement.isType(42)); // false
      }
    
      // Usage in dynamic scenarios
      public processComponent(component: unknown) {
        if (CustomElement.isType(component)) {
          // Safe to use as custom element
          const definition = CustomElement.getDefinition(component);
          this.logger.info('Processing element:', definition.name);
        } else {
          this.logger.info('Not a custom element type');
        }
      }
    }
    CustomElement.annotate<K extends keyof PartialCustomElementDefinition>(
      Type: Constructable,
      prop: K,
      value: PartialCustomElementDefinition[K]
    ): void
    import { CustomElement } from 'aurelia';
    
    class MyElement {}
    
    // Manually annotate the class (decorators do this automatically)
    CustomElement.annotate(MyElement, 'template', '${message}');
    CustomElement.annotate(MyElement, 'bindables', ['message']);
    CustomElement.annotate(MyElement, 'containerless', true);
    CustomElement.getAnnotation<K extends keyof PartialCustomElementDefinition>(
      Type: Constructable,
      prop: K
    ): PartialCustomElementDefinition[K] | undefined
    import { CustomElement, customElement, ILogger } from 'aurelia';
    
    @customElement({
      name: 'annotated-element',
      template: '${content}'
    })
    class AnnotatedElement {
      constructor(private logger: ILogger) {}
    
      public logAnnotations() {
        // Retrieve annotations
        const template = CustomElement.getAnnotation(AnnotatedElement, 'template');
        const bindables = CustomElement.getAnnotation(AnnotatedElement, 'bindables');
    
        this.logger.info('Template:', template);
        this.logger.info('Bindables:', bindables);
      }
    }
    CustomElement.generateName(): string
    import { CustomElement } from 'aurelia';
    
    // Generate unique names for dynamic elements
    const uniqueName1 = CustomElement.generateName(); // 'unnamed-1'
    const uniqueName2 = CustomElement.generateName(); // 'unnamed-2'
    
    // Use with dynamic element creation
    class DynamicElement {
      public data = '';
    }
    
    const DynamicElementType = CustomElement.define(uniqueName1, DynamicElement);
    CustomElement.generateType<P extends object = object>(
      name: string,
      proto?: P
    ): CustomElementType<Constructable<P>>
    import { CustomElement } from 'aurelia';
    
    // Generate a type with custom properties and methods
    const DynamicElement = CustomElement.generateType('dynamic-element', {
      message: 'Hello from Dynamic Element!',
      count: 0,
    
      increment() {
        this.count++;
      },
    
      showMessage() {
        alert(`${this.message} Count: ${this.count}`);
      }
    });
    
    // Define the generated type
    CustomElement.define('dynamic-element', DynamicElement);
    
    // Usage in templates: <dynamic-element></dynamic-element>
    CustomElement.createInjectable<T = any>(): InterfaceSymbol<T>
    import { CustomElement, resolve } from 'aurelia';
    
    // Create injectable tokens for custom scenarios
    const MyServiceToken = CustomElement.createInjectable<MyService>();
    
    // Use in dependency injection
    class MyElement {
      private service = resolve(MyServiceToken);
    }
    CustomElement.keyFrom(name: string): string
    import { CustomElement, ILogger } from 'aurelia';
    
    class KeyGeneratorService {
      constructor(private logger: ILogger) {}
    
      public demonstrateKeyGeneration() {
        const key = CustomElement.keyFrom('my-element');
        this.logger.info(key); // 'au:ce:my-element' (internal format)
    
        // Used internally for container registration/lookup
        const hasElement = container.has(CustomElement.keyFrom('my-element'));
      }
    }
    @customElement(name: string)
    @customElement(definition: PartialCustomElementDefinition)
    import { customElement } from 'aurelia';
    
    // Simple name-based definition
    @customElement('hello-world')
    class HelloWorld {
      public message = 'Hello, World!';
    }
    
    // Full definition object
    @customElement({
      name: 'advanced-component',
      template: `
        <h1>\${title}</h1>
        <div class="content">
          <au-slot></au-slot>
        </div>
      `,
      bindables: ['title', 'theme'],
      shadowOptions: { mode: 'open' },
      dependencies: []
    })
    class AdvancedComponent {
      public title = '';
      public theme = 'light';
    }
    @useShadowDOM(options?: { mode: 'open' | 'closed' })
    import { customElement, useShadowDOM } from 'aurelia';
    
    @customElement('shadow-element')
    @useShadowDOM({ mode: 'open' })
    class ShadowElement {
      // This element will render in Shadow DOM
    }
    
    // Or with default mode (open)
    @customElement('shadow-element-simple')
    @useShadowDOM()
    class ShadowElementSimple {}
    @containerless()
    @containerless(target: Constructable, context: ClassDecoratorContext)
    import { customElement, containerless } from 'aurelia';
    
    @customElement('invisible-wrapper')
    @containerless()
    class InvisibleWrapper {
      // This element won't create its own DOM node
      // Only its content will be rendered
    }
    
    // Usage: <invisible-wrapper>content</invisible-wrapper>
    // Renders: content (without the wrapper element)
    @capture()
    @capture(filter: (attr: string) => boolean)
    import { customElement, capture } from 'aurelia';
    
    @customElement('flexible-element')
    @capture() // Capture all unrecognized attributes
    class FlexibleElement {
      // Any attribute not defined as bindable will be captured
    }
    
    @customElement('filtered-element')
    @capture((attrName) => attrName.startsWith('data-'))
    class FilteredElement {
      // Only capture attributes that start with 'data-'
    }
    @processContent(hook: ProcessContentHook)
    @processContent(methodName: string | symbol)
    @processContent() // Decorator for static method
    import { customElement, processContent, IPlatform } from 'aurelia';
    
    @customElement('content-processor')
    class ContentProcessor {
      @processContent()
      static processContent(node: HTMLElement, platform: IPlatform): boolean | void {
        // Modify the element's content before compilation
        const children = Array.from(node.children);
        children.forEach(child => {
          if (child.tagName === 'SPECIAL') {
            child.setAttribute('processed', 'true');
          }
        });
        return true; // Continue with normal compilation
      }
    }
    
    // Or reference a method by name
    @customElement('named-processor')
    @processContent('customProcessor')
    class NamedProcessor {
      static customProcessor(node: HTMLElement, platform: IPlatform): boolean | void {
        // Process content
        return true;
      }
    }
    interface PartialCustomElementDefinition {
      name?: string;                    // Element name (kebab-case)
      template?: string | Node | null;  // HTML template
      bindables?: string[] | object;    // Bindable properties
      dependencies?: any[];             // Required dependencies
      aliases?: string[];               // Alternative names
      containerless?: boolean;          // Render without container
      shadowOptions?: { mode: 'open' | 'closed' } | null; // Shadow DOM options
      hasSlots?: boolean;              // Has <au-slot> elements
      capture?: boolean | ((attr: string) => boolean); // Capture unbound attributes
      enhance?: boolean;               // Enhance existing DOM
      instructions?: any[][];          // Template instructions
      surrogates?: any[];             // Surrogate instructions
      needsCompile?: boolean;         // Requires compilation
      injectable?: any;               // DI token
      watches?: any[];               // Property watchers
      strict?: boolean;              // Strict binding mode
      processContent?: Function;     // Content processing hook
    }
    const elementDefinition: PartialCustomElementDefinition = {
      name: 'my-component',
      template: `
        <h1>\${title}</h1>
        <p class="description">\${description}</p>
        <div class="actions">
          <au-slot name="actions"></au-slot>
        </div>
      `,
      bindables: ['title', 'description'],
      shadowOptions: { mode: 'open' },
      containerless: false,
      hasSlots: true,
      capture: false,
      dependencies: [],
      aliases: ['my-comp']
    };
    interface CustomElementDefinition {
      readonly Type: CustomElementType;      // The element class
      readonly name: string;                 // Element name
      readonly template: string | Node | null; // Compiled template
      readonly bindables: Record<string, BindableDefinition>; // Resolved bindables
      readonly aliases: string[];            // Alternative names
      readonly key: string;                  // Registry key
      readonly containerless: boolean;       // Container rendering mode
      readonly shadowOptions: { mode: 'open' | 'closed' } | null; // Shadow DOM
      readonly hasSlots: boolean;           // Contains slots
      readonly capture: boolean | Function; // Attribute capturing
      readonly enhance: boolean;            // DOM enhancement mode
      readonly dependencies: any[];         // Required dependencies
      readonly instructions: any[][];       // Template instructions
      readonly surrogates: any[];          // Surrogate instructions
      readonly needsCompile: boolean;      // Compilation requirement
      readonly watches: any[];             // Property watchers
      readonly strict: boolean | undefined; // Strict binding mode
      readonly processContent: Function | null; // Content processor
    }
    import { alias, customElement } from '@aurelia/runtime-html';
    
    @alias('counter-panel', 'stats-card')
    @customElement({
      name: 'au-counter',
      template: `
        <section class="counter">
          <h2>\${title}</h2>
          <slot></slot>
        </section>
      `
    })
    export class CounterPanel {
      title = 'Visitors';
    }
    import { AppTask, CustomElement, registerAliases } from '@aurelia/runtime-html';
    import { IContainer } from '@aurelia/kernel';
    
    export const LegacyCounterAliases = AppTask.creating(IContainer, container => {
      const definition = CustomElement.getDefinition(CounterPanel);
      registerAliases(['legacy-counter', 'legacy-panel'], CustomElement, definition.key, container);
    });
    // Preferred
    @customElement('my-element')
    class MyElement {}
    
    // Avoid unless in dynamic scenarios
    CustomElement.define('my-element', MyElement);
    interface MyElementViewModel {
      title: string;
      count: number;
      increment(): void;
    }
    
    const controller = CustomElement.for<MyElementViewModel>(element);
    controller.viewModel.increment(); // Fully typed
    // Use optional flag when controller might not exist
    const controller = CustomElement.for(element, { optional: true });
    if (controller) {
      // Safe to use
    } else {
      // Handle missing controller
    }
    @customElement({
      name: 'complex-element',
      template: complexTemplate,
      shadowOptions: { mode: 'open' },
      bindables: ['data', 'config'],
      dependencies: [SomeService, AnotherDependency]
    })
    class ComplexElement {}
    interface User {
      id: number;
      name: string;
      email: string;
      role: string;
      status: 'active' | 'inactive' | 'pending';
      lastLogin: Date;
      tasksCompleted: number;
    }
    
    type SortColumn = 'name' | 'email' | 'role' | 'status' | 'lastLogin' | 'tasksCompleted';
    type SortDirection = 'asc' | 'desc';
    
    export class DataTable {
      // Raw data (would normally come from API)
      private allUsers: User[] = [
        {
          id: 1,
          name: 'Alice Johnson',
          email: '[email protected]',
          role: 'Admin',
          status: 'active',
          lastLogin: new Date('2025-01-08'),
          tasksCompleted: 127
        },
        {
          id: 2,
          name: 'Bob Smith',
          email: '[email protected]',
          role: 'User',
          status: 'active',
          lastLogin: new Date('2025-01-09'),
          tasksCompleted: 89
        },
        {
          id: 3,
          name: 'Carol Williams',
          email: '[email protected]',
          role: 'Manager',
          status: 'inactive',
          lastLogin: new Date('2024-12-15'),
          tasksCompleted: 203
        },
        {
          id: 4,
          name: 'David Brown',
          email: '[email protected]',
          role: 'User',
          status: 'pending',
          lastLogin: new Date('2025-01-07'),
          tasksCompleted: 45
        },
        {
          id: 5,
          name: 'Eve Davis',
          email: '[email protected]',
          role: 'User',
          status: 'active',
          lastLogin: new Date('2025-01-09'),
          tasksCompleted: 156
        },
        // Add more sample data...
        {
          id: 6,
          name: 'Frank Miller',
          email: '[email protected]',
          role: 'Admin',
          status: 'active',
          lastLogin: new Date('2025-01-08'),
          tasksCompleted: 312
        },
        {
          id: 7,
          name: 'Grace Wilson',
          email: '[email protected]',
          role: 'Manager',
          status: 'active',
          lastLogin: new Date('2025-01-09'),
          tasksCompleted: 178
        },
        {
          id: 8,
          name: 'Henry Moore',
          email: '[email protected]',
          role: 'User',
          status: 'inactive',
          lastLogin: new Date('2024-11-20'),
          tasksCompleted: 67
        },
        {
          id: 9,
          name: 'Iris Taylor',
          email: '[email protected]',
          role: 'User',
          status: 'active',
          lastLogin: new Date('2025-01-09'),
          tasksCompleted: 234
        },
        {
          id: 10,
          name: 'Jack Anderson',
          email: '[email protected]',
          role: 'Manager',
          status: 'active',
          lastLogin: new Date('2025-01-08'),
          tasksCompleted: 189
        }
      ];
    
      // Filter state
      searchQuery = '';
      selectedRole: string = 'all';
      selectedStatus: string = 'all';
    
      // Sort state
      sortColumn: SortColumn = 'name';
      sortDirection: SortDirection = 'asc';
    
      // Pagination state
      currentPage = 1;
      pageSize = 5;
    
      // Selection state
      selectedRows = new Set<number>();
    
      // Loading state
      isLoading = false;
    
      // Computed: Filtered data
      get filteredUsers(): User[] {
        return this.allUsers.filter(user => {
          // Search filter
          const query = this.searchQuery.toLowerCase();
          const matchesSearch = !query ||
            user.name.toLowerCase().includes(query) ||
            user.email.toLowerCase().includes(query);
    
          // Role filter
          const matchesRole = this.selectedRole === 'all' ||
            user.role === this.selectedRole;
    
          // Status filter
          const matchesStatus = this.selectedStatus === 'all' ||
            user.status === this.selectedStatus;
    
          return matchesSearch && matchesRole && matchesStatus;
        });
      }
    
      // Computed: Sorted data
      get sortedUsers(): User[] {
        const sorted = [...this.filteredUsers];
    
        sorted.sort((a, b) => {
          let aVal: any = a[this.sortColumn];
          let bVal: any = b[this.sortColumn];
    
          // Handle dates
          if (aVal instanceof Date) {
            aVal = aVal.getTime();
            bVal = (bVal as Date).getTime();
          }
    
          // Handle strings (case-insensitive)
          if (typeof aVal === 'string') {
            aVal = aVal.toLowerCase();
            bVal = bVal.toLowerCase();
          }
    
          if (aVal < bVal) return this.sortDirection === 'asc' ? -1 : 1;
          if (aVal > bVal) return this.sortDirection === 'asc' ? 1 : -1;
          return 0;
        });
    
        return sorted;
      }
    
      // Computed: Paginated data
      get paginatedUsers(): User[] {
        const start = (this.currentPage - 1) * this.pageSize;
        const end = start + this.pageSize;
        return this.sortedUsers.slice(start, end);
      }
    
      // Computed: Pagination info
      get totalPages(): number {
        return Math.ceil(this.sortedUsers.length / this.pageSize);
      }
    
      get totalResults(): number {
        return this.sortedUsers.length;
      }
    
      get startResult(): number {
        if (this.totalResults === 0) return 0;
        return (this.currentPage - 1) * this.pageSize + 1;
      }
    
      get endResult(): number {
        return Math.min(this.currentPage * this.pageSize, this.totalResults);
      }
    
      pageSizeChanged(newValue: number | string) {
        const numeric = typeof newValue === 'string' ? Number(newValue) : newValue;
        if (typeof numeric === 'number' && !Number.isNaN(numeric) && numeric !== this.pageSize) {
          this.pageSize = numeric;
          return;
        }
        this.currentPage = 1;
      }
    
      get pages(): number[] {
        const pages: number[] = [];
        const maxVisible = 5;
        const half = Math.floor(maxVisible / 2);
    
        let start = Math.max(1, this.currentPage - half);
        let end = Math.min(this.totalPages, start + maxVisible - 1);
    
        // Adjust start if we're near the end
        if (end - start < maxVisible - 1) {
          start = Math.max(1, end - maxVisible + 1);
        }
    
        for (let i = start; i <= end; i++) {
          pages.push(i);
        }
    
        return pages;
      }
    
      // Computed: Selection state
      get allPageSelected(): boolean {
        if (this.paginatedUsers.length === 0) return false;
        return this.paginatedUsers.every(user => this.selectedRows.has(user.id));
      }
    
      get somePageSelected(): boolean {
        if (this.paginatedUsers.length === 0) return false;
        return this.paginatedUsers.some(user => this.selectedRows.has(user.id)) &&
          !this.allPageSelected;
      }
    
      // Actions
      sort(column: SortColumn) {
        if (this.sortColumn === column) {
          // Toggle direction
          this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
        } else {
          // New column, default to ascending
          this.sortColumn = column;
          this.sortDirection = 'asc';
        }
      }
    
      goToPage(page: number) {
        if (page < 1 || page > this.totalPages) return;
        this.currentPage = page;
      }
    
      nextPage() {
        this.goToPage(this.currentPage + 1);
      }
    
      previousPage() {
        this.goToPage(this.currentPage - 1);
      }
    
      toggleAllPageSelection() {
        if (this.allPageSelected) {
          // Deselect all on page
          this.paginatedUsers.forEach(user => this.selectedRows.delete(user.id));
        } else {
          // Select all on page
          this.paginatedUsers.forEach(user => this.selectedRows.add(user.id));
        }
      }
    
      clearSelection() {
        this.selectedRows.clear();
      }
    
      deleteSelected() {
        if (this.selectedRows.size === 0) return;
    
        const confirmed = confirm(`Delete ${this.selectedRows.size} user(s)?`);
        if (!confirmed) return;
    
        // Remove selected users
        this.allUsers = this.allUsers.filter(user => !this.selectedRows.has(user.id));
    
        // Clear selection
        this.selectedRows.clear();
    
        // Adjust page if needed
        if (this.currentPage > this.totalPages && this.totalPages > 0) {
          this.currentPage = this.totalPages;
        }
      }
    
      // Reset filters
      resetFilters() {
        this.searchQuery = '';
        this.selectedRole = 'all';
        this.selectedStatus = 'all';
        this.currentPage = 1;
      }
    
      // Watch for filter changes and reset to page 1
      searchQueryChanged() {
        this.currentPage = 1;
      }
    
      selectedRoleChanged() {
        this.currentPage = 1;
      }
    
      selectedStatusChanged() {
        this.currentPage = 1;
      }
    }
    <div class="data-table">
      <!-- Header with filters -->
      <div class="table-header">
          <h2>Users</h2>
    
          <div class="table-actions">
            <button
              type="button"
              click.trigger="deleteSelected()"
              disabled.bind="selectedRows.size === 0"
              class="btn btn-danger">
              Delete Selected (${selectedRows.size})
            </button>
          </div>
        </div>
    
        <!-- Filters -->
        <div class="table-filters">
          <div class="filter-group">
            <label for="search">Search</label>
            <input
              type="text"
              id="search"
              value.bind="searchQuery & debounce:300"
              placeholder="Search by name or email...">
          </div>
    
          <div class="filter-group">
            <label for="role">Role</label>
            <select id="role" value.bind="selectedRole">
              <option value="all">All Roles</option>
              <option value="Admin">Admin</option>
              <option value="Manager">Manager</option>
              <option value="User">User</option>
            </select>
          </div>
    
          <div class="filter-group">
            <label for="status">Status</label>
            <select id="status" value.bind="selectedStatus">
              <option value="all">All Statuses</option>
              <option value="active">Active</option>
              <option value="inactive">Inactive</option>
              <option value="pending">Pending</option>
            </select>
          </div>
    
          <div class="filter-group">
            <label for="pageSize">Per Page</label>
            <select id="pageSize" value.bind="pageSize">
              <option value="5">5</option>
              <option value="10">10</option>
              <option value="25">25</option>
              <option value="50">50</option>
            </select>
          </div>
    
          <button
            type="button"
            click.trigger="resetFilters()"
            class="btn btn-secondary">
            Reset Filters
          </button>
        </div>
    
        <!-- Results summary -->
        <div class="table-summary">
          Showing ${startResult}-${endResult} of ${totalResults} users
          <span if.bind="selectedRows.size > 0">
            (${selectedRows.size} selected)
          </span>
        </div>
    
        <!-- Data Table -->
        <div class="table-wrapper">
          <table class="table">
            <thead>
              <tr>
                <th class="col-checkbox">
                  <input
                    type="checkbox"
                    checked.bind="allPageSelected"
                    indeterminate.bind="somePageSelected"
                    change.trigger="toggleAllPageSelection()"
                    aria-label="Select all on page">
                </th>
                <th
                  click.trigger="sort('name')"
                  class="sortable ${sortColumn === 'name' ? 'sorted' : ''}">
                  Name
                  <span class="sort-icon" if.bind="sortColumn === 'name'">
                    ${sortDirection === 'asc' ? '↑' : '↓'}
                  </span>
                </th>
                <th
                  click.trigger="sort('email')"
                  class="sortable ${sortColumn === 'email' ? 'sorted' : ''}">
                  Email
                  <span class="sort-icon" if.bind="sortColumn === 'email'">
                    ${sortDirection === 'asc' ? '↑' : '↓'}
                  </span>
                </th>
                <th
                  click.trigger="sort('role')"
                  class="sortable ${sortColumn === 'role' ? 'sorted' : ''}">
                  Role
                  <span class="sort-icon" if.bind="sortColumn === 'role'">
                    ${sortDirection === 'asc' ? '↑' : '↓'}
                  </span>
                </th>
                <th
                  click.trigger="sort('status')"
                  class="sortable ${sortColumn === 'status' ? 'sorted' : ''}">
                  Status
                  <span class="sort-icon" if.bind="sortColumn === 'status'">
                    ${sortDirection === 'asc' ? '↑' : '↓'}
                  </span>
                </th>
                <th
                  click.trigger="sort('lastLogin')"
                  class="sortable ${sortColumn === 'lastLogin' ? 'sorted' : ''}">
                  Last Login
                  <span class="sort-icon" if.bind="sortColumn === 'lastLogin'">
                    ${sortDirection === 'asc' ? '↑' : '↓'}
                  </span>
                </th>
                <th
                  click.trigger="sort('tasksCompleted')"
                  class="sortable ${sortColumn === 'tasksCompleted' ? 'sorted' : ''} col-number">
                  Tasks
                  <span class="sort-icon" if.bind="sortColumn === 'tasksCompleted'">
                    ${sortDirection === 'asc' ? '↑' : '↓'}
                  </span>
                </th>
              </tr>
            </thead>
            <tbody>
              <tr
                repeat.for="user of paginatedUsers"
                class="${selectedRows.has(user.id) ? 'selected' : ''}">
                <td class="col-checkbox">
                  <input
                    type="checkbox"
                    model.bind="user.id"
                    checked.bind="selectedRows"
                    aria-label="Select ${user.name}">
                </td>
                <td>${user.name}</td>
                <td>${user.email}</td>
                <td>
                  <span class="badge badge-${user.role.toLowerCase()}">
                    ${user.role}
                  </span>
                </td>
                <td>
                  <span class="status-${user.status}">
                    ${user.status}
                  </span>
                </td>
                <td>${user.lastLogin | dateFormat:'MMM d, yyyy'}</td>
                <td class="col-number">${user.tasksCompleted}</td>
              </tr>
            </tbody>
          </table>
    
          <!-- Empty state -->
          <div if.bind="paginatedUsers.length === 0" class="empty-state">
            <p>No users found</p>
            <button
              type="button"
              click.trigger="resetFilters()"
              class="btn btn-primary">
              Clear Filters
            </button>
          </div>
        </div>
    
        <!-- Pagination -->
        <div if.bind="totalPages > 1" class="table-pagination">
          <button
            type="button"
            click.trigger="previousPage()"
            disabled.bind="currentPage === 1"
            class="btn btn-secondary"
            aria-label="Previous page">
            ← Previous
          </button>
    
          <div class="pagination-pages">
            <button
              if.bind="pages[0] > 1"
              type="button"
              click.trigger="goToPage(1)"
              class="btn btn-page">
              1
            </button>
            <span if.bind="pages[0] > 2" class="pagination-ellipsis">...</span>
    
            <button
              repeat.for="page of pages"
              type="button"
              click.trigger="goToPage(page)"
              class="btn btn-page ${page === currentPage ? 'active' : ''}"
              aria-label="Page ${page}"
              aria-current="${page === currentPage ? 'page' : undefined}">
              ${page}
            </button>
    
            <span if.bind="pages[pages.length - 1] < totalPages - 1" class="pagination-ellipsis">...</span>
            <button
              if.bind="pages[pages.length - 1] < totalPages"
              type="button"
              click.trigger="goToPage(totalPages)"
              class="btn btn-page">
              ${totalPages}
            </button>
          </div>
    
          <button
            type="button"
            click.trigger="nextPage()"
            disabled.bind="currentPage === totalPages"
            class="btn btn-secondary"
            aria-label="Next page">
            Next →
          </button>
        </div>
      </div>
    .data-table {
      width: 100%;
    }
    
    .table-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 1.5rem;
    }
    
    .table-filters {
      display: flex;
      gap: 1rem;
      margin-bottom: 1rem;
      flex-wrap: wrap;
      align-items: flex-end;
    }
    
    .filter-group {
      display: flex;
      flex-direction: column;
      gap: 0.25rem;
    }
    
    .filter-group label {
      font-size: 0.875rem;
      font-weight: 500;
    }
    
    .table-summary {
      margin-bottom: 0.5rem;
      font-size: 0.875rem;
      color: #666;
    }
    
    .table-wrapper {
      overflow-x: auto;
      border: 1px solid #e0e0e0;
      border-radius: 4px;
    }
    
    .table {
      width: 100%;
      border-collapse: collapse;
    }
    
    .table thead {
      background-color: #f5f5f5;
    }
    
    .table th,
    .table td {
      padding: 0.75rem 1rem;
      text-align: left;
      border-bottom: 1px solid #e0e0e0;
    }
    
    .table th.sortable {
      cursor: pointer;
      user-select: none;
    }
    
    .table th.sortable:hover {
      background-color: #e8e8e8;
    }
    
    .table th.sorted {
      background-color: #e3f2fd;
    }
    
    .sort-icon {
      margin-left: 0.25rem;
      font-size: 0.75rem;
    }
    
    .col-checkbox {
      width: 40px;
      text-align: center;
    }
    
    .col-number {
      text-align: right;
    }
    
    .table tbody tr:hover {
      background-color: #f9f9f9;
    }
    
    .table tbody tr.selected {
      background-color: #e3f2fd;
    }
    
    .badge {
      padding: 0.25rem 0.5rem;
      border-radius: 4px;
      font-size: 0.75rem;
      font-weight: 500;
    }
    
    .badge-admin {
      background-color: #ff5722;
      color: white;
    }
    
    .badge-manager {
      background-color: #2196f3;
      color: white;
    }
    
    .badge-user {
      background-color: #4caf50;
      color: white;
    }
    
    .status-active {
      color: #4caf50;
    }
    
    .status-inactive {
      color: #999;
    }
    
    .status-pending {
      color: #ff9800;
    }
    
    .empty-state {
      text-align: center;
      padding: 3rem;
      color: #999;
    }
    
    .table-pagination {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-top: 1rem;
      gap: 1rem;
    }
    
    .pagination-pages {
      display: flex;
      gap: 0.25rem;
    }
    
    .btn-page {
      min-width: 40px;
      padding: 0.5rem;
    }
    
    .btn-page.active {
      background-color: #2196f3;
      color: white;
    }
    
    .pagination-ellipsis {
      padding: 0.5rem;
      color: #999;
    }
    
    /* Responsive */
    @media (max-width: 768px) {
      .table-filters {
        flex-direction: column;
        align-items: stretch;
      }
    
      .table-pagination {
        flex-direction: column;
      }
    
      .table {
        font-size: 0.875rem;
      }
    
      .table th,
      .table td {
        padding: 0.5rem;
      }
    }
    async loadUsers() {
      this.isLoading = true;
    
      const params = new URLSearchParams({
        page: this.currentPage.toString(),
        pageSize: this.pageSize.toString(),
        search: this.searchQuery,
        role: this.selectedRole,
        status: this.selectedStatus,
        sortColumn: this.sortColumn,
        sortDirection: this.sortDirection
      });
    
      try {
        const response = await fetch(`/api/users?${params}`);
        const data = await response.json();
    
        this.allUsers = data.users;
        this.totalResults = data.total; // Server provides total count
      } finally {
        this.isLoading = false;
      }
    }
    editingRow: number | null = null;
    
    startEdit(userId: number) {
      this.editingRow = userId;
    }
    
    async saveEdit(user: User) {
      await fetch(`/api/users/${user.id}`, {
        method: 'PUT',
        body: JSON.stringify(user)
      });
    
      this.editingRow = null;
    }
    
    cancelEdit() {
      this.editingRow = null;
      // Restore original data
    }
    visibleColumns = {
      name: true,
      email: true,
      role: true,
      status: true,
      lastLogin: true,
      tasksCompleted: true
    };
    <th if.bind="visibleColumns.email">Email</th>
    // Install
    npm i @aurelia/router
    
    // Configure in main.ts
    import { RouterConfiguration } from '@aurelia/router';
    
    Aurelia
      .register(RouterConfiguration.customize({
        useUrlFragmentHash: false,  // Clean URLs (default)
        historyStrategy: 'push',     // Browser history
      }))
      .app(MyApp)
      .start();
    import { route } from '@aurelia/router';
    
    @route({
      routes: [
        { path: '', component: Home, title: 'Home' },
        { path: 'about', component: About, title: 'About' },
        { path: 'users/:id', component: UserDetail }
      ]
    })
    export class MyApp {}
    <!-- In your root component template -->
    <nav>
      <a href="home">Home</a>
      <a href="about">About</a>
    </nav>
    
    <au-viewport></au-viewport>
    RouterConfiguration.customize({
      useUrlFragmentHash: true  // URLs like /#/about
    })
    <!-- Using href (simple) -->
    <a href="about">About</a>
    <a href="users/42">User 42</a>
    
    <!-- Using load (structured) -->
    <a load="route: users; params.bind: {id: userId}">User Profile</a>
    import { IRouter } from '@aurelia/router';
    import { resolve } from '@aurelia/kernel';
    
    export class MyComponent {
      private readonly router = resolve(IRouter);
    
      navigateToUser(id: number) {
        this.router.load(`users/${id}`);
    
        // Or with options
        this.router.load('users', {
          params: { id },
          queryParams: { tab: 'profile' }
        });
      }
    }
    // Configure active class globally
    RouterConfiguration.customize({
      activeClass: 'active'
    })
    <!-- Use with load attribute -->
    <a load="home" active.bind="isHomeActive">Home</a>
    
    <!-- Or use the configured active class -->
    <a load="home">Home</a>  <!-- Gets 'active' class automatically -->
    <!-- Using href with ../ prefix -->
    <a href="../sibling">Go to sibling route</a>
    
    <!-- Using load with context -->
    <a load="route: sibling; context.bind: parentContext">Sibling</a>
    // Programmatically
    router.load('search', {
      queryParams: { q: 'aurelia', page: 1 }
    });
    // Result: /search?q=aurelia&page=1
    <!-- These automatically bypass the router (no special attributes needed!) -->
    <a href="https://example.com">External site</a>
    <a href="mailto:[email protected]">Email</a>
    <a href="tel:+1234567890">Phone</a>
    <a href="//cdn.example.com/file.pdf">Protocol-relative</a>
    <a href="ftp://files.example.com">FTP</a>
    
    <!-- Also bypassed: -->
    <a href="/internal" target="_blank">New tab</a>
    <a href="/internal" target="other">Named target</a>
    <!-- When URL looks internal but should bypass router -->
    <a href="/api/download" external>API endpoint</a>
    <a href="/old-page.html" external>Legacy HTML page</a>
    @route({
      routes: [
        { path: 'users/:id', component: UserDetail },           // Required
        { path: 'posts/:id?', component: PostDetail },          // Optional
        { path: 'files/*path', component: FileViewer },         // Wildcard
        { path: 'items/:id{{^\\d+$}}', component: ItemDetail }, // Constrained
      ]
    })
    import { IRouteViewModel, Params } from '@aurelia/router';
    
    export class UserDetail implements IRouteViewModel {
      userId: string;
    
      canLoad(params: Params) {
        this.userId = params.id;
        return true;
      }
    }
    import { IRouteContext } from '@aurelia/router';
    import { resolve } from '@aurelia/kernel';
    
    export class NestedComponent {
      private readonly routeContext = resolve(IRouteContext);
    
      attached() {
        const allParams = this.routeContext.getRouteParameters<{
          companyId: string;
          projectId: string;
          userId: string;
        }>({ includeQueryParams: true });
      }
    }
    {
      path: 'users/:id{{^\\d+$}}',  // Only numbers
      component: UserDetail
    }
    import { lifecycleHooks } from '@aurelia/runtime-html';
    import { IRouteViewModel, Params, RouteNode } from '@aurelia/router';
    
    @lifecycleHooks()
    export class AuthHook {
      canLoad(viewModel: IRouteViewModel, params: Params, next: RouteNode) {
        const isLoggedIn = !!localStorage.getItem('authToken');
    
        if (!isLoggedIn) {
          return 'login';  // Redirect to login
        }
    
        return true;  // Allow navigation
      }
    }
    @lifecycleHooks()
    export class AuthorizationHook {
      canLoad(viewModel: IRouteViewModel, params: Params, next: RouteNode) {
        const requiredPermission = next.data?.permission;
    
        if (requiredPermission && !this.hasPermission(requiredPermission)) {
          return 'forbidden';
        }
    
        return true;
      }
    
      private hasPermission(permission: string): boolean {
        // Check user permissions
        return true;
      }
    }
    // In route configuration
    {
      path: 'admin',
      component: AdminPanel,
      data: { permission: 'admin' }
    }
    import { IRouteViewModel, RouteNode } from '@aurelia/router';
    
    export class EditForm implements IRouteViewModel {
      private isDirty = false;
    
      canUnload(next: RouteNode | null, current: RouteNode) {
        if (this.isDirty) {
          return confirm('You have unsaved changes. Leave anyway?');
        }
        return true;
      }
    }
    export class Dashboard implements IRouteViewModel {
      canLoad(params: Params) {
        const userRole = this.authService.getRole();
    
        if (userRole === 'admin') {
          return 'admin/dashboard';
        } else if (userRole === 'user') {
          return 'user/dashboard';
        }
    
        return 'login';
      }
    }
    import { IRouteViewModel, Params } from '@aurelia/router';
    
    export class UserDetail implements IRouteViewModel {
      user: User | null = null;
    
      async loading(params: Params) {
        this.user = await fetch(`/api/users/${params.id}`)
          .then(r => r.json());
      }
    }
    export class Dashboard implements IRouteViewModel {
      loaded(params: Params) {
        // Track page view
        analytics.track('page_view', { page: 'dashboard' });
    
        // Scroll to top
        window.scrollTo(0, 0);
      }
    }
    // Component hook
    export class MyComponent implements IRouteViewModel {
      canLoad(params: Params) {
        // Runs only for this component
      }
    }
    
    // Router hook (shared)
    @lifecycleHooks()
    export class AuthHook {
      canLoad(viewModel: IRouteViewModel, params: Params) {
        // Runs for all components where this is registered
      }
    }
    @route({
      routes: [
        { path: 'home', component: Home },
        { path: 'about', component: About },
        { path: 'not-found', component: NotFound }
      ],
      fallback: 'not-found'  // Redirect unknown routes here
    })
    export class MyApp {}
    @route({
      routes: [
        { path: '', redirectTo: 'home' },
        { path: 'about-us', redirectTo: 'about' },
        { path: 'home', component: Home },
        { path: 'about', component: About }
      ]
    })
    <au-viewport name="left"></au-viewport>
    <au-viewport name="right"></au-viewport>
    <!-- Load components into both viewports -->
    <a href="products@left+details/42@right">Products + Details</a>
    // Programmatically
    router.load([
      { component: Products, viewport: 'left' },
      { component: Details, params: { id: 42 }, viewport: 'right' }
    ]);
    @route({
      routes: [
        {
          path: 'users/:id',
          component: UserLayout,
          // Child routes defined in UserLayout
        }
      ]
    })
    export class MyApp {}
    
    // In UserLayout
    @route({
      routes: [
        { path: '', component: UserProfile },
        { path: 'posts', component: UserPosts },
        { path: 'settings', component: UserSettings }
      ]
    })
    export class UserLayout {}
    <!-- UserLayout template -->
    <h2>User: ${userId}</h2>
    <nav>
      <a href="posts">Posts</a>
      <a href="settings">Settings</a>
    </nav>
    <au-viewport></au-viewport>
    @route({
      routes: [
        { path: 'home', component: Home },
        // Dynamic import for lazy loading
        { path: 'admin', component: () => import('./admin/admin-panel') }
      ]
    })
    // In route configuration
    {
      path: 'about',
      component: About,
      title: 'About Us'
    }
    
    // Programmatically
    router.load('about', { title: 'Custom Title' });
    
    // Custom title building
    RouterConfiguration.customize({
      buildTitle(transition) {
        const titles = transition.routeTree.root.children.map(c => c.title);
        return `${titles.join(' - ')} | My App`;
      }
    })
    import { IRouter } from '@aurelia/router';
    import { resolve } from '@aurelia/kernel';
    
    const router = resolve(IRouter);
    
    // Generate path
    const userPath = await router.generatePath({
      component: 'users',
      params: { id: 42 }
    });
    // Result: "/users/42"
    
    // Use in template
    <a href.bind="userPath">View User</a>
    RouterConfiguration.customize({
      basePath: '/tenant1/app'  // All routes will be prefixed
    })
    <base href="/tenant1/app">
    // The router handles this automatically with historyStrategy
    
    // To control history behavior per navigation:
    router.load('page', {
      historyStrategy: 'replace'  // Don't create history entry
    });
    
    router.load('page', {
      historyStrategy: 'push'  // Create history entry (default)
    });
    import { ICurrentRoute } from '@aurelia/router';
    import { resolve } from '@aurelia/kernel';
    
    export class MyComponent {
      private readonly currentRoute = resolve(ICurrentRoute);
    
      attached() {
        console.log('Current path:', this.currentRoute.path);
        console.log('Parameters:', this.currentRoute.parameterInformation);
      }
    }
    <!-- Internal-looking URLs that should bypass router -->
    <a href="/api/download" external>API endpoint</a>
    <a href="/static/old-page.html" external>Legacy page</a>
    <a href="../sibling">Sibling Route</a>
    import { IRouteViewModel } from '@aurelia/router';
    
    export class MyComponent implements IRouteViewModel {
      canLoad(params: Params) { /* ... */ }
    }
    {
      path: 'users/:id',
      component: UserDetail,
      transitionPlan: 'invoke-lifecycles'  // Re-invoke hooks
    }
    import { IRouterEvents } from '@aurelia/router';
    import { resolve } from '@aurelia/kernel';
    
    export class MyApp {
      constructor() {
        const events = resolve(IRouterEvents);
    
        events.subscribe('au:router:navigation-start', (evt) => {
          console.log('Navigation started:', evt);
        });
    
        events.subscribe('au:router:navigation-end', (evt) => {
          console.log('Navigation ended:', evt);
        });
    
        events.subscribe('au:router:navigation-error', (evt) => {
          console.error('Navigation error:', evt);
        });
      }
    }
    CustomElement.isType
    CustomElement.keyFrom
    @capture
    @processContent
    Value Converters

    unloading

    Before removal

    Cleanup, save drafts

    Route Parameters Guide
    Navigation
    Viewports
    Lifecycle Hooks
    Router Hooks
    Transition Plans
    Navigation Model
    Router Events
    Error Handling
    Testing Guide
    Troubleshooting

    basePath

    string | null

    null

    Overrides the base segment used to resolve relative routes. Defaults to document.baseURI.

    activeClass

    string | null

    null

    CSS class applied by the load attribute when a link is active.

    useNavigationModel

    boolean

    true

    Generates the navigation model so you can build menus from IRouter.navigation.

    buildTitle

    (transition: Transition) => string | null

    null

    Customises how page titles are produced. Return null to skip title updates.

    restorePreviousRouteTreeOnError

    boolean

    true

    Restores the previous route tree if a navigation throws, preventing partial states.

    treatQueryAsParameters

    boolean

    false (deprecated)

    Treats query parameters as route parameters. Avoid new usage; scheduled for removal in the next major release.

    useUrlFragmentHash

    boolean

    false

    When true, uses hash (#/path) URLs instead of pushState. Leave false for clean URLs.

    useHref

    boolean

    true

    Enables the router to intercept standard href links. Set to false if you only want to route via the load attribute.

    historyStrategy

    'push' | 'replace' | 'none' | (instructions) => HistoryStrategy

    'push'

    MDN
    base#href
    external attribute
    default behavior of building the title
    data property
    external attribute
    navigation options
    load custom attribute
    ICurrentRoute docs

    Controls how each navigation interacts with history. Provide a function to choose per navigation.

    Modify binding modes - force one-way, two-way, or one-time binding behavior

  • Customize event handling - filter events or change which events trigger updates

  • Add debugging capabilities - inspect, log, or visualize binding behavior

  • Implement complex logic - create reusable binding modifications

  • Syntax

    Binding behaviors use the & operator and follow similar syntax to value converters:

    Parameter syntax flexibility:

    Throttle

    Aurelia provides several built-in binding behaviors to address common scenarios. The throttle behavior is designed to limit the rate at which updates propagate. This can apply to updates from the view-model to the view (in to-view or one-way bindings) or from the view to the view-model (in two-way bindings).

    By default, throttle enforces a minimum time interval of 200ms between updates. You can easily customize this interval.

    Here are some practical examples:

    Limiting property updates to a maximum of once every 200ms

    In this example, the searchQuery property in your view model will update at most every 200ms, even if the user types more rapidly in the input field. This is especially useful for search inputs or other scenarios where frequent updates can be inefficient or overwhelming.

    You'll notice the & symbol, which is used to introduce binding behavior expressions. The syntax for binding behaviors mirrors that of value converters:

    • Arguments: Binding behaviors can accept arguments, separated by colons: propertyName & behaviorName:arg1:arg2.

    • Chaining: Multiple binding behaviors can be chained together: propertyName & behavior1 & behavior2:arg1.

    • Combined with Value Converters: Binding expressions can include both value converters and binding behaviors: ${data | valueConverter:arg & bindingBehavior:arg2}.

    Let's see how to customize the throttling interval:

    Limiting property updates to a maximum of once every 850ms

    The throttle behavior is particularly valuable when used with event bindings, especially for events that fire frequently, such as mousemove.

    Handling mousemove events at most every 200ms

    In this case, the mouseMoveHandler method in your view model will be invoked at most every 200ms, regardless of how frequently the mousemove event is triggered as the user moves their mouse.

    Flushing Pending Throttled Updates

    In certain situations, you might need to immediately apply any pending throttled updates. Consider a form with throttled input fields. When a user tabs out of a field after typing, you might want to ensure the latest value is immediately processed, even if the throttle interval hasn't elapsed yet.

    The throttle binding behavior supports this via a "signal". You can specify a signal name as the second argument to throttle. Then, using Aurelia's ISignaler, you can dispatch this signal to force a flush of the throttled update.

    In this example:

    • value.bind="formValue & throttle:200:'flushInput'": The formValue binding is throttled to 200ms and associated with the signal 'flushInput'.

    • blur.trigger="signaler.dispatchSignal('flushInput')": When the input loses focus (blur event), signaler.dispatchSignal('flushInput') is called. This immediately triggers any pending throttled update associated with the 'flushInput' signal, ensuring the formValue is updated in the view model right away.

    You can also specify multiple signals using an array:

    This allows multiple different signals to trigger the same throttled update, providing flexibility in complex scenarios where updates might need to be flushed from different parts of your application.

    Debounce

    The debounce binding behavior is another rate-limiting tool. debounce delays updates until a specified time interval has passed without any further changes. This is ideal for scenarios where you want to react only after a user has paused interacting.

    A classic use case is a search input that triggers an autocomplete or search operation. Making an API call with every keystroke is inefficient. debounce ensures the search logic is invoked only after the user has stopped typing for a moment.

    Updating a property after typing has stopped for 200ms

    Updating a property after typing has stopped for 850ms

    Similar to throttle, debounce is highly effective with event bindings.

    Calling mouseMoveHandler after the mouse stops moving for 500ms

    Flushing Pending Debounced Calls

    Like throttle, debounce also supports flushing pending updates using signals. This is useful in scenarios like form submission where you want to ensure the most recent debounced values are processed immediately, even if the debounce interval hasn't elapsed.

    In this example, the validateInput method (which could perform input validation or other actions) will be called when the input field loses focus, even if the 300ms debounce interval isn't fully over, ensuring timely validation.

    As with throttle, you can also provide multiple signal names to debounce:

    UpdateTrigger

    The updateTrigger binding behavior allows you to customize which DOM events trigger updates from the view to the view model for input elements. By default, Aurelia uses the change and input events for most input types.

    However, you can override this default behavior. For example, you might want to update the view model only when an input field loses focus (blur event).

    Updating the view model only on blur

    You can specify multiple events that should trigger updates:

    Updating the view model on blur or paste events

    This is useful in scenarios where you need fine-grained control over when view-model updates occur based on specific user interactions with input elements.

    Signal

    The signal binding behavior provides a mechanism to explicitly tell a binding to refresh itself. This is particularly useful when a binding's result depends on external factors or global state changes that Aurelia's observation system might not automatically detect.

    Consider a "translate" value converter that translates keys into localized strings, e.g., ${'greeting.key' | translate}. If your application allows users to change the language dynamically, how do you refresh all the translation bindings to reflect the new language?

    Another example is a value converter that displays a "time ago" string relative to the current time, e.g., Posted ${post.date | timeAgo}. As time progresses, this binding needs to refresh periodically to show updated relative times like "5 minutes ago," "an hour ago," etc.

    signal binding behavior solves these refresh scenarios:

    Using a Signal to Refresh Bindings

    In this example, signal:'time-update' assigns the signal name 'time-update' to this binding. Multiple bindings can share the same signal name.

    To trigger a refresh of all bindings with the signal name 'time-update', you use the ISignaler:

    Dispatching a Signal to Refresh Bindings

    Every 5 seconds, the setInterval function updates lastUpdated and then calls signaler.dispatchSignal('time-update'). This tells Aurelia to re-evaluate all bindings that are configured with & signal:'time-update', causing them to refresh and display the updated "time ago" value.

    Binding Mode Behaviors

    Aurelia exposes four mode behaviors in @aurelia/runtime-html (oneTime, toView, fromView, twoWay). Each one derives from the shared BindingModeBehavior base class which simply assigns a different binding.mode during bind and restores it on unbind. They are especially handy when:

    • You are consuming a component whose bindable defaults to two-way, but a specific usage should stay strictly view-model → view.

    • You want to keep .bind syntax but override the direction inside repeat.for, if/else, or other inline templates without changing the child API.

    • You need to chain additional behaviors/value converters and prefer not to switch to the .one-time command mid-expression.

    StandardConfiguration registers these behaviors for you. If you need something more custom—say, a behavior that forces BindingMode.twoWay only when the target implements a particular interface—you can extend BindingModeBehavior yourself:

    After registration you can apply &dirtyChecked in any binding expression.

    Aurelia provides binding behaviors that explicitly specify binding modes. While binding commands (.bind, .one-way, .two-way) are more commonly used, these behaviors offer programmatic control over binding modes.

    oneTime

    The oneTime binding behavior creates the most efficient binding by evaluating the expression only once and never observing it for changes.

    oneTime bindings eliminate observation overhead entirely, making them ideal for:

    • Static configuration values

    • IDs and other immutable data

    • Large lists where some properties never change

    • Performance-critical rendering scenarios

    toView (One-Way)

    Forces one-way data flow from view-model to view only.

    fromView

    Forces one-way data flow from view to view-model only. The view-model property will be updated when the view changes, but view-model changes won't update the view.

    This is useful for scenarios like:

    • Collecting user input without reflecting programmatic changes back to the UI

    • One-way form submission scenarios

    • Performance optimization when you don't need view updates

    twoWay

    Forces bidirectional data synchronization between view and view-model.

    Binding Mode Summary

    Behavior
    Direction
    Use Case
    Command Equivalent

    oneTime

    None (static)

    Static content, performance

    N/A

    toView

    VM → View

    Display-only data

    .one-way

    fromView

    View → VM

    Input-only scenarios

    Naming Convention: Binding mode behaviors use camelCase (toView, fromView, twoWay) because they're JavaScript expressions, while binding commands use dash-case (.to-view, .from-view, .two-way) due to HTML's case-insensitive nature.

    Self

    The self binding behavior is used in event bindings to ensure that the event handler only responds to events dispatched directly from the element the listener is attached to, and not from any of its descendant elements due to event bubbling.

    Consider a scenario with a panel component:

    Scenario without self binding behavior

    Without self, the onMouseDown handler will be invoked not only when the user mousedown on the <header> element itself, but also on any element inside the header, such as the "Settings" and "Close" buttons, due to event bubbling. This might not be the desired behavior if you want the panel to react only to direct interactions with the header, not its contents.

    You could handle this in your event handler by checking the event.target:

    Event Handler without self binding behavior (manual check)

    However, this mixes DOM event handling logic with component-specific behavior. The self binding behavior offers a cleaner, more declarative solution:

    Using self binding behavior

    Event Handler with self binding behavior

    By adding & self to the event binding, Aurelia ensures that onMouseDown is only called when the mousedown event originates directly from the <header> element, simplifying your event handler logic and separating concerns.

    Attr

    The attr binding behavior forces a binding to use attribute accessor instead of property accessor. This is particularly useful when working with custom attributes or when you need to ensure the HTML attribute is set rather than just the property.

    Forcing attribute binding:

    When to use attr:

    • Custom attributes that require actual HTML attributes to be set

    • Interoperability with third-party libraries that read HTML attributes

    • SEO considerations where attributes need to be present in the DOM

    • Cases where you need the attribute to be visible in browser dev tools

    Example with custom attribute:

    Custom Binding Behaviors

    You can create your own custom binding behaviors to encapsulate reusable binding modifications. Like value converters, custom binding behaviors are view resources.

    Custom binding behaviors implement bind(scope, binding, [...args]) and unbind(scope, binding) methods:

    • bind(scope, binding, [...args]): Called when the binding is created and attached to the DOM. This is where you implement the behavior modification.

      • scope: The binding's scope, providing access to the view model (scope.bindingContext) and override context (scope.overrideContext)

      • binding: The binding instance whose behavior you want to alter (implements IBinding interface)

      • [...args]: Any arguments passed to the binding behavior in the template (e.g., & myBehavior:arg1:arg2)

    • unbind(scope, binding): Called when the binding is detached from the DOM. Clean up any changes made in the bind method to restore the binding to its original state and prevent memory leaks.

    Important: Note the parameter order - scope comes first, then binding. This is different from some other Aurelia lifecycle methods.

    Let's look at some practical examples of custom binding behaviors.

    Log Binding Context Behavior

    This behavior logs the current binding context to the browser's console every time the binding updates its target (view). This is invaluable for debugging and understanding data flow in your Aurelia application.

    Usage in Template:

    Now, whenever the userName binding updates the input element, you'll see the current binding context logged to the console, helping you inspect the data available at that point.

    Inspect Value Binding Behavior (Tooltip)

    This behavior adds a temporary tooltip to the element displaying the binding's current value whenever it updates. This offers a quick way to inspect binding values directly in the UI without resorting to console logs.

    Usage in Template:

    As the itemName binding updates, the input element will temporarily display a tooltip showing the current value, providing immediate visual feedback for debugging.

    Highlight Updates Binding Behavior

    This behavior visually highlights an element by briefly changing its background color whenever the binding updates the element's target property. This visual cue helps quickly identify which parts of the UI are reacting to data changes, particularly useful during development and debugging complex views.

    Usage in Template:

    Whenever the message binding updates the textContent of the div, the div's background will briefly flash light blue for 1 second (1000ms), visually indicating the update. You can customize the highlight color and duration by passing arguments to the binding behavior in the template.

    Built-in Behaviors Reference

    Rate Limiting

    Behavior
    Purpose
    Default
    Parameters
    Signals

    throttle

    Limit update frequency

    200ms

    delay, signal

    ✅

    debounce

    Delay until input stops

    200ms

    delay, signal

    ✅

    Binding Modes

    Behavior
    Direction
    Use Case

    oneTime

    None

    Static content, performance

    toView

    VM → View

    Display-only data

    fromView

    View → VM

    Input-only scenarios

    twoWay

    VM ↔ View

    Interactive forms

    Event & DOM

    Behavior
    Purpose
    Use Case

    self

    Filter event source

    Prevent event bubbling

    updateTrigger

    Custom DOM events

    Control when updates occur

    attr

    Force attribute access

    Custom attributes, SEO

    Utility

    Behavior
    Purpose
    Use Case

    signal

    Manual refresh

    Dynamic content, translations

    Best Practices

    Performance Considerations

    Rate limiting for expensive operations:

    Static content optimization:

    Memory Management

    Proper cleanup in custom behaviors:

    Debugging and Development

    Progressive enhancement approach:

    Common Patterns

    Form handling:

    Search functionality:

    Dynamic content:

    Summary

    Binding behaviors provide powerful ways to customize Aurelia's binding system:

    • Built-in behaviors cover common scenarios like rate limiting, binding modes, and event handling

    • Custom behaviors enable unlimited extensibility for specialized requirements

    • Proper cleanup is essential to prevent memory leaks in custom implementations

    • Performance benefits come from using appropriate behaviors for different use cases

    • Debugging capabilities make development and troubleshooting easier

    Use binding behaviors to create more efficient, maintainable, and user-friendly applications by controlling exactly how your data flows between view and view-model.

    import Aurelia from 'aurelia';
    import { RouterConfiguration } from '@aurelia/router';
    import { MyApp } from './my-app';
    
    Aurelia
      .register(RouterConfiguration.customize({ useUrlFragmentHash: true }))
      .app(MyApp)
      .start();
    <head>
      <base href="/">
    </head>
    <!-- app1/index.html -->
    <head>
      <base href="/app1">
    </head>
    
    <!-- app2/index.html -->
    <head>
      <base href="/app2">
    </head>
      // this can either be '/', '/app[/+]', or '/TENANT_NAME/app[/+]'
      let basePath = location.pathname;
      const tenant =
        (!basePath.startsWith('/app') && basePath != '/'
          ? basePath.split('/')[1]
          : null) ?? 'none';
      if (tenant === 'none') {
        basePath = '/app';
      }
      const host = document.querySelector<HTMLElement>('app');
      const au = new Aurelia();
      au.register(
        StandardConfiguration,
        RouterConfiguration.customize({
          basePath,
        }),
        Registration.instance(ITenant, tenant) // <-- this is just to inject the tenant name in the `my-app.ts`
      );
    tenant: ${tenant}
    <nav>
      <a href="${baseUrl}/foo/app" external>Switch to tenant foo</a>
      <a href="${baseUrl}/bar/app" external>Switch to tenant bar</a>
    </nav>
    <nav>
      <a load="home">Home</a>
      <a load="about">About</a>
    </nav>
    
    <au-viewport></au-viewport>
    
    import { customElement } from '@aurelia/runtime-html';
    import { route } from '@aurelia/router';
    import template from './my-app.html?raw';
    import { Home } from './home';
    import { About } from './about';
    import { DI } from '@aurelia/kernel';
    
    export const ITenant = DI.createInterface<string>('tenant');
    
    @route({
      routes: [
        {
          path: ['', 'home'],
          component: Home,
          title: 'Home',
        },
        {
          path: 'about',
          component: About,
          title: 'About',
        },
      ],
    })
    @customElement({ name: 'my-app', template })
    export class MyApp {
      private baseUrl = location.origin;
      private readonly tenant: string = resolve(ITenant);
    }
    import { Aurelia } from '@aurelia/runtime-html';
    import { RouterConfiguration, ILocationManager } from '@aurelia/router';
    import { Registration } from '@aurelia/kernel';
    
    class WebViewLocationManager implements ILocationManager {
      constructor(private readonly host = window) {}
    
      startListening() {/* connect to native host */}
      stopListening() {/* disconnect */}
      handleEvent() {/* publish au:router:location-change */}
      pushState(state: unknown, title: string, url: string) {
        this.host.history.replaceState(state, title, `/app/${url}`);
      }
      replaceState(state: unknown, title: string, url: string) {
        this.host.history.replaceState(state, title, `/app/${url}`);
      }
      getPath() {
        return this.host.location.pathname.replace('/app/', '');
      }
      addBaseHref(path: string) { return `/app/${path}`; }
      removeBaseHref(path: string) { return path.replace('/app/', ''); }
    }
    
    Aurelia.register(
      RouterConfiguration.customize(),
      Registration.singleton(ILocationManager, WebViewLocationManager),
    );
    import { RouterConfiguration, RouterOptions, IRouterOptions, type IUrlParser } from '@aurelia/router';
    import { AppTask, Writable } from '@aurelia/runtime-html';
    
    const signedParser: IUrlParser = {
      parse(value) {
        const raw = value.replace(/;sig=.*$/, '');
        return RouterOptions.create({})._urlParser.parse(raw);
      },
      stringify(path, query, fragment, isRooted) {
        const base = RouterOptions.create({})._urlParser.stringify(path, query, fragment, isRooted);
        return `${base};sig=${sessionStorage.getItem('signature') ?? ''}`;
      },
    };
    
    Aurelia.register(
      RouterConfiguration.customize(),
      AppTask.creating(IRouterOptions, (options: RouterOptions) => {
        (options as Writable<RouterOptions>)._urlParser = signedParser;
      }),
    );
    import { route, IRouteViewModel } from '@aurelia/router';
    @route({
        title: 'Aurelia', // <-- this is the base title
        routes: [
          {
            path: ['', 'home'],
            component: import('./components/home-page'),
            title: 'Home',
          }
        ]
    })
    export class MyApp implements IRouteViewModel {}
    // main.ts
    import { RouterConfiguration, Transition } from '@aurelia/router';
    import { Aurelia } from '@aurelia/runtime-html';
    const au = new Aurelia();
    au.register(
      RouterConfiguration.customize({
        buildTitle(tr: Transition) {
          const root = tr.routeTree.root;
          const baseTitle = root.context.config.title;
          const titlePart = root.children.map(c => c.title).join(' - ');
          return `${baseTitle} - ${titlePart}`;
        },
      }),
    );
    import { IRouteViewModel, Routeable } from "aurelia";
    export class MyApp implements IRouteViewModel {
      static title: string = 'Aurelia';
      static routes: Routeable[] = [
        {
          path: ['', 'home'],
          component: import('./components/home-page'),
          title: 'Home',
          data: {
            i18n: 'routes.home'
          }
        }
      ];
    }
    import { I18N, Signals } from '@aurelia/i18n';
    import { IEventAggregator } from '@aurelia/kernel';
    import { IRouter, RouterConfiguration, Transition } from '@aurelia/router';
    import { AppTask, Aurelia } from '@aurelia/runtime-html';
    (async function () {
      const host = document.querySelector<HTMLElement>('app');
      const au = new Aurelia();
      const container = au.container;
      let i18n: I18N | null = null;
      let router: IRouter | null = null;
      au.register(
        // other registrations such as the StandardRegistration, I18NRegistrations come here
        RouterConfiguration.customize({
          buildTitle(tr: Transition) {
            // Use the I18N to translate the titles using the keys from data.i18n.
            i18n ??= container.get(I18N);
            const root = tr.routeTree.root;
            const baseTitle = root.context.config.title;
            const child = tr.routeTree.root.children[0];
            return `${baseTitle} - ${i18n.tr(child.data.i18n as string)}`;
          },
        }),
        AppTask.afterActivate(IEventAggregator, ea => {
          // Ensure that the title changes whenever the locale is changed.
          ea.subscribe(Signals.I18N_EA_CHANNEL, () => {
            (router ??= container.get(IRouter)).updateTitle();
          });
        }),
      );
      // start aurelia here
    })().catch(console.error);
    {
      "routes": {
        "home": "Startseite"
      }
    }
    <a href="mailto:[email protected]">Email Me</a>
    <a href="mailto:[email protected]" external>Email Me</a>
    import Aurelia from 'aurelia';
    import { RouterConfiguration } from '@aurelia/router';
    
    Aurelia
      .register(RouterConfiguration.customize({
        useHref: false,
      }))
      .app(MyApp)
      .start();
    import { RouterConfiguration } from '@aurelia/router';
    import { Aurelia, StandardConfiguration } from '@aurelia/runtime-html';
    import { MyApp as component } from './my-app';
    
    (async function () {
      const host = document.querySelector<HTMLElement>('app');
      const au = new Aurelia();
      au.register(
        StandardConfiguration,
        RouterConfiguration.customize({
          historyStrategy: 'push', // default value can can be omitted
        })
      );
      au.app({ host, component });
      await au.start();
    })().catch(console.error);
    import { resolve } from '@aurelia/kernel';
    import { IHistory } from '@aurelia/runtime-html';
    import { IRouterEvents } from '@aurelia/router';
    
    export class MyApp {
      private history: string;
      public constructor() {
        let i = 0;
        const history = resolve(IHistory);
        resolve(IRouterEvents).subscribe('au:router:navigation-end', () => {
          this.history = `#${++i} - len: ${history.length} - state: ${JSON.stringify(history.state)}`;
        });
      }
    }
    import {
      RouterConfiguration,
      type HistoryStrategy,
      type ViewportInstructionTree,
      type ViewportInstruction,
    } from '@aurelia/router';
    
    const touchesViewport = (instruction: ViewportInstruction, name: string): boolean => {
      if (instruction.viewport === name) {
        return true;
      }
    
      return instruction.children.some(child => touchesViewport(child, name));
    };
    
    RouterConfiguration.customize({
      historyStrategy(instructions: ViewportInstructionTree): HistoryStrategy {
        const updatesSettingsPanel = instructions.children.some(child => touchesViewport(child, 'settings'));
        return updatesSettingsPanel ? 'replace' : 'push';
      },
    });
    // main.ts
    RouterConfiguration.customize({
      activeClass: 'active-route'
    })
    /* styles.css */
    .active-route {
      font-weight: bold;
      color: #007acc;
      text-decoration: underline;
    }
    <!-- These links will get the 'active-route' class when their routes are active -->
    <a load="home">Home</a>
    <a load="about">About</a>
    RouterConfiguration.customize({
      useNavigationModel: false
    })
    // Default behavior - restore previous route on error (recommended)
    RouterConfiguration.customize({
      restorePreviousRouteTreeOnError: true
    })
    
    // Strict mode - leave application in error state
    RouterConfiguration.customize({
      restorePreviousRouteTreeOnError: false
    })
    import { RouterConfiguration, IRouterEvents, NavigationEndEvent, ICurrentRoute } from '@aurelia/router';
    import { DI } from '@aurelia/kernel';
    
    const container = DI.createContainer();
    container.register(
      RouterConfiguration.customize({ useHref: false }) // for example
    );
    
    const routerEvents = container.get(IRouterEvents);
    const currentRoute = container.get(ICurrentRoute);
    
    routerEvents.subscribe('au:router:navigation-end', (evt: NavigationEndEvent) => {
      console.log('Navigation ended on:', evt.finalInstructions.toUrl());
      console.log('Active route object:', currentRoute.path);
    });
    // Production-ready configuration
    RouterConfiguration.customize({
      useUrlFragmentHash: false,           // Use clean URLs
      historyStrategy: 'push',             // Standard browser navigation
      activeClass: 'active',               // Highlight active nav items
      useNavigationModel: true,            // Enable navigation model for menus
      restorePreviousRouteTreeOnError: true, // Graceful error recovery
      buildTitle: (transition) => {
        // Custom title building with SEO considerations
        const routeTitle = transition.routeTree.root.children
          .map(child => child.title)
          .filter(title => title)
          .join(' - ');
        return routeTitle ? `${routeTitle} | My App` : 'My App';
      }
    })
    // environment-based configuration
    const isDevelopment = process.env.NODE_ENV === 'development';
    const isProduction = process.env.NODE_ENV === 'production';
    
    RouterConfiguration.customize({
      useUrlFragmentHash: isDevelopment,            // Hash routing in dev for simplicity
      historyStrategy: isDevelopment ? 'replace' : 'push', // Keep history noise low in dev, full history in prod
      restorePreviousRouteTreeOnError: !isDevelopment, // Let errors surface in dev, recover in prod
      buildTitle: isProduction
        ? (tr) => buildSEOTitle(tr)                 // SEO-optimised titles in production
        : (tr) => `[DEV] ${tr.routeTree.root.title ?? 'Unknown route'}`,
    });
    // Determine base path from current location
    const currentPath = window.location.pathname;
    const microFrontendName = currentPath.split('/')[1]; // e.g., 'admin', 'customer', 'reports'
    
    RouterConfiguration.customize({
      basePath: `/${microFrontendName}`,
      useUrlFragmentHash: false,
      historyStrategy: 'push',
      buildTitle: (transition) => {
        const appName = microFrontendName.charAt(0).toUpperCase() + microFrontendName.slice(1);
        const routeTitle = transition.routeTree.root.children[0]?.title;
        return routeTitle ? `${routeTitle} - ${appName}` : appName;
      }
    })
    RouterConfiguration.customize({
      basePath: '/spa',                     // App lives under /spa path
      useUrlFragmentHash: true,             // Hash routing to avoid conflicts
      historyStrategy: 'replace',           // Don't interfere with main site navigation
      useHref: false,                       // Only use load attribute to avoid conflicts
      activeClass: 'spa-active',            // Namespaced CSS class
    })
    RouterConfiguration.customize({
      historyStrategy: 'replace',           // Reduce memory usage on mobile
      useNavigationModel: false,            // Disable if using custom mobile navigation
      restorePreviousRouteTreeOnError: true, // Important for unreliable mobile networks
    })
    RouterConfiguration.customize({
      useUrlFragmentHash: true,             // Easier to debug without server setup
      restorePreviousRouteTreeOnError: false, // See errors clearly in development
      buildTitle: (transition) => {
        // Detailed debugging information in title
        const route = transition.routeTree.root.children[0];
        return `[${route?.component?.name || 'Unknown'}] ${route?.title || 'No Title'}`;
      }
    })
    import { Aurelia, AppTask } from '@aurelia/runtime-html';
    import { RouterConfiguration, IRouter } from '@aurelia/router';
    
    Aurelia.register(
      RouterConfiguration.customize(),
      AppTask.activated(IRouter, router => router.start(false)), // skip initial navigation
      AppTask.deactivated(IRouter, router => router.stop()),
    );
    // Solution: Ensure base tag is set correctly
    // In your index.html:
    <base href="/">
    
    // And configure your server for SPA routing
    RouterConfiguration.customize({
      useUrlFragmentHash: false
    })
    // Solution 1: Disable href processing
    RouterConfiguration.customize({
      useHref: false  // Only use load attribute for routing
    })
    
    // Solution 2: Mark external links explicitly
    // <a href="mailto:[email protected]" external>Contact</a>
    // Check your history strategy
    RouterConfiguration.customize({
      historyStrategy: 'push'  // Ensure this is not 'none'
    })
    <!-- Basic usage -->
    <input value.bind="searchQuery & debounce">
    
    <!-- With parameters -->
    <input value.bind="query & throttle:500">
    
    <!-- Multiple parameters -->
    <input value.bind="data & throttle:200:'signalName'">
    
    <!-- Chaining behaviors -->
    <input value.bind="text & debounce:300 & signal:'update'">
    
    <!-- Combined with value converters -->
    <span>${price | currency:'USD' & signal:'refresh'}</span>
    <!-- All of these are equivalent -->
    <input value.bind="data & throttle:200:'signal'">
    <input value.bind="data & throttle :200 : 'signal'">
    <input value.bind="data & throttle: 200 : 'signal'">
    <input type="text" value.bind="searchQuery & throttle">
    <p>Searching for: ${searchQuery}</p>
    <input type="text" value.bind="query & throttle:850">
    <div mousemove.trigger="mouseMoveHandler($event) & throttle"></div>
    <input value.bind="formValue & throttle:200:'flushInput'" blur.trigger="signaler.dispatchSignal('flushInput')">
    import { ISignaler } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    export class MyApp {
      formValue = '';
      signaler = resolve(ISignaler); // Inject ISignaler
    
      constructor() {}
    }
    <input value.bind="value & throttle:200:['finishTyping', 'urgentUpdate']">
    <input type="text" value.bind="searchQuery & debounce">
    <input type="text" value.bind="searchQuery & debounce:850">
    <div mousemove.trigger="mouseMoveHandler($event) & debounce:500"></div>
    <input value.bind="formValue & debounce:300:'validateInput'" blur.trigger="signaler.dispatchSignal('validateInput')">
    import { ISignaler } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    export class MyApp {
      formValue = '';
      signaler = resolve(ISignaler); // Inject ISignaler
    
      constructor() {}
    
      validateInput() {
        console.log('Input validated:', this.formValue);
        // Perform validation logic here
      }
    }
    <input value.bind="searchQuery & debounce:500:['search', 'validate']">
    <input value.bind="firstName & updateTrigger:'blur'">
    <input value.bind="firstName & updateTrigger:'blur':'paste'">
    <p>Last updated: ${lastUpdated | timeAgo & signal:'time-update'}</p>
    import { ISignaler } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    export class MyApp {
      lastUpdated = new Date();
      signaler = resolve(ISignaler);
    
      constructor() {
        setInterval(() => {
          this.lastUpdated = new Date(); // Update the time
          this.signaler.dispatchSignal('time-update'); // Signal bindings to refresh
        }, 5000); // Refresh every 5 seconds
      }
    }
    <!-- Force read-only values on a child component that defaults to two-way -->
    <order-line line.bind="line & toView"></order-line>
    
    <!-- Keep track of pending edits but stop pushing DOM mutations back up -->
    <textarea value.bind="draft.summary & fromView"></textarea>
    
    <!-- Kick off an expensive computation once, never re-run -->
    <span class="snapshot">${report.total & oneTime}</span>
    import { BindingMode, BindingModeBehavior } from '@aurelia/runtime-html';
    
    export class DirtyCheckedBindingBehavior extends BindingModeBehavior {
      public static readonly $au = { type: 'binding-behavior', name: 'dirtyChecked' } as const;
      public get mode() {
        return BindingMode.twoWay;
      }
    }
    
    Aurelia.register(DirtyCheckedBindingBehavior);
    <!-- Perfect for static content -->
    <span>${appVersion & oneTime}</span>
    <img src.bind="logoUrl & oneTime" alt="Company Logo">
    
    <!-- Useful in repeaters for static data -->
    <div repeat.for="item of items">
      <span>${item.id & oneTime}</span> <!-- ID never changes -->
      <span>${item.name}</span> <!-- Name might change -->
    </div>
    <!-- Equivalent syntaxes -->
    <input value.bind="dataItem & toView">
    <input value.one-way="dataItem">
    <!-- Input updates view-model, but view-model changes don't update input -->
    <input value.bind="userInput & fromView">
    <!-- Equivalent to -->
    <input value.from-view="userInput">
    <!-- Equivalent syntaxes -->
    <input value.bind="userInput & twoWay">
    <input value.two-way="userInput">
    <panel>
      <header mousedown.trigger='onMouseDown($event)' ref='headerElement'>
        <button>Settings</button>
        <button>Close</button>
      </header>
    </panel>
    export class PanelComponent {
      headerElement: HTMLElement; // Injected via @ViewChild('headerElement')
    
      onMouseDown(event: MouseEvent) {
        if (event.target !== this.headerElement) {
          return; // Ignore events from header's descendants
        }
        // Mouse down directly on the header, start panel dragging logic...
        // ...
      }
    }
    <panel>
      <header mousedown.trigger='onMouseDown($event) & self'>
        <button class='settings'></button>
        <button class='close'></button>
      </header>
    </panel>
    export class PanelComponent {
      onMouseDown(event: MouseEvent) {
        // No need to check event.target, 'self' behavior ensures
        // this handler is only called for events directly on the header element.
        // Mouse down on header, start panel dragging logic...
        // ...
      }
    }
    <!-- Forces setting the 'data-value' attribute -->
    <div data-value.bind="itemValue & attr">
    
    <!-- Useful for custom attributes that need actual HTML attributes -->
    <custom-element custom-attr.bind="value & attr">
    
    <!-- CSS class binding as attribute -->
    <div class.bind="cssClasses & attr">
    // Custom attribute that reads from HTML attribute
    export class TooltipCustomAttribute {
      attached() {
        // This requires the actual HTML attribute to be set
        const tooltipText = this.element.getAttribute('tooltip');
        // Setup tooltip with tooltipText
      }
    }
    <!-- Without attr - might not work -->
    <div tooltip.bind="helpText">Content</div>
    
    <!-- With attr - ensures HTML attribute is set -->
    <div tooltip.bind="helpText & attr">Content</div>
    import { bindingBehavior } from '@aurelia/runtime-html';
    import { type IBinding, type Scope } from '@aurelia/runtime';
    
    export class LogBindingContextBehavior {
      private originalUpdateTarget = new WeakMap<IBinding, Function>();
    
      public bind(scope: Scope, binding: IBinding) {
        // Store the original updateTarget method
        const original = binding.updateTarget;
        this.originalUpdateTarget.set(binding, original);
    
        // Override updateTarget to add logging
        binding.updateTarget = (value) => {
          console.log('Binding context:', scope.bindingContext);
          console.log('Binding value:', value);
          original.call(binding, value);
        };
      }
    
      public unbind(scope: Scope, binding: IBinding) {
        // Restore original updateTarget method
        const original = this.originalUpdateTarget.get(binding);
        if (original) {
          binding.updateTarget = original;
          this.originalUpdateTarget.delete(binding);
        }
      }
    }
    
    bindingBehavior('logBindingContext')(LogBindingContextBehavior);
    <import from="./log-binding-context-behavior.ts"></import>
    <input value.bind="userName & logBindingContext">
    import { bindingBehavior } from '@aurelia/runtime-html';
    import { type IBinding, type Scope } from '@aurelia/runtime';
    
    export class InspectBindingBehavior {
      private originalMethods = new WeakMap<IBinding, Function>();
    
      public bind(scope: Scope, binding: IBinding) {
        const original = binding.updateTarget;
        this.originalMethods.set(binding, original);
    
        binding.updateTarget = (value) => {
          original.call(binding, value);
          // Add tooltip showing current value
          if (binding.target && 'title' in binding.target) {
            binding.target.title = `Current value: ${JSON.stringify(value)}`;
          }
        };
      }
    
      public unbind(scope: Scope, binding: IBinding) {
        // Restore original method
        const original = this.originalMethods.get(binding);
        if (original) {
          binding.updateTarget = original;
          this.originalMethods.delete(binding);
        }
    
        // Clear tooltip
        if (binding.target && 'title' in binding.target) {
          binding.target.title = '';
        }
      }
    }
    
    bindingBehavior('inspect')(InspectBindingBehavior);
    <import from="./inspect-binding-behavior.ts"></import>
    <input value.bind="itemName & inspect">
    import { bindingBehavior } from '@aurelia/runtime-html';
    import { type IBinding, type Scope } from '@aurelia/runtime';
    
    export class HighlightUpdatesBindingBehavior {
      private originalMethods = new WeakMap<IBinding, Function>();
      private timeouts = new WeakMap<IBinding, number>();
    
      public bind(scope: Scope, binding: IBinding, highlightColor: string = 'yellow', duration: number = 500) {
        const original = binding.updateTarget;
        this.originalMethods.set(binding, original);
    
        binding.updateTarget = (value) => {
          original.call(binding, value);
    
          // Clear any existing timeout
          const existingTimeout = this.timeouts.get(binding);
          if (existingTimeout) {
            clearTimeout(existingTimeout);
          }
    
          if (binding.target && binding.target.style) {
            const originalBg = binding.target.style.backgroundColor;
            binding.target.style.backgroundColor = highlightColor;
    
            const timeout = setTimeout(() => {
              binding.target.style.backgroundColor = originalBg;
              this.timeouts.delete(binding);
            }, duration);
    
            this.timeouts.set(binding, timeout);
          }
        };
      }
    
      public unbind(scope: Scope, binding: IBinding) {
        // Restore original method
        const original = this.originalMethods.get(binding);
        if (original) {
          binding.updateTarget = original;
          this.originalMethods.delete(binding);
        }
    
        // Clear any pending timeouts
        const timeout = this.timeouts.get(binding);
        if (timeout) {
          clearTimeout(timeout);
          this.timeouts.delete(binding);
        }
    
        // Reset background color
        if (binding.target && binding.target.style) {
          binding.target.style.backgroundColor = '';
        }
      }
    }
    
    bindingBehavior('highlightUpdates')(HighlightUpdatesBindingBehavior);
    <import from="./highlight-updates-binding-behavior.ts"></import>
    <div textContent.bind="message & highlightUpdates:'lightblue':'1000'"></div>
    <!-- API calls -->
    <input value.bind="searchTerm & debounce:500">
    
    <!-- DOM updates -->
    <div scroll.trigger="onScroll($event) & throttle:16">
    <!-- Use oneTime for truly static content -->
    <span>${config.version & oneTime}</span>
    <img src.bind="staticLogoUrl & oneTime">
    export class MyBehavior {
      private cleanupMethods = new WeakMap();
    
      bind(scope: Scope, binding: IBinding) {
        // Setup with cleanup tracking
      }
    
      unbind(scope: Scope, binding: IBinding) {
        // Always clean up to prevent memory leaks
        const cleanup = this.cleanupMethods.get(binding);
        cleanup?.();
        this.cleanupMethods.delete(binding);
      }
    }
    <!-- Development: with debugging -->
    <input value.bind="data & logBindingContext & highlightUpdates">
    
    <!-- Production: optimized -->
    <input value.bind="data & debounce:300">
    <!-- Real-time validation with debounce -->
    <input value.bind="email & debounce:300 & signal:'validate'">
    
    <!-- Immediate validation on blur -->
    <input value.bind="email & updateTrigger:'blur'"
           blur.trigger="signaler.dispatchSignal('validate')">
    <!-- Debounced search -->
    <input value.bind="searchQuery & debounce:400">
    
    <!-- Immediate search button -->
    <button click.trigger="search() & signal:'search-now'">Search</button>
    <!-- Time-sensitive content -->
    <span>${timestamp | timeAgo & signal:'time-update'}</span>
    
    <!-- Localized content -->
    <span>${'greeting.hello' | translate & signal:'locale-change'}</span>

    .from-view

    twoWay

    VM ↔ View

    Interactive forms

    .two-way

    router-lite - historyStrategy - replace - StackBlitzStackBlitz
    router-lite - historyStrategy - push - StackBlitzStackBlitz
    router-lite - buildTitle - StackBlitzStackBlitz
    router-lite - historyStrategy - none - StackBlitzStackBlitz
    router-lite - translate title - StackBlitzStackBlitz
    Logo
    Logo
    Logo
    router-lite - base[href] w/o configuration - StackBlitzStackBlitz
    router-lite - basePath - StackBlitzStackBlitz
    Logo
    Logo
    Logo
    Logo

    Lifecycle Visual Diagrams

    Visual explanations of Aurelia 2's component lifecycle with parent-child timing.

    Table of Contents

    1. Activation Sequence (Parent-Child)

    2. Deactivation Sequence (Parent-Child)


    1. Activation Sequence (Parent-Child)

    How lifecycle hooks execute when activating a parent with children:


    2. Deactivation Sequence (Parent-Child)

    How lifecycle hooks execute when deactivating:


    3. Stack-Based Coordination

    How Aurelia ensures correct timing using activation/deactivation stacks:


    4. Async Lifecycle Behavior

    How async hooks affect timing:


    5. Common Pitfalls

    Real-world mistakes and how to avoid them:


    Summary

    Key Takeaways:

    1. Activation is top-down until attached (which is bottom-up)

    2. Deactivation is bottom-up throughout

    3. binding() blocks children, attaching() doesn't

    4. Stacks coordinate timing between parent and children

    For more details, see the main documentation.

    Slotted content

    Learn how to project content into custom elements using native slots and au-slot, and how to observe and react to slot changes.

    Aurelia provides two ways to project content into custom elements:

    • <slot> - Native Web Components slot that requires Shadow DOM

    • <au-slot> - Aurelia's slot implementation that works without Shadow DOM

    This guide focuses on slot usage, observation, and advanced patterns. For detailed information about configuring Shadow DOM, styling, and constraints, see the Shadow DOM guide.

    Native Slots (<slot>)

    The <slot> element is the browser-native way to project content into Shadow DOM components.

    Native <slot> elements require Shadow DOM. Attempting to use <slot> without enabling Shadow DOM will throw a compilation error: Template compilation error: detected a usage of "<slot>" element without specifying shadow DOM options.

    For content projection without Shadow DOM, use instead.

    Enabling Shadow DOM for Slots

    Before using <slot>, enable Shadow DOM on your component:

    Because we named our component au-modal we will then use it like this:

    Notice how we add slot="content" to the projected node? This tells Aurelia to send that markup to the <slot name="content"> target. When you target the default slot you omit the slot attribute entirely. Custom elements can have multiple slots, so how do we tell Aurelia where to project our content?

    Named slots

    A named slot is no different to a conventional slot. The only difference is the slot has a name we can reference. A slot without a name gets the name default by default.

    Now, to use our element with a named slot, you can do this:

    Fallback content

    A slot can display default content when nothing is explicitly projected into it. Fallback content works for default and named slot elements.

    Listening to projection change

    At the projection target (<slot> element), with the

    The <slot> element comes with an event based way to listen to its changes. This can be done via listening to slotchange even on the <slot> element, like the following example:

    At the projection source (custom element host), with the @children decorator

    In case where it's not desirable to go to listen to projection change at the targets (<slot> elements), it's also possible to listen to projection at the source with @children decorator. Decorating a property on a custom element class with @children decorator will setup mutation observer to notify of any changes, like the following example:

    After the initial rendering, myDetails.divs will be an array of 1 <div> element, and any future addition of any <div> elements to the <my-details> element will update the divs property on myDetails instance, with corresponding array.

    Additionally, the @children decorator will also call a callback as a reactive change handler. The name of the callback, if omitted in the decorator, will be derived based on the property being decorated, example: divs -> divsChanged

    @children decorator usage

    Usage
    Meaning

    Note: the @children decorator wont update if the children of a slotted node change — only if you change (e.g. add or delete) the actual nodes themselves.

    Retrieving component view models

    When using @children to target projected element components, it's often desirable to get the underlying component instances rather than the host elements of those. The @children decorator by default automatically retrieves those instances, like the following examples:

    As items property is decorated with @children('my-item'), its values is always a list of MyItem instances instead of <my-item> elements. You can alter this behavior by specifying a map option, like the following example:

    In the above example, we give map option a function to decide that we want to take the host element instead of the component instance.

    Au-slot

    Aurelia provides another way of content projection with au-slot. This is similar to the native slot when working with content projection. However, it does not use Shadow DOM. au-slot is useful where you want externally defined styles to penetrate the component boundary, facilitating easy styling of components.

    Suppose you create your own set of custom elements solely used in your application. In that case, you might want to avoid the native slots in the custom elements, as it might be difficult to style them from your application.

    However, if you still want slot-like behavior, then you can use au-slot, as that makes styling those custom elements/components easier. Instead of using shadow DOM, the resulting view is composed purely by the Aurelia compilation pipeline.

    There are other aspects of au-slot as well which will be explored in this section with examples.

    An obvious question might be, "Why not simply 'turn off' shadow DOM, and use the slot itself"? We feel that goes opposite to Aurelia's promise of keeping things as close to native behavior as possible. Moreover, using a different name like au-slot makes it clear that the native slot is not used in this case. However, still brings slotting behavior to use.

    If you have used the replaceable and replace part before or with Aurelia1, it is replaced with au-slot.

    Basic templating usage

    Like slot, a "projection target"/"slot" can be defined using a <au-slot> element, and a projection to that slot can be provided using a [au-slot] attribute. Consider the following example.

    In the example above, the my-element custom element defines two slots: one default and one named. The slots can optionally have fallback content; i.e. when no projection is provided for the slot, the fallback content will be displayed. Projecting to a slot is, therefore, also optional. However, when a projection is provided for a slot, that overrides the fallback content of that slot.

    Similar to native shadow DOM and <slot/>/[slot] pair, [au-slot] attribute is not mandatory if you are targeting the default slot. All content without explicit [au-slot] is treated as targeting the default slot. Having no [au-slot] is also equal to having explicit au-slot on the content:

    Another important point to note is that the usage of [au-slot] attribute is supported only on the direct children elements of a custom element. This means that the following examples do not work.

    Using [au-slot] with template controllers

    You can combine [au-slot] with Aurelia template controllers such as if.bind, repeat.for, or your own custom controllers. During compilation Aurelia moves the slotted content into projection templates before it runs those controllers, so the control-flow logic still executes exactly where you expect it to.

    The compiler handles both if.bind and repeat.for on the slotted projections, so you do not need to wrap them inside extra <template> tags unless you prefer that style.

    Inject the projected slot information

    It is possible to inject an instance of IAuSlotsInfo in a element component view model. This provides information related to the slots inside a custom element. The information includes only the slot names for which content has been projected. Let's consider the following example.

    The following would be logged to the console for the instances of my-element.

    Binding scope

    It is also possible to use data-binding, interpolation etc., while projecting. While doing so, the scope accessing rule can be described by the following thumb rule:

    1. When the projection is provided, the scope of the custom element providing the projection is used.

    2. When the projection is not provided, the scope of the inner custom element is used.

    3. The outer custom element can still access the inner scope using the $host keyword while projecting.

    Examples

    To further explain how these rules apply, these rules are explained with the following examples.

    Projection uses the outer scope by defaultthe

    Let's consider the following example with interpolation.

    Although the my-element has a message property, but as my-app projects to s1 slot, scope of my-app is used to evaluate the interpolation expression. Similar behavior can also be observed when binding properties of custom elements, as shown in the following example.

    Fallback uses the inner scope by default

    Let's consider the following example with interpolation. This is the same example as before, but this time without projection.

    Note that in the absence of projection, the fallback content uses the scope of my-element. For completeness, the following example shows that it also holds while binding values to the @bindables in custom elements.

    Access the inner scope with $host

    The outer custom element can access the inner custom element's scope using the $host keyword, as shown in the following example.

    Note that using the $host.message expression, MyApp can access the MyElement#message. The following example demonstrates the same behavior for binding values to custom elements.

    Let's consider another example of $host which highlights the communication between the inside and outside of a custom element that employs <au-slot>

    In the example above, we replace the 'content' template of the grid, defined in my-element, from my-app. While doing so, we can grab the scope of the <au-slot name="content" /> and use the properties made available by the binding expose.bind="{ person, $even, $odd, $index }", and use those in the projection template.

    Note that $host allows us to access whatever the <au-slot/> element exposes, and this value can be changed to enable powerful scenarios. Without the $host it might not have been easy to provide a template for the repeater from the outside.

    expose.bind is reactive. Updating the bound object (for example, when an inner input changes) immediately updates the $host object that all projected templates see.

    The last example is also interesting from another aspect. It shows that many parts of the grid can be replaced with projection while working with a grid. This includes the header of the grid (au-slot="header"), the template column of the grid (au-slot="content"), or even the whole grid itself (au-slot="grid").

    The $host keyword can only be used in the context of projection. Using it in any other context is not supported and will throw errors with high probability.

    Multiple projections for a single slot

    It is possible to provide multiple projections to a single slot.

    This is useful for many cases. One evident example would a 'tabs' custom element.

    This helps keep things closer that belong together. For example, keeping the tab-header and tab-content next to each other provides better readability and understanding of the code to the developer. On other hand, it still places the projected contents in the right slot.

    Duplicate slots

    Having more than one <au-slot> with the same name is also supported. This lets us project the same content to multiple slots declaratively, as can be seen in the following example.

    Note that projection for the name is provided once, but it gets duplicated in 2 slots. You can also see this example in action .

    Listening to <au-slot> change

    Similar like the standard <slot> element allows the ability to listen to changes in the content projected, <au-slot> also provides the capability to listen & react to changes.

    With @slotted decorator

    One way to subscribe to au-slot changes is via the @slotted decorator, like the following example:

    After rendering, the MySummaryElement instance will have paragraphs value as an array of 2 <p> element as seen in the app.html.

    The @slotted decorator will invoke change handler upon initial rendering, and whenever there's a mutation after wards, while the owning custom element is still active. By default, the callback will be selected based on the name of the decorated property. For example: paragraphs -> paragraphsChanged, like the following example:

    ### Change handler callback reminders - The change handler will be called upon the initial rendering, and after every mutation afterwards while the custom element is still active {% %}

    @slotted usage

    The @slotted decorator can be used in multiple forms:

    Usage
    Meaning

    Note: the `@slotted` decorator won't be notified if the children of a slotted node change — only if you change (e.g. add or delete) the actual nodes themselves. {% %}

    With slotchange binding

    The standard <slot> element dispatches slotchange events for application to react to changes in the projection. This behavior is also supported with <au-slot>. The different are with <slot>, it's an event while for <au-slot>, it's a callback as there's no host to dispatch an event, for <au-slot> is a containerless element.

    The callback will be passed 2 parameters:

    name
    type
    description

    An example of using slotchange behavior may look like the following:

    slotchange callback reminders

    • The callback will not be called upon the initial rendering, it's only called when there's a mutation after the initial rendering.

    Always clean up in the opposite hook (attached ↔ detaching)

  • Use attached() for DOM work, not binding()

  • Stack-Based Coordination
    Async Lifecycle Behavior
    Common Pitfalls
    Component Lifecycles

    @slotted('*', '*')

    Observe projection on all slots, and select all elements

    @slotted({ query: 'div' })

    Observe projection on the default slot, and select only div elements

    @slotted({ slotName: 'footer' })

    Observe projection on footer slot, and select all elements

    @slotted({ callback: 'nodeChanged' })

    Observe projection on default slot, and select all elements, and call nodeChanged method on projection change

  • The callback pass to slotchange of <au-slot> will be call with an undefined this, so you should either give it a lambda expression, or a function like the example above.

  • The nodes passed to the 2nd parameter of the slotchange callback will always be the latest list of nodes.

  • the slotchange callback doesn't fire if the children of a slotted node change — only if you change (e.g. add or delete) the actual nodes themselves.

  • slotchange is independent of @slotted; you can bind it even if you do not observe the slot contents elsewhere.

  • @children() prop

    Use default options, observe mutation, and select all elements

    @children('div') prop

    Observe mutation, and select only div elements

    @children({ query: 'my-child' })

    Observe mutation, and select only my-child elements, get the component instance if available and fallback to the element itself

    @children({ query: 'my-child', map: (node, viewModel) => viewModel ?? node })

    Observe mutation, and select only my-child elements, get the component instance if available and fallback to the element itself

    @slotted() prop

    Use default options, observe projection on the default slot, and select all elements

    @slotted('div') prop

    Observe projection on the default slot, and select only div elements

    @slotted('div', 'footer') prop

    Observe projection on the footer slot and select only div elements

    @slotted('$all')

    Observe projection on the default slot, and select all nodes, including text

    @slotted('*')

    Observe projection on the default slot, and select all elements

    @slotted('div', '*')

    Observe projection on all slots, and select only div elements

    name

    string

    the name of the slot calling the change callback

    nodes

    Node[]

    the list of the latest nodes that belongs to the slot calling the change callback

    <au-slot>
    slotchange event
    here
    import { customElement, useShadowDOM } from 'aurelia';
    
    @customElement('au-modal')
    @useShadowDOM()
    export class AuModal {}
    <div class="modal">
        <div class="modal-inner">
            <slot></slot>
        </div>
    </div>
    SCENARIO: Parent component with 2 children activates
    ═══════════════════════════════════════════════════════════════
    
    Timeline:
    ─────────────────────────────────────────────────────────────
    
    Time  Parent              Child-1            Child-2
    ────  ──────              ───────            ───────
      0   constructor()
      1   define()
      2   hydrating()
      3   hydrated()
          created() ←──────── created() ←─────── created()
                              │                  │
                              └─ children first ─┘
    
      4   binding() ──────┐
          ↓ (if async,    │
          blocks children)│
                          │
      5   ← resolve ──────┘
          bind() (connects bindings)
    
      6   attaching() ────┐
          _attach() DOM ──┤  binding() ────┐   binding() ────┐
                          │                │                  │
                          │  bind()        │   bind()         │
                          │                │                  │
                          │  attaching() ──┤   attaching() ───┤
                          │  _attach() DOM │   _attach() DOM  │
                          │                │                  │
                      ┌───┴────────────────┴──────────────────┘
                      │   (parent's attaching() and
                      │    children activation run in PARALLEL)
                      │
      7               └─→ Wait for all to complete
    
          attached() ←───── attached() ←──── attached()
          │                 │                 │
          └─ children first (bottom-up) ─────┘
    
      8   ACTIVATED
    
    
    DETAILED ACTIVATION FLOW
    ═══════════════════════════════════════════════════════════
    
    ┌────────────────────────────────────────────────────┐
    │ CONSTRUCTION PHASE (Top ➞ Down)                    │
    ├────────────────────────────────────────────────────┤
    │                                                    │
    │ Parent.constructor()                               │
    │   → Child1.constructor()                           │
    │   → Child2.constructor()                           │
    │                                                    │
    │ Parent.define()                                    │
    │   → Child1.define()                                │
    │   → Child2.define()                                │
    │                                                    │
    │ Parent.hydrating()                                 │
    │   → Child1.hydrating()                             │
    │   → Child2.hydrating()                             │
    │                                                    │
    │ Parent.hydrated()                                  │
    │   → Child1.hydrated()                              │
    │   → Child2.hydrated()                              │
    │                                                    │
    └────────────────────────────────────────────────────┘
             ↓
    ┌────────────────────────────────────────────────────┐
    │ CREATED PHASE (Bottom ➞ Up)                        │
    ├────────────────────────────────────────────────────┤
    │                                                    │
    │   Child1.created()                                 │
    │   Child2.created()                                 │
    │     → Parent.created()  ← After all children      │
    │                                                    │
    └────────────────────────────────────────────────────┘
             ↓
    ┌────────────────────────────────────────────────────┐
    │ BINDING PHASE (Top ➞ Down, Blocks Children)       │
    ├────────────────────────────────────────────────────┤
    │                                                    │
    │ Parent.binding()                                   │
    │   ↓ (if async, children wait)                     │
    │   ↓                                                │
    │ [ await parent.binding() ]                         │
    │   ↓                                                │
    │ Parent.bind() - connects bindings to scope        │
    │   ↓                                                │
    │   Child1.binding()                                 │
    │     ↓                                              │
    │   [ await child1.binding() ]                       │
    │     ↓                                              │
    │   Child1.bind()                                    │
    │                                                    │
    │   Child2.binding()                                 │
    │     ↓                                              │
    │   [ await child2.binding() ]                       │
    │     ↓                                              │
    │   Child2.bind()                                    │
    │                                                    │
    └────────────────────────────────────────────────────┘
             ↓
    ┌────────────────────────────────────────────────────┐
    │ BOUND PHASE (Bottom ➞ Up)                          │
    ├────────────────────────────────────────────────────┤
    │                                                    │
    │   Child1.bound()                                   │
    │   Child2.bound()                                   │
    │     → Parent.bound()  ← After children             │
    │                                                    │
    └────────────────────────────────────────────────────┘
             ↓
    ┌────────────────────────────────────────────────────┐
    │ ATTACHING PHASE (Parallel!)                        │
    ├────────────────────────────────────────────────────┤
    │                                                    │
    │ Parent.attaching()         activatingStack = 1    │
    │   ↓                              ↓                 │
    │ Parent._attach()                 │                 │
    │   → Append to DOM                │                 │
    │                                  │                 │
    │ [ Both run in PARALLEL ]         │                 │
    │   ├─ await parent.attaching()    │                 │
    │   └─ Child activation ───────────┘                 │
    │        ├─ Child1.binding()       activatingStack++ │
    │        ├─ Child1.bind()                            │
    │        ├─ Child1.bound()                           │
    │        ├─ Child1.attaching()                       │
    │        ├─ Child1._attach()                         │
    │        │    → Append to DOM                        │
    │        │                                           │
    │        ├─ Child2.binding()       activatingStack++ │
    │        ├─ Child2.bind()                            │
    │        ├─ Child2.bound()                           │
    │        ├─ Child2.attaching()                       │
    │        └─ Child2._attach()                         │
    │             → Append to DOM                        │
    │                                                    │
    └────────────────────────────────────────────────────┘
             ↓
    ┌────────────────────────────────────────────────────┐
    │ ATTACHED PHASE (Bottom ➞ Up)                       │
    ├────────────────────────────────────────────────────┤
    │                                                    │
    │ _leaveActivating() called on each child           │
    │   activatingStack-- for each                      │
    │                                                    │
    │ When activatingStack === 0:                        │
    │   Child1.attached()            activatingStack--  │
    │   Child2.attached()            activatingStack--  │
    │     → Parent.attached()        activatingStack--  │
    │                                ↓                   │
    │                           activatingStack === 0   │
    │                           → state = activated     │
    │                                                    │
    └────────────────────────────────────────────────────┘
    
    
    KEY IMPLEMENTATION DETAILS
    ═══════════════════════════════════════════════════════════
    
    1. _enterActivating() increments activatingStack
       - Called when starting binding phase
       - Recursively increments parent's stack
    
    2. Parent's attaching() runs in PARALLEL with children
       - Children start activating while parent is still attaching
       - This allows for better performance
    
    3. attached() only called when stack === 0
       - _leaveActivating() decrements stack
       - When stack reaches 0, attached() is invoked
       - This ensures bottom-up execution
    
    4. binding() can block
       - If it returns a Promise, children wait
       - This is why it's marked "blocks children" in docs
    SCENARIO: Parent with 2 children deactivates
    ═══════════════════════════════════════════════════════════
    
    Timeline:
    ─────────────────────────────────────────────────────────────
    
    Time  Parent              Child-1            Child-2
    ────  ──────              ───────            ───────
      0   deactivate() ───┐
                          │
      1                   └──→ deactivate() ──→ deactivate()
                               │                 │
                               (children first)  │
                               │                 │
      2                        detaching() ←─────┘
                               │
                               (builds linked list)
                               │
      3   detaching() ←────────┘
          │
          (initiator collects all)
          │
      4   _leaveDetaching()
          └─→ detachingStack === 0
    
      5   removeNodes() ──┐
                          ├─→ removeNodes()
                          └─→ removeNodes()
    
          (DOM removed from all)
    
      6   unbinding() ────┐
                          ├─→ unbinding()
                          └─→ unbinding()
    
          (process linked list)
    
      7   unbind() ───────┐
                          ├─→ unbind()
                          └─→ unbind()
    
          DEACTIVATED
    
    
    DETAILED DEACTIVATION FLOW
    ═══════════════════════════════════════════════════════════
    
    ┌────────────────────────────────────────────────────┐
    │ CHILD DEACTIVATION (Children First)                │
    ├────────────────────────────────────────────────────┤
    │                                                    │
    │ Parent.deactivate() called                         │
    │   ↓                                                │
    │   state = deactivating                             │
    │   ↓                                                │
    │   for each child:                                  │
    │     child.deactivate(initiator, parent)           │
    │       ↓                                            │
    │       Child1.deactivate() ────┐                    │
    │       Child2.deactivate() ────┤                    │
    │                               │                    │
    └───────────────────────────────┼────────────────────┘
                                    ↓
    ┌────────────────────────────────────────────────────┐
    │ DETACHING PHASE (Children First, Build List)       │
    ├────────────────────────────────────────────────────┤
    │                                                    │
    │   Child1.detaching()         detachingStack++     │
    │     ↓ (await if async)                            │
    │   Add Child1 to linked list                        │
    │                                                    │
    │   Child2.detaching()         detachingStack++     │
    │     ↓ (await if async)                            │
    │   Add Child2 to linked list                        │
    │                                                    │
    │ Parent.detaching()           detachingStack++     │
    │   ↓ (await if async)                              │
    │ Add Parent to linked list                          │
    │                                                    │
    │ Linked list: Child1 → Child2 → Parent            │
    │                                                    │
    └────────────────────────────────────────────────────┘
             ↓
    ┌────────────────────────────────────────────────────┐
    │ DETACH PHASE (Initiator Processes All)             │
    ├────────────────────────────────────────────────────┤
    │                                                    │
    │ Initiator._leaveDetaching()                        │
    │   ↓                                                │
    │   detachingStack--                                 │
    │   ↓                                                │
    │ When stack === 0:                                  │
    │   Process linked list:                             │
    │     ↓                                              │
    │   Parent.removeNodes()                             │
    │   Child1.removeNodes()                             │
    │   Child2.removeNodes()                             │
    │     ↓                                              │
    │   (DOM physically removed)                         │
    │                                                    │
    └────────────────────────────────────────────────────┘
             ↓
    ┌────────────────────────────────────────────────────┐
    │ UNBINDING PHASE (Process List, Children First)     │
    ├────────────────────────────────────────────────────┤
    │                                                    │
    │ Walk linked list (Child1 → Child2 → Parent):      │
    │                                                    │
    │   Child1.unbinding()         unbindingStack++     │
    │     ↓ (await if async)                            │
    │   Child1.unbind()                                  │
    │     → disconnect bindings                          │
    │     → scope = null                                 │
    │                                                    │
    │   Child2.unbinding()         unbindingStack++     │
    │     ↓ (await if async)                            │
    │   Child2.unbind()                                  │
    │     → disconnect bindings                          │
    │     → scope = null                                 │
    │                                                    │
    │   Parent.unbinding()         unbindingStack++     │
    │     ↓ (await if async)                            │
    │   Parent.unbind()                                  │
    │     → disconnect bindings                          │
    │     → scope.parent = null                          │
    │     → state = deactivated                          │
    │                                                    │
    └────────────────────────────────────────────────────┘
    
    
    KEY IMPLEMENTATION DETAILS
    ═══════════════════════════════════════════════════════════
    
    1. Children deactivate first
       - Parent calls deactivate on each child
       - Children process before parent continues
    
    2. Linked list built during detaching
       - Each component adds itself to the list
       - List maintains deactivation order
    
    3. Only initiator processes the list
       - Non-initiator components just add themselves
       - Initiator handles all DOM removal and unbinding
    
    4. removeNodes() called before unbinding
       - DOM physically removed first
       - Then bindings are disconnected
    
    5. unbinding() processed via linked list
       - Walks the list in order
       - Calls unbinding hooks sequentially
    
    
    PARALLEL DETACHING
    ═══════════════════════════════════════════════════════════
    
    When detaching() returns a Promise:
    
    Parent.detaching() ─────┐
                            ├─ await (parallel)
    Child1.detaching() ─────┤
                            ├─ await (parallel)
    Child2.detaching() ─────┘
    
    All detaching() hooks await in PARALLEL, then:
      → removeNodes() on all
      → unbinding() in sequence via linked list
      → unbind() completes deactivation
    
    This allows exit animations to run simultaneously!
    ACTIVATION STACK MECHANISM
    ═══════════════════════════════════════════════════════════
    
    Purpose: Ensure attached() only fires after ALL children are ready
    
    private _activatingStack: number = 0;
    
    _enterActivating() {
      ++this._activatingStack;     // Increment
      if (this.$initiator !== this) {
        this.parent._enterActivating();  // Propagate up
      }
    }
    
    _leaveActivating() {
      if (--this._activatingStack === 0) {  // Decrement
        // Stack is 0, all children done!
        this.attached();            // Call attached()
        this.state = activated;
      }
      if (this.$initiator !== this) {
        this.parent._leaveActivating();    // Propagate up
      }
    }
    
    
    STACK TIMELINE (Parent + 2 Children)
    ═══════════════════════════════════════════════════════════
    
    Time  Action                           Parent Stack
    ────  ──────                           ────────────
      0   Parent.activate()                    0
      1   _enterActivating() (binding)         1  (Enter)
    
      2   Parent.binding() completes           1
      3   Parent.bind()                        1
      4   Parent.attaching()                   1
      5   _enterActivating() (attaching)       2  (Enter again)
    
      6   Child1 starts activating             3  (Child enters)
      7   Child1 attaching                     3
    
      8   Child2 starts activating             4  (Child enters)
      9   Child2 attaching                     4
    
     10   Parent.attaching() completes         4
     11   _leaveActivating() (attaching)       3  (Leave)
    
     12   Child1.attaching() completes         3
     13   Child1 _leaveActivating()            2  (Child leaves)
     14   Child1.attached()                    2  (Stack > 0, can't call parent yet)
    
     15   Child2.attaching() completes         2
     16   Child2 _leaveActivating()            1  (Child leaves)
     17   Child2.attached()                    1
    
     18   _leaveActivating() (binding)         0  (Stack === 0!)
     19   Parent.attached() -----------------  0  (NOW parent can fire)
          state = activated
    
    
    DETACHING STACK MECHANISM
    ═══════════════════════════════════════════════════════════
    
    Purpose: Await all detaching() Promises before removing DOM
    
    private _detachingStack: number = 0;
    
    _enterDetaching() {
      ++this._detachingStack;
    }
    
    _leaveDetaching() {
      if (--this._detachingStack === 0) {
        // All detaching() complete!
        this.removeNodes();       // Now safe to remove DOM
        // Process unbinding via linked list...
      }
    }
    
    
    DETACHING STACK TIMELINE
    ═══════════════════════════════════════════════════════════
    
    Time  Action                           Stack
    ────  ──────                           ─────
      0   Parent.deactivate()                 0
      1   Child1.deactivate()                 0
      2   Child1.detaching() -> Promise       0
      3   _enterDetaching()                   1  (Track Promise)
    
      4   Child2.deactivate()                 1
      5   Child2.detaching() -> Promise       1
      6   _enterDetaching()                   2  (Track Promise)
    
      7   Parent.detaching() -> Promise       2
      8   _enterDetaching()                   3  (Track Promise)
    
      9   Child1 Promise resolves             3
     10   _leaveDetaching()                   2  (Done)
    
     11   Child2 Promise resolves             2
     12   _leaveDetaching()                   1  (Done)
    
     13   Parent Promise resolves             1
     14   _leaveDetaching()                   0  (Stack === 0!)
          removeNodes() on all --------------  0  (Now safe)
          unbinding() on all ----------------  0
    
    
    WHY STACKS ARE NECESSARY
    ═══════════════════════════════════════════════════════════
    
    Problem without stacks:
      - Parent's attached() might fire before children ready
      - DOM might be removed while animations still running
      - Race conditions between parent and children
    
    Solution with stacks:
      - attached() only fires when stack === 0 (all done)
      - DOM only removed when all detaching() complete
      - Clean coordination between parent and children
    
    
    MULTIPLE ENTER/LEAVE CALLS
    ═══════════════════════════════════════════════════════════
    
    A single controller can call _enterActivating() multiple times:
    
    1. Once for binding phase
    2. Once for attaching phase
    
    This is intentional! The stack tracks ALL pending work:
      - Parent's own lifecycle phases
      - Each child's activation
    
    When stack reaches 0, everything is truly done.
    SYNC VS ASYNC BINDING
    ═══════════════════════════════════════════════════════════
    
    Synchronous binding():
    ──────────────────────────────────────────
    export class MyComponent {
      binding() {
        this.data = setupData();  // ← Sync
      }
    }
    
    Timeline:
    Parent.binding() ──┐
                       ├─ immediate
    Parent.bind() ─────┘
      ↓
    Child.binding() ───┐
                       ├─ immediate
    Child.bind() ──────┘
    
    Total: ~0ms blocking time
    
    
    Asynchronous binding():
    ──────────────────────────────────────────
    export class MyComponent {
      async binding() {
        this.data = await fetch('/api/data');  // ← Async
      }
    }
    
    Timeline:
    Parent.binding() ──┐
                       ├─ await ─────────────┐ (500ms)
                       │                     │
                       │ (children blocked)  │
                       │                     │
                       └─────────────────────┘
    Parent.bind() ─────┘
      ↓
    Child.binding() ───┐  ← Only starts after parent resolves
                       ├─ immediate
    Child.bind() ──────┘
    
    Total: ~500ms blocking time
    
    
    REAL-WORLD IMPACT
    ═══════════════════════════════════════════════════════════
    
    Bad - Blocks children unnecessarily:
    export class Parent {
      async binding() {
        // This blocks children for 1 second!
        await delay(1000);
        this.data = 'loaded';
      }
    }
    
    Good - Use loading() instead:
    export class Parent {
      async loading() {
        // Children can start while this runs
        await delay(1000);
        this.data = 'loaded';
      }
    
      binding() {
        // Sync, doesn't block children
      }
    }
    
    
    ATTACHING() DOESN'T BLOCK CHILDREN
    ═══════════════════════════════════════════════════════════
    
    Key difference from binding():
    
    export class Parent {
      async attaching() {
        // This runs in PARALLEL with children!
        await animateIn();
      }
    }
    
    Timeline:
    Parent.attaching() ────┐
                           ├─ async animation (parallel)
    Child activation ──────┤
                           ├─ runs simultaneously
    Both complete ─────────┘
      ↓
    Parent.attached()
    Child.attached()
    
    Note: attaching() and child activation run in parallel
    
    
    ATTACHED() AWAITS ATTACHING()
    ═══════════════════════════════════════════════════════════
    
    export class MyComponent {
      async attaching() {
        await animateIn();  // ← Async
      }
    
      attached() {
        // Only called AFTER attaching() resolves
        console.log('Animation complete!');
      }
    }
    
    Timeline:
    attaching() ──────┐
                      ├─ await animation
                      └──→ [ animation completes ]
                             ↓
                           attached() ← Called now
    
    This ensures you can safely measure DOM in attached()
    
    
    DETACHING() PARALLEL BEHAVIOR
    ═══════════════════════════════════════════════════════════
    
    Detaching hooks await in PARALLEL:
    
    export class Parent {
      async detaching() {
        await this.animateOut();  // 500ms
      }
    }
    
    export class Child1 {
      async detaching() {
        await this.animateOut();  // 300ms
      }
    }
    
    export class Child2 {
      async detaching() {
        await this.animateOut();  // 400ms
      }
    }
    
    Timeline:
    Parent.detaching() ────────┐ (500ms)
    Child1.detaching() ─────┐  │ (300ms)
    Child2.detaching() ──────┤  │ (400ms)
                             │  │
                             │  │ (all run in parallel)
                             │  │
    All complete ────────────┴──┘
      ↓ (after 500ms - longest)
    removeNodes()
    unbinding()
    
    Total time: 500ms (not 1200ms!)
    
    
    PROMISE REJECTION HANDLING
    ═══════════════════════════════════════════════════════════
    
    If a lifecycle hook Promise rejects:
    
    export class MyComponent {
      async binding() {
        throw new Error('Failed to load data');
      }
    }
    
    Behavior:
    ret.catch((err: Error) => {
      this._reject(err);  // Propagates to controller.$promise
    });
    
    The activation aborts and the error propagates to the parent.
    The component will NOT be activated.
    
    
    BEST PRACTICES
    ═══════════════════════════════════════════════════════════
    
    DO use async in attaching() for animations
      (runs in parallel, doesn't block)
    
    DO use async in detaching() for exit animations
      (all run in parallel)
    
    AVOID async in binding() unless necessary
      (blocks all children from starting)
    
    DO use loading() for data fetching
      (router lifecycle, doesn't block children)
    
    AVOID long-running operations in binding()
      (delays entire component tree activation)
    PITFALL #1: Memory Leaks from Event Listeners
    ═══════════════════════════════════════════════════════════
    
    BAD - Leaks memory:
    export class MyComponent {
      attached() {
        window.addEventListener('resize', this.handleResize);
      }
      // Missing cleanup!
    }
    
    GOOD - Properly cleaned up:
    export class MyComponent {
      attached() {
        window.addEventListener('resize', this.handleResize);
      }
    
      detaching() {
        window.removeEventListener('resize', this.handleResize);
      }
    }
    
    BETTER - Use bound method:
    export class MyComponent {
      private handleResize = () => { /* ... */ };
    
      attached() {
        window.addEventListener('resize', this.handleResize);
      }
    
      detaching() {
        window.removeEventListener('resize', this.handleResize);
      }
    }
    
    
    PITFALL #2: Accessing DOM Before It's Ready
    ═══════════════════════════════════════════════════════════
    
    BAD - DOM not ready:
    export class MyComponent {
      binding() {
        // DOM not attached yet!
        const width = this.element.offsetWidth;  // Might be 0
      }
    }
    
    GOOD - Wait for attached:
    export class MyComponent {
      attached() {
        // DOM is now in document and laid out
        const width = this.element.offsetWidth;  // Correct!
      }
    }
    
    Why: binding() happens before DOM is attached.
    Use attached() for DOM measurements.
    
    
    PITFALL #3: Blocking Children with Slow binding()
    ═══════════════════════════════════════════════════════════
    
    BAD - Blocks entire tree:
    export class Parent {
      async binding() {
        // This delays ALL children for 2 seconds!
        this.data = await slowApiCall();  // 2000ms
      }
    }
    
    GOOD - Use loading() or attached():
    export class Parent {
      async loading() {
        // Children can start while this runs
        this.data = await slowApiCall();
      }
    
      binding() {
        // Quick, synchronous setup only
      }
    }
    
    Or if not using router:
    export class Parent {
      binding() {
        // Synchronous setup
      }
    
      attached() {
        // Async data loading after activation
        void this.loadData();
      }
    
      private async loadData() {
        this.data = await slowApiCall();
      }
    }
    
    
    PITFALL #4: Not Awaiting Async Hooks
    ═══════════════════════════════════════════════════════════
    
    BAD - Missing await:
    export class MyComponent {
      detaching() {
        this.animateOut();  // Missing await/return!
      }
    
      private async animateOut() {
        await animation.play();
      }
    }
    // Animation cut short because DOM removed immediately!
    
    GOOD - Properly awaited:
    export class MyComponent {
      detaching() {
        return this.animateOut();  // Return the Promise
      }
    
      private async animateOut() {
        await animation.play();
      }
    }
    // Framework waits for animation before removing DOM
    
    
    PITFALL #5: Heavy Work in Constructor
    ═══════════════════════════════════════════════════════════
    
    BAD - Premature work:
    export class MyComponent {
      @bindable data: any;
    
      constructor() {
        // data is undefined! Bindables not set yet
        this.processData(this.data);  // undefined!
      }
    }
    
    GOOD - Wait for binding:
    export class MyComponent {
      @bindable data: any;
    
      binding() {
        // Bindables are now set
        this.processData(this.data);  // Correct!
      }
    }
    
    Rule: Constructor runs before bindables are set.
    Use binding() or later hooks to access bindables.
    
    
    PITFALL #6: Forgetting dispose() for Long-Lived Resources
    ═══════════════════════════════════════════════════════════
    
    BAD - Resource leak:
    export class MyComponent {
      private subscription: Subscription;
    
      attached() {
        this.subscription = eventAggregator.subscribe('event', this.handler);
      }
    
      detaching() {
        this.subscription.dispose();  // Not enough!
      }
    }
    // If component is cached (repeat.for), subscription persists!
    
    GOOD - Clean up in dispose:
    export class MyComponent {
      private subscription: Subscription;
    
      attached() {
        this.subscription = eventAggregator.subscribe('event', this.handler);
      }
    
      detaching() {
        // Short-lived cleanup
      }
    
      dispose() {
        // Permanent cleanup
        this.subscription?.dispose();
      }
    }
    
    When to use each:
    - detaching(): Temporary deactivation (might reactivate)
    - dispose(): Permanent cleanup (never coming back)
    
    
    PITFALL #7: Modifying @observable During Deactivation
    ═══════════════════════════════════════════════════════════
    
    BAD - Triggers bindings during teardown:
    export class MyComponent {
      @observable isActive: boolean = true;
    
      unbinding() {
        this.isActive = false;  // Triggers change handlers!
      }
    }
    // Can cause errors if bindings partially disconnected
    
    GOOD - Set state before unbinding:
    export class MyComponent {
      @observable isActive: boolean = true;
    
      detaching() {
        // Bindings still active, safe to modify
        this.isActive = false;
      }
    
      unbinding() {
        // Just cleanup, no state changes
      }
    }
    
    
    PITFALL #8: Not Handling Deactivation During Activation
    ═══════════════════════════════════════════════════════════
    
    BAD - Race condition:
    export class MyComponent {
      private data: any;
    
      async binding() {
        this.data = await fetch('/api/slow');  // 5 seconds
        // User navigates away after 1 second...
        this.doSomething(this.data);  // Component might be gone!
      }
    }
    
    GOOD - Check state:
    export class MyComponent {
      private data: any;
      private isActive = true;
    
      async binding() {
        this.data = await fetch('/api/slow');
    
        if (!this.isActive) {
          return;  // Don't continue if deactivated
        }
    
        this.doSomething(this.data);
      }
    
      unbinding() {
        this.isActive = false;
      }
    }
    
    BETTER - Use AbortController:
    export class MyComponent {
      private abortController = new AbortController();
    
      async binding() {
        try {
          const data = await fetch('/api/slow', {
            signal: this.abortController.signal
          });
          this.doSomething(data);
        } catch (err) {
          if (err.name === 'AbortError') {
            return;  // Deactivated, ignore
          }
          throw err;
        }
      }
    
      unbinding() {
        this.abortController.abort();
      }
    }
    
    
    PITFALL #9: Incorrect Parent-Child Communication Timing
    ═══════════════════════════════════════════════════════════
    
    BAD - Child calls parent too early:
    export class Child {
      @bindable onReady: () => void;
    
      binding() {
        this.onReady();  // Parent might not be bound yet!
      }
    }
    
    GOOD - Wait for attached:
    export class Child {
      @bindable onReady: () => void;
    
      attached() {
        this.onReady();  // Parent is definitely attached
      }
    }
    
    Timeline:
    Parent.binding()
      -> Child.binding() (Too early to communicate up)
      -> Child.bound()
      -> Parent.bound()
      -> Child.attached() (Safe to communicate up)
      -> Parent.attached()
    
    
    PITFALL #10: 3rd Party Library Lifecycle Mismatch
    ═══════════════════════════════════════════════════════════
    
    BAD - Library not ready:
    export class ChartComponent {
      binding() {
        // DOM not in document yet!
        this.chart = new Chart(this.canvasElement);  // Might fail
      }
    }
    
    GOOD - Initialize in attached:
    export class ChartComponent {
      private chart: Chart | null = null;
    
      attached() {
        // DOM is in document and measured
        this.chart = new Chart(this.canvasElement);
      }
    
      detaching() {
        // Clean up before DOM removal
        this.chart?.destroy();
        this.chart = null;
      }
    }
    
    Many libraries need:
    1. Element in DOM (use attached)
    2. Measured layout (use attached)
    3. Cleanup before removal (use detaching)
    
    
    QUICK REFERENCE: Which Hook For What?
    ═══════════════════════════════════════════════════════════
    
    Task                              Hook
    ─────────────────────────────────────────────────────────
    Inject services                   constructor
    Access @bindable values           binding or later
    Fetch data (router)               loading
    Fetch data (no router)            attached
    Set up DOM listeners              attached
    Initialize 3rd party library      attached
    Measure DOM elements              attached
    Start animations                  attaching
    Exit animations                   detaching
    Remove DOM listeners              detaching
    Clean up 3rd party library        detaching
    Dispose long-lived subscriptions  dispose
    Avoid async here                  binding (blocks children)
    Async OK here                     attaching, detaching, attached
    <au-modal>
        <div>
            <p>Modal content inside of the modal</p>
        </div>
    </au-modal>
    <div class="modal">
        <div class="modal-inner">
            <slot name="content"></slot>
        </div>
    </div>
    <au-modal>
        <div slot="content">
            <p>Modal content inside of the modal</p>
        </div>
    </au-modal>
    <div class="modal">
        <button type="button" data-action="close" class="close" aria-label="Close" click.trigger="close()" ><span aria-hidden="true">&times;</span></button>
        <div class="modal-inner">
            <slot>This is the default content shown if the user does not supply anything.</slot>
        </div>
    </div>
    my-app.html
    <slot slotchange.trigger="handleSlotChange($event.target.assignedNodes())"></slot>
    my-app.ts overflow=
    class MyApp {
      handleSlotChange(nodes: Node[]) {
        console.log('new nodes are:', nodes);
      }
    }
    my-details.ts
    import { children } from 'aurelia';
    
    export class MyDetails {
      @children('div') divs: HTMLElement[];
    }
    my-app.html
    <my-details>
      <div>@children decorator is a good way to listen to node changes without having to deal with boilerplate yourself</div>
    </my-details>
    my-item.ts
    export class MyItem {
      ...
    }
    my-list.ts
    import { children } from 'aurelia';
    import { MyItem } from './my-item';
    
    export class MyList {
      @children('my-item') items: MyItem[];
    }
    my-app.html
    <import from="my-list">
    
    <my-list>
      <my-item repeat.for="option of options" value.bind="option.value"></my-item>
    </my-list>
    my-list.ts
    import { children } from 'aurelia';
    import { MyItem } from './my-item';
    
    export class MyList {
      @children({
        query: 'my-item',
        map: (node) => node
      })
      items: HTMLElement[];
    }
    my-element.html
    static content
    <au-slot>fallback content for default slot.</au-slot>
    <au-slot name="s1">fallback content for s1 slot.</au-slot>
    my-app.html
    <!-- Usage without projection -->
    <my-element></my-element>
    <!-- Rendered (simplified): -->
    <!--
      <my-element>
        static content
        fallback content for default slot.
        fallback content for s1 slot.
      </my-element>
    -->
    
    <!-- Usage with projection -->
    <my-element>
      <div>d</div>        <!-- using `au-slot="default"` explicitly also works. -->
      <div au-slot="s1">p1</div>
    </my-element>
    <!-- Rendered (simplified): -->
    <!--
      <my-element>
        static content
        <div>d</div>
        <div>p1</div>
      </my-element>
    -->
    
    <my-element>
      <template au-slot="s1">p1</template>
    </my-element>
    <!-- Rendered (simplified): -->
    <!--
      <my-element>
        static content
        fallback content for default slot.
        p1
      </my-element>
    -->
    my-app.html
    <template as-custom-element="my-element">
      <au-slot>dfb</au-slot>
    </template>
    
    
    <my-element><div au-slot>projection</div></my-element>
    <!-- Rendered (simplified): -->
    <!--
      <my-element>
        <div>projection</div>
      </my-element>
    -->
    my-app.html
    <!-- Do NOT work. -->
    
    <div au-slot></div>
    
    <template><div au-slot></div></template>
    
    <my-element>
      <div>
        <div au-slot></div>
      </div>
    </my-element>
    my-card.html
    <au-slot name="actions"></au-slot>
    <ul>
      <au-slot name="items"></au-slot>
    </ul>
    app.html
    <my-card>
      <div au-slot="actions" if.bind="showActions">
        <button click.trigger="dismiss()">Dismiss</button>
      </div>
    
      <template au-slot="items" repeat.for="item of items">
        <li>${item}</li>
      </template>
    </my-card>
    <au-slot>dfb</au-slot>
    <au-slot name="s1">s1fb</au-slot>
    <au-slot name="s2">s2fb</au-slot>
    import { resolve } from 'aurelia';
    import { IAuSlotsInfo } from '@aurelia/runtime-html';
    
    class MyElement {
      private readonly slotInfo = resolve(IAuSlotsInfo);
    
      binding() {
        console.log(this.slotInfo.projectedSlots);
      }
    }
    <!-- my_element_instance_1 -->
    <my-element>
      <div au-slot="default">dp</div>
      <div au-slot="s1">s1p</div>
    </my-element>
    <!-- my_element_instance_2 -->
    <my-element></my-element>
    // my_element_instance_1
    ['default', 's1']
    
    // my_element_instance_2
    []
    <my-element>
      <div au-slot="s1">${message}</div>
    </my-element>
    <!-- Rendered (simplified): -->
    <!--
      <my-element>
        <div>outer</div>
      </my-element>
    -->
    export class MyApp {
      public readonly message: string = 'outer';
    }
    <au-slot name="s1">${message}</au-slot>
    export class MyElement {
      public readonly message: string = 'inner';
    }
    <my-element>
      <foo-bar au-slot="s1" foo.bind="message"></foo-bar>
    </my-element>
    <!-- Rendered (simplified): -->
    <!--
      <my-element>
        <foo-bar>outer</foo-bar>
      </my-element>
    -->
    export class MyApp {
      public readonly message: string = 'outer';
    }
    <au-slot name="s1">${message}</au-slot>
    export class MyElement {
      public readonly message: string = 'inner';
    }
    ${foo}
    export class FooBar {
      @bindable public foo: string;
    }
    <my-element></my-element>
    <!-- Rendered (simplified): -->
    <!--
      <my-element>
        inner
      </my-element>
    -->
    export class MyApp {
      public readonly message: string = 'outer';
    }
    <au-slot name="s1">${message}</au-slot>
    export class MyElement {
      public readonly message: string = 'inner';
    }
    <my-element></my-element>
    <!-- Rendered (simplified): -->
    <!--
      <my-element>
        <foo-bar>inner</foo-bar>
      </my-element>
    -->
    export class MyApp {
      public readonly message: string = 'outer';
    }
    <au-slot name="s1">
      <foo-bar foo.bind="message"></foo-bar>
    </au-slot>
    export class MyElement {
      public readonly message: string = 'inner';
    }
    ${foo}
    export class FooBar {
      @bindable public foo: string;
    }
    <my-element>
      <div au-slot="s1">${$host.message}</div>
      <div au-slot="s2">${message}</div>
    </my-element>
    <!-- Rendered (simplified): -->
    <!--
      <my-element>
        <div>inner</div>
        <div>outer</div>
      </my-element>
    -->
    export class MyApp {
      public readonly message: string = 'outer';
    }
    <au-slot name="s1"></au-slot>
    <au-slot name="s2"></au-slot>
    export class MyElement {
      public readonly message: string = 'inner';
    }
    <my-element>
      <foo-bar au-slot="s1" foo.bind="$host.message"></foo-bar>
    </my-element>
    <!-- Rendered (simplified): -->
    <!--
      <my-element>
        <foo-bar>inner</foo-bar>
      </my-element>
    -->
    export class MyApp {
      public readonly message: string = 'outer';
    }
    <au-slot name="s1"></au-slot>
    export class MyElement {
      public readonly message: string = 'inner';
    }
    ${foo}
    export class FooBar {
      @bindable public foo: string;
    }
    <template as-custom-element="my-element">
      <bindable name="people"></bindable>
      <au-slot name="grid">
        <au-slot name="header">
          <h4>First Name</h4>
          <h4>Last Name</h4>
        </au-slot>
        <template repeat.for="person of people">
          <au-slot name="content" expose.bind="{ person, $event, $odd, $index }">
            <div>${person.firstName}</div>
            <div>${person.lastName}</div>
          </au-slot>
        </template>
      </au-slot>
    </template>
    
    <my-element people.bind="people">
      <template au-slot="header">
        <h4>Meta</h4>
        <h4>Surname</h4>
        <h4>Given name</h4>
      </template>
      <template au-slot="content">
        <div>${$host.$index}-${$host.$even}-${$host.$odd}</div>
        <div>${$host.person.lastName}</div>
        <div>${$host.person.firstName}</div>
      </template>
    </my-element>
    <!-- Rendered (simplified): -->
    <!--
      <my-element>
        <h4>Meta</h4>           <h4>Surname</h4>      <h4>Given name</h4>
    
        <div>0-true-false</div> <div>Doe</div>        <div>John</div>
        <div>1-false-true</div> <div>Mustermann</div> <div>Max</div>
      </my-element>
    -->
    export class MyApp {
      public readonly people: Person[] = [
        new Person('John', 'Doe'),
        new Person('Max', 'Mustermann'),
      ];
    }
    
    class Person {
      public constructor(
        public firstName: string,
        public lastName: string,
      ) { }
    }
    my-element.html
    <au-slot name="s1">s1</au-slot>
    <au-slot name="s2">s2</au-slot>
    my-app.html
    <my-element>
      <div au-slot="s2">p20</div>
      <div au-slot="s1">p11</div>
      <div au-slot="s2">p21</div>
      <div au-slot="s1">p12</div>
    </my-element>
    <!-- Rendered (simplified): -->
    <!--
      <my-element>
        <div>p11</div>
        <div>p12</div>
    
        <div>p20</div>
        <div>p21</div>
      </my-element>
    -->
    my-element.html
    <au-slot name="header"></au-slot>
    <au-slot name="content"></au-slot>
    my-app.html
    <my-tabs>
      <h3 au-slot="header">Tab1</h3>
      <div au-slot="content">Tab1 content</div>
    
      <h3 au-slot="header">Tab2</h3>
      <div au-slot="content">Tab2 content</div>
    
      <!--...-->
    </my-tabs>
    person-card.html
    <let details-shown.bind="false"></let>
    <au-slot name="name"></au-slot>
    <button click.trigger="detailsShown=!detailsShown">Toggle details</button>
    <div if.bind="detailsShown">
      <au-slot name="name"></au-slot>
      <au-slot name="role"></au-slot>
      <au-slot name="details"></au-slot>
    </div>
    my-app.html
    <person-card>
      <span au-slot="name"> John Doe </span>
      <span au-slot="role"> Role1 </span>
      <span au-slot="details"> Lorem ipsum </span>
    </person-card>
    app.html
    <my-summary>
      <p>This is a demo of the @slotted decorator</p>
      <p>It can get all the "p" elements with a simple decorator</p>
    </my-summary>
    my-summary.html
    <p>Heading text</p>
    <div>
      <au-slot></au-slot>
    </div>
    my-summary.ts
    import { slotted } from 'aurelia';
    
    export class MySummaryElement {
      @slotted('p') paragraphs // assert paragraphs.length === 2
    }
    my-summary.ts
    import { slotted } from 'aurelia';
    
    export class MySummaryElement {
      @slotted('p') paragraphs // assert paragraphs.length === 2
    
      paragraphsChanged(ps: HTMLParagraphElement[]) {
        // do things
      }
    }
    my-summary.html
    <p>Heading text</p>
    <div>
      <au-slot></au-slot>
    </div>
    app.html
    <my-summary>
      <p>This is a demo of the @slotted decorator</p>
      <p>It can get all the "p" elements with a simple decorator</p>
    </my-summary>
    app.html
    <my-summary>
      <p>This is a demo of the @slotted decorator</p>
      <p if.bind="describeMore">It can get all the "p" elements with a simple decorator</p>
    </my-summary>
    my-summary.html
    <p>Heading text</p>
    <div>
      <au-slot slotchange.bind="onContentChange"></au-slot>
      <au-slot slotchange.bind="(name, nodes) => doSomething(name, nodes)"></au-slot>
    </div>
    my-summary.ts
    import { slotted } from 'aurelia';
    
    export class MySummaryElement {
      @slotted('p') paragraphs // assert paragraphs.length === 1
    
      onContentChange = (name: string, nodes: Node[]) => {
        // handle the new set of nodes here
        console.assert(this === undefined);
      }
    
      doSomething(name: string, nodes: Node[]) {
        console.assert(this instanceof MySummaryElement);
      }
    }

    Defining routes

    Learn about configuring routes in Router.

    Bundler note: These examples import '.html' files as raw strings (showing '?raw' for Vite/esbuild). Configure your bundler as described in so the imports resolve to strings on Webpack, Parcel, etc.

    The router takes your routing instructions and matches the URL to one of the configured Routes to determine which components to render. To register routes you can either use the @route decorator or you can use the static routes property to register one or more routes in your application. This section describes the route configuration options in details.

    Route configuration basics

    The routing configuration syntax for router is similar to that of other routers you might have worked with before. If you have worked with Express.js routing, then the syntax will be very familiar to you.

    A route is an object containing a few required properties that tell the router what component to render, what URL it should match on and other route-specific configuration options.

    The most usual case of defining a route configuration is by specifying the path and the component properties. The idea is to use the path property to define a pattern, which when seen in the URL path, the view model defined using the component property is activated by the router. Simply put, a routing configuration is a mapping between one or more path patterns to components. Below is the simple example (from the getting started section) of this.

    For the example above, when the router sees either the path / or /home, it loads the Home component and if it sees the /about path it loads the About component.

    Note that you can map multiple paths to a single component. Although these paths can be thought of as aliases, multiple paths, in combination with path parameters gets interesting. Another way of creating aliases is to use the redirectTo configuration option.

    Note that the example above uses the @route decorator. In case you cannot use the decorator, you can use the static properties instead. The example shown above can be rewritten as follows.

    As the re-written example shows, you can convert the properties in the options object used for the @route decorator into static properties in the view model class.

    Apart from the static API including the @route decorator, there is also an instance-level hook named getRouteConfig that you can use to configure your routes. This is shown in the example below.

    See this in action below.

    Note that the hook is also supplied with a parent route configuration, and the new route node. These values can be nullable; for example, for root node there is no parent route configuration.

    The getRouteConfig can also be async. This is shown in the example below.

    See this in action below.

    path and parameters

    The path defines one or more patterns, which are used by the router to evaluate whether or not an URL matches a route or not. A path can be either a static string (empty string is also allowed, and is considered as the default route) without any additional dynamic parts in it, or it can contain parameters. The paths defined on every routing hierarchy (note that routing configurations can be hierarchical) must be unique.

    Required parameters

    Required parameters are prefixed with a colon. The following example shows how to use a required parameter in the path.

    When a given URL matches one such route, the parameter value is made available in the canLoad, and load routing hooks.

    Note that the value of the id parameter as defined in the route configuration (:id) is available via the params.id. Check out the live example to see this in action.

    Optional parameters

    Optional parameters start with a colon and end with a question mark. The following example shows how to use an optional parameter in the path.

    In the example, shown above, the Product component is loaded when the router sees paths like /product or /product/some-id, that is irrespective of a value for the id parameter. You can see the live example below.

    Note that there is an additional link added to the products.html to fetch a random product.

    As the id parameter is optional, even without a value for the id parameter, clicking the link loads the Product component. Depending on whether or not there is a value present for the id parameter, the Product component generates a random id and loads that.

    Wildcard parameters

    The wildcard parameters, start with an asterisk instead of a colon, act as a catch-all, capturing everything provided after it. The following example shows how to use a wildcard parameter in the path.

    In the example, shown above, the Product component is loaded when the router sees paths like /product/some-id or /product/some-id/foo/bar. You can see the live example below.

    The example utilizes a wildcard parameter named rest, and when the value of rest is 'image', an image for the product is shown. To this end, the canLoad hook of the Product view-model reads the rest parameter.

    Constrained parameters

    Any required and optional parameters can be constrained by using a regular expression.

    The following example shows how to use a wildcard parameter in the path.

    Note that the syntax to define a parameter constraint is as follows.

    The example above shows that the Product component is loaded when the router sees paths like /product/123, but not /product/abc.

    You can see the live example below.

    Note that ^ and $ implies that the value of complete path segment must match the regular expression. You are however free to choose any regular expression that fits your needs. For example, 'product/:id{{^\\d+}}' is also a valid constraint and will match paths like /product/123, /product/123abc etc.

    Setting the title

    You can configure the title for the routes while you are configuring the routes. The title can be configured in the root level, as well as in the individual route level. This can be seen in the following example using the @route decorator.

    If you prefer using the static routes property, the title can be set using a static title property in the class. The following example has exactly the same effect as of the previous example.

    With this configuration in place, the default-built title will be Home | Aurelia when user is navigated to / or /home route. That is, the titles of the child routes precedes the base title. You can customize this default behavior by using a custom buildTitle function when customizing the router configuration.

    Note that, instead of a string, a function can also be used for title to lazily set the title.

    Redirect to another path

    By specifying the redirectTo property on our route, we can create route aliases. These allow us to redirect to other routes. In the following example, we redirect our default route to the home page and the about-us to about page.

    You can see this action below.

    Note that redirection also works when there are multiple paths/aliases defined for the same component.

    You can see this action below.

    You can use route parameters for redirectTo. The following example shows that the parameters from the about-us path is rearranged to the about path.

    You can see this action below.

    Fallback: redirecting the unknown path

    We can instruct the router to redirect the users to a different configured path, whenever it sees any unknown/un-configured paths. To this end, we can use the fallback configuration option. Following example shows how to use this configuration option.

    As the example shows, the fallback is configured as follows.

    There is a custom element, named NotFound, which is meant to be loaded when any unknown/un-configured route is encountered. As you can see in the above example, clicking the "Foo" link that is with un-configured href, leads to the NotFound view.

    It is recommended that you configure a fallback at the root to handle the navigation to un-configured routes gracefully.

    Another way of defining the fallback is to use the route-id. The following example demonstrates this behavior, where the NotFound view can be reached via multiple aliases, and instead of choosing one of these aliases the route-id is used to refer the route.

    The name of the custom element, meant to be displayed for any un-configured route can also be used to define fallback. The following example demonstrates this behavior, where not-found, the name of custom element NotFound, is used to refer the route.

    An important point to note here is that when you are using the custom element name as fallback, you need to ensure that the custom element is registered to the DI container. Note that in the example above, the NotFound component is registered to the DI container in main.ts.

    A fallback defined on parent is inherited by the children (to know more about hierarchical routing configuration, refer the documentation). However, every child can override the fallback as needed. The following example demonstrate this. The root has two sibling viewports and two children components can be loaded into each of those by clicking the link. Every child defines their own child routing configuration. The root defines a fallback and one of the children overrides the fallback by defining one of its' own. With this configuration in place, when navigation to a un-configured route ('Foo') is attempted for each children, one loads the overridden version whereas the other loads the fallback inherited from the parent (in this case the root).

    A function can be used for fallback. The function takes the following signature.

    An example can look like below, where the example redirects the user to NF1 component if an attempt to load a path /foo is made. Every other attempt to load an unknown path is results loading the NF2 component.

    You can also see this in action below.

    You can also use non-string fallbacks. For example, you can use a class as the value for fallback; such as fallback: NotFound. Or, if you are using a function, you choose to return a class instead of returning a string. These combinations are also supported by router.

    Case sensitive routes

    Routes can be marked as case-sensitive in the configuration, allowing the navigation to the component only when the case matches exactly the configured path. See the example below where the navigation to the "about" page is only successful when the casing matches.

    Thus, only an attempt to the /AbOuT path loads the About component; any attempt with a different casing is navigated to the fallback. See this in action below.

    Advanced route configuration options

    There are few other routing configuration which aren't discussed above. Our assumption is that these options are more involved and might not be used that often. Moreover, to understand the utility of these options fully, knowledge of other parts of the route would be beneficial. Therefore, this section only briefly introduces these options providing links to the sections with detailed examples.

    • id — The unique ID for this route. The router implicitly generates a id for a given route, if an explicit value for this property is missing. Although this is not really an advanced property, due to the fact that a route can be uniquely identified with id, it can be used in many interesting ways. For example, this can be used to generate the hrefs in the view when using the load custom attribute or using the Router#load API. Using this property is also very convenient when there are multiple aliases for a single route, and we need a unique way to refer to this route.

    • transitionPlan — How to behave when the currently active component is scheduled to be loaded again in the same viewport. For more details, please refer the documentation.

    • viewport — The name of the viewport this component should be loaded into. This demands a full fledged documentation of its own. Refer to the for more details.

    • data — Any custom data that should be accessible to matched components or hooks. The value of this configuration property must be an object and the object can take any shape (that is there is no pre-defined interface/class for this object). A typical use-case for the data property is to define the permissions, required by the users, when they attempt to navigate to this route. Refer of this.

    • nav - Set this flag to false (default value is true), to instruct the router not to add the route to the . This is typically useful to from the public navigation menu.

    Specifying component

    Before finishing the section on the route configuration, we need to discuss one last topic for completeness, and that is how many different ways you can configure the component. Throughout various examples so far we have seen that components are configured by importing and using those in the routing configuration. However, there are many other ways in which the components can be configured. This section discusses those.

    Using inline import()

    Components can be configured using the import() or dynamic import. Instead of statically importing the components, those can be imported using import()-syntax, as the example shows below.

    You can see this in action below.

    If you are using TypeScript, ensure that the module property set to esnext in your tsconfig.json to support inline import statements.

    Using the name

    Components can be configured using only the custom-element name of the component.

    However, when configuring the route this way, you need to register the components to the DI.

    You can see this configuration in action below.

    Using a function returning the class

    Components can be configured using a function that returns a class.

    You can see this configuration in action below.

    Using custom element definition

    Components can be configured using custom element definition.

    You can see this configuration in action below.

    Using custom element instance

    Components can be configured using custom element instance.

    You can see this configuration in action below.

    Using conventional HTML-only custom element

    When using HTML-only custom elements, facilitated via the convention, the custom element can be directly used while configuring routes.

    Using import() function directly is also supported.

    When importing the HTML-only custom elements in the HTML file using <require from=""> syntax, use the custom element name in the route configuration.

    Using a navigation strategy

    Components can be configured using a navigation strategy. A navigation strategy is an instance of NavigationStrategy class that takes a factory method to return a routable component. The following example shows how to use a navigation strategy.

    The factory method takes has the following signature.

    The parameters can be used further to determine which component to load.

    Using classes as routes

    Using router it is also possible to use the routed view model classes directly as routes configuration. While doing so, if no paths have been explicitly configured for the components, the custom element name and aliases can be used as routing instructions. The following example demonstrates that the C1 and C2 classes are used directly as the child routes for the Root.

    The example above implies that router.load('c-1'), or router.load('c-a') and router.load('c-2'), router.load('c-two') will load the C1 and C2 respectively.

    To know more about the router API refer this section.

    Distributed routing configurations

    The examples discussed so far demonstrate the classic use-cases of route configurations where the parents define the child routes. Another aspect of these examples are that all the route configurations are centralized on the parent component. This section provides some examples where that configuration is distributed across different components.

    We start by noting that every component can define its own path. This is shown in the following example.

    The example shows that both Home and About uses the @route decorator to define their own paths. This reduces the child-route configuration for MyApp to @route({ routes: [Home, About] }). The example can be seen in action below.

    Note that other properties of route configuration can also be used in this way.

    The previous example demonstrates that the Home and About components define the title for themselves. The example can be seen in action below.

    While adapting to distributes routing configuration, the parent can still override the configuration for its children. This makes sense, because even if a component defines its own path, title etc. the parent may choose to reach (route) the component via a different path or display a different title when the component is loaded. This is shown below, where the MyApp overrides the routing configurations defined by About.

    This can be seen in action below.

    You can also use the @route decorator and the getRouteConfig together.

    This can be seen in action below.

    The advantage of this kind of distributed configuration is that the routing configuration of every component is defined by every component itself, thereby encouraging encapsulation, leaving the routing configuration at the parent level to merely listing out its child components. On the other hand, highly distributed route configuration may prevent easy overview of the configured routes. That's the trade-off. Feel free to mix and match as per your need and aesthetics.

    Retrieving the current route and query parameters

    Apart from configuring routes, you may also want to read which route is currently active and obtain any query parameters. If you only defined or named a few of them as route parameters, others might appear as query parameters. Here is a short example:

    You can inject ICurrentRoute in any routed view-model. For an elaborate approach, see additional references in the navigation model and navigation docs.

    Importing external HTML templates with bundlers
    import { customElement } from '@aurelia/runtime-html';
    import { route } from '@aurelia/router';
    import { Home } from './home';
    import { About } from './about';
    
    @route({
      routes: [Home, About],
    })
    @customElement({
      name: 'my-app',
      template: `
    <nav>
      <a href="home">Home</a>
      <a href="about">About</a>
    </nav>
    
    <au-viewport></au-viewport>
    `
    })
    export class MyApp {}
    
    import { route } from '@aurelia/router';
    import { customElement } from '@aurelia/runtime-html';
    
    @route(['', 'home'])
    @customElement({ name: 'ho-me', template: '<h1>${message}</h1>' })
    export class Home {
      private readonly message: string = 'Welcome to Aurelia2 router!';
    }
    import { route } from '@aurelia/router';
    import { customElement } from '@aurelia/runtime-html';
    
    @route('about')
    @customElement({ name: 'ab-out', template: '<h1>${message}</h1>' })
    export class About {
      private readonly message = 'Aurelia2 router is simple';
    }
    import { customElement } from '@aurelia/runtime-html';
    import { route } from '@aurelia/router';
    import { Home } from './home';
    import { About } from './about';
    
    @route({
      routes: [Home, About],
    })
    @customElement({
      name: 'my-app',
      template: `
    <nav>
      <a href="home">Home</a>
      <a href="about">About</a>
    </nav>
    
    <au-viewport></au-viewport>
    `
    })
    export class MyApp {}
    
    import { route } from '@aurelia/router';
    import { customElement } from '@aurelia/runtime-html';
    
    @route({ path: ['', 'home'], title: 'Home' })
    @customElement({ name: 'ho-me', template: '<h1>${message}</h1>' })
    export class Home {
      private readonly message: string = 'Welcome to Aurelia2 router!';
    }
    import { route } from '@aurelia/router';
    import { customElement } from '@aurelia/runtime-html';
    
    @route({ path: 'about', title: 'About' })
    @customElement({ name: 'ab-out', template: '<h1>${message}</h1>' })
    export class About {
      private readonly message = 'Aurelia2 router is simple';
    }
    import { customElement } from '@aurelia/runtime-html';
    import { route } from '@aurelia/router';
    import { Home } from './home';
    import { About } from './about';
    
    @route({
      routes: [
        Home,
        { path: 'about-us', component: About, title: 'About us' }
      ],
    })
    @customElement({
      name: 'my-app',
      template: `
    <nav>
      <a href="home">Home</a>
      <a href="about-us">About</a>
    </nav>
    
    <au-viewport></au-viewport>
    `
    })
    export class MyApp {}
    
    import { route } from '@aurelia/router';
    import { customElement } from '@aurelia/runtime-html';
    
    @route({ path: ['', 'home'], title: 'Home' })
    @customElement({ name: 'ho-me', template: '<h1>${message}</h1>' })
    export class Home {
      private readonly message: string = 'Welcome to Aurelia2 router!';
    }
    import { route } from '@aurelia/router';
    import { customElement } from '@aurelia/runtime-html';
    
    @route({ path: 'about', title: 'About' })
    @customElement({ name: 'ab-out', template: '<h1>${message}</h1>' })
    export class About {
      private readonly message = 'Aurelia2 router is simple';
    }
    import { customElement } from '@aurelia/runtime-html';
    import {
      IRouteConfig,
      IRouteViewModel,
      route,
      RouteNode,
    } from '@aurelia/router';
    import template from './my-app.html?raw';
    import { Home } from './home';
    import { About } from './about';
    
    @route({ title: 'Aurelia2' })
    @customElement({
      name: 'my-app',
      template: `
    <nav>
      <a href="home">Home</a>
      <a href="about">About</a>
    </nav>
    
    <au-viewport></au-viewport>
    `
    })
    export class MyApp implements IRouteViewModel {
      public getRouteConfig?(
        parentConfig: IRouteConfig | null,
        routeNode: RouteNode | null
      ): IRouteConfig {
        return {
          routes: [Home, About],
        };
      }
    }
    import { route } from '@aurelia/router';
    import { customElement } from '@aurelia/runtime-html';
    
    @route({ path: ['', 'home'], title: 'Home' })
    @customElement({ name: 'ho-me', template: '<h1>${message}</h1>' })
    export class Home {
      private readonly message: string = 'Welcome to Aurelia2 router!';
    }
    import { route } from '@aurelia/router';
    import { customElement } from '@aurelia/runtime-html';
    
    @route({ path: 'about', title: 'About' })
    @customElement({ name: 'ab-out', template: '<h1>${message}</h1>' })
    export class About {
      private readonly message = 'Aurelia2 router is simple';
    }
    import { route } from '@aurelia/router';
    import { Home } from './home';
    import { About } from './about';
    
    @route({
      title: 'Aurelia',
      routes: [
        {
          path: ['', 'home'],
          component: Home,
        },
        {
          path: 'about',
          component: About,
        },
      ],
    })
    export class MyApp {}
    import { Routeable } from '@aurelia/router';
    import { Home } from './home';
    import { About } from './about';
    
    export class MyApp {
      // corresponds to the `title` property in the options object used in the @route decorator.
      static title: string = 'Aurelia';
    
      // corresponds to the `routes` property in the options object used in the @route decorator.
      static routes: Routeable[] = [
        {
          path: ['', 'home'],
          component: Home,
        },
        {
          path: 'about',
          component: About,
        },
      ];
    }
    import { IRouteConfig, RouteNode } from '@aurelia/router';
    import { Home } from './home';
    import { About } from './about';
    
    export class MyApp {
      public getRouteConfig(_parentConfig: IRouteConfig | null, _routeNode: RouteNode | null): IRouteConfig {
        return {
          routes: [
            {
              path: ['', 'home'],
              component: Home,
              title: 'Home',
            },
            {
              path: 'about',
              component: About,
              title: 'About',
            },
          ],
        };
      }
    }
    import { IRouteConfig, RouteNode } from '@aurelia/router';
    
    export class MyApp {
      public getRouteConfig(_parentConfig: IRouteConfig | null, _routeNode: RouteNode | null): IRouteConfig {
        return {
          routes: [
            {
              path: ['', 'home'],
              component: await import('./home').then((x) => x.Home),
              title: 'Home',
            },
            {
              path: 'about',
              component: await import('./about').then((x) => x.About),
              title: 'About',
            },
          ],
        };
      }
    }
    import { route } from '@aurelia/router';
    import { Product } from './product';
    
    @route({
      routes: [
        {
          path: 'products/:id',
          component: Product,
        },
      ],
    })
    export class MyApp {}
    import { IRouteViewModel, Params } from '@aurelia/router';
    import { customElement } from '@aurelia/runtime-html';
    import template from './product.html?raw';
    
    @customElement({ name: 'pro-duct', template })
    export class Product implements IRouteViewModel {
      public canLoad(params: Params): boolean {
        console.log(params.id);
        return true;
      }
    }
    import { route } from '@aurelia/router';
    import { Product } from './product';
    
    @route({
      routes: [
        {
          path: 'product/:id?',
          component: Product,
        },
      ],
    })
    export class MyApp {}
    <li>
      <a href="../product">Random product</a>
    </li>
    public canLoad(params: Params): boolean {
      let id = Number(params.id);
      if (Number.isNaN(id)) {
        id = Math.ceil(Math.random() * 30);
      }
    
      this.promise = this.productService.get(id);
      return true;
    }
    import { route } from '@aurelia/router';
    import { Product } from './product';
    
    @route({
      routes: [
        {
          id: 'foo',
          path: ['product/:id', 'product/:id/*rest'],
          component: Product,
        },
      ],
    })
    export class MyApp {}
    public canLoad(params: Params): boolean {
      const id = Number(params.id);
      this.promise = this.productService.get(id);
      this.showImage = params.rest == 'image';
      return true;
    }
    import { route } from '@aurelia/router';
    import { Product } from './product';
    
    @route({
      routes: [
        {
          id: 'foo',
          path: ['product/:id{{^\\d+$}}'],
          component: Product,
        },
      ],
    })
    export class MyApp {}
    :PARAM_NAME{{REGEX_CONSTRAINT}}
    import { route, IRouteViewModel } from '@aurelia/router';
    @route({
        title: 'Aurelia', // <-- this is the base title
        routes: [
          {
            path: ['', 'home'],
            component: import('./components/home-page'),
            title: 'Home',
          }
        ]
    })
    export class MyApp implements IRouteViewModel {}
    import { IRouteViewModel, Routeable } from "aurelia";
    export class MyApp implements IRouteViewModel {
      static title: string = 'Aurelia'; // <-- this is the base title
      static routes: Routeable[] = [
        {
          path: ['', 'home'],
          component: import('./components/home-page'),
          title: 'Home',
        }
      ];
    }
    @route({
      routes: [
        { path: '', redirectTo: 'home' },
        { path: 'about-us', redirectTo: 'about' },
        {
          path: 'home',
          component: Home,
        },
        {
          path: 'about',
          component: About,
        },
      ],
    })
    export class MyApp {}
    @route({
      routes: [
        { path: 'foo', redirectTo: 'home' },
        { path: 'bar', redirectTo: 'about' },
        { path: 'fizz', redirectTo: 'about-us' },
        {
          path: ['', 'home'],
          component: Home,
          title: 'Home',
        },
        {
          path: ['about', 'about-us'],
          component: About,
        },
      ],
    })
    export class MyApp {}
    import { route } from '@aurelia/router';
    import { About } from './about';
    
    @route({
      routes: [
        { path: 'about-us/:foo/:bar', redirectTo: 'about/:bar/:foo' },
        {
          path: 'about/:p1?/:p2?',
          component: About,
          title: 'About',
        },
      ],
    })
    export class MyApp {}
    import { route } from '@aurelia/router';
    import template from './my-app.html?raw';
    import { Home } from './home';
    import { About } from './about';
    import { NotFound } from './not-found';
    
    @route({
      routes: [
        {
          path: ['', 'home'],
          component: Home,
          title: 'Home',
        },
        {
          path: 'about',
          component: About,
          title: 'About',
        },
        {
          path: 'notfound',
          component: NotFound,
          title: 'Not found',
        },
      ],
      fallback: 'notfound', // <-- fallback configuration
    })
    export class MyApp {}
    fallback(viewportInstruction: ViewportInstruction, routeNode: RouteNode, context: IRouteContext): string;
    import { customElement } from '@aurelia/runtime-html';
    import {
      IRouteContext,
      ITypedNavigationInstruction_string,
      route,
      RouteNode,
      ViewportInstruction,
    } from '@aurelia/router';
    
    @customElement({ name: 'ce-a', template: 'a' })
    class A {}
    
    @customElement({ name: 'n-f-1', template: 'nf1' })
    class NF1 {}
    
    @customElement({ name: 'n-f-2', template: 'nf2' })
    class NF2 {}
    
    @route({
      routes: [
        { id: 'r1', path: ['', 'a'], component: A },
        { id: 'r2', path: ['nf1'], component: NF1 },
        { id: 'r3', path: ['nf2'], component: NF2 },
      ],
      fallback(vi: ViewportInstruction, _rn: RouteNode, _ctx: IRouteContext): string {
        return (vi.component as ITypedNavigationInstruction_string).value === 'foo' ? 'r2' : 'r3';
      },
    })
    @customElement({
      name: 'my-app',
      template: `
      <nav>
      <a href="a">A</a>
      <a href="foo">Foo</a>
      <a href="bar">Bar</a>
    </nav>
    
    <au-viewport></au-viewport>`
    })
    export class MyApp {}
    import { route } from '@aurelia/router';
    import { About } from './about';
    
    @route({
      routes: [
        {
          path: 'AbOuT',
          component: About,
          caseSensitive: true,
        },
      ],
    })
    export class MyApp {}
      import { customElement } from '@aurelia/runtime-html';
      import { route } from '@aurelia/router';
      import template from './my-app.html?raw';
    - import { About } from './about';
    - import { Home } from './home';
    
      @route({
        routes: [
          {
            path: ['', 'home'],
    -       component: Home,
    +       component: import('./home'),
            title: 'Home',
          },
          {
            path: 'about',
    -       component: About,
    +       component: import('./about'),
            title: 'About',
          },
        ],
      })
    @customElement({ name: 'my-app', template })
    export class MyApp {}
      import { customElement } from '@aurelia/runtime-html';
      import { route } from '@aurelia/router';
      import template from './my-app.html?raw';
    - import { About } from './about';
    - import { Home } from './home';
    
      @route({
        routes: [
          {
            path: ['', 'home'],
    -       component: Home,
    +       component: 'ho-me', // <-- assuming that Home component has the name 'ho-me'
            title: 'Home',
          },
          {
            path: 'about',
    -       component: About,
    +       component: 'ab-out', // <-- assuming that About component has the name 'ab-out'
            title: 'About',
          },
        ],
      })
    @customElement({ name: 'my-app', template })
    export class MyApp {}
    // main.ts
    import { RouterConfiguration } from '@aurelia/router';
    import { Aurelia, StandardConfiguration } from '@aurelia/runtime-html';
    import { About } from './about';
    import { Home } from './home';
    import { MyApp as component } from './my-app';
    
    (async function () {
      const host = document.querySelector<HTMLElement>('app');
      const au = new Aurelia();
      au.register(
        StandardConfiguration,
        RouterConfiguration,
    
        // component registrations
        Home,
        About,
      );
      au.app({ host, component });
      await au.start();
    })().catch(console.error);
      import { customElement } from '@aurelia/runtime-html';
      import { route } from '@aurelia/router';
      import template from './my-app.html?raw';
    - import { About } from './about';
    - import { Home } from './home';
    
      @route({
        routes: [
          {
            path: ['', 'home'],
    -       component: Home,
    +       component: () => {
    +         @customElement({ name: 'ho-me', template: '<h1>${message}</h1>' })
    +         class Home {
    +           private readonly message: string = 'Welcome to Aurelia2 router!';
    +         }
    +         return Home;
    +       },
            title: 'Home',
          },
          {
            path: 'about',
    -       component: About,
    +       component: () => {
    +         @customElement({ name: 'ab-out', template: '<h1>${message}</h1>' })
    +         class About {
    +           private readonly message = 'Aurelia2 router is simple';
    +         }
    +         return About;
    +       },
            title: 'About',
          },
        ],
      })
    @customElement({ name: 'my-app', template })
    export class MyApp {}
      import { customElement } from '@aurelia/runtime-html';
      import { route } from '@aurelia/router';
      import template from './my-app.html?raw';
    - import { About } from './about';
    - import { Home } from './home';
    
    + class Home {
    +   private readonly message: string = 'Welcome to Aurelia2 router!';
    + }
    + const homeDefn = CustomElementDefinition.create(
    +   { name: 'ho-me', template: '<h1>${message}</h1>' },
    +   Home
    + );
    + CustomElement.define(homeDefn, Home);
    +
    + class About {
    +   private readonly message = 'Aurelia2 router is simple';
    + }
    + const aboutDefn = CustomElementDefinition.create(
    +   { name: 'ab-out', template: '<h1>${message}</h1>' },
    +   About
    + );
    + CustomElement.define(aboutDefn, About);
    
      @route({
        routes: [
          {
            path: ['', 'home'],
    -       component: Home,
    +       component: homeDefn,
            title: 'Home',
          },
          {
            path: 'about',
    -       component: About,
    +       component: aboutDefn,
            title: 'About',
          },
        ],
      })
    @customElement({ name: 'my-app', template })
    export class MyApp {}
      import { customElement } from '@aurelia/runtime-html';
      import { route } from '@aurelia/router';
      import template from './my-app.html?raw';
    - import { About } from './about';
    - import { Home } from './home';
    
    + @customElement({ name: 'ho-me', template: '<h1>${message}</h1>' })
    + class Home {
    +   private readonly message: string = 'Welcome to Aurelia2 router!';
    + }
    +
    + @customElement({ name: 'ab-out', template: '<h1>${message}</h1>' })
    + class About {
    +   private readonly message = 'Aurelia2 router is simple';
    + }
    
      @route({
        routes: [
          {
            path: ['', 'home'],
    -       component: Home,
    +       component: new Home(),
            title: 'Home',
          },
          {
            path: 'about',
    -       component: About,
    +       component: new About(),
            title: 'About',
          },
        ],
      })
    @customElement({ name: 'my-app', template })
    export class MyApp {}
      import { customElement } from '@aurelia/runtime-html';
      import { route } from '@aurelia/router';
      import template from './my-app.html?raw';
    - import { About } from './about';
    - import { Home } from './home';
    + import * as About from './about.html';
    + import * as Home from './home.html';
    
      @route({
        routes: [
          {
            path: ['', 'home'],
            component: Home,
            title: 'Home',
          },
          {
            path: 'about',
            component: About,
            title: 'About',
          },
        ],
      })
    @customElement({ name: 'my-app', template })
    export class MyApp {}
      import { customElement } from '@aurelia/runtime-html';
      import { route } from '@aurelia/router';
      import template from './my-app.html?raw';
    - import { About } from './about';
    - import { Home } from './home';
    
      @route({
        routes: [
          {
            path: ['', 'home'],
    -       component: Home,
    +       component: import('./home.html'),
            title: 'Home',
          },
          {
            path: 'about',
    -       component: About,
    +       component: import('./about.html'),
            title: 'About',
          },
        ],
      })
    @customElement({ name: 'my-app', template })
    export class MyApp {}
      <require from="./about.html"></require>
      <require from="./home.html"></require>
    
      <au-viewport></au-viewport>
      import { customElement } from '@aurelia/runtime-html';
      import { route } from '@aurelia/router';
      import template from './my-app.html?raw';
    - import { About } from './about';
    - import { Home } from './home';
    
      @route({
        routes: [
          {
            path: ['', 'home'],
    -       component: Home,
    +       component: 'home',
            title: 'Home',
          },
          {
            path: 'about',
    -       component: About,
    +       component: 'about',
            title: 'About',
          },
        ],
      })
    @customElement({ name: 'my-app', template })
    export class MyApp {}
      import { customElement } from '@aurelia/runtime-html';
      import { route } from '@aurelia/router';
      import template from './my-app.html?raw';
      import { About } from './about';
      import { Home } from './home';
    
    + let dataLoaded = false;
    
      @route({
        routes: [
          {
            path: ['', 'home'],
            component: Home,
            title: 'Home',
          },
          {
            path: 'about',
            component: About,
            title: 'About',
          },
    +     {
    +      path: 'foo',
    +      component: new NavigationStrategy(() => {
    +        if (dataLoaded) return Home;
    +
    +        dataLoaded = true;
    +        return About;
    +      })
    +     }
        ],
      })
      @customElement({ name: 'my-app', template })
      export class MyApp {}
    type NavigationStrategyComponent = string | RouteType | Promise<IModule> | CustomElementDefinition;
    function getComponent(viewportInstruction: IViewportInstruction, ctx: IRouteContext, node: RouteNode, route: RecognizedRoute<unknown>): string | RouteType | Promise<IModule> | CustomElementDefinition;
    @customElement({ name: 'c-1', template: 'c1', aliases: ['c-a', 'c-one'] })
    class C1 { }
    
    @customElement({ name: 'c-2', template: 'c2', aliases: ['c-b', 'c-two'] })
    class C2 { }
    
    @route({
      routes: [C1, C2]
    })
    @customElement({ name: 'ro-ot', template: '<au-viewport></au-viewport>' })
    class Root { }
    import { ICurrentRoute } from '@aurelia/router';
    import { resolve } from '@aurelia/kernel';
    
    export class MyComponent {
      private currentRoute = resolve(ICurrentRoute);
    
      attached() {
        console.log('Current path:', this.currentRoute.path);
        console.log('Query params:', this.currentRoute.url.split('?')[1] ?? 'no query');
        // Or, to see them as a structured object:
        console.log('Parsed query:', Object.fromEntries(this.currentRoute.parameterInformation[0].params ?? {}));
      }
    }
    viewport documentation
    an example
    navigation model
    exclude routes

    Visual Diagrams

    Visual explanations of Aurelia 2's router architecture and concepts.

    Table of Contents

    1. Route Matching Pipeline

    2. Navigation Flow


    1. Route Matching Pipeline

    How the router resolves a URL to components:

    Key Points:

    • Routes are matched top-to-bottom in configuration order

    • First matching route wins

    • Parameters are extracted during matching

    • Constraints ({{regex}}) are validated


    2. Navigation Flow

    How different navigation methods work:

    Decision Guide:

    • Use href for simple, static links

    • Use load when you need parameter binding or active state

    • Use IRouter.load() for conditional/programmatic navigation


    3. Lifecycle Hook Execution Order

    Complete sequence when navigating from ComponentA to ComponentB:

    Important Notes:

    1. All hooks can be async (return Promise)

    2. Router waits for each hook to complete before proceeding

    3. Returning false from guard hooks stops navigation

    4. Router hooks run before component hooks


    4. Component vs Router Hooks

    Two ways to implement lifecycle logic:

    Decision Guide:

    • Router hooks for: Authentication, authorization, logging, analytics

    • Component hooks for: Data fetching, validation, component state

    • Both when you need layered checks (global + local)

    |


    5. Viewport Hierarchy

    How viewports nest and relate to each other:

    Key Concepts:

    • Default viewport: <au-viewport></au-viewport> (no name)

    • Named viewport: <au-viewport name="aside"></au-viewport>

    • Target viewport: Use @viewportName in navigation


    6. History Strategy

    How router interacts with browser history:

    Decision Guide:

    • push: Normal navigation, want history

    • replace: Redirects, corrections, interim states

    • none: Modals, overlays, no history needed


    7. Transition Plans

    What happens when navigating to the same component with different parameters:

    Rule of Thumb:

    • Default (replace): Safe, always works

    • invoke-lifecycles: Optimize when parameters drive content, not fundamentally different pages


    8. Route Parameter Flow

    How parameters flow from URL to component:

    Key Points:

    • All parameters are strings

    • Path params come from URL segments

    • Query params come from ?key=value

    • Access via lifecycle hooks or ICurrentRoute

    |


    Summary

    These diagrams cover the core architectural concepts of Aurelia 2's router:

    1. Route Matching - How URLs become components

    2. Navigation - Three ways to navigate and their differences

    3. Lifecycle - Complete hook execution sequence

    4. Hooks - Component vs Router hooks

    For more details, see the complete .

    Bindable properties

    How to create components that accept one or more bindable properties. You might know these as "props" if you are coming from other frameworks and libraries.

    Bindable properties

    When creating components, sometimes you will want the ability for data to be passed into them instead of their host elements. The @bindable decorator allows you to specify one or more bindable properties for a component.

    The @bindable attribute also can be used with custom attributes as well as custom elements. The decorator denotes bindable properties on components on the view model of a component.

    Hierarchical routes build a route tree

    unloading and loading happen in parallel for performance

    Hierarchical: Nested components each have their own viewport

  • Sibling: Multiple viewports at the same level

  • Always validate and convert types

    Viewports - Nested and sibling viewport patterns

  • History - Push vs Replace vs None strategies

  • Transitions - Replace vs invoke-lifecycles behavior

  • Parameters - How data flows from URL to component

  • Lifecycle Hook Execution Order
    Component vs Router Hooks
    Viewport Hierarchy
    History Strategy
    Transition Plans
    Route Parameter Flow
    Route matching documentation →
    Navigation documentation →
    Lifecycle hooks documentation →
    Router hooks →
    Component hooks →
    Viewports documentation →
    History strategy documentation →
    Transition plans documentation →
    Path parameters →
    Query parameters →
    Router Documentation
    ┌──────────────────────────────────────────────────────────┐
    │ User navigates to: /products/42/reviews                  │
    └──────────────────┬───────────────────────────────────────┘
                       ↓
            ┌──────────────────────┐
            │ 1. Parse URL         │
            │ Path: /products/42/  │
            │       reviews        │
            │ Fragment: #section2  │
            │ Query: ?sort=date    │
            └──────────┬───────────┘
                       ↓
            ┌──────────────────────┐
            │ 2. Match Routes      │
            │ - Check path pattern │
            │ - Extract params     │
            │ - Apply constraints  │
            └──────────┬───────────┘
                       ↓
            ┌──────────────────────┐
            │ 3. Build Route Tree  │
            │ Root                 │
            │  └─ Products (:id)   │
            │      └─ Reviews      │
            └──────────┬───────────┘
                       ↓
            ┌──────────────────────┐
            │ 4. Execute Hooks     │
            │ - canLoad (guard)    │
            │ - loading (data)     │
            │ - canUnload (prev)   │
            └──────────┬───────────┘
                       ↓
            ┌──────────────────────┐
            │ 5. Render Components │
            │ - Swap viewports     │
            │ - loaded hooks       │
            │ - Update title       │
            └──────────────────────┘
    
    Route Configuration Match Example:
    
    routes: [
      {
        path: 'products/:id',           ✓ Matches /products/42
        component: ProductDetail,
        routes: [
          { path: 'reviews', ... }      ✓ Matches /reviews
        ]
      },
      {
        path: 'products/:id{{^\\d+$}}', ✓ Only if :id is numeric
        component: ProductDetail
      }
    ]
    ┌─────────────────────────────────────────────────────────────┐
    │                     NAVIGATION METHODS                      │
    └─────────────────────────────────────────────────────────────┘
    
    METHOD 1: href attribute (Declarative)
    ─────────────────────────────────────────
    <a href="products/42">             ┌──────────────┐
        ─────────────────────────────>│ href handler │
                                       └──────┬───────┘
                                              ↓
                                   ┌──────────────────┐
                                   │ Parse URL string │
                                   └──────────┬───────┘
                                              ↓
                                       Navigate to URL
    
    Context: Current route context by default
    Use ../  to navigate to parent context
    
    
    METHOD 2: load attribute (Structured)
    ──────────────────────────────────────────
    <a load="route: products;          ┌──────────────┐
             params.bind: {id: 42}">───>│ load handler │
                                       └──────┬───────┘
                                              ↓
                                   ┌──────────────────────┐
                                   │ Build instruction    │
                                   │ from structured data │
                                   └──────────┬───────────┘
                                              ↓
                                       Navigate to route
    
    Context: Current by default, can bind custom context
    Active: Supports .active bindable for styling
    
    
    METHOD 3: IRouter.load() (Programmatic)
    ────────────────────────────────────────────
    router.load('products/42', {       ┌──────────────┐
      queryParams: { ... },            │ IRouter.load │
      context: this                    └──────┬───────┘
    });                                       ↓
                                   ┌──────────────────────┐
                                   │ Full JavaScript API  │
                                   │ - Error handling     │
                                   │ - Async/await        │
                                   │ - Options object     │
                                   └──────────┬───────────┘
                                              ↓
                                       Navigate to route
    
    Context: Root by default (different from href/load!)
    Returns: Promise<boolean> for success/failure
    
    
    ALL METHODS CONVERGE
    ────────────────────────────────────────────
                        ↓
             ┌────────────────────┐
             │ Router Core Engine │
             └──────────┬─────────┘
                        ↓
             ┌────────────────────┐
             │ Route Matching     │
             │ Hook Execution     │
             │ Component Loading  │
             └────────────────────┘
    ┌────────────────────────────────────────────────────────┐
    │ Navigation: /page-a  →  /page-b                        │
    └────────────────────────────────────────────────────────┘
    
    PHASE 1: CAN UNLOAD (Current Component)
    ════════════════════════════════════════
    ComponentA (current)
      ↓
    ┌─────────────────────────────────┐
    │ 1. canUnload()                  │ → Return false to cancel navigation
    │    - Check unsaved changes      │   Return true to allow
    │    - User confirmation          │
    └─────────────────┬───────────────┘
                      ↓
             [Navigation Cancelled?] ─── No ──→ Continue
                      │
                     Yes
                      ↓
                Stay on page A
    
    
    PHASE 2: CAN LOAD (Next Component)
    ══════════════════════════════════════
    ComponentB (next)
      ↓
    ┌─────────────────────────────────┐
    │ 2. Router hooks: canLoad()      │ → Return false to block
    │    - Authentication checks       │   Return NavigationInstruction to redirect
    │    - Authorization               │   Return true to allow
    └─────────────────┬───────────────┘
                      ↓
    ┌─────────────────────────────────┐
    │ 3. Component: canLoad()         │ → Component-level validation
    │    - Parameter validation        │
    │    - Conditional logic           │
    └─────────────────┬───────────────┘
                      ↓
             [Navigation Allowed?] ─── No ──→ Show fallback or redirect
                      │
                     Yes
                      ↓
                Continue to load
    
    
    PHASE 3: UNLOADING (Current Component)
    ═══════════════════════════════════════
    ComponentA (current)
      ↓
    ┌─────────────────────────────────┐
    │ 4. Router hooks: unloading()    │
    │    - Global cleanup             │
    └─────────────────┬───────────────┘
                      ↓
    ┌─────────────────────────────────┐
    │ 5. Component: unloading()       │
    │    - Save drafts                │
    │    - Cleanup subscriptions      │
    │    - Log analytics              │
    └─────────────────┬───────────────┘
                      ↓
    ┌─────────────────────────────────┐
    │ 6. Component detached           │ ← Standard Aurelia lifecycle
    │    - DOM removal                │
    └─────────────────────────────────┘
    
    
    PHASE 4: LOADING (Next Component)
    ══════════════════════════════════════
    ComponentB (next)
      ↓
    ┌─────────────────────────────────┐
    │ 7. Router hooks: loading()      │
    │    - Shared data loading        │
    └─────────────────┬───────────────┘
                      ↓
    ┌─────────────────────────────────┐
    │ 8. Component: loading()         │
    │    - Fetch component data       │
    │    - Initialize state           │
    │    - Show loading UI            │
    └─────────────────┬───────────────┘
                      ↓
    ┌─────────────────────────────────┐
    │ 9. Component attached           │ ← Standard Aurelia lifecycle
    │    - DOM insertion              │
    └─────────────────┬───────────────┘
                      ↓
             Swap viewport content
             (ComponentA → ComponentB)
                      ↓
    ┌─────────────────────────────────┐
    │ 10. Component: loaded()         │
    │     - Post-render effects       │
    │     - Scroll to top             │
    │     - Track page view           │
    └─────────────────┬───────────────┘
                      ↓
    ┌─────────────────────────────────┐
    │ 11. Update browser history      │
    │     Update document title       │
    └─────────────────────────────────┘
                      ↓
               Navigation Complete
    
    
    TIMING DIAGRAM (with async operations)
    ═══════════════════════════════════════════
    
    Time  ComponentA              ComponentB
    ────  ──────────              ──────────
      0ms canUnload() ────────┐
                              │
    100ms                     └─> [approved]
                                  canLoad() ──────┐
                                                  │
    200ms                                         └─> [approved]
          unloading() ───────┐
                             │
    250ms                    └─> [cleanup done]
                                 loading() ──────┐
                                                 │ ← async data fetch
    400ms                                        └─> [data loaded]
          [detached]
                                 [attached]
                                 loaded() ───────┐
                                                 │
    410ms                                        └─> [done]
          ████████ (visible)    ░░░░░░░░ (hidden)
          ░░░░░░░░ (hidden)     ████████ (visible)
    ┌─────────────────────────────────────────────────────────────┐
    │               COMPONENT HOOKS (Local)                       │
    ├─────────────────────────────────────────────────────────────┤
    │                                                              │
    │  export class ProductDetail implements IRouteViewModel {    │
    │    canLoad(params: Params): boolean {                       │
    │      // 'this' refers to component instance                 │
    │      return this.validateProduct(params.id);                │
    │    }                                                         │
    │  }                                                           │
    │                                                              │
    │  ✓ Use for component-specific logic                         │
    │  ✓ Direct access to component state via 'this'              │
    │  ✓ Runs only for this component                             │
    │  ✗ Cannot share logic across components                     │
    └─────────────────────────────────────────────────────────────┘
    
                                  ↓↑
    
    ┌─────────────────────────────────────────────────────────────┐
    │              ROUTER HOOKS (Shared/Global)                   │
    ├─────────────────────────────────────────────────────────────┤
    │                                                              │
    │  @lifecycleHooks()                                           │
    │  export class AuthHook {                                     │
    │    canLoad(                                                  │
    │      viewModel: IRouteViewModel,  ← component instance      │
    │      params: Params,                                         │
    │      next: RouteNode                                         │
    │    ): boolean {                                              │
    │      // 'this' is the hook instance, not the component      │
    │      return this.authService.isAuthenticated();             │
    │    }                                                         │
    │  }                                                           │
    │                                                              │
    │  // Register globally                                        │
    │  Aurelia.register(AuthHook);                                 │
    │                                                              │
    │  ✓ Share logic across all components                        │
    │  ✓ Centralized cross-cutting concerns                       │
    │  ✓ Access component via viewModel parameter                 │
    │  ✗ Extra indirection to access component state              │
    └─────────────────────────────────────────────────────────────┘
    
    
    EXECUTION ORDER (both registered)
    ══════════════════════════════════════════════════════════════
    
    Navigation triggered
            ↓
    ┌─────────────────────┐
    │ 1. Router Hooks     │ ← Runs first (global checks)
    │    canLoad()        │
    └──────────┬──────────┘
               ↓
        [return false?] ─── Yes ──→ Navigation blocked
               │
              No
               ↓
    ┌─────────────────────┐
    │ 2. Component Hook   │ ← Runs second (local checks)
    │    canLoad()        │
    └──────────┬──────────┘
               ↓
        [return false?] ─── Yes ──→ Navigation blocked
               │
              No
               ↓
       Navigation continues
    
    
    COMMON PATTERNS
    ═══════════════════════════════════════════════════════════
    
    Pattern 1: Authentication (Router Hook)
    ────────────────────────────────────────
    @lifecycleHooks()
    class AuthHook {
      canLoad(...) {
        if (!isLoggedIn) return 'login';
        return true;
      }
    }
    → Applies to all routes
    → Centralized auth logic
    
    
    Pattern 2: Data Loading (Component Hook)
    ─────────────────────────────────────────
    class ProductDetail implements IRouteViewModel {
      async loading(params: Params) {
        this.product = await fetchProduct(params.id);
      }
    }
    → Component-specific data
    → Direct state access
    
    
    Pattern 3: Mixed Approach (Both)
    ─────────────────────────────────────────
    @lifecycleHooks()
    class PermissionHook {
      canLoad(vm, params, next) {
        const requiredPermission = next.data?.permission;
        return this.hasPermission(requiredPermission);
      }
    }
    
    class AdminPanel implements IRouteViewModel {
      canLoad(params) {
        // Additional component-specific checks
        return this.validateContext(params);
      }
    }
    → Global permission check first
    → Then component-specific validation
    SIMPLE (SINGLE VIEWPORT)
    ════════════════════════════════════
    
    <my-app>
      <nav>...</nav>
      <au-viewport></au-viewport>  ← Single viewport
    </my-app>
    
    Route: /products
             ↓
    ┌────────────────┐
    │ <my-app>       │
    │   <nav>        │
    │   ┌──────────┐ │
    │   │ Products │ │ ← Loaded into viewport
    │   └──────────┘ │
    │ </my-app>      │
    └────────────────┘
    
    
    HIERARCHICAL (NESTED VIEWPORTS)
    ═══════════════════════════════════════════════
    
    <my-app>
      <au-viewport></au-viewport>        ← Root viewport
        ↓
        <products-page>
          <au-viewport></au-viewport>    ← Child viewport
            ↓
            <product-detail>
            </product-detail>
        </products-page>
    </my-app>
    
    Route: /products/42/reviews
             ↓
    ┌─────────────────────────────────────┐
    │ Root Component (my-app)             │
    │ ┌─────────────────────────────────┐ │
    │ │ Products (viewport: default)    │ │
    │ │ ┌─────────────────────────────┐ │ │
    │ │ │ Product 42 (viewport: deflt)│ │ │
    │ │ │ ┌─────────────────────────┐ │ │ │
    │ │ │ │ Reviews (viewport: def) │ │ │ │
    │ │ │ └─────────────────────────┘ │ │ │
    │ │ └─────────────────────────────┘ │ │
    │ └─────────────────────────────────┘ │
    └─────────────────────────────────────┘
    
    Route Tree:
    Root
     └─ products
         └─ 42 (product-detail)
             └─ reviews
    
    
    SIBLING VIEWPORTS (MULTIPLE VIEWPORTS)
    ═══════════════════════════════════════════════
    
    <my-app>
      <div class="layout">
        <au-viewport name="left"></au-viewport>
        <au-viewport name="right"></au-viewport>
      </div>
    </my-app>
    
    Route: products@left+details/42@right
             ↓
    ┌───────────────────────────────────────┐
    │ Root Component                        │
    │ ┌───────────────┬─────────────────┐   │
    │ │ Products      │ Product Details │   │
    │ │ (left)        │ (right)         │   │
    │ │               │ ID: 42          │   │
    │ │ - Item 1      │                 │   │
    │ │ - Item 2      │ Description...  │   │
    │ │ - Item 3      │                 │   │
    │ └───────────────┴─────────────────┘   │
    └───────────────────────────────────────┘
    
    Route Configuration:
    routes: [
      { path: 'products', component: ProductList },
      { path: 'details/:id', component: ProductDetail }
    ]
    
    Navigation:
    <a href="products@left+details/42@right">Load both</a>
    router.load([
      { component: ProductList, viewport: 'left' },
      { component: ProductDetail, params: { id: 42 }, viewport: 'right' }
    ]);
    
    
    COMPLEX (NESTED + SIBLING)
    ═══════════════════════════════════════════════
    
    <my-app>
      <au-viewport></au-viewport>           ← Root
        ↓
        <dashboard>
          <au-viewport name="main"></au-viewport>
          <au-viewport name="sidebar"></au-viewport>
            ↓                    ↓
            <content>       <sidebar-content>
              <au-viewport></au-viewport>  ← Nested in main
            </content>
        </dashboard>
    </my-app>
    
    Route: /dashboard/content@main+sidebar@sidebar/nested
             ↓
    ┌──────────────────────────────────────────┐
    │ Root (my-app)                            │
    │ ┌──────────────────────────────────────┐ │
    │ │ Dashboard                            │ │
    │ │ ┌─────────────────┬────────────────┐ │ │
    │ │ │ Main            │ Sidebar        │ │ │
    │ │ │ ┌─────────────┐ │                │ │ │
    │ │ │ │ Nested Comp │ │ Sidebar Content│ │ │
    │ │ │ └─────────────┘ │                │ │ │
    │ │ └─────────────────┴────────────────┘ │ │
    │ └──────────────────────────────────────┘ │
    └──────────────────────────────────────────┘
    STRATEGY: 'push' (default)
    ══════════════════════════════════════════
    
    User Journey:
      /home  →  /about  →  /contact
    
    Browser History Stack:
    ┌─────────────┐
    │  /contact   │ ← Current (length: 3)
    ├─────────────┤
    │  /about     │   [Back button goes here]
    ├─────────────┤
    │  /home      │
    └─────────────┘
    
    Code:
    router.load('contact', { historyStrategy: 'push' });
    
    ✓ Each navigation adds new entry
    ✓ Back button works as expected
    ✓ Forward button available after going back
    ✗ History grows unbounded
    
    
    STRATEGY: 'replace'
    ══════════════════════════════════════════
    
    User Journey:
      /home  →  /about  →  /contact (replace)
    
    Browser History Stack:
    ┌─────────────┐
    │  /contact   │ ← Current (length: 2)
    ├─────────────┤
    │  /home      │   [Back button goes here]
    └─────────────┘
         ↑
     /about was replaced by /contact
    
    Code:
    router.load('contact', { historyStrategy: 'replace' });
    
    ✓ No history pollution
    ✓ Good for redirects/corrections
    ✓ Prevents "back" to intermediate states
    ✗ Can't navigate back to replaced pages
    
    
    STRATEGY: 'none'
    ══════════════════════════════════════════
    
    User Journey:
      /home  →  /about  →  /contact (none)
    
    Browser History Stack:
    ┌─────────────┐
    │  /home      │ ← Current (length: 1)
    └─────────────┘
    
    URL bar shows: /contact
    But history still has: /home
    
    Code:
    router.load('contact', { historyStrategy: 'none' });
    
    ✓ No history interaction at all
    ✓ Good for modal-style navigation
    ✗ Back button goes to previous app page, not /about
    ✗ URL and history out of sync
    
    
    COMPARISON
    ══════════════════════════════════════════════════════════
    
    Use Case                           | Strategy
    ─────────────────────────────────────────────────────────
    Normal navigation                  | 'push'
    Login redirect                     | 'replace'
    Fixing invalid route               | 'replace'
    Multi-step form (same logical page)| 'replace'
    Modal / overlay content            | 'none'
    Wizard steps (want back to work)   | 'push'
    Correcting user typos in URL       | 'replace'
    
    
    REAL-WORLD EXAMPLE: Login Flow
    ═══════════════════════════════════
    
    // User tries to access protected route
    canLoad() {
      if (!isLoggedIn) {
        // Redirect to login WITH replace
        // So after login, "back" doesn't go to login page
        router.load('login', { historyStrategy: 'replace' });
        return false;
      }
    }
    
    // After successful login
    login() {
      authenticate();
      // Navigate to dashboard WITH replace
      // So "back" from dashboard doesn't go to login
      router.load('dashboard', { historyStrategy: 'replace' });
    }
    
    History progression:
    1. User at /home
    2. Tries /admin → redirected to /login (replace)
       History: [/home, /login]
    3. After login → /admin (replace)
       History: [/home, /admin]
    4. Back button → goes to /home (skips /login)
    
    
    REAL-WORLD EXAMPLE: Wizard
    ═══════════════════════════════════
    
    // Multi-step form
    wizard.nextStep() {
      currentStep++;
      // Use push so back button works
      router.load(`wizard/step${currentStep}`, {
        historyStrategy: 'push'
      });
    }
    
    History: /wizard/step1 → /wizard/step2 → /wizard/step3
    Back button goes through steps correctly
    
    
    REAL-WORLD EXAMPLE: Search Filters
    ══════════════════════════════════════
    
    // User adjusts filters
    applyFilters() {
      // Use replace to update URL without history spam
      router.load('search', {
        queryParams: { ...filters },
        historyStrategy: 'replace'
      });
    }
    
    Without replace:
    /search → /search?cat=A → /search?cat=A&sort=price
             → /search?cat=A&sort=price&page=2
             → /search?cat=A&sort=price&page=3
    [User hits back 4 times to go back!]
    
    With replace:
    /search → /search?cat=A&sort=price&page=3
    [User hits back once to go back!]
    Scenario: Navigate from /users/1 to /users/2
    (Same component, different parameter)
    
    
    TRANSITION PLAN: 'replace' (default)
    ════════════════════════════════════════
    
    /users/1 (ComponentA, id=1)
        ↓
    router.load('/users/2')
        ↓
    ┌──────────────────────────────┐
    │ 1. Unload current instance   │
    │    - unloading() called      │
    │    - detached() called       │
    │    - Component destroyed     │
    └────────────┬─────────────────┘
                 ↓
    ┌──────────────────────────────┐
    │ 2. Create new instance       │
    │    - New component instance  │
    │    - canLoad() called        │
    │    - loading() called        │
    │    - attached() called       │
    │    - loaded() called         │
    └────────────┬─────────────────┘
                 ↓
    /users/2 (ComponentA, id=2) ← Different instance
    
    Timeline:
    ComponentA(id=1)  ComponentA(id=2)
      unloading()
      detached()
      [destroyed]
                      canLoad()
                      loading()
                      attached()
                      loaded()
    
    ✓ Clean slate, no stale state
    ✓ Simple mental model
    ✗ Slower (full recreation)
    ✗ Loses component state
    ✗ Re-runs constructor, bound, etc.
    
    
    TRANSITION PLAN: 'invoke-lifecycles'
    ════════════════════════════════════════
    
    /users/1 (ComponentA, id=1)
        ↓
    router.load('/users/2')
        ↓
    ┌──────────────────────────────┐
    │ 1. Keep existing instance    │
    │    - Same component object   │
    │    - No destruction          │
    └────────────┬─────────────────┘
                 ↓
    ┌──────────────────────────────┐
    │ 2. Re-invoke hooks           │
    │    - canLoad() called        │
    │    - loading() called        │
    │    - loaded() called         │
    │    (NO attach/detach)        │
    └────────────┬─────────────────┘
                 ↓
    /users/2 (ComponentA, id=2) ← Same instance!
    
    Timeline:
    ComponentA(id=1)
      canLoad(id=2)
      loading(id=2)
      loaded(id=2)
    ComponentA(id=2)
    
    ✓ Faster (reuses instance)
    ✓ Can preserve component state
    ✓ Smoother transitions/animations
    ✗ Must handle state updates correctly
    ✗ Potential for stale data bugs
    
    
    COMPARISON
    ══════════════════════════════════════════════════════════
    
    Aspect                  | replace          | invoke-lifecycles
    ────────────────────────────────────────────────────────────────
    Instance                | New              | Reused
    Speed                   | Slower           | Faster
    State                   | Fresh            | Preserved*
    Lifecycle hooks         | All              | Subset
    DOM                     | Removed/readded  | Stays
    Use for                 | Default behavior | Param-only changes
    
    * Preserved state can be a pro or con depending on use case
    
    
    CONFIGURATION
    ═══════════════════════════════════════════════════════════
    
    Global configuration:
    @route({
      transitionPlan: 'invoke-lifecycles',  ← All routes
      routes: [...]
    })
    
    Per-route configuration:
    {
      path: 'users/:id',
      component: UserDetail,
      transitionPlan: 'invoke-lifecycles'   ← Just this route
    }
    
    Per-navigation override:
    router.load('users/2', {
      transitionPlan: 'invoke-lifecycles'   ← Just this navigation
    });
    
    
    REAL-WORLD EXAMPLE: User Profile Tabs
    ═══════════════════════════════════════════════════════════
    
    Component:
    class UserProfile implements IRouteViewModel {
      userId: string;
      userData: User;
      selectedTab = 'overview';  ← Component state
    
      loading(params: Params) {
        if (this.userId !== params.id) {
          // Different user - fetch new data
          this.userId = params.id;
          this.userData = await fetchUser(params.id);
        }
        // Update tab from URL
        this.selectedTab = params.tab || 'overview';
      }
    }
    
    Routes:
    {
      path: 'users/:id/:tab?',
      component: UserProfile,
      transitionPlan: 'invoke-lifecycles'  ← Preserve state
    }
    
    Navigation:
    /users/123/overview → /users/123/posts
    └─ Same user, keep loaded data, just update tab
    
    /users/123/posts → /users/456/posts
    └─ Different user, fetch new data in loading()
    
    
    WHEN TO USE EACH
    ═══════════════════════════════════════════════════════════
    
    Use 'replace' when:
    ✓ You want clean state each time
    ✓ Component has complex initialization
    ✓ Different params mean completely different data
    ✓ You don't trust yourself to handle reuse correctly
    
    Use 'invoke-lifecycles' when:
    ✓ Only parameters change (same logical entity)
    ✓ You want to preserve UI state (scroll, selections)
    ✓ Performance matters (frequent navigation)
    ✓ You have good loading() logic that handles updates
    
    
    COMMON PITFALL
    ═══════════════════════════════════════════════════════════
    
    // ✗ BAD: Doesn't update when params change
    class ProductDetail implements IRouteViewModel {
      product: Product;
    
      constructor() {
        this.product = fetchProduct(params.id);  ← params not available!
      }
    }
    
    // ✓ GOOD: Updates on every navigation
    class ProductDetail implements IRouteViewModel {
      product: Product;
    
      loading(params: Params) {
        this.product = await fetchProduct(params.id);  ← Correct!
      }
    }
    URL: /products/42/reviews?sort=date&page=2#reviews-section
         \_______/\__/\______/\__________________/\_____________/
             │     │     │            │                 │
          path   param  path      query            fragment
    
    
    PARSING
    ═══════════════════════════════════════════════════════════
    
    Router processes URL:
    ┌────────────────────────────────┐
    │ Path segments: [products, 42,  │
    │                 reviews]        │
    │ Path params:   {id: '42'}      │
    │ Query params:  {sort: 'date',  │
    │                 page: '2'}     │
    │ Fragment:      'reviews-section'│
    └────────────────────────────────┘
    
    
    ROUTE MATCHING
    ═══════════════════════════════════════════════════════════
    
    Configuration:
    {
      path: 'products/:id',
      component: ProductDetail,
      routes: [
        { path: 'reviews', component: Reviews }
      ]
    }
    
    Match result:
    ┌─────────────────────────────────────────┐
    │ Route Tree:                             │
    │   products (:id = '42')                 │
    │     └─ reviews                          │
    │                                         │
    │ Params object:                          │
    │   { id: '42' }                          │
    │                                         │
    │ Query object:                           │
    │   { sort: 'date', page: '2' }          │
    └─────────────────────────────────────────┘
    
    
    ACCESS IN COMPONENT
    ═══════════════════════════════════════════════════════════
    
    Method 1: Lifecycle hooks
    ──────────────────────────────────────────
    class ProductDetail implements IRouteViewModel {
      productId: string;
    
      canLoad(params: Params, next: RouteNode) {
        // Path parameters
        this.productId = params.id;  // '42'
    
        // Query parameters
        const sort = next.queryParams.get('sort');  // 'date'
        const page = next.queryParams.get('page');  // '2'
    
        // Fragment
        const fragment = next.fragment;  // 'reviews-section'
    
        return true;
      }
    }
    
    
    Method 2: ICurrentRoute
    ──────────────────────────────────────────
    import { ICurrentRoute } from '@aurelia/router';
    import { resolve } from '@aurelia/kernel';
    
    class ProductDetail {
      private readonly currentRoute = resolve(ICurrentRoute);
    
      attached() {
        // Current path
        console.log(this.currentRoute.path);  // 'products/42/reviews'
    
        // Parameters (includes all from parent routes)
        const params = this.currentRoute.parameterInformation[0].params;
        console.log(params.get('id'));  // '42'
    
        // Query string (need to parse)
        const url = this.currentRoute.url;
        const queryString = url.split('?')[1];  // 'sort=date&page=2'
      }
    }
    
    
    Method 3: getRouteParameters (aggregates hierarchy)
    ────────────────────────────────────────────────────
    import { IRouteContext } from '@aurelia/router';
    import { resolve } from '@aurelia/kernel';
    
    class NestedComponent {
      private readonly context = resolve(IRouteContext);
    
      attached() {
        // Get all params from entire route hierarchy
        const allParams = this.context.getRouteParameters<{
          companyId: string;    // From /companies/:companyId
          projectId: string;    // From /projects/:projectId
          userId: string;       // From /users/:userId
        }>({
          includeQueryParams: true  // Also include ?foo=bar
        });
    
        console.log(allParams.companyId);  // Nearest definition wins
      }
    }
    
    
    PARAMETER TYPES
    ═══════════════════════════════════════════════════════════
    
    All parameters are strings!
    ─────────────────────────────────────────
    URL: /products/42?count=10&active=true
    
    params.id      // '42' (string, not number!)
    params.count   // '10' (string, not number!)
    params.active  // 'true' (string, not boolean!)
    
    Always convert:
    const id = Number(params.id);
    const count = parseInt(params.count, 10);
    const active = params.active === 'true';
    
    
    PARAMETER BINDING WITH load
    ═══════════════════════════════════════════════════════════
    
    Template:
    <a load="route: products; params.bind: {id: productId}">
      View Product
    </a>
    
    Component:
    productId = 42;
    
    Generated URL:
    /products/42
    
    With multiple params:
    <a load="route: items;
             params.bind: {
               id: itemId,
               category: itemCategory,
               extra: 'value'
             }">
      View Item
    </a>
    
    Route: /items/:id/:category?
    Generated: /items/42/electronics?extra=value
                     │        │           └─ query (not in path)
                     │        └─ matches :category
                     └─ matches :id
    
    
    PROGRAMMATIC WITH OPTIONS
    ═══════════════════════════════════════════════════════════
    
    router.load('products/42', {
      queryParams: {
        sort: 'price',
        page: 1
      },
      fragment: 'reviews'
    });
    
    Generated URL:
    /products/42?sort=price&page=1#reviews
    
    
    Or with structured instruction:
    router.load({
      component: 'products',
      params: { id: 42 },
      children: [
        { component: 'reviews' }
      ]
    }, {
      queryParams: { sort: 'date' }
    });
    
    Generated URL:
    /products/42/reviews?sort=date
    
    
    PARAMETER CONSTRAINTS
    ═══════════════════════════════════════════════════════════
    
    Validate during routing:
    {
      path: 'products/:id{{^\\d+$}}',  // Only digits
      component: ProductDetail
    }
    
    URL: /products/42      ✓ Matches
    URL: /products/abc     ✗ Doesn't match, goes to fallback
    
    Custom validation in component:
    canLoad(params: Params) {
      const id = Number(params.id);
    
      if (!Number.isInteger(id) || id <= 0) {
        return 'not-found';  // Redirect to 404
      }
    
      return true;
    }
    This will allow our component to be passed in values. Our specified bindable property here is called loading and can be used like this:

    In the example above, we are binding the boolean literal true to the loading property.

    Instead of literal, you can also bind another property (loadingVal in the following example) to the loading property.

    As seen in the following example, you can also bind values without the loading.bind part.

    Aurelia treats attribute values as strings. This means when working with primitives such as booleans or numbers, they won't come through in that way and need to be coerced into their primitive type using a bindable setter or specifying the bindable type explicitly using bindable coercion.

    The @bindable decorator signals to Aurelia that a property is bindable in our custom element. Let's create a custom element where we define two bindable properties.

    import { bindable, BindingMode } from 'aurelia';
    
    export class NameComponent {
        @bindable({ mode: BindingMode.toView }) firstName = '';
        @bindable({ mode: BindingMode.toView }) lastName  
    
    <p>Hello ${firstName} ${lastName}. How are you today?</p>

    You can then use the component in this way,`<name-component first-name="John" last-name="Smith"></name-component>

    Calling a change function when bindable is modified

    By default, Aurelia will call a change callback (if it exists) which takes the bindable property name followed by Changed added to the end. For example, firstNameChanged(newVal, previousVal) would fire every time the firstName bindable property is changed.

    {% hint style="warning" %} Due to the way the Aurelia binding system works, change callbacks will not be fired upon initial component initialization. If you worked with Aurelia 1, this behavior differs from what you might expect. {% endhint %}

    If you would like to call your change handler functions when the component is initially bound (like v1), you can achieve this the following way:

    ``

    `typescript import { bindable } from 'aurelia';

    export class NameComponent { @bindable firstName = ''; @bindable lastName = '';

    }

    In the above example, even though propertyChanged can be used for multiple properties (like firstName and lastName), it's only called individually for each of those properties. If you wish to act on a group of changes, like both firstName and lastName at once in the above example, propertiesChanged callback can used instead, like the following example:

    For the order of callbacks when there are multiple callbacks involved, refer to the following example: If we have a component class that looks like this:

    When we do

    the console logs will look like the following:

    Note: The individual change callback (propChanged) and propertyChanged execute immediately when the property is set, while propertiesChanged is deferred and executes asynchronously in the next tick.

    Configuring bindable properties

    Like almost everything in Aurelia, you can configure how bindable properties work.

    Change the binding mode using mode

    You can specify the binding mode using the mode property and passing in a valid BindingMode to it; @bindable({ mode: BindingMode.twoWay}) - this determines which way changes flow in your binding. By default, this will be BindingMode.oneWay

    Please consult the binding modes documentation below to learn how to change the binding modes. By default, the binding mode for bindable properties will be one-way

    Change the name of the change callback

    You can change the name of the callback that is fired when a change is made @bindable({ callback: 'propChanged' })

    Bindable properties support many different binding modes determining the direction the data is bound in and how it is bound.

    One way binding

    By default, bindable properties will be one-way binding (also known as toView). This means values flow into your component but not back out of it (hence the name, one way).

    Bindable properties without a mode explicitly set will be toView (one-way) by default. You can also explicitly specify the binding mode.

    Two-way binding

    Unlike the default, the two-way binding mode allows data to flow in both directions. If the value is changed with your component, it flows back out.

    Working with two-way binding

    Much like most facets of binding in Aurelia, two-way binding is intuitive. Instead of .bind you use .two-way if you need to be explicit, but in most instances, you will specify the type of binding relationship a bindable property is using with @bindable instead.

    Explicit two-way binding looks like this:

    The myVal variable will get a new value whenever the text input is updated. Similarly, if myVal were updated from within the view model, the input would get the updated value.

    When using .bind for input/form control values such as text inputs, select dropdowns and other form elements. Aurelia will automatically create a two-way binding relationship. So, the above example using a text input can be rewritten to be value.bind="myVal" , and it would still be a two-way binding.

    Bindable setter

    In some cases, you want to make an impact on the value that is binding. For such a scenario, you can use the possibility of new set.

    Suppose you have a carousel component in which you want to enable navigator feature for it.

    In version two, you can easily implement such a capability with the set feature.

    Define your property like this:

    For set part, we need functionality to check the input. If the value is one of the following, we want to return true, otherwise, we return the false value.

    • '': No input for a standalone navigator property.

    • true: When the navigator property set to true.

    • "true": When the navigator property set to "true".

    So our function will be like this

    Now, we should set truthyDetector function as follows:

    Although, there is another way to write the functionality too:

    You can simply use any of the above four methods to enable/disable your feature. As you can see, set can be used to transform the values being bound into your bindable property and offer more predictable results when dealing with primitives like booleans and numbers.

    Bindable & getter/setter

    By default you'll work with bindable fields most of the time, like the examples above. But there are cases where it makes sense to expose a bindable getter (or getter/setter pair) so you can compute the value on the fly.

    For example, a component card nav that allow parent component to query its active status. With bindable on field, it would be written like this:

    Note that because active value needs to computed from other variables, we have to "actively" call setActive. It's not a big deal, but sometimes not desirable.

    For cases like this, we can turn active into a getter, and decorate it with bindable, like the following:

    Simpler, since the value of active is computed, and observed based on the properties/values accessed inside the getter.

    Bindable coercion

    The bindable setter section shows how to adapt the value is bound to a @bindable property. One common usage of the setter is to coerce the values that are bound from the view. Consider the following example.

    Without any setter for the @bindable num we will end up with the string '42' as the value for num in MyEl. You can write a setter to coerce the value. However, it is a bit annoying to write setters for every @bindable.

    Automatic type coercion

    To address this issue, Aurelia 2 supports type coercion. To maintain backward compatibility, automatic type coercion is disabled by default and must be enabled explicitly.

    There are two relevant configuration options.

    enableCoercion

    The default value is false; that is Aurelia 2 does not coerce the types of the @bindable by default. It can be set to true to enable the automatic type-coercion.

    coerceNullish

    The default value is false; that is Aurelia2 does not coerce the null and undefined values. It can be set to true to coerce the null and undefined values as well. This property can be thought of as the global counterpart of the nullable property in the bindable definition (see Coercing nullable values section).

    Additionally, depending on whether you are using TypeScript or JavaScript for your app, there can be several ways to use automatic type coercion.

    Specify type in @bindable

    You need to specify the explicit type in the @bindable definition.

    The rest of the document is based on TypeScript examples. However, we trust that you can transfer that knowledge to your JavaScript codebase if necessary.

    Coercing primitive types

    Currently, coercing four primitive types are supported out of the box. These are number, string, boolean, and bigint. The coercion functions for these types are respectively Number(value), String(value), Boolean(value), and BigInt(value).

    Be mindful when dealing with bigint as the BigInt(value) will throw if the value cannot be converted to bigint; for example null, undefined, or non-numeric string literal.

    Coercing to instances of classes

    It is also possible to coerce values into instances of classes. There are two ways how that can be done.

    Using a static coerce method

    You can define a static method named coerce in the class used as a @bindable type. This method will be called by Aurelia2 automatically to coerce the bound value.

    This is shown in the following example with the Person class.

    According to the Person#coercer implementation, for the example above MyEl#person will be assigned an instance of Person that is equivalent to new Person('john', null).

    Using the @coercer decorator

    Aurelia2 also offers a @coercer decorator to declare a static method in the class as the coercer. The previous example can be rewritten as follows using the @coercer decorator.

    With the @coercer decorator, you are free to name the static method as you like.

    Coercing nullable values

    To maintain backward compatibility, Aurelia2 does not attempt to coerce null and undefined values. We believe that this default choice should avoid unnecessary surprises and code breaks when migrating to newer versions of Aurelia.

    However, you can explicitly mark a @bindable to be not nullable.

    When nullable is set to false, Aurelia2 will try to coerce the null and undefined values.

    set and auto-coercion

    It is important to note that an explicit set (see bindable setter) function is always prioritized over the type. In fact, the auto-coercion is the fallback for the set function. Hence whenever set is defined, the auto-coercion becomes non-operational.

    However, this gives you an opportunity to:

    • Override any of the default primitive type coercing behavior, or

    • Disable coercion selectively for a few selective @bindable by using a noop function for set.

    Aurelia2 already exposes a noop function saving your effort to write such boring functions.

    Union types

    When using TypeScript, usages of union types are not rare. However, using union types for @bindable will deactivate the auto-coercion.

    For the example above, the type metadata supplied by TypeScript will be Object disabling the auto-coercion.

    To coerce union types, you can explicitly specify a type.

    However, using a setter would be more straightforward to this end.

    Even though using a noop function for set function is a straightforward choice, Object can also be used for type in the bindable definition to disable the auto-coercion for selective @bindables (that is when the automatic type-coercion is enabled).

    Bindables spreading

    Spreading syntaxes are supported for simpler binding of multiple bindable properties.

    Given the following component:

    with template:

    and its usage template:

    The rendered html will be:

    Here we are using ...$bindables to express that we want to bind all properties in the object { first: 'John', last: 'Doe' } to bindable properties on <name-tag> component. The ...$bindables="..." syntax will only connect properties that are matching with bindable properties on <name-tag>, so even if an object with hundreds of properties are given to a ...$bindables binding, it will still resulted in 2 bindings for first and last.

    ...$bindables also work with any expression, rather than literal object, per the following examples:

    Shorthand syntax

    Sometimes when the expression of the spread binding is simple, we can simplify the binding even further. Default templating syntax of Aurelia supports a shorter version of the above examples:

    • Remember that HTML is case insensitive, so ...firstName actually will be seen as ...firstname, for example

    • Bindables properties will be tried to matched as is, which means a firstName bindable property will match an object firstName property, but not first-name

    • If the expression contains space, it will result into multiple attributes and thus won't work as intended with spread syntax .... For example ...a + b will be actually turned into 3 attributes: ...a, + and b

    Binding orders

    The order of the bindings created will be the same with the order declared in the template. For example, for the NameTag component above, if we have a usage

    Then the value of the first property in NameTag with id=1 will be Jane, and the value of first property in NameTag with id=2 will be John.

    • An exception of this order is when bindables spreading is used together with ...$attrs, ...$attrs will always result in bindings after ...$bindables/$bindables.spread/...expression.

    Observation behavior

    Bindings will be created based on the keys available in the object evaluated from the expression of a spread binding. The following example illustrate the behavior:

    For the NameTag component above:

    The rendered HTML of <name-tag> will be

    When clicking on the button with text Change last name, the rendered html of <name-tag> won't be changed, as the original object given to <name-tag> doesn't contain last, hence it wasn't observed, which ignore our new value set from the button click. If it's desirable to reset the observation, give a new object to the spread binding, like the following example:

    • With the above behavior of non-eager binding, applications can have the opportunity to leave some bindable properties untouched, while with the opposite behavior of always observing all properties on the given object based on the number of bindable properties, missing value (null/undefined) will start flowing in in an unwanted way.

    There are some other behaviors of the spread binding that are worth noting:

    • All bindings created with $bindables.spread or ... syntax will have binding mode equivalent to to-view, binding behavior cannot alter this. Though other binding behavior like throttle/debounce can still work.

    • If the same object is returned from evaluating the expression, the spread binding won't try to rebind its inner bindings. This means mutating and then reassigning won't result in new binding, instead, give the spread binding a new object.

    Attributes Transferring

    Attribute transferring is a way to relay the binding(s) on a custom element to its child element(s).

    As an application grows, the components inside it also grow. Something that starts simple, like the following component

    with the template

    can quickly grow out of hand with a number of needs for configuration: aria, type, min, max, pattern, tooltip, validation etc...

    After a while, the FormInput component above will become more and more like a relayer to transfer the bindings from outside, to the elements inside it. This often results in an increase in the number of @bindable. While this is fine, you end up with components that have a lot of boilerplate.

    And the usage of our component would look like this:

    to be repeated like this inside:

    To juggle all the relevant pieces for such a task isn't difficult, but somewhat tedious. With attribute transferring, which is roughly close to object spreading in JavaScript, the above template should be as simple as:

    , which reads like this: for some bindings on <form-input>, change the targets of those bindings to the <input> element inside it.

    Usage

    To transfer attributes & bindings from a custom element, there are two steps:

    • Set capture to true on a custom element via @customElement decorator:

    Or use the capture decorator from aurelia package if you don't want to declare the customElement decorator and have to specify your name and template values.

    As the name suggests, this is to signal the template compiler that all the bindings & attributes, with some exceptions, should be captured for future usage.

    Spread the captured attributes onto an element

    Using the ellipsis syntax which you might be accustomed to from Javascript, we can spread our attributes onto an element proceeding the magic variable $attrs

    Spread attributes and overriding specific ones

    In case you want to spread all attributes while explicitly overriding individual ones, make sure these come after the spread operator.

    It's recommended that this feature should not be overused in multi-level capturing & transferring. This is often known as prop-drilling in React and could have a bad effect on the overall & long-term maintainability of an application. It's probably healthy to limit the max level of transferring to 2.

    Usage with conventions

    Aurelia conventions enable the setting of capture metadata from the template via <capture> tag, like the following example:

    Attribute filtering

    Sometimes it is desirable to capture only certain attributes on a custom element. Aurelia supports this via a function form of the custom element capture value: a function that takes 1 parameter (the attribute name) and returns a boolean to indicate whether it should be captured.

    Note: When using a capture filter function, you cannot use the standalone @capture decorator. You must specify the filter function within the @customElement decorator's capture property.

    How it works

    What attributes are captured

    Everything except the template controller and custom element bindables are captured.

    A usage example is as follows:

    What is captured:

    • value.bind="extraComment"

    • class="form-control"

    • style="background: var(--theme-purple)"

    • tooltip="Hello, ${tooltip}"

    What is not captured:

    • if.bind="needsComment" (if is a template controller)

    • label.bind="label" (label is a bindable property)

    How will attributes be applied in ...$attrs

    Attributes that are spread onto an element will be compiled as if it was declared on that element.

    This means .bind command will work as expected when it's transferred from some element onto some element that uses .two-way for .bind.

    It also means that spreading onto a custom element will also work: if a captured attribute targets a bindable property of the applied custom element. An example:

    if value is a bindable property of my-input, the end result will be a binding that connects the message property of the corresponding app.html view model with <my-input> view model value property. The binding mode is also preserved like normal attributes.

    Performance Considerations

    Spread vs Individual Bindings

    When deciding between spread syntax and individual bindings, consider the following performance implications:

    Spread Syntax Benefits:

    • Reduces template verbosity and maintains cleaner code

    • Automatically handles dynamic property sets

    • Eliminates the need for manual bindable declarations for pass-through properties

    Individual Binding Benefits:

    • Slightly more efficient for small, known sets of properties

    • Explicit property access provides better type safety

    • Easier to debug specific binding issues

    Memory and Observation Overhead

    Understanding the observation behavior helps optimize performance:

    Best Practice: Use spread syntax when you need to pass through a focused set of properties, not entire large objects.

    Advanced Patterns

    Complex Expression Patterns

    Spread syntax supports complex expressions and transformations:

    Combining Spread with Other Binding Features

    Error Handling Patterns

    Best Practices

    When to Use Spread Syntax

    ✅ Good Use Cases:

    • Component composition and wrapper components

    • Dynamic forms with varying field sets

    • Passing through configuration objects

    • Creating reusable component libraries

    ❌ Avoid When:

    • You need explicit control over individual bindings

    • Working with large objects with many irrelevant properties

    • Performance is critical and you're binding a small, known set of properties

    • You need different binding modes for different properties

    Maintainability Guidelines

    Recommended Limits:

    • Maximum 2 levels of attribute transferring to avoid prop-drilling

    • Use spread for groups of related properties, not entire application state

    • Document spread behavior in component interfaces

    Type Safety Considerations

    Common Patterns and Anti-Patterns

    ✅ Recommended Patterns

    ❌ Anti-Patterns to Avoid

    Debugging and Troubleshooting

    Common Issues

    1. HTML Case Sensitivity

    2. Property Observation Not Working

    3. Binding Mode Conflicts

    Debugging Tips

    Understanding these patterns and considerations will help you use Aurelia's spread syntax effectively while maintaining good performance and code maintainability.

    router-lite - redirect - multiple paths - StackBlitzStackBlitz
    router-lite - required param - StackBlitzStackBlitz
    router-lite - optional param - StackBlitzStackBlitz
    router-lite - fallback - using ce name - StackBlitzStackBlitz
    router-lite - async getRouteConfig hook - StackBlitzStackBlitz
    import { bindable, BindingMode } from 'aurelia';
    
    export class NameComponent {
        @bindable({ mode: BindingMode.twoWay}) firstName = '';
        @bindable({ callback: 'lnameChanged' }) lastName  = '';
    
        lnameChanged(val) {}
    }
    @customElement({ name:'my-el', template: 'not important' })
    export class MyEl {
      @bindable public num: number;
    }
    @customElement({ name:'my-app', template: '<my-el num="42"></my-el>' })
    export class MyApp { }
    export class Person {
      public constructor(
        public readonly name: string,
        public readonly age: number,
      ) { }
      public static coerce(value: unknown): Person {
        if (value instanceof Person) return value;
        if (typeof value === 'string') {
          try {
            const json = JSON.parse(value) as Person;
            return new this(json.name, json.age);
          } catch {
            return new this(value, null!);
          }
        }
        if (typeof value === 'number') {
          return new this(null!, value);
        }
        if (typeof value === 'object' && value != null) {
          return new this((value as any).name, (value as any).age);
        }
        return new this(null!, null!);
      }
    }
    import { Person } from './person.ts';
    @customElement({ name:'my-el', template: 'not important' })
    export class MyEl {
      @bindable public person: Person;
    }
    @customElement({ name:'my-app', template: '<my-el person="john"></my-el>' })
    export class MyApp { }
    import { coercer } from '@aurelia/runtime-html';
    
    export class Person {
      public constructor(
        public readonly name: string,
        public readonly age: number,
      ) { }
    
      @coercer
      public static createFrom(value: unknown): Person {
        if (value instanceof Person) return value;
        if (typeof value === 'string') {
          try {
            const json = JSON.parse(value) as Person;
            return new this(json.name, json.age);
          } catch {
            return new this(value, null!);
          }
        }
        if (typeof value === 'number') {
          return new this(null!, value);
        }
        if (typeof value === 'object' && value != null) {
          return new this((value as any).name, (value as any).age);
        }
        return new this(null!, null!);
      }
    }
    import { Person } from './person.ts';
    
    @customElement({ name:'my-el', template: 'not important' })
    export class MyEl {
      @bindable public person: Person;
    }
    @customElement({ name:'my-app', template: '<my-el person="john"></my-el>' })
    export class MyApp { }
    <!-- Problem: HTML converts to lowercase -->
    <component ...firstName> <!-- becomes ...firstname -->
    
    <!-- Solution: Use explicit binding -->
    <component ...$bindables="{ firstName: user.firstName }">
    // Problem: Adding properties after initial binding
    this.userData.newProperty = 'value'; // Not observed
    
    // Solution: Replace the entire object
    this.userData = { ...this.userData, newProperty: 'value' };
    <!-- Problem: Spread overrides explicit binding -->
    <input value.two-way="data.value" ...formConfig>
    
    <!-- Solution: Put explicit bindings after spread -->
    <input ...formConfig value.two-way="data.value">
    loader-component.ts
    import { bindable } from 'aurelia';
    
    export class LoaderComponent {
        @bindable loading = false;
    }
    loader-component.html
    <loader loading.bind="true"></loader>
    loader-component.html
    <loader loading.bind="loadingVal"></loader>
    <loader loading="true"></loader>
    bound() {
        this.firstNameChanged(this.firstName, undefined);
    }
    
    firstNameChanged(newVal, oldVal) {
        console.log('Value changed');
    }
    
    If you have multiple bindable properties like `firstName`/`lastName` in the above example, and want to use a single callback to react to those changes,
    you can use `propertyChanged` callback. `propertyChanged` callback will be called immediately after the targeted change callback.
    The parameters of this callback will be `key`/`newValue`/`oldValue`, similar like the following example:
    
    ```ts
    export class NameComponent {
        @bindable firstName = '';
        @bindable lastName  = '';
    
        propertyChanged(key, newVal, oldVal) {
          if (key === 'firstName') {
    
          } else if (key === 'lastName') {
    
          }
        }
    propertiesChanged({ firstName, lastName }) {
      if (firstName && lastName) {
        // both firstName and lastName were changed at the same time
        // apply first update strategy
        const { newValue: newFirstName, oldValue: oldFirstName } = firstName;
        const { newValue: newLastName, oldValue: oldLastName } = lastName;
      } else if (firstName) {
        // only firstName was changed - apply second update strategy
        // ...
      } else {
        // only lastName was changed - apply third update strategy
        // ...
      }
    }
    class MyComponent {
      @bindable prop = 0
    
      propChanged() { console.log('prop changed'); }
    
      propertyChanged(name) { console.log(`property "${name}" changed`) }
    
      propertiesChanged(changes) {
        console.log('changes are:', changes)
      }
    }
    myComponent.prop = 1;
    console.log('after assign');
    propChanged
    property "prop" changed
    after assign
    changes are, { prop: { newValue: 1, oldValue: 0 } }
    import { bindable, BindingMode } from 'aurelia';
    
    export class Loader {
        @bindable({ mode: BindingMode.toView })
    }
    import { bindable, BindingMode } from 'aurelia';
    
    export class Loader {
        @bindable({ mode: BindingMode.twoWay})
    }
    <input type="text" value.two-way="myVal">
    @bindable({
        set: value => someFunction(value),  /* HERE */
        // Or set: value => value,
        mode: /* ... */
    })
    <!-- Enable -->
    <my-carousel navigator.bind="true">
    <my-carousel navigator="true">
    <my-carousel navigator=true>
    <my-carousel navigator>
    
    <!-- Disable -->
    <my-carousel navigator.bind="false">
    <my-carousel navigator="false">
    <my-carousel navigator=false>
    <my-carousel>
    @bindable({ set: /* ? */, mode: BindingMode.toView }) public navigator: boolean = false;
    export function truthyDetector(value: unknown) {
        return value === '' || value === true || value === "true";
    }
    @bindable({ set: truthyDetector, mode: BindingMode.toView }) public navigator: boolean = false;
    @bindable({ set: v => v === '' || v === true || v === "true", mode: BindingMode.toView }) public navigator: boolean = false;
    import { BindingMode, bindable, customElement, ICustomElementViewModel } from 'aurelia';
    
    @customElement({ name: 'card-nav', template })
    export class CardNav implements ICustomElementViewModel {
      @bindable routes: RouteLink[] = [];
    
      @bindable({ mode: BindingMode.fromView }) active?: string;
    
      bound() {
        this.setActive();
      }
    
      setActive() {
        this.active = this.routes.find((y) => y.isActive)?.path;
      }
    
      handleClick(route: RouteLink) {
        this.routes.forEach((x) => (x.isActive = x === route));
        this.setActive();
      }
    }
    import { BindingMode, bindable, customElement, ICustomElementViewModel } from 'aurelia';
    
    @customElement({ name: 'card-nav', template })
    export class CardNav implements ICustomElementViewModel {
      @bindable routes: RouteLink[] = [];
    
      @bindable({ mode: BindingMode.fromView }) get active() {
        return this.routes.find((y) => y.isActive)?.path;
      }
    
      handleClick(route: RouteLink) {
        this.routes.forEach((x) => (x.isActive = x === route));
      }
    }
    new Aurelia()
        .register(
          StandardConfiguration
            .customize((config) => {
              config.coercingOptions.enableCoercion = true;
              // config.coercingOptions.coerceNullish = true;
            }),
          ...
        );
    @customElement({ name:'my-el', template: 'not important' })
    export class MyEl {
      @bindable({ type: Number }) num : number;
    }
    @customElement({ name:'my-el', template: 'not important' })
    export class MyEl {
      @bindable({ nullable: false }) public num: number;
    }
    @customElement({ name:'my-el', template: 'not important' })
    export class MyEl {
      @bindable public num: number | string;
    }
    @customElement({ name:'my-el', template: 'not important' })
    export class MyEl {
      @bindable({type: String}) public num: number | string;
    }
    @customElement({ name:'my-el', template: 'not important' })
    export class MyEl {
      @bindable({set(v: unknown) {... return coercedV;}}) public num: number | string;
    }
    export class NameTag {
      @bindable first
      @bindable last
    }
    <b>${first.toUpperCase()}</b> ${last}
    <name-tag ...$bindables="{ first: 'John', last: 'Doe' }"></name-tag>
    <b>JOHN</b> Doe
    <name-tag $bindables.spread="customer1">
    <name-tag $bindables.spread="customer.details">
    <name-tag $bindables.spread="customer[this_that]">
    <name-tag $bindables="customer1 | mapDetails">
    <name-tag $bindables="customer.details | simplify">
    <name-tag $bindables="customer[this_that] | addDetails">
    <name-tag ...customer1>
    <name-tag ...customer.details>
    <name-tag ...customer[this_that]>
    
    or if you need space in the expression:
    <name-tag ...$bindables="customer1 | mapDetails">
    <name-tag ...$bindables="customer.details | simplify">
    <name-tag ...$bindables="customer[this_that] | addDetails">
    <name-tag id="1" first="John" ...$bindables="{ first: 'Jane' }">
    <name-tag id="2" ...$bindables="{ first: 'Jane' }" first="John">
    <let item.bind="{ first: 'John' }">
    <name-tag ...item></name-tag>
    <button click.trigger="item.last = 'Doe'">Change last name</button>
    <b>JOHN</b>
    <let item.bind="{ first: 'John' }">
    <name-tag ...item></name-tag>
    <button click.trigger="item = { first: item.name, last: 'Doe' }">Change last name</button>
    export class FormInput {
      @bindable label
      @bindable value
    }
    <label>${label}
      <input value.bind="value">
    </label>
    export class FormInput {
      @bindable label
      @bindable value
      @bindable type
      @bindable tooltip
      @bindable arias
      @bindable etc
    }
    <form-input
      label.bind="label"
      value.bind="message"
      tooltip.bind="Did you know Aurelia syntax comes from an idea of an Angular community member? We greatly appreciate Angular and its community for this."
      validation.bind="...">
    <label>${label}
      <input value.bind tooltip.bind validation.bind min.bind max.bind>
    </label>
    <label>${label}
      <input ...$attrs>
    </label>
    @customElement({
      ...,
      capture: true
    })
    import { capture } from 'aurelia';
    
    @capture
    export class MyCustomElement {
      ...
    }
    
    // either form is valid
    @capture()
    export class MyCustomElement {
      ...
    }
    <input ...$attrs>
    <input value.bind="..." ...$attrs> spread wins
    <input ...$attrs value.bind="..."> explicit wins
    <capture>
    
    <input ...$attrs>
    @customElement({
      capture: attr => attr !== 'class'  // Captures all attributes except 'class'
    })
    form-input.ts
    export class FormInput {
      @bindable label
    }
    my-app.html
    <form-input
      if.bind="needsComment"
      label.bind="label"
      value.bind="extraComment"
      class="form-control"
      style="background: var(--theme-purple)"
      tooltip="Hello, ${tooltip}">
    app.html
    <input-field value.bind="message">
    
    input-field.html
    <my-input ...$attrs>
    // Performance comparison
    export class ComponentWrapper {
      // Good for small, known property sets
      @bindable name: string;
      @bindable age: number;
      
      // Better for dynamic or large property sets
      @bindable userData: UserData;
    }
    // More efficient - limited observation
    const userData = { name: 'John', age: 30 };
    <user-profile ...userData>
    
    // Less efficient - observes all properties
    const userData = { name: 'John', age: 30, metadata: {...}, history: [...] };
    <user-profile ...userData>
    <!-- Object transformation -->
    <user-card ...user.profile>
    <user-card ...user.permissions.admin>
    <user-card ...getCurrentUser().settings>
    
    <!-- With value converters -->
    <user-card ...$bindables="user | formatUser">
    <user-card ...$bindables="user.profile | selectFields:['name', 'email']">
    
    <!-- Dynamic property selection -->
    <user-card ...$bindables="user | pick:allowedFields">
    <!-- Spread with binding behaviors -->
    <input-field ...$bindables="formData | filterEmpty & debounce:500">
    
    <!-- Spread with conditional logic -->
    <user-form ...$bindables="user & if:isEditMode">
    
    <!-- Mixed binding patterns -->
    <input-field ...user 
                 ...$attrs 
                 id.bind="fieldId" 
                 class="form-control"
                 validation.bind="userValidation">
    // Null-safe spreading
    @customElement({ name: 'safe-component' })
    export class SafeComponent {
      @bindable data: any;
      
      get safeData() {
        return this.data || {};
      }
    }
    <!-- Template with null safety -->
    <safe-component ...safeData>
    <!-- or -->
    <safe-component ...$bindables="data || {}">
    // Good: Clear, focused spreading
    export class FormField {
      @bindable label: string;
      @bindable required: boolean;
      
      // Spread for input-specific attributes
      @customElement({ capture: true })
    }
    <!-- Template shows clear intent -->
    <label>${label}
      <input ...$attrs class="form-control">
    </label>
    // Define interfaces for spread objects
    interface UserProfile {
      name: string;
      email: string;
      avatar?: string;
    }
    
    export class UserCard {
      @bindable profile: UserProfile;
    }
    <!-- Type-safe spreading -->
    <user-card ...user.profile>
    // Pattern 1: Wrapper Components
    @customElement({ name: 'styled-input', capture: true })
    export class StyledInput {
      @bindable label: string;
      @bindable theme: string;
    }
    <div class="input-group ${theme}">
      <label>${label}</label>
      <input ...$attrs>
    </div>
    // Pattern 2: Configuration Objects
    export class DataTable {
      @bindable columns: Column[];
      @bindable options: TableOptions;
    }
    <data-table columns.bind="userColumns" ...tableConfig>
    // Anti-pattern 1: Spreading entire application state
    export class BadComponent {
      @bindable appState: ApplicationState; // Too broad
    }
    <!-- Anti-pattern 2: Excessive nesting -->
    <wrapper-1 ...data>
      <wrapper-2 ...data>
        <wrapper-3 ...data>
          <actual-component ...data>
        </wrapper-3>
      </wrapper-2>
    </wrapper-1>
    // Enable detailed binding information in development
    @customElement({ 
      name: 'debug-component',
      capture: (attr: string) => {
        if (__DEV__) {
          console.log('Capturing attribute:', attr);
        }
        return !attr.startsWith('debug-');
      }
    })
    =
    ''
    ;
    }
    router-lite - wildcard param - StackBlitzStackBlitz
    router-lite - getRouteConfig hook - StackBlitzStackBlitz
    router-lite - redirect - parameterized - StackBlitzStackBlitz
    router-lite - fallback - using routeid - StackBlitzStackBlitz
    router-lite - fallback - using function - StackBlitzStackBlitz
    router-lite - component - ce-name - StackBlitzStackBlitz
    router-lite - fallback - StackBlitzStackBlitz
    router-lite - component - inline import - StackBlitzStackBlitz
    router-lite - case-sensitive - StackBlitzStackBlitz
    router-lite - redirect - StackBlitzStackBlitz
    router-lite - component - function - StackBlitzStackBlitz
    Logo
    Logo
    Logo
    Logo
    Logo
    router-lite - fallback - hierarchical - StackBlitzStackBlitz

    Value converters (pipes)

    Master Aurelia's value converters for powerful data transformation. Learn formatting, localization, custom converters, performance optimization, and real-world patterns.

    Value converters are a powerful feature in Aurelia 2 that transform data as it flows between your view model and view. They enable clean separation of concerns by moving data formatting logic out of your view models while keeping templates readable and maintainable.

    Overview

    Value converters excel at:

    • Data formatting

    - dates, numbers, currencies, text transformations
  • Localization - dynamic content based on user locale

  • Display logic - conditional formatting without cluttering view models

  • Two-way transformations - handling both display and input conversion

  • Reactive updates - automatic re-evaluation on global state changes

  • Performance optimization - caching expensive transformations

  • Key Advantages

    • Pure functions - predictable, testable transformations

    • Reusable - use the same converter across multiple components

    • Composable - chain multiple converters for complex transformations

    • Framework integration - seamless integration with Aurelia's binding system

    • TypeScript support - full type safety and intellisense

    Data Flow

    Converters work in two directions:

    • toView: Prepares model data for display.

    • fromView: Adjusts view data before updating the model (useful with two-way binding).

    Both methods receive the primary value as the first argument, with any extra arguments used as configuration.

    Example Methods

    Basic Usage

    Template Syntax

    Use the pipe symbol (|) to apply a converter in templates:

    Simple Converter Example

    Usage in template:

    Parameter Passing

    Converters accept parameters using colons (:) for configuration:

    Static Parameters

    Bound Parameters

    Object Parameters

    Chaining Converters

    Chain multiple converters for complex transformations:

    Chain execution order: Left to right, where each converter receives the output of the previous one.

    Advanced Template Patterns

    Conditional Formatting

    Dynamic Parameter Selection

    Nested Object Access

    Receiving the Caller Context

    By default, value converters receive only the value to transform and any configuration parameters. In some advanced scenarios, you may need to know more about the binding or calling context that invoked the converter—for example, to adjust the transformation based on the host element, attributes, or other binding-specific state.

    Aurelia 2 provides an opt-in mechanism to receive the binding instance itself as an additional parameter. To enable this feature:

    1. Add withContext: true to your value converter class:

    Then use your converter in templates as usual:

    At runtime, Aurelia will detect withContext: true in the value converter and pass the binding instance as the second parameter. Depending on how the converter is used:

    • Property Binding (foo.bind or attr.bind): the caller is a PropertyBinding instance

    • Interpolation (${ } with converters): the caller is an InterpolationPartBinding instance

    • Other Bindings: the caller corresponds to the specific binding type in use

    Common Use Cases

    • Logging or debugging which binding invoked the converter

    • Applying different formatting based on binding context

    • Accessing binding metadata or context not available through standard converter parameters

    Use this feature sparingly, only when you truly need insights into the calling context. For most formatting scenarios, simple converter parameters and camelCase converter names are sufficient.

    Accessing the View Model and Binding Context

    Once withContext: true is enabled, your converter receives a caller parameter with direct access to the view model and binding information:

    Caller Context Properties

    • caller.source: The view model instance of the component where the converter is used

      • This is the actual component class instance with all its properties and methods

      • Allows converters to access component state, computed properties, and methods

      • Always available when converter is used within a component

    • caller.binding: The binding instance that invoked the converter

      • Contains binding-specific information and metadata

      • Useful for debugging or advanced binding manipulation

      • Type varies: PropertyBinding

    Real-World Example: User Permission Converter

    Usage in template:

    Registration Patterns

    Aurelia 2 provides flexible registration patterns for different use cases and architectural preferences.

    1. Decorator Registration (Recommended)

    The most common and straightforward approach:

    2. Configuration Object Registration

    For advanced options including aliases:

    Usage with aliases:

    3. Static Definition

    Using the static $au property (alternative registration approach):

    4. Manual Registration

    For dynamic or runtime registration scenarios:

    5. Local vs Global Registration

    Global Registration (Application-wide)

    Local Registration (Component-specific)

    Scoped Registration (Feature Module)

    6. Conditional Registration

    Register converters based on environment or feature flags:

    Best Practices for Registration

    1. Use decorators for most cases - Simple and straightforward

    2. Group related converters - Organize by feature or domain

    3. Consider lazy loading - Register heavy converters only when needed

    4. Document aliases - Make alternative names clear to team members

    5. Avoid global pollution - Use local registration for component-specific logic

    Creating Custom Value Converters

    Custom value converters are classes that implement transformation logic. They provide a clean way to handle data formatting throughout your application.

    Basic Structure

    TypeScript Best Practices

    Strong Typing

    Generic Converters

    Bidirectional Converters (Two-Way Binding)

    Perfect for form inputs that need both display formatting and input parsing:

    Phone Number Formatter

    Usage with two-way binding:

    Credit Card Formatter

    Error Handling and Validation

    Performance Optimization

    Memoized Converter

    Utility Converters

    Null-Safe Converter

    Debug Converter

    Usage:

    Signals-Based Reactivity

    Value converters can automatically re-evaluate when specific signals are dispatched, perfect for locale changes, theme updates, or global state changes.

    To trigger re-evaluation from anywhere in your app:

    Now all localeDate converters automatically update when the locale changes, without needing to manually refresh bindings.

    Built-in Signal-Aware Converters

    Aurelia 2 includes several built-in converters that leverage signals:

    Built-in Value Converters

    Aurelia 2 includes several built-in converters ready for use:

    Sanitize Converter

    Aurelia 2 includes a sanitize converter, but it requires you to provide your own sanitizer implementation:

    Then you can use the sanitize converter:

    Note: The built-in sanitize converter throws an error by default. You must provide your own ISanitizer implementation for it to work.

    I18n Converters (when @aurelia/i18n is installed)

    Advanced Configuration Options

    Date Formatter Example

    This converter formats dates based on locale:

    Import it in your view:

    Usage examples:

    View this in action on StackBlitz.

    Real-World Converter Examples

    File Size Converter

    Convert bytes to human-readable file sizes:

    Relative Time Converter

    Display time relative to now (e.g., "2 hours ago"):

    Truncate with Tooltip Converter

    Truncate text with full text available on hover:

    Markdown to HTML Converter

    Convert markdown text to HTML (using marked library):

    Search Highlight Converter

    Highlight search terms in text:

    Sort Array Converter

    Sort arrays by property or custom function:

    Color Converter

    Convert between color formats:

    Performance Optimization

    Caching Strategies

    Implement intelligent caching for expensive operations:

    Lazy Evaluation

    Defer expensive operations until actually needed:

    Memory Management

    Prevent memory leaks in converters:

    Benchmark and Profile

    Use performance measurement for optimization:

    Best Practices

    1. Design Principles

    Single Responsibility

    Pure Functions

    2. TypeScript Integration

    Strong Typing

    Generic Constraints

    3. Error Handling

    Graceful Degradation

    4. Testing Strategies

    Unit Testing

    Troubleshooting Common Issues

    Issue: Converter Not Found

    Problem: Template shows error "No ValueConverter named 'myConverter' was found"

    Solutions:

    1. Import the converter:

    2. Check decorator name:

    3. Global registration:

    Issue: Performance Problems

    Problem: Page becomes slow with converters in loops

    Solutions:

    1. Implement caching:

    2. Use signals for global updates:

    3. Optimize template usage:

    Issue: Context Access Not Working

    Problem: caller parameter is undefined in toView

    Solutions:

    1. Enable context access:

    2. Correct parameter order:

    Issue: Signals Not Triggering

    Problem: Converter doesn't update when signal is dispatched

    Solutions:

    1. Declare signals array:

    2. Dispatch signals correctly:

    Built-in Converters Reference

    Core Converters

    Converter
    Purpose
    Package
    Parameters

    sanitize

    HTML sanitization

    @aurelia/runtime-html

    None (requires ISanitizer implementation)

    I18n Converters (when @aurelia/i18n is installed)

    Converter
    Purpose
    Parameters
    Example

    t

    Translation

    key, options

    `${'hello'

    df

    Date formatting

    options

    `${date

    nf

    Number formatting

    options

    Usage Examples

    Summary

    Value converters in Aurelia 2 provide a powerful, flexible system for data transformation:

    • Bidirectional support - Handle both display formatting and input parsing

    • Signal-based reactivity - Automatic updates on global state changes

    • Context awareness - Access binding context when needed

    • Performance optimization - Built-in caching and lazy evaluation support

    • Type safety - Full TypeScript support with strong typing

    • Flexible registration - Multiple registration patterns for different needs

    • Extensibility - Easy to create custom converters for specific requirements

    Use value converters to keep your templates clean and maintainable while providing rich data formatting capabilities throughout your application.

    Logo
    Logo
    Logo
    Logo
    Logo
    Logo
    Logo
    Logo
    Logo
    Logo
    Logo
    router-lite - route config on vm - StackBlitzStackBlitz
    router-lite - component - ce-defn - StackBlitzStackBlitz
    router-lite - parent overrides comp route config - StackBlitzStackBlitz
    router-lite - path on vm - StackBlitzStackBlitz
    Logo
    router-lite - hybrid config - StackBlitzStackBlitz
    router-lite - component - ce-instance - StackBlitzStackBlitz
    import { valueConverter } from 'aurelia';
    
    @valueConverter({ name: 'myConverter' })
    export class MyConverter {
      public readonly withContext = true;
    
      public toView(value, caller, ...args) {
        // `caller` is an object with:
        // - `source`: The closest custom element view-model, if any.
        // - `binding`: The binding instance (e.g., PropertyBinding, InterpolationPartBinding).
        console.log('Converter called by binding:', caller.binding);
        console.log('Source/Component VM:', caller.source);
    
        // Use binding-specific state if needed, then return transformed value
        return /* your transformation logic */;
      }
    
      public fromView?(value, caller, ...args) {
        // For two-way binding scenarios, you can similarly access the caller properties
        return /* reverse transformation logic */;
      }
    }
    <import from="./my-converter"></import>
    @valueConverter('myConverter') // Must match template usage
    export class MyConverter { }
    // In main.ts
    import { MyConverter } from './my-converter';
    
    Aurelia.register(MyConverter).app(MyApp).start();
    private cache = new Map();
    toView(value: string): string {
      if (this.cache.has(value)) return this.cache.get(value);
      // ... expensive operation
    }
    readonly signals = ['data-changed'];
    // Update only when signal is dispatched
    <!-- ❌ Bad - converter called for every item -->
    <div repeat.for="item of items">
      ${expensiveData | expensiveConverter}
    </div>
    
    <!-- ✅ Good - converter called once -->
    <div repeat.for="item of items">
      ${item.name}
    </div>
    <div>${expensiveData | expensiveConverter}</div>
    readonly withContext = true; // Required property
    toView(value: unknown, caller: ICallerContext, ...args: unknown[]): unknown {
      // caller is always second parameter when withContext = true
    }
    readonly signals = ['my-signal']; // Array of signal names
    import { resolve } from '@aurelia/kernel';
    import { ISignaler } from '@aurelia/runtime';
    
    private signaler = resolve(ISignaler);
    
    updateData(): void {
      // Update data first
      this.signaler.dispatchSignal('my-signal');
    }
    // toView: from model to view
    toView(value, ...args) { /* transform value for display */ }
    
    // fromView: from view to model
    fromView(value, ...args) { /* transform value for the model */ }
    <!-- String interpolation -->
    <h1>${userName | capitalize}</h1>
    <p>${price | currency:'USD'}</p>
    
    <!-- Property binding -->
    <input value.bind="searchTerm | normalize">
    
    <!-- Attribute binding -->
    <div class.bind="status | statusClass">
    import { valueConverter } from 'aurelia';
    
    @valueConverter('capitalize')
    export class CapitalizeConverter {
      toView(value: string): string {
        if (!value) return '';
        return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
      }
    }
    <span>${'hello world' | capitalize}</span>
    <!-- Output: "Hello world" -->
    <!-- Fixed locale -->
    <span>${date | dateFormat:'en-GB'}</span>
    
    <!-- Multiple parameters -->
    <span>${price | currency:'EUR':'symbol':'1.2-2'}</span>
    <span>${date | dateFormat:userLocale}</span>
    <span>${text | truncate:maxLength:appendEllipsis}</span>
    export class MyComponent {
      userLocale = 'fr-FR';
      maxLength = 50;
      appendEllipsis = true;
    }
    <div repeat.for="item of items | sort:sortConfig">
      ${item.name}
    </div>
    export class MyComponent {
      sortConfig = {
        property: 'name',
        direction: 'asc',
        caseSensitive: false
      };
    }
    <!-- Apply multiple transformations in sequence -->
    <span>${userInput | sanitize | capitalize | truncate:100}</span>
    
    <!-- With parameters -->
    <span>${rawText | normalize | highlight:searchTerm | capitalize}</span>
    <span class.bind="status | statusToClass">
      ${status | statusToDisplay}
    </span>
    <span>${date | dateFormat:(isDetailed ? 'long' : 'short')}</span>
    <span>${user.profile | formatProfile:user.preferences}</span>
    <import from="./my-converter"></import>
    <p>${ someValue | myConverter }</p>
    @valueConverter('vmAware')
    export class ViewModelAwareConverter {
      readonly withContext = true;
      
      toView(value: unknown, caller: ICallerContext): string {
        // Direct access to the view model instance
        const viewModel = caller.source as MyComponent;
        
        // Access view model properties and methods
        if (viewModel.isAdmin) {
          return `Admin: ${value}`;
        }
        
        // Use view model data for transformation
        return `${value} (${viewModel.userName})`;
      }
    }
    interface UserComponent {
      currentUser: { role: string; permissions: string[] };
      isOwner(itemId: string): boolean;
    }
    
    @valueConverter('userPermission')
    export class UserPermissionConverter {
      readonly withContext = true;
      
      toView(
        action: string, 
        caller: ICallerContext,
        requiredPermission?: string
      ): boolean {
        const component = caller.source as UserComponent;
        
        // Access view model properties
        const user = component.currentUser;
        if (!user) return false;
        
        // Use view model methods
        if (action === 'delete' && component.isOwner) {
          return component.isOwner(requiredPermission || '');
        }
        
        // Check permissions
        return user.permissions.includes(requiredPermission || action);
      }
    }
    <button if.bind="'edit' | userPermission:'edit-posts'">
      Edit Post
    </button>
    
    <button if.bind="'delete' | userPermission:post.id">
      Delete Post
    </button>
    import { valueConverter } from 'aurelia';
    
    // Simple registration
    @valueConverter('capitalize')
    export class CapitalizeConverter {
      toView(value: string): string {
        return value?.charAt(0).toUpperCase() + value?.slice(1).toLowerCase() || '';
      }
    }
    @valueConverter({ 
      name: 'currency', 
      aliases: ['money', 'cash'] 
    })
    export class CurrencyConverter {
      toView(value: number, locale = 'en-US', currency = 'USD'): string {
        return new Intl.NumberFormat(locale, {
          style: 'currency',
          currency
        }).format(value);
      }
      
      fromView(value: string): number {
        // Parse currency string back to number for two-way binding
        const numericValue = parseFloat(value.replace(/[^\d.-]/g, ''));
        return isNaN(numericValue) ? 0 : numericValue;
      }
    }
    <span>${price | currency}</span>
    <span>${price | money:'en-GB':'GBP'}</span>
    <span>${price | cash}</span>
    export class DateFormatConverter {
      static readonly $au: ValueConverterStaticAuDefinition = {
        type: 'value-converter',
        name: 'dateFormat',
        aliases: ['df']
      };
    
      toView(value: Date, format: string = 'short'): string {
        return new Intl.DateTimeFormat('en-US', 
          format === 'short' ? { dateStyle: 'short' } : { dateStyle: 'full' }
        ).format(value);
      }
    }
    import { ValueConverter, IContainer } from 'aurelia';
    
    // Method 1: ValueConverter.define()
    const DynamicConverter = ValueConverter.define('dynamic', class {
      toView(value: unknown): string {
        return `[Dynamic: ${value}]`;
      }
    });
    
    // Method 2: Container registration
    export class RuntimeConverter {
      toView(value: unknown): string {
        return String(value);
      }
    }
    
    // Register manually in main.ts or configure function
    container.register(ValueConverter.define('runtime', RuntimeConverter));
    // Available throughout the entire application
    @valueConverter('global')
    export class GlobalConverter {
      toView(value: string): string {
        return value.toUpperCase();
      }
    }
    import { LocalConverter } from './local-converter';
    
    @customElement({
      name: 'my-element',
      template: '<span>${data | localConverter}</span>',
      dependencies: [LocalConverter] // Only available in this component tree
    })
    export class MyElement {
      data = 'hello world';
    }
    // feature-module.ts
    import { IContainer } from 'aurelia';
    
    export function configure(container: IContainer) {
      container.register(
        ValueConverter.define('featureSpecific', FeatureConverter)
      );
    }
    // main.ts
    import { IContainer } from 'aurelia';
    
    export function configure(container: IContainer) {
      // Production vs Development converters
      if (process.env.NODE_ENV === 'development') {
        container.register(DebugConverter);
      }
      
      // Feature flag based registration
      if (featureFlags.enableAdvancedFormatting) {
        container.register(AdvancedFormattingConverter);
      }
    }
    import { valueConverter } from 'aurelia';
    
    @valueConverter('converterName')
    export class ConverterNameValueConverter {
      // Required: transform data for display
      toView(value: InputType, ...args: unknown[]): OutputType {
        // Transform value for display
        return transformedValue;
      }
    
      // Optional: transform data from user input back to model
      fromView?(value: InputType, ...args: unknown[]): OutputType {
        // Transform user input back to model format
        return transformedValue;
      }
    
      // Optional: signals for automatic re-evaluation
      readonly signals?: string[] = ['signal-name'];
    
      // Optional: enables binding context access
      readonly withContext?: boolean = false;
    }
    interface FormattingOptions {
      locale?: string;
      style?: 'decimal' | 'currency' | 'percent';
      minimumFractionDigits?: number;
      maximumFractionDigits?: number;
    }
    
    @valueConverter('numberFormat')
    export class NumberFormatConverter {
      toView(value: number | null | undefined, options: FormattingOptions = {}): string {
        if (value == null || isNaN(value)) return '';
        
        const {
          locale = 'en-US',
          style = 'decimal',
          minimumFractionDigits,
          maximumFractionDigits
        } = options;
    
        return new Intl.NumberFormat(locale, {
          style,
          minimumFractionDigits,
          maximumFractionDigits
        }).format(value);
      }
    
      fromView(value: string, options: FormattingOptions = {}): number {
        const numericValue = parseFloat(value.replace(/[^\d.-]/g, ''));
        return isNaN(numericValue) ? 0 : numericValue;
      }
    }
    @valueConverter('arrayJoin')
    export class ArrayJoinConverter<T = unknown> {
      toView(array: T[] | null | undefined, separator = ', ', formatter?: (item: T) => string): string {
        if (!Array.isArray(array)) return '';
        
        const items = formatter 
          ? array.map(formatter)
          : array.map(String);
          
        return items.join(separator);
      }
    }
    @valueConverter('phoneNumber')
    export class PhoneNumberConverter {
      toView(value: string | null | undefined): string {
        if (!value) return '';
        
        // Remove all non-digits
        const digits = value.replace(/\D/g, '');
        
        // Format as (XXX) XXX-XXXX for US numbers
        if (digits.length >= 10) {
          return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`;
        }
        
        return digits;
      }
    
      fromView(value: string): string {
        // Store only digits in the model
        return value.replace(/\D/g, '');
      }
    }
    <input value.two-way="user.phone | phoneNumber" placeholder="Phone number">
    @valueConverter('creditCard')
    export class CreditCardConverter {
      toView(value: string | null | undefined): string {
        if (!value) return '';
        
        const digits = value.replace(/\D/g, '');
        
        // Format as XXXX XXXX XXXX XXXX
        return digits.replace(/(.{4})/g, '$1 ').trim();
      }
    
      fromView(value: string): string {
        return value.replace(/\D/g, '');
      }
    }
    @valueConverter('safeJson')
    export class SafeJsonConverter {
      toView(value: unknown, pretty = false): string {
        try {
          return JSON.stringify(value, null, pretty ? 2 : undefined);
        } catch (error) {
          console.warn('SafeJsonConverter: Invalid JSON value', error);
          return '[Invalid JSON]';
        }
      }
    
      fromView(value: string): unknown {
        if (!value.trim()) return null;
        
        try {
          return JSON.parse(value);
        } catch (error) {
          console.warn('SafeJsonConverter: Invalid JSON string', error);
          return value; // Return original string if parsing fails
        }
      }
    }
    @valueConverter('expensiveTransform')
    export class ExpensiveTransformConverter {
      private cache = new Map<string, string>();
      
      toView(value: string, config: TransformConfig): string {
        const cacheKey = `${value}-${JSON.stringify(config)}`;
        
        if (this.cache.has(cacheKey)) {
          return this.cache.get(cacheKey)!;
        }
        
        const result = this.performExpensiveTransformation(value, config);
        this.cache.set(cacheKey, result);
        
        // Prevent memory leaks
        if (this.cache.size > 1000) {
          const firstKey = this.cache.keys().next().value;
          this.cache.delete(firstKey);
        }
        
        return result;
      }
      
      private performExpensiveTransformation(value: string, config: TransformConfig): string {
        // Expensive operation here
        return value;
      }
    }
    @valueConverter('nullSafe')
    export class NullSafeConverter {
      toView(value: unknown, fallback = ''): string {
        if (value == null || value === '') return String(fallback);
        return String(value);
      }
    }
    @valueConverter('debug')
    export class DebugConverter {
      toView(value: unknown, label = 'Debug'): unknown {
        console.log(`${label}:`, value);
        return value;
      }
    }
    <span>${complexData | debug:'User Data' | format}</span>
    import { valueConverter, ISignaler, resolve } from 'aurelia';
    
    @valueConverter('localeDate')
    export class LocaleDateConverter {
      private signaler = resolve(ISignaler);
      public readonly signals = ['locale-changed', 'timezone-changed'];
    
      toView(value: string, locale?: string) {
        const currentLocale = locale || this.getCurrentLocale();
        return new Intl.DateTimeFormat(currentLocale, {
          month: 'long',
          day: 'numeric',
          year: 'numeric'
        }).format(new Date(value));
      }
    
      private getCurrentLocale() {
        // Get current locale from your app state
        return 'en-US';
      }
    }
    import { resolve, ISignaler } from 'aurelia';
    
    export class LocaleService {
      private signaler = resolve(ISignaler);
    
      changeLocale(newLocale: string) {
        // Update your locale
        this.signaler.dispatchSignal('locale-changed');
      }
    }
    <!-- Automatically updates when locale changes -->
    <p>${message | t}</p> <!-- Translation -->
    <p>${date | df}</p> <!-- Date format -->
    <p>${number | nf}</p> <!-- Number format -->
    <p>${date | rt}</p> <!-- Relative time -->
    import { ISanitizer } from 'aurelia';
    
    // You must register your own sanitizer implementation
    export class MyHtmlSanitizer implements ISanitizer {
      sanitize(input: string): string {
        // Implement your sanitization logic
        // You might use a library like DOMPurify here
        return input; // This is just an example - implement proper sanitization!
      }
    }
    
    // Register it in your main configuration
    container.register(singletonRegistration(ISanitizer, MyHtmlSanitizer));
    <div innerHTML.bind="userContent | sanitize"></div>
    <!-- Translation -->
    <p>${'welcome.message' | t}</p>
    <p>${'welcome.user' | t:{ name: userName }}</p>
    
    <!-- Date formatting -->
    <p>${date | df}</p>
    <p>${date | df:{ year: 'numeric', month: 'long' }}</p>
    
    <!-- Number formatting -->
    <p>${price | nf:{ style: 'currency', currency: 'USD' }}</p>
    
    <!-- Relative time -->
    <p>${timestamp | rt}</p>
    import { valueConverter } from 'aurelia';
    
    @valueConverter('date')
    export class FormatDate {
      toView(value: string, locale = 'en-US') {
        const date = new Date(value);
        if (Number.isNaN(date.valueOf())) {
          return 'Invalid Date';
        }
        return new Intl.DateTimeFormat(locale, {
          month: 'long',
          day: 'numeric',
          year: 'numeric',
          timeZone: 'UTC'
        }).format(date);
      }
    }
    <import from="./date-value-converter" />
    <p>${'2021-06-22T09:21:26.699Z' | date}</p>
    <p>${'2021-06-22T09:21:26.699Z' | date:'en-GB'}</p>
    @valueConverter('fileSize')
    export class FileSizeConverter {
      private units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
      
      toView(bytes: number | null | undefined, precision = 1): string {
        if (bytes == null || bytes === 0) return '0 B';
        if (bytes < 0) return 'Invalid size';
        
        const unitIndex = Math.floor(Math.log(bytes) / Math.log(1024));
        const value = bytes / Math.pow(1024, unitIndex);
        const unit = this.units[unitIndex] || 'XX';
        
        return `${value.toFixed(precision)} ${unit}`;
      }
    }
    <span>File size: ${fileSize | fileSize:2}</span>
    <!-- Output: "File size: 1.24 MB" -->
    @valueConverter('timeAgo')
    export class TimeAgoConverter {
      readonly signals = ['time-tick'];
      
      private units = [
        { name: 'year', seconds: 31536000 },
        { name: 'month', seconds: 2592000 },
        { name: 'week', seconds: 604800 },
        { name: 'day', seconds: 86400 },
        { name: 'hour', seconds: 3600 },
        { name: 'minute', seconds: 60 },
        { name: 'second', seconds: 1 }
      ];
    
      toView(date: Date | string | number | null | undefined): string {
        if (!date) return '';
        
        const now = Date.now();
        const targetTime = new Date(date).getTime();
        const diffInSeconds = Math.floor((now - targetTime) / 1000);
        
        if (diffInSeconds < 0) return 'in the future';
        if (diffInSeconds < 30) return 'just now';
        
        for (const unit of this.units) {
          const count = Math.floor(diffInSeconds / unit.seconds);
          if (count >= 1) {
            return `${count} ${unit.name}${count > 1 ? 's' : ''} ago`;
          }
        }
        
        return 'just now';
      }
    }
    @valueConverter('truncate')
    export class TruncateConverter {
      readonly withContext = true;
      
      toView(
        text: string | null | undefined, 
        caller: { binding: any, source: unknown }, 
        maxLength = 50, 
        suffix = '...'
      ): string {
        if (!text || text.length <= maxLength) return text || '';
        
        const truncated = text.substring(0, maxLength - suffix.length) + suffix;
        
        // Add full text as tooltip if binding target supports it
        if (caller.binding?.target && 'title' in caller.binding.target) {
          caller.binding.target.title = text;
        }
        
        return truncated;
      }
    }
    import { marked } from 'marked';
    
    @valueConverter('markdown')
    export class MarkdownConverter {
      private renderer = new marked.Renderer();
      
      constructor() {
        // Configure marked for security
        marked.setOptions({
          breaks: true,
          sanitize: true
        });
      }
      
      toView(markdown: string | null | undefined): string {
        if (!markdown) return '';
        
        try {
          return marked(markdown);
        } catch (error) {
          console.error('MarkdownConverter error:', error);
          return markdown; // Fallback to original text
        }
      }
    }
    @valueConverter('highlight')
    export class HighlightConverter {
      toView(
        text: string | null | undefined, 
        searchTerm: string | null | undefined, 
        className = 'highlight'
      ): string {
        if (!text || !searchTerm) return text || '';
        
        const regex = new RegExp(`(${this.escapeRegex(searchTerm)})`, 'gi');
        return text.replace(regex, `<span class="${className}">$1</span>`);
      }
      
      private escapeRegex(str: string): string {
        return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      }
    }
    interface SortConfig<T = unknown> {
      property?: keyof T;
      direction?: 'asc' | 'desc';
      compareFunction?: (a: T, b: T) => number;
      caseSensitive?: boolean;
    }
    
    @valueConverter('sort')
    export class SortConverter {
      toView<T>(
        array: T[] | null | undefined, 
        config: SortConfig<T> | string = {}
      ): T[] {
        if (!Array.isArray(array)) return [];
        
        // Handle string property shorthand
        const sortConfig = typeof config === 'string' 
          ? { property: config as keyof T } 
          : config;
          
        const { 
          property, 
          direction = 'asc', 
          compareFunction, 
          caseSensitive = true 
        } = sortConfig;
        
        const sorted = [...array];
        
        if (compareFunction) {
          sorted.sort(compareFunction);
        } else if (property) {
          sorted.sort((a, b) => {
            let aVal = a[property] as any;
            let bVal = b[property] as any;
            
            // Handle string case sensitivity
            if (typeof aVal === 'string' && typeof bVal === 'string' && !caseSensitive) {
              aVal = aVal.toLowerCase();
              bVal = bVal.toLowerCase();
            }
            
            if (aVal < bVal) return direction === 'asc' ? -1 : 1;
            if (aVal > bVal) return direction === 'asc' ? 1 : -1;
            return 0;
          });
        }
        
        return direction === 'desc' ? sorted.reverse() : sorted;
      }
    }
    @valueConverter('color')
    export class ColorConverter {
      toView(
        color: string | null | undefined, 
        format: 'hex' | 'rgb' | 'hsl' = 'hex'
      ): string {
        if (!color) return '';
        
        try {
          const rgb = this.parseColor(color);
          
          switch (format) {
            case 'rgb':
              return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
            case 'hsl':
              return this.rgbToHsl(rgb);
            case 'hex':
            default:
              return this.rgbToHex(rgb);
          }
        } catch (error) {
          console.warn('ColorConverter: Invalid color format', color);
          return color;
        }
      }
      
      private parseColor(color: string): { r: number; g: number; b: number } {
        // Implementation for parsing various color formats
        // This is simplified - you'd want a more robust color parsing library
        if (color.startsWith('#')) {
          const hex = color.slice(1);
          return {
            r: parseInt(hex.slice(0, 2), 16),
            g: parseInt(hex.slice(2, 4), 16),
            b: parseInt(hex.slice(4, 6), 16)
          };
        }
        throw new Error(`Unsupported color format: ${color}`);
      }
      
      private rgbToHex({ r, g, b }: { r: number; g: number; b: number }): string {
        return `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}`;
      }
      
      private rgbToHsl({ r, g, b }: { r: number; g: number; b: number }): string {
        // HSL conversion logic
        r /= 255; g /= 255; b /= 255;
        const max = Math.max(r, g, b), min = Math.min(r, g, b);
        let h = 0, s = 0, l = (max + min) / 2;
        
        if (max !== min) {
          const d = max - min;
          s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
          switch (max) {
            case r: h = (g - b) / d + (g < b ? 6 : 0); break;
            case g: h = (b - r) / d + 2; break;
            case b: h = (r - g) / d + 4; break;
          }
          h /= 6;
        }
        
        return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`;
      }
    }
    @valueConverter('expensiveFormat')
    export class ExpensiveFormatConverter {
      private cache = new Map<string, string>();
      private maxCacheSize = 1000;
      
      toView(value: string, config: ComplexConfig): string {
        const cacheKey = this.createCacheKey(value, config);
        
        if (this.cache.has(cacheKey)) {
          return this.cache.get(cacheKey)!;
        }
        
        const result = this.performExpensiveTransformation(value, config);
        
        // Implement LRU cache behavior
        if (this.cache.size >= this.maxCacheSize) {
          const firstKey = this.cache.keys().next().value;
          this.cache.delete(firstKey);
        }
        
        this.cache.set(cacheKey, result);
        return result;
      }
      
      private createCacheKey(value: string, config: ComplexConfig): string {
        return `${value}:${JSON.stringify(config)}`;
      }
    }
    @valueConverter('lazyTransform')
    export class LazyTransformConverter {
      private transformPromises = new WeakMap<object, Promise<string>>();
      
      toView(data: ComplexData): string | Promise<string> {
        if (this.transformPromises.has(data)) {
          return this.transformPromises.get(data)!;
        }
        
        const promise = this.performAsyncTransformation(data);
        this.transformPromises.set(data, promise);
        
        return promise;
      }
      
      private async performAsyncTransformation(data: ComplexData): Promise<string> {
        // Expensive async operation
        return 'transformed result';
      }
    }
    @valueConverter('memoryAware')
    export class MemoryAwareConverter {
      private observers = new Set<() => void>();
      private cache = new Map();
      
      toView(value: string): string {
        // Clean up old observers
        this.cleanup();
        
        // Your transformation logic
        return this.transform(value);
      }
      
      private cleanup(): void {
        // Dispose observers and clear caches periodically
        if (this.observers.size > 100) {
          this.observers.forEach(cleanup => cleanup());
          this.observers.clear();
          this.cache.clear();
        }
      }
    }
    @valueConverter('profiled')
    export class ProfiledConverter {
      private performanceMetrics = new Map<string, number>();
      
      toView(value: string, operation: string): string {
        const start = performance.now();
        const result = this.performTransformation(value, operation);
        const duration = performance.now() - start;
        
        // Track performance metrics
        const key = `${operation}-${typeof value}`;
        const existing = this.performanceMetrics.get(key) || 0;
        this.performanceMetrics.set(key, (existing + duration) / 2);
        
        return result;
      }
      
      getPerformanceReport(): Record<string, number> {
        return Object.fromEntries(this.performanceMetrics);
      }
    }
    // ✅ Good - focused on one transformation
    @valueConverter('capitalize')
    export class CapitalizeConverter {
      toView(text: string): string {
        return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
      }
    }
    
    // ❌ Bad - doing too many things
    @valueConverter('formatEverything')
    export class FormatEverythingConverter {
      toView(value: unknown, type: string): string {
        // This converter tries to handle too many different cases
      }
    }
    // ✅ Good - no side effects
    @valueConverter('multiply')
    export class MultiplyConverter {
      toView(value: number, factor: number): number {
        return value * factor;
      }
    }
    
    // ❌ Bad - side effects
    @valueConverter('logAndMultiply')  
    export class LogAndMultiplyConverter {
      toView(value: number, factor: number): number {
        console.log('Processing:', value); // Side effect
        this.updateGlobalCounter(); // Side effect
        return value * factor;
      }
    }
    interface DateFormatOptions {
      locale?: string;
      dateStyle?: 'full' | 'long' | 'medium' | 'short';
      timeStyle?: 'full' | 'long' | 'medium' | 'short';
    }
    
    @valueConverter('dateFormat')
    export class DateFormatConverter {
      toView(
        date: Date | string | number | null | undefined,
        options: DateFormatOptions = {}
      ): string {
        if (!date) return '';
        
        const dateObj = new Date(date);
        if (isNaN(dateObj.getTime())) return 'Invalid Date';
        
        const { locale = 'en-US', ...formatOptions } = options;
        return new Intl.DateTimeFormat(locale, formatOptions).format(dateObj);
      }
    }
    interface Filterable {
      [key: string]: unknown;
    }
    
    @valueConverter('filter')
    export class FilterConverter {
      toView<T extends Filterable>(
        items: T[] | null | undefined,
        predicate: (item: T) => boolean
      ): T[] {
        if (!Array.isArray(items)) return [];
        return items.filter(predicate);
      }
    }
    @valueConverter('resilient')
    export class ResilientConverter {
      toView(value: unknown, options: ConversionOptions = {}): string {
        try {
          return this.performConversion(value, options);
        } catch (error) {
          // Log for debugging but don't break the UI
          console.warn(`ResilientConverter failed for value:`, value, error);
          
          // Return safe fallback
          return options.fallback || String(value) || '';
        }
      }
      
      private performConversion(value: unknown, options: ConversionOptions): string {
        // Potentially throwing conversion logic
        throw new Error('Conversion failed');
      }
    }
    describe('CurrencyConverter', () => {
      let converter: CurrencyConverter;
      
      beforeEach(() => {
        converter = new CurrencyConverter();
      });
      
      it('should format USD currency correctly', () => {
        const result = converter.toView(1234.56, { locale: 'en-US', currency: 'USD' });
        expect(result).toBe('$1,234.56');
      });
      
      it('should handle null values gracefully', () => {
        const result = converter.toView(null);
        expect(result).toBe('');
      });
      
      it('should parse formatted currency back to number', () => {
        const result = converter.fromView('$1,234.56');
        expect(result).toBe(1234.56);
      });
    });
    <!-- Translation with parameters -->
    <span>${'welcome.message' | t:{ name: userName }}</span>
    
    <!-- Date formatting -->
    <span>${createdDate | df:{ dateStyle: 'full', timeStyle: 'short' }}</span>
    
    <!-- Currency formatting -->
    <span>${price | nf:{ style: 'currency', currency: 'EUR' }}</span>
    
    <!-- Relative time -->
    <span>Posted ${postDate | rt}</span>
    ,
    InterpolationPartBinding
    , etc.

    `${price

    rt

    Relative time

    None

    `${timestamp

    Logo
    Logo
    Logo
    Logo
    Logo
    Logo

    Custom attributes

    Learn how to build and enhance Aurelia 2 custom attributes, including advanced configuration, binding strategies, and accessing the host element.

    Custom attributes in Aurelia empower you to extend and decorate standard HTML elements by embedding custom behavior and presentation logic. They allow you to wrap or integrate existing HTML plugins and libraries, or simply enhance your UI components with additional dynamic functionality. This guide provides a comprehensive overview—from basic usage to advanced techniques—to help you leverage custom attributes effectively in your Aurelia 2 projects.


    Table of Contents


    Introduction

    Custom attributes are one of the core building blocks in Aurelia 2. Similar to components, they encapsulate behavior and style, but are applied as attributes to existing DOM elements. This makes them especially useful for:

    • Decorating elements with additional styling or behavior.

    • Wrapping third-party libraries that expect to control their own DOM structure.

    • Creating reusable logic that enhances multiple elements across your application.

    • Creating template controllers that control the rendering of content.


    Creating a Basic Custom Attribute

    At its simplest, a custom attribute is defined as a class that enhances an element. Consider this minimal example:

    When you apply a similar pattern using CustomElement instead, you are defining a component. Custom attributes are a more primitive (yet powerful) way to extend behavior without wrapping the entire element in a component.

    Example: Red Square Attribute

    This custom attribute adds a fixed size and a red background to any element it is applied to:

    Usage in HTML:

    The <import> tag ensures that Aurelia's dependency injection is aware of your custom attribute. When applied, the <div> will render with the specified styles.


    Custom Attribute Definition Approaches

    Aurelia 2 provides multiple approaches for defining custom attributes. For most user scenarios, you'll use either the convention-based or decorator-based approach:

    Convention-Based Approach

    Classes ending with CustomAttribute are automatically recognized as custom attributes:

    The attribute name is derived from the class name (red-square in this case).

    Decorator-Based Approach (Recommended)

    Use the @customAttribute decorator for explicit control and better IDE support:

    Static Definition Approach (Framework Internal)

    For completeness, the framework also supports defining attributes using a static $au property. This approach is primarily used by the framework itself to avoid conventions and decorators, but is available if needed:

    When to use each approach:

    • Convention-based: Quick prototyping, simple attributes where the class name matches desired attribute name

    • Decorator-based: Production code, when you need explicit control over naming, aliases, or other configuration


    Explicit Custom Attributes

    To gain finer control over your attribute's name and configuration, Aurelia provides the @customAttribute decorator. This lets you explicitly define the attribute name and even set up aliases.

    Explicit Attribute Naming

    By default, the class name might be used to infer the attribute name. However, you can explicitly set a custom name:

    Attribute Aliases

    You can define one or more aliases for your custom attribute. This allows consumers of your attribute flexibility in naming:

    Now the attribute can be used interchangeably using any of the registered names:


    Single Value Binding

    For simple cases, you might want to pass a single value to your custom attribute without explicitly declaring a bindable property. Aurelia will automatically populate the value property if a value is provided.

    Usage:

    To further handle changes in the value over time, you can define the property as bindable:

    Usage with dynamic binding:


    Bindable Properties and Change Detection

    Custom attributes often need to be configurable. Using the @bindable decorator, you can allow users to pass in parameters that change the behavior or style dynamically.

    Binding Modes

    Bindable properties support different binding modes that determine how data flows:

    Available binding modes:

    • BindingMode.toView (default): Data flows from view model to view

    • BindingMode.fromView: Data flows from view to view model

    • BindingMode.twoWay: Data flows both ways

    Primary Bindable Property

    You can mark one property as primary, allowing simpler binding syntax:

    With a primary property defined, you can bind directly:

    Bindable Interceptors and Type Coercion

    You can intercept and transform values being set on bindable properties using the set option, or leverage Aurelia's built-in type coercion system:

    Built-in Type Coercers:

    • Number: Converts strings to numbers ("123" → 123)

    • String: Converts values to strings (123 → "123")

    Advanced Coercion Example:


    Options Binding for Multiple Properties

    When you have more than one bindable property, you can use options binding syntax to bind multiple properties at once. This powerful syntax supports complex expressions, binding behaviors, and value converters:

    Basic Options Binding

    Advanced Options Binding Features

    Escaping Special Characters

    Use backslashes to escape colons in URLs or other values:

    Disabling Multi-Binding Parsing

    For attributes that need to handle complex strings without parsing:


    Advanced Bindable Configuration

    You can also define bindables in the static definition or decorator:

    Or using the static $au approach:


    Lifecycle Hooks

    Custom attributes support a comprehensive set of lifecycle hooks that allow you to run code at different stages of their existence:

    • created(controller): Called after the attribute instance is created

    • binding(initiator, parent): Called before data binding begins

    • bind(): Called when data binding begins (simplified version)

    Example: Using Lifecycle Hooks


    Aggregated Change Callbacks

    Custom attributes provide powerful batching capabilities for handling multiple property changes efficiently:


    Accessing the Host Element

    A key aspect of custom attributes is that they work directly on DOM elements. To manipulate these elements (e.g., updating styles or initializing plugins), you need to access the host element. Aurelia provides a safe way to do this using dependency injection with INode.

    Note: While you can also use resolve(Element) or resolve(HTMLElement), using INode is safer in environments where global DOM constructors might not be available (such as Node.js).


    Finding Related Custom Attributes

    In complex UIs, you might have multiple custom attributes working together (for example, a dropdown with associated toggle buttons). Aurelia offers the CustomAttribute.closest function to traverse the DOM and locate a related custom attribute. This function can search by attribute name or by constructor.

    Example: Searching by Attribute Name

    Example: Searching by Constructor (Type-Safe)

    If you want to search based on the attribute's constructor (for stronger typing), you can do so:

    Practical Use Case: Coordinated Form Validation

    Usage:

    Important Notes

    • DOM Traversal: closest() walks up the DOM tree, checking each ancestor element

    • Multiple Matches: Returns the first (closest) matching attribute found

    • Error Handling: Throws an error if searching by constructor for a class without an attribute definition


    Template Controller Custom Attributes

    Custom attributes can also function as template controllers, which control the rendering of content. Template controllers are similar to built-in directives like if.bind and repeat.for.

    Creating a Template Controller

    Usage:

    You can also use the static definition approach:


    Advanced Configuration Options

    Custom attributes support several advanced configuration options:

    No Multi-Bindings

    By default, custom attributes support multiple bindings (attr="prop1: value1; prop2: value2"). You can disable this:

    Dependencies

    You can specify dependencies that should be registered when the attribute is used:

    Container Strategy (Template Controllers Only)

    For template controller custom attributes, you can specify the container strategy to control service isolation:

    Container Strategy Options:

    • 'reuse' (default): Child views share the parent's container

      • More memory efficient

      • Services are singleton across parent and child views

      • Faster view creation

    When to Use Container Isolation:

    Definition Metadata Reference

    Decorators and conventions eventually funnel into a PartialCustomAttributeDefinition, defined in @aurelia/runtime-html. Knowing every field on that definition unlocks advanced behaviors without sprinkling ad-hoc logic through your class. The table below summarizes the metadata you can provide (either via decorators or by calling CustomAttribute.define()/CustomAttributeDefinition.create() yourself):

    Property
    Type
    Description

    Inspecting definitions at runtime

    CustomAttributeDefinition.getDefinition(MyAttribute) returns the normalized definition object—including inferred defaults and decorator metadata. That makes it simple to write tooling or plugin code that reacts to attribute settings:

    Creating attributes without decorators

    When you need to generate attributes dynamically (for example, inside a plugin) call CustomAttribute.define or CustomAttributeDefinition.create with a PartialCustomAttributeDefinition:

    Because all of these options live on the definition, you keep your constructor and lifecycle hooks focused on runtime behavior while the metadata decides how the attribute integrates with the templating pipeline.


    Watch Integration

    Custom attributes can integrate with Aurelia's @watch decorator for advanced property observation:


    Integrating Third-Party Libraries

    Often, you'll want to incorporate functionality from third-party libraries—such as sliders, date pickers, or custom UI components—into your Aurelia applications. Custom attributes provide an excellent way to encapsulate the integration logic, ensuring that the third-party library initializes, updates, and cleans up properly within Aurelia's lifecycle.

    When to Use Custom Attributes for Integration

    • DOM Manipulation: Many libraries require direct access to the DOM element for initialization.

    • Lifecycle Management: You can leverage Aurelia's lifecycle hooks (attached() and detached()) to manage resource allocation and cleanup.

    • Dynamic Updates: With bindable properties, you can pass configuration options to the library and update it reactively when those options change.

    Example: Integrating a Hypothetical Slider Library

    Consider a third-party slider library called AwesomeSlider that initializes a slider on a given DOM element. Below is an example of how to wrap it in a custom attribute.

    In place of our hypothetical AwesomeSlider library, you can use any third-party library that requires DOM manipulation such as jQuery plugins, D3.js, or even custom UI components.


    Best Practices

    Separation of Concerns

    Keep your custom attribute logic focused on enhancing the host element, and avoid heavy business logic. Custom attributes should be presentational or behavioral enhancements, not data processing units.

    Performance

    • Minimize DOM manipulations: Cache style properties and batch updates when possible

    • Use propertiesChanged: For multiple property changes, batch updates to reduce DOM thrashing

    • Lifecycle hook timing: Use appropriate hooks for initialization

    Memory Management

    • Clean up event listeners: Always remove event listeners to prevent memory leaks

    • Dispose third-party instances: Call proper cleanup methods for external libraries

    • Weak references: Use WeakMap/WeakSet for object references when appropriate

    Error Handling

    • Graceful degradation: Handle initialization failures gracefully

    • Validation: Validate bindable property values

    • Logging: Use Aurelia's logging system for debugging

    Testing

    Write comprehensive unit tests covering lifecycle hooks, property changes, and edge cases:

    Documentation and Maintainability

    • Document public APIs: Clearly document bindable properties and their expected types

    • Use meaningful names: Choose descriptive names for attributes and properties

    • Provide usage examples: Include HTML usage examples in comments

    • Type everything: Use strong TypeScript typing for better IDE support

    Type Safety Best Practices

    Advanced Features Summary

    Computed Bindables with Getters

    Custom attributes support getter-based bindables for computed properties:

    Bindable Inheritance

    Bindable properties properly inherit from parent classes:

    Error Handling and Lifecycle Management

    Custom Change Callbacks

    Static definition: Advanced scenarios, framework extensions, or when you need to avoid decorators for tooling reasons

    BindingMode.oneTime: Data is set once and never updated
    Boolean: Converts values to booleans ("true" → true, "" → false)
  • BigInt: Converts to BigInt values

  • Custom functions: Any function that accepts a value and returns a transformed value

  • bound(initiator, parent): Called after data binding is complete
  • attaching(initiator, parent): Called before the element is attached to the DOM

  • attached(initiator): Called after the element is attached to the DOM

  • detaching(initiator, parent): Called before the element is detached from the DOM

  • unbinding(initiator, parent): Called before data binding is removed

  • unbind(): Called when data binding is removed (simplified version)

  • Performance: Efficient DOM traversal, but cache results if called frequently
  • Type Safety: Constructor-based searches provide better TypeScript support

  • 'new': Creates a new container for child views

    • Provides service isolation

    • Each child view gets its own service instances

    • Useful for plugin systems or complex nested scenarios

  • isTemplateController

    boolean

    Marks the attribute as a template controller so Aurelia replaces the host element with the controller’s view.

    noMultiBindings

    boolean

    Treats the attribute value as a single literal string instead of prop: value pairs. Useful for URLs and DSL-like syntaxes.

    watches

    IWatchDefinition[]

    Registers @watch entries without decorators—great for framework-level attributes or generated code.

    dependencies

    Key[]

    Additional registrations to install when the attribute definition is added to a container (for example, helper services).

    containerStrategy

    'reuse' | 'new'

    Controls whether template controllers reuse the parent container or spin up an isolated child container.

    constructor(): Basic setup, non-DOM operations
  • attached(): DOM-dependent initialization, third-party library setup

  • detaching(): Cleanup before DOM removal

  • name

    string

    The canonical attribute name. When omitted, Aurelia infers it from the class name.

    aliases

    string[]

    Additional attribute names that should map to the same implementation. Use when you need both awesome-slider and awesomeSlider.

    bindables

    Record<string, PartialBindableDefinition> or array

    Declares bindable inputs. You can mix shorthand strings with full objects { name, mode, callback, attribute }.

    defaultBindingMode

    BindingMode or string

    Overrides the default for all bindables (unless a bindable explicitly sets its own mode).

    Introduction
    Creating a Basic Custom Attribute
    Custom Attribute Definition Approaches
    Convention-Based Approach
    Decorator-Based Approach
    Static Definition Approach
    Explicit Custom Attributes
    Explicit Attribute Naming
    Attribute Aliases
    Single Value Binding
    Bindable Properties and Change Detection
    Binding Modes
    Primary Bindable Property
    Bindable Interceptors
    Options Binding for Multiple Properties
    Advanced Bindable Configuration
    Lifecycle Hooks
    Aggregated Change Callbacks
    Accessing the Host Element
    Finding Related Custom Attributes
    Template Controller Custom Attributes
    Advanced Configuration Options
    Definition Metadata Reference
    Watch Integration
    Integrating Third-Party Libraries
    Best Practices
    export class CustomPropertyCustomAttribute {
      // Custom logic can be added here
    }
    import { INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    export class RedSquareCustomAttribute {
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
    
      constructor() {
        // Set fixed dimensions and a red background on initialization
        this.element.style.width = this.element.style.height = '100px';
        this.element.style.backgroundColor = 'red';
      }
    }
    <import from="./red-square"></import>
    
    <div red-square></div>
    import { INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    export class RedSquareCustomAttribute {
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
    
      constructor() {
        this.element.style.width = this.element.style.height = '100px';
        this.element.style.backgroundColor = 'red';
      }
    }
    import { customAttribute, INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute({ name: 'red-square' })
    export class RedSquare {
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
    
      constructor() {
        this.element.style.width = this.element.style.height = '100px';
        this.element.style.backgroundColor = 'red';
      }
    }
    import { INode } from '@aurelia/runtime-html';
    import { resolve, type CustomAttributeStaticAuDefinition } from '@aurelia/kernel';
    
    export class RedSquare {
      public static readonly $au: CustomAttributeStaticAuDefinition = {
        type: 'custom-attribute',
        name: 'red-square'
      };
    
      private element: HTMLElement = resolve(INode) as HTMLElement;
    
      constructor() {
        this.element.style.width = this.element.style.height = '100px';
        this.element.style.backgroundColor = 'red';
      }
    }
    import { customAttribute, INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute({ name: 'red-square' })
    export class RedSquare {
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
    
      constructor() {
        this.element.style.width = this.element.style.height = '100px';
        this.element.style.backgroundColor = 'red';
      }
    }
    import { customAttribute, INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute({ name: 'red-square', aliases: ['redify', 'redbox'] })
    export class RedSquare {
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
    
      constructor() {
        this.element.style.width = this.element.style.height = '100px';
        this.element.style.backgroundColor = 'red';
      }
    }
    <div red-square></div>
    <div redify></div>
    <div redbox></div>
    import { INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    export class HighlightCustomAttribute {
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
      public value: string;
    
      constructor() {
        // Apply default highlighting style
        this.element.style.backgroundColor = 'yellow';
        this.element.style.padding = '2px 4px';
        this.element.style.borderRadius = '3px';
      }
    
      bind() {
        // Override default color if a specific color is provided
        if (this.value) {
          this.element.style.backgroundColor = this.value;
        }
      }
    }
    <import from="./highlight"></import>
    
    <!-- Uses default yellow highlighting -->
    <span highlight>Important text</span>
    
    <!-- Uses custom color -->
    <span highlight="lightblue">Custom highlighted text</span>
    import { bindable, INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    export class HighlightCustomAttribute {
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
    
      @bindable() public value: string;
    
      constructor() {
        // Apply default highlighting style
        this.element.style.backgroundColor = 'yellow';
        this.element.style.padding = '2px 4px';
        this.element.style.borderRadius = '3px';
        this.element.style.transition = 'background-color 0.3s ease';
      }
    
      bound() {
        if (this.value) {
          this.element.style.backgroundColor = this.value;
        }
      }
    
      valueChanged(newValue: string, oldValue: string) {
        this.element.style.backgroundColor = newValue || 'yellow';
      }
    }
    <import from="./highlight"></import>
    
    <!-- Color changes reactively based on view model property -->
    <span highlight.bind="selectedColor">Dynamic highlighting</span>
    import { bindable, INode, BindingMode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    export class InputWrapperCustomAttribute {
      @bindable({ mode: BindingMode.twoWay }) public value: string = '';
      @bindable({ mode: BindingMode.toView }) public placeholder: string = '';
      @bindable({ mode: BindingMode.fromView }) public isValid: boolean = true;
      @bindable({ mode: BindingMode.oneTime }) public label: string = '';
    
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
    
      // ... implementation
    }
    import { bindable, INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    export class ColorSquareCustomAttribute {
      @bindable({ primary: true }) public color: string = 'red';
      @bindable() public size: string = '100px';
    
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
    
      constructor() {
        this.applyStyles();
      }
    
      bound() {
        this.applyStyles();
      }
    
      colorChanged(newColor: string) {
        this.element.style.backgroundColor = newColor;
      }
    
      sizeChanged(newSize: string) {
        this.element.style.width = this.element.style.height = newSize;
      }
    
      private applyStyles() {
        this.element.style.width = this.element.style.height = this.size;
        this.element.style.backgroundColor = this.color;
      }
    }
    <import from="./color-square"></import>
    
    <!-- Using a literal value -->
    <div color-square="blue"></div>
    
    <!-- Or binding the value dynamically -->
    <div color-square.bind="myColour"></div>
    import { bindable, INode, coercer } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    export class ValidatedInputCustomAttribute {
      // Custom value transformation
      @bindable({
        set: (value: string) => value?.trim().toLowerCase()
      }) public email: string = '';
    
      // Range clamping
      @bindable({
        set: (value: number) => Math.max(0, Math.min(100, value))
      }) public progress: number = 0;
    
      // Built-in type coercion (automatic number conversion)
      @bindable() public count: number = 0;
    
      // Explicit coercion with nullable handling
      @bindable({ 
        type: Number, 
        nullable: false  // Won't coerce null/undefined to 0
      }) public price: number;
    
      // Custom coercer function
      @bindable({ 
        set: coercer(Boolean) // Converts any value to boolean
      }) public isActive: boolean;
    
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
    }
    @customAttribute('typed-inputs')
    export class TypedInputsCustomAttribute {
      // Date parsing coercion
      @bindable({
        set: (value: string | Date) => {
          if (typeof value === 'string') {
            const date = new Date(value);
            return isNaN(date.getTime()) ? null : date;
          }
          return value;
        }
      }) public startDate: Date | null;
    
      // Array coercion from comma-separated strings
      @bindable({
        set: (value: string | string[]) => {
          return typeof value === 'string' 
            ? value.split(',').map(s => s.trim())
            : value;
        }
      }) public tags: string[] = [];
    }
    
    ### Custom Change Callbacks
    
    You can specify custom callback names for change handlers:
    
    ```typescript
    import { bindable } from '@aurelia/runtime-html';
    
    export class DataVisualizationCustomAttribute {
      @bindable({ callback: 'onDataUpdate' }) public dataset: any[] = [];
      @bindable({ callback: 'onConfigChange' }) public config: any = {};
    
      onDataUpdate(newData: any[], oldData: any[]) {
        // Handle data changes
        this.redrawChart();
      }
    
      onConfigChange(newConfig: any, oldConfig: any) {
        // Handle configuration changes
        this.updateChartSettings();
      }
    }
    <import from="./color-square"></import>
    
    <!-- Basic property binding -->
    <div color-square="color.bind: myColor; size.bind: mySize;"></div>
    
    <!-- Mix of binding modes -->
    <div advanced-input="
      value.two-way: inputValue; 
      placeholder.to-view: placeholderText; 
      maxLength.one-time: 50;
    "></div>
    <!-- Value converters and binding behaviors -->
    <div chart-widget="
      data.bind: chartData | sortBy:'date' & debounce:500;
      config.bind: chartConfig;
      theme.bind: currentTheme;
    "></div>
    
    <!-- Complex expressions -->
    <div validator="
      rules.bind: validationRules;
      isEnabled.bind: userRole === 'admin' || isOwner;
      onError.bind: errors => handleValidationErrors(errors);
    "></div>
    
    <!-- Object literals and arrays -->
    <div data-table="
      columns.bind: [
        { field: 'name', title: 'Name' },
        { field: 'email', title: 'Email' }
      ];
      options.bind: { 
        pageSize: 10, 
        sortable: true,
        filterable: currentUser.isAdmin
      };
    "></div>
    <!-- Escape colons in URLs -->
    <div url-handler="baseUrl: http\://example.com\:8080/api;"></div>
    
    <!-- Alternative: use binding for complex values -->
    <div url-handler="baseUrl.bind: apiBaseUrl;"></div>
    @customAttribute({
      name: 'sql-query',
      noMultiBindings: true  // Treats entire value as single string
    })
    export class SqlQueryCustomAttribute {
      public value: string; // Receives: "SELECT * FROM users WHERE role: 'admin'"
    }
    <!-- This won't be parsed as bindings due to noMultiBindings: true -->
    <div sql-query="SELECT * FROM users WHERE role: 'admin'"></div>
    import { customAttribute, INode, BindingMode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute({
      name: 'advanced-input',
      bindables: {
        value: { mode: BindingMode.twoWay, primary: true },
        placeholder: { mode: BindingMode.toView },
        validation: { callback: 'validateInput' }
      }
    })
    export class AdvancedInputCustomAttribute {
      public value: string;
      public placeholder: string;
      public validation: any;
    
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
    
      validateInput(newValidation: any, oldValidation: any) {
        // Handle validation changes
      }
    }
    import { INode, BindingMode } from '@aurelia/runtime-html';
    import { resolve, type CustomAttributeStaticAuDefinition } from '@aurelia/kernel';
    
    export class AdvancedInput {
      public static readonly $au: CustomAttributeStaticAuDefinition = {
        type: 'custom-attribute',
        name: 'advanced-input',
        bindables: {
          value: { mode: BindingMode.twoWay, primary: true },
          placeholder: { mode: BindingMode.toView },
          validation: { callback: 'validateInput' }
        }
      };
    
      public value: string;
      public placeholder: string;
      public validation: any;
    
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
    
      validateInput(newValidation: any, oldValidation: any) {
        // Handle validation changes
      }
    }
    import { bindable, INode, customAttribute, ICustomAttributeController, IHydratedController } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute({ name: 'lifecycle-demo' })
    export class LifecycleDemoCustomAttribute {
      @bindable() public value: string = '';
    
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
    
      created(controller: ICustomAttributeController) {
        // Called when the attribute instance is created
        console.log('Custom attribute created');
      }
    
      binding(initiator: IHydratedController, parent: IHydratedController) {
        // Called before binding begins - good for setup
        console.log('Starting to bind');
      }
    
      bind() {
        // Simplified binding hook - most commonly used
        this.applyInitialValue();
      }
    
      bound(initiator: IHydratedController, parent: IHydratedController) {
        // Called after binding is complete
        console.log('Binding complete');
      }
    
      attaching(initiator: IHydratedController, parent: IHydratedController) {
        // Called before DOM attachment
        console.log('About to attach to DOM');
      }
    
      attached(initiator: IHydratedController) {
        // Called after DOM attachment - good for DOM manipulation
        this.initializeThirdPartyLibrary();
      }
    
      valueChanged(newValue: string, oldValue: string) {
        // Called whenever the value changes
        this.updateDisplay();
      }
    
      detaching(initiator: IHydratedController, parent: IHydratedController) {
        // Called before DOM detachment - good for cleanup
        this.cleanupEventListeners();
      }
    
      unbinding(initiator: IHydratedController, parent: IHydratedController) {
        // Called before unbinding
        console.log('About to unbind');
      }
    
      unbind() {
        // Simplified unbinding hook - good for final cleanup
        this.finalCleanup();
      }
    
      private applyInitialValue() {
        this.element.textContent = this.value;
      }
    
      private updateDisplay() {
        this.element.textContent = this.value;
      }
    
      private initializeThirdPartyLibrary() {
        // Initialize any third-party libraries that need DOM access
      }
    
      private cleanupEventListeners() {
        // Remove event listeners to prevent memory leaks
      }
    
      private finalCleanup() {
        // Final cleanup before the attribute is destroyed
      }
    }
    import { bindable, customAttribute } from '@aurelia/runtime-html';
    
    @customAttribute('batch-processor')
    export class BatchProcessorCustomAttribute {
      @bindable() public prop1: string;
      @bindable() public prop2: number;
      @bindable() public prop3: boolean;
    
      // Called when any bindable property changes (batched until next microtask)
      // This is the most efficient way to handle multiple property changes
      propertiesChanged(changes: Record<string, { newValue: unknown; oldValue: unknown }>) {
        console.log('Properties changed:', changes);
        // Example output: { prop1: { newValue: 'new', oldValue: 'old' } }
    
        // Process all changes at once for better performance
        this.processBatchedChanges(changes);
      }
    
      // Called for every property change (immediate, not batched)
      // Note: Both propertiesChanged AND individual callbacks will fire
      propertyChanged(key: PropertyKey, newValue: unknown, oldValue: unknown) {
        console.log(`Property ${String(key)} changed from ${oldValue} to ${newValue}`);
      }
    
      // Individual property callbacks still work alongside aggregated callbacks
      prop1Changed(newValue: string, oldValue: string) {
        console.log('Prop1 individual callback');
      }
    
      private processBatchedChanges(changes: Record<string, any>) {
        // Efficiently handle multiple property changes
        // Example: Update a chart that depends on multiple data properties
        if ('prop1' in changes || 'prop2' in changes) {
          this.updateVisualization();
        }
      }
    }
    import { INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    export class RedSquareCustomAttribute {
      // Resolve the host element safely, even in Node.js environments
      private element: HTMLElement = resolve(INode) as HTMLElement;
    
      constructor() {
        // Now you can modify the host element directly
        this.element.style.width = this.element.style.height = '100px';
        this.element.style.backgroundColor = 'red';
      }
    }
    <div foo="1">
      <center>
        <div foo="3">
          <div bar="2"></div>
        </div>
      </center>
    </div>
    import { CustomAttribute, INode, customAttribute } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute('bar')
    export class Bar {
      private readonly host: HTMLElement = resolve(INode) as HTMLElement;
    
      binding() {
        // Find the closest ancestor that has the 'foo' custom attribute
        const closestFoo = CustomAttribute.closest(this.host, 'foo');
        if (closestFoo) {
          console.log('Found foo attribute:', closestFoo.viewModel);
          // Access the attribute's value
          console.log('Foo value:', closestFoo.viewModel.value); 
        }
      }
    }
    import { CustomAttribute, INode, customAttribute } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    import { Foo } from './foo';
    
    @customAttribute('bar')
    export class Bar {
      private readonly host: HTMLElement = resolve(INode) as HTMLElement;
    
      binding() {
        // Find the closest ancestor that is an instance of the Foo custom attribute
        const parentFoo = CustomAttribute.closest(this.host, Foo);
        if (parentFoo) {
          // parentFoo.viewModel is now strongly typed as Foo
          parentFoo.viewModel.someMethod();
          parentFoo.viewModel.someProperty = 'new value';
        }
      }
    }
    @customAttribute('form-section')
    export class FormSectionCustomAttribute {
      @bindable() public sectionName: string;
      @bindable() public isValid: boolean = true;
    
      validateSection(): boolean {
        // Section-specific validation logic
        return this.isValid;
      }
    }
    
    @customAttribute('form-field')
    export class FormFieldCustomAttribute {
      @bindable() public fieldName: string;
      @bindable() public required: boolean = false;
    
      private readonly host = resolve(INode) as HTMLElement;
    
      validate(): boolean {
        // Find the parent form section
        const section = CustomAttribute.closest(this.host, FormSectionCustomAttribute);
        
        if (section) {
          console.log(`Validating field ${this.fieldName} in section ${section.viewModel.sectionName}`);
          
          // Coordinate with parent section validation
          const isValid = this.performFieldValidation();
          section.viewModel.isValid = section.viewModel.isValid && isValid;
          
          return isValid;
        }
        
        return this.performFieldValidation();
      }
    
      private performFieldValidation(): boolean {
        // Field-specific validation logic
        return true;
      }
    }
    <form>
      <div form-section="section-name: personal; is-valid.two-way: personalSectionValid">
        <input form-field="field-name: firstName; required: true" />
        <input form-field="field-name: lastName; required: true" />
      </div>
      
      <div form-section="section-name: contact; is-valid.two-way: contactSectionValid">
        <input form-field="field-name: email; required: true" />
      </div>
    </form>
    import { templateController, IViewFactory, ISyntheticView, IRenderLocation, bindable, ICustomAttributeController } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @templateController('permission')
    export class PermissionTemplateController {
      @bindable() public userRole: string;
      @bindable() public requiredRole: string;
    
      public readonly $controller!: ICustomAttributeController<this>;
    
      private view: ISyntheticView;
      private readonly factory = resolve(IViewFactory);
      private readonly location = resolve(IRenderLocation);
    
      bound() {
        this.updateView();
      }
    
      userRoleChanged() {
        if (this.$controller.isActive) {
          this.updateView();
        }
      }
    
      requiredRoleChanged() {
        if (this.$controller.isActive) {
          this.updateView();
        }
      }
    
      private updateView() {
        const hasPermission = this.userRole === this.requiredRole;
    
        if (hasPermission) {
          if (!this.view) {
            this.view = this.factory.create().setLocation(this.location);
          }
          if (!this.view.isActive) {
            this.view.activate(this.view, this.$controller, this.$controller.scope);
          }
        } else {
          if (this.view?.isActive) {
            this.view.deactivate(this.view, this.$controller);
          }
        }
      }
    
      unbind() {
        if (this.view?.isActive) {
          this.view.deactivate(this.view, this.$controller);
        }
      }
    }
    <div permission="user-role.bind: currentUser.role; required-role: admin">
      <h2>Admin Panel</h2>
      <p>Only admins can see this content</p>
    </div>
    import { IViewFactory, ISyntheticView, IRenderLocation } from '@aurelia/runtime-html';
    import { resolve, type CustomAttributeStaticAuDefinition } from '@aurelia/kernel';
    
    export class PermissionTemplateController {
      public static readonly $au: CustomAttributeStaticAuDefinition = {
        type: 'custom-attribute',
        name: 'permission',
        isTemplateController: true,
        bindables: ['userRole', 'requiredRole']
      };
    
      // ... implementation same as above
    }
    import { customAttribute } from '@aurelia/runtime-html';
    
    @customAttribute({
      name: 'simple-url',
      noMultiBindings: true
    })
    export class SimpleUrlCustomAttribute {
      public value: string; // Will receive the entire attribute value as a string
    }
    <!-- With noMultiBindings: true, this won't be parsed as bindings -->
    <a simple-url="https://example.com:8080/path">Link</a>
    import { customAttribute } from '@aurelia/runtime-html';
    import { SomeService } from './some-service';
    
    @customAttribute({
      name: 'dependent-attr',
      dependencies: [SomeService]
    })
    export class DependentAttributeCustomAttribute {
      // SomeService will be registered when this attribute is used
    }
    import { templateController, IViewFactory, bindable } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @templateController({
      name: 'isolated-scope',
      containerStrategy: 'new' // Creates a new container for child views
    })
    export class IsolatedScopeTemplateController {
      @bindable() public isolatedServices: boolean = true;
      
      private readonly factory = resolve(IViewFactory);
      private readonly location = resolve(IRenderLocation);
      
      bound() {
        // Views created by this template controller will have their own container
        // allowing for isolated service instances
        const view = this.factory.create().setLocation(this.location);
        // Services registered in child views won't interfere with parent
      }
    }
    
    @templateController({
      name: 'shared-scope',
      containerStrategy: 'reuse' // Reuses parent container (default)
    })
    export class SharedScopeTemplateController {
      // Child views share the same container as the parent
      // More efficient but services are shared
    }
    // Good candidate for 'new' container strategy
    @templateController('plugin-host')
    export class PluginHostTemplateController {
      @bindable() public pluginConfig: PluginConfiguration;
      
      // Each plugin needs isolated services to prevent conflicts
      // Plugin A's HttpClient shouldn't interfere with Plugin B's
    }
    
    // Good candidate for 'reuse' strategy (default)
    @templateController('simple-conditional')  
    export class SimpleConditionalTemplateController {
      @bindable() public condition: boolean;
      
      // Simple conditional rendering doesn't need service isolation
      // Sharing parent container is more efficient
    }
    
    ### Default Binding Mode
    
    You can set a default binding mode for all bindable properties:
    
    ```typescript
    import { customAttribute, BindingMode } from '@aurelia/runtime-html';
    
    @customAttribute({
      name: 'two-way-default',
      defaultBindingMode: BindingMode.twoWay
    })
    export class TwoWayDefaultCustomAttribute {
      @bindable() public value1: string; // Will default to two-way binding
      @bindable() public value2: string; // Will default to two-way binding
      @bindable({ mode: BindingMode.toView }) public value3: string; // Explicitly one-way
    }
    import { CustomAttributeDefinition } from '@aurelia/runtime-html';
    
    const def = CustomAttributeDefinition.getDefinition(PluginHostCustomAttribute);
    if (def.containerStrategy === 'new') {
      console.debug('PluginHost will isolate services per instance.');
    }
    import { BindingMode, CustomAttribute } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    const TrackingAttribute = CustomAttribute.define({
      name: 'tracking',
      bindables: {
        category: { mode: BindingMode.oneTime },
        data: { mode: BindingMode.twoWay }
      },
      watches: [
        { expression: 'data.total', callback: 'logChange' }
      ],
      dependencies: [AnalyticsService]
    }, class {
      private readonly analytics = resolve(AnalyticsService);
    
      public logChange(newValue: unknown) {
        this.analytics.track(newValue);
      }
    });
    
    Aurelia.register(TrackingAttribute);
    import { bindable, customAttribute, watch } from '@aurelia/runtime-html';
    
    @customAttribute('data-processor')
    export class DataProcessorCustomAttribute {
      @bindable() public data: any[];
      @bindable() public config: any;
    
      @watch('data', { immediate: true })
      @watch('config')
      onDataOrConfigChange(newValue: any, oldValue: any, propertyName: string) {
        console.log(`${propertyName} changed from`, oldValue, 'to', newValue);
        this.reprocessData();
      }
    
      private reprocessData() {
        // Process data based on current data and config
      }
    }
    import { customAttribute, bindable, INode } from '@aurelia/runtime-html';
    import { resolve, ILogger } from '@aurelia/kernel';
    // Import the third-party slider library (this is a hypothetical example)
    import AwesomeSlider from 'awesome-slider';
    
    @customAttribute('awesome-slider')
    export class AwesomeSliderCustomAttribute {
      // Allow dynamic options to be bound from the view
      @bindable() public options: any = {};
    
      // The instance of the third-party slider
      private sliderInstance: any;
    
      // Safely resolve the host element
      private readonly element: HTMLElement = resolve(INode) as HTMLElement;
      private readonly logger = resolve(ILogger);
    
      attached() {
        // Initialize the slider when the element is attached to the DOM.
        // This ensures that the DOM is ready for manipulation.
        try {
          this.sliderInstance = new AwesomeSlider(this.element, this.options);
        } catch (error) {
          this.logger.error('Failed to initialize AwesomeSlider:', error);
        }
      }
    
      optionsChanged(newOptions: any, oldOptions: any) {
        // Update the slider if its configuration changes at runtime.
        // This callback is triggered when the bound `options` property changes.
        if (this.sliderInstance && typeof this.sliderInstance.updateOptions === 'function') {
          this.sliderInstance.updateOptions(newOptions);
        }
      }
    
      detached() {
        // Clean up the slider instance when the element is removed from the DOM.
        // This prevents memory leaks and removes event listeners.
        if (this.sliderInstance && typeof this.sliderInstance.destroy === 'function') {
          this.sliderInstance.destroy();
          this.sliderInstance = null;
        }
      }
    }
    // ✅ Good - focused on DOM enhancement
    @customAttribute('tooltip')
    export class TooltipCustomAttribute {
      @bindable() public text: string;
      // Implementation focused on showing/hiding tooltip
    }
    
    // ❌ Bad - mixing business logic
    @customAttribute('tooltip')
    export class TooltipCustomAttribute {
      @bindable() public userId: string;
      
      async fetchUserData() {
        // Don't do data fetching in custom attributes
        return await this.api.getUser(this.userId);
      }
    }
    @customAttribute('performance-optimized')
    export class PerformanceOptimizedCustomAttribute {
      @bindable() public width: string;
      @bindable() public height: string;
      @bindable() public color: string;
    
      // ✅ Batch multiple property changes
      propertiesChanged(changes: Record<string, any>) {
        const element = this.element;
        if ('width' in changes) element.style.width = changes.width.newValue;
        if ('height' in changes) element.style.height = changes.height.newValue;
        if ('color' in changes) element.style.backgroundColor = changes.color.newValue;
      }
    }
    @customAttribute('event-handler')
    export class EventHandlerCustomAttribute {
      private eventListener: EventListener;
      private thirdPartyInstance: any;
    
      attached() {
        this.eventListener = this.handleClick.bind(this);
        this.element.addEventListener('click', this.eventListener);
        
        this.thirdPartyInstance = new SomeLibrary(this.element);
      }
    
      detaching() {
        // ✅ Always clean up
        this.element.removeEventListener('click', this.eventListener);
        this.thirdPartyInstance?.destroy();
        this.thirdPartyInstance = null;
      }
    }
    @customAttribute('robust-attribute')
    export class RobustCustomAttribute {
      @bindable() public config: any;
      private readonly logger = resolve(ILogger);
    
      attached() {
        try {
          this.initializeFeature();
        } catch (error) {
          this.logger.error('Failed to initialize feature:', error);
          // Fallback behavior
          this.element.classList.add('feature-unavailable');
        }
      }
    
      configChanged(newConfig: any) {
        if (!this.isValidConfig(newConfig)) {
          this.logger.warn('Invalid configuration provided');
          return;
        }
        this.updateConfiguration(newConfig);
      }
    }
    // Example test structure
    describe('MyCustomAttribute', () => {
      it('should initialize correctly', () => { /* ... */ });
      it('should handle property changes', () => { /* ... */ });
      it('should clean up on detach', () => { /* ... */ });
      it('should handle invalid input gracefully', () => { /* ... */ });
    });
    // ✅ Strong typing with interfaces
    interface ChartConfiguration {
      readonly type: 'line' | 'bar' | 'pie';
      readonly data: ChartData;
      readonly options?: ChartOptions;
    }
    
    @customAttribute('chart')
    export class ChartCustomAttribute {
      @bindable() public config: ChartConfiguration;
      
      // ✅ Typed change handlers
      configChanged(newConfig: ChartConfiguration, oldConfig: ChartConfiguration) {
        // TypeScript will catch type errors
        if (newConfig.type !== oldConfig?.type) {
          this.recreateChart(newConfig);
        }
      }
    }
    interface SliderOptions {
      min: number;
      max: number;
      step: number;
    }
    
    @customAttribute('typed-slider')
    export class TypedSliderCustomAttribute {
      @bindable() public options: SliderOptions = { min: 0, max: 100, step: 1 };
      @bindable() public value: number = 0;
    
      optionsChanged(newOptions: SliderOptions, oldOptions: SliderOptions) {
        // Type-safe change handling
      }
    }
    @customAttribute('computed-display')
    export class ComputedDisplayCustomAttribute {
      @bindable() public firstName: string = '';
      @bindable() public lastName: string = '';
    
      // Computed bindable using getter
      @bindable()
      get fullName(): string {
        return `${this.firstName} ${this.lastName}`.trim();
      }
    
      // Optional setter for two-way binding
      set fullName(value: string) {
        const parts = value.split(' ');
        this.firstName = parts[0] || '';
        this.lastName = parts.slice(1).join(' ') || '';
      }
    
      fullNameChanged(newName: string) {
        // Responds to computed property changes
        this.updateDisplay(newName);
      }
    }
    @customAttribute('base-widget')
    export class BaseWidgetCustomAttribute {
      @bindable() public theme: string = 'default';
      @bindable() public size: 'small' | 'medium' | 'large' = 'medium';
    }
    
    @customAttribute('advanced-widget')
    export class AdvancedWidgetCustomAttribute extends BaseWidgetCustomAttribute {
      @bindable() public animation: boolean = true;
      @bindable() public tooltip: string = '';
      
      // Inherits theme and size bindables from parent class
      // Can override parent behavior if needed
      themeChanged(newTheme: string, oldTheme: string) {
        super.themeChanged?.(newTheme, oldTheme);
        this.applyAdvancedThemeFeatures(newTheme);
      }
    }
    @customAttribute('robust-widget')
    export class RobustWidgetCustomAttribute {
      private disposables: Array<() => void> = [];
      private readonly logger = resolve(ILogger);
    
      created(controller: ICustomAttributeController) {
        this.logger.debug('Widget attribute created', { controller });
      }
    
      attached() {
        try {
          this.initializeWidget();
        } catch (error) {
          this.logger.error('Widget initialization failed', error);
          this.fallbackToDefaultBehavior();
        }
      }
    
      detaching() {
        // Clean up all disposables
        this.disposables.forEach(dispose => {
          try {
            dispose();
          } catch (error) {
            this.logger.warn('Cleanup error', error);
          }
        });
        this.disposables.length = 0;
      }
    
      private addDisposable(dispose: () => void) {
        this.disposables.push(dispose);
      }
    }

    Advanced Patterns

    Complex form scenarios with multi-step wizards, dynamic fields, conditional validation, and more.

    What you'll learn...

    • Multi-step wizard forms with progress tracking

    • Dynamic forms (add/remove fields at runtime)

    • Conditional validation based on field dependencies

    • Form state management (dirty, pristine, touched)

    • Autosave and draft management

    • Complex file uploads with preview and progress

    • Form arrays (repeating field groups)

    Prerequisites

    All examples assume you have the validation plugin installed and configured:

    See for setup details.


    Multi-Step Wizard Forms

    Multi-step forms break complex forms into manageable steps, improving user experience and completion rates.

    Complete Example: User Onboarding Wizard

    Key Features:

    • Step-by-step validation (only validate current step)

    • Progress indicator

    • Conditional validation rules with .when()

    • Navigate to first step with errors on final submit


    Dynamic Forms (Add/Remove Fields)

    Forms where users can add or remove fields at runtime, like adding multiple email addresses or phone numbers.

    Complete Example: Contact Form with Dynamic Emails

    Key Features:

    • Add/remove email entries dynamically

    • Ensure at least one email exists

    • Automatically handle primary email when removing

    • Validate all emails in the array


    Conditional Validation (Field Dependencies)

    Validation rules that change based on the values of other fields.

    Complete Example: Shipping Form

    Key Features:

    • Conditional field visibility with if.bind

    • Conditional validation with .when()

    • Fields depend on checkbox state

    • Fields depend on select values


    Form State Management (Dirty, Pristine, Touched)

    Track whether forms have been modified and warn users before losing changes.

    Complete Example: Article Editor with Unsaved Changes Warning

    Key Features:

    • Dirty state tracking (compare current vs original)

    • Autosave to localStorage every 30 seconds

    • Last saved timestamp with human-readable formatting

    • Prevent navigation with canUnload router hook


    Form Arrays (Repeating Field Groups)

    Form arrays allow users to add/remove entire groups of fields, like invoice line items or multiple addresses.

    Complete Example: Invoice Form with Line Items

    Key Features:

    • Add/remove line items dynamically

    • Duplicate line items

    • Auto-calculate line totals and invoice totals

    • Validate entire array of items


    Complex File Uploads with Preview & Progress

    Handle multiple file uploads with image previews, progress tracking, and validation.

    Complete Example: Image Gallery Upload

    Key Features:

    • Drag & drop support

    • Image preview generation

    • Progress tracking for each file

    • File type and size validation


    Dependent Dropdowns (Cascading Selects)

    Dropdowns where options depend on previous selections, like country → state → city.

    Complete Example: Location Selector

    Key Features:

    • Cascading selects (country → state → city)

    • Computed properties for filtered options

    • Auto-reset dependent fields when parent changes

    • Loading states while fetching options


    Reusable Form Field Components

    Create reusable form field components that encapsulate label, input, validation, and error display.

    Complete Example: Validated Text Field Component

    Usage Example

    Key Features:

    • Encapsulates label, input, validation, error display

    • Reusable across entire application

    • Consistent styling and behavior

    • Accessible (proper ARIA attributes)


    Related Documentation

    • - Basic form concepts

    • - Complete validation guide

    • - Handling form submission

    • - Working with form collections


    Summary

    These advanced patterns handle complex real-world scenarios:

    1. Multi-step wizards - Break complex forms into manageable steps with conditional validation and progress tracking

    2. Dynamic forms - Add/remove individual fields at runtime with validation

    3. Conditional validation - Validation rules that depend on other field values

    4. Form state management - Track changes, prevent data loss, implement autosave with router guards

    All examples use proper Aurelia 2 syntax with the validation plugin and follow accessibility best practices. Each pattern includes complete, production-ready code with TypeScript interfaces, validation rules, and accessible HTML templates.

    Accessible navigation buttons

    Unique IDs for each entry (accessibility and key binding)

    Automatic revalidation when dependencies change

    Visual indicators for save state

  • Disable actions while saving

  • Prevent removing last item
  • Unique IDs for each line item

  • Accessible labels for screen readers

  • Multiple file selection
  • Individual or bulk upload

  • Error handling per file

  • Visual status indicators

  • Disabled state until parent is selected

  • Helpful hints for users

  • Reduced boilerplate in forms

    File Uploads - File upload patterns

    Form arrays - Repeating field groups (like invoice line items) with add/remove/duplicate functionality

  • Complex file uploads - Multiple file uploads with drag & drop, previews, progress tracking, and per-file validation

  • Dependent dropdowns - Cascading selects (country → state → city) with auto-reset and loading states

  • Reusable form fields - Encapsulated field components with built-in validation display

  • Validation documentation
    Form Basics
    Validation Plugin
    Form Submission
    Collections (Checkboxes, Radios, Select)
    npm install @aurelia/validation @aurelia/validation-html
    // src/main.ts
    import Aurelia from 'aurelia';
    import { ValidationHtmlConfiguration } from '@aurelia/validation-html';
    
    Aurelia.register(ValidationHtmlConfiguration)
      .app(component)
      .start();
    // src/components/onboarding-wizard.ts
    import { newInstanceForScope, resolve } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController } from '@aurelia/validation-html';
    
    interface UserProfile {
      // Step 1: Account
      email: string;
      password: string;
      confirmPassword: string;
    
      // Step 2: Personal Info
      firstName: string;
      lastName: string;
      dateOfBirth: string;
      phone: string;
    
      // Step 3: Preferences
      newsletter: boolean;
      notifications: boolean;
      theme: 'light' | 'dark';
      language: string;
    }
    
    export class OnboardingWizard {
      private currentStep = 1;
      private readonly totalSteps = 3;
    
      private profile: UserProfile = {
        email: '',
        password: '',
        confirmPassword: '',
        firstName: '',
        lastName: '',
        dateOfBirth: '',
        phone: '',
        newsletter: true,
        notifications: true,
        theme: 'light',
        language: 'en'
      };
    
      private validation = resolve(newInstanceForScope(IValidationController));
      private validationRules = resolve(IValidationRules);
    
      constructor() {
        this.setupValidation();
      }
    
      private setupValidation() {
        // Step 1 validation rules
        this.validationRules
          .on(this.profile)
          .ensure('email')
            .required()
            .email()
            .when(() => this.currentStep === 1)
          .ensure('password')
            .required()
            .minLength(8)
            .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
            .withMessage('Password must contain uppercase, lowercase, and number')
            .when(() => this.currentStep === 1)
          .ensure('confirmPassword')
            .required()
            .satisfies((value: string) => value === this.profile.password)
            .withMessage('Passwords must match')
            .when(() => this.currentStep === 1)
    
          // Step 2 validation rules
          .ensure('firstName')
            .required()
            .minLength(2)
            .when(() => this.currentStep === 2)
          .ensure('lastName')
            .required()
            .minLength(2)
            .when(() => this.currentStep === 2)
          .ensure('dateOfBirth')
            .required()
            .satisfies((value: string) => {
              const age = this.calculateAge(new Date(value));
              return age >= 18 && age <= 120;
            })
            .withMessage('You must be at least 18 years old')
            .when(() => this.currentStep === 2)
          .ensure('phone')
            .required()
            .matches(/^\+?[\d\s\-()]+$/)
            .withMessage('Please enter a valid phone number')
            .when(() => this.currentStep === 2);
      }
    
      private calculateAge(birthDate: Date): number {
        const today = new Date();
        let age = today.getFullYear() - birthDate.getFullYear();
        const monthDiff = today.getMonth() - birthDate.getMonth();
    
        if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
          age--;
        }
    
        return age;
      }
    
      async next() {
        // Validate current step before proceeding
        const result = await this.validation.validate();
    
        if (!result.valid) {
          return; // Stay on current step if validation fails
        }
    
        if (this.currentStep < this.totalSteps) {
          this.currentStep++;
        }
      }
    
      previous() {
        if (this.currentStep > 1) {
          this.currentStep--;
        }
      }
    
      async submit() {
        // Validate all steps
        const result = await this.validation.validate();
    
        if (!result.valid) {
          // Find first step with errors
          const firstErrorStep = this.findFirstErrorStep(result.results);
          this.currentStep = firstErrorStep;
          return;
        }
    
        // Submit the form
        try {
          await this.saveProfile(this.profile);
          console.log('Profile saved successfully!');
        } catch (error) {
          console.error('Failed to save profile:', error);
        }
      }
    
      private findFirstErrorStep(results: any[]): number {
        const step1Fields = ['email', 'password', 'confirmPassword'];
        const step2Fields = ['firstName', 'lastName', 'dateOfBirth', 'phone'];
    
        for (const result of results) {
          if (!result.valid) {
            if (step1Fields.includes(result.propertyName)) return 1;
            if (step2Fields.includes(result.propertyName)) return 2;
          }
        }
    
        return this.currentStep;
      }
    
      private async saveProfile(profile: UserProfile): Promise<void> {
        // API call to save profile
        const response = await fetch('/api/onboarding', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(profile)
        });
    
        if (!response.ok) {
          throw new Error('Failed to save profile');
        }
      }
    
      get progress(): number {
        return (this.currentStep / this.totalSteps) * 100;
      }
    
      get isFirstStep(): boolean {
        return this.currentStep === 1;
      }
    
      get isLastStep(): boolean {
        return this.currentStep === this.totalSteps;
      }
    }
    <!-- src/components/onboarding-wizard.html -->
      <div class="wizard">
        <!-- Progress bar -->
        <div class="wizard-progress">
          <div class="wizard-progress-bar" style="width: ${progress}%"></div>
          <div class="wizard-steps">
            <div class="wizard-step ${currentStep >= 1 ? 'active' : ''} ${currentStep > 1 ? 'completed' : ''}">
              <span class="step-number">1</span>
              <span class="step-label">Account</span>
            </div>
            <div class="wizard-step ${currentStep >= 2 ? 'active' : ''} ${currentStep > 2 ? 'completed' : ''}">
              <span class="step-number">2</span>
              <span class="step-label">Personal Info</span>
            </div>
            <div class="wizard-step ${currentStep >= 3 ? 'active' : ''}">
              <span class="step-number">3</span>
              <span class="step-label">Preferences</span>
            </div>
          </div>
        </div>
    
        <!-- Step 1: Account -->
        <div class="wizard-content" if.bind="currentStep === 1">
          <h2>Create Your Account</h2>
    
          <div class="form-field">
            <label for="email">Email</label>
            <input
              type="email"
              id="email"
              value.bind="profile.email & validate"
              placeholder="[email protected]">
          </div>
    
          <div class="form-field">
            <label for="password">Password</label>
            <input
              type="password"
              id="password"
              value.bind="profile.password & validate"
              placeholder="Min. 8 characters">
          </div>
    
          <div class="form-field">
            <label for="confirmPassword">Confirm Password</label>
            <input
              type="password"
              id="confirmPassword"
              value.bind="profile.confirmPassword & validate">
          </div>
        </div>
    
        <!-- Step 2: Personal Info -->
        <div class="wizard-content" if.bind="currentStep === 2">
          <h2>Tell Us About Yourself</h2>
    
          <div class="form-row">
            <div class="form-field">
              <label for="firstName">First Name</label>
              <input
                type="text"
                id="firstName"
                value.bind="profile.firstName & validate">
            </div>
    
            <div class="form-field">
              <label for="lastName">Last Name</label>
              <input
                type="text"
                id="lastName"
                value.bind="profile.lastName & validate">
            </div>
          </div>
    
          <div class="form-field">
            <label for="dateOfBirth">Date of Birth</label>
            <input
              type="date"
              id="dateOfBirth"
              value.bind="profile.dateOfBirth & validate">
          </div>
    
          <div class="form-field">
            <label for="phone">Phone Number</label>
            <input
              type="tel"
              id="phone"
              value.bind="profile.phone & validate"
              placeholder="+1 (555) 123-4567">
          </div>
        </div>
    
        <!-- Step 3: Preferences -->
        <div class="wizard-content" if.bind="currentStep === 3">
          <h2>Customize Your Experience</h2>
    
          <div class="form-field">
            <label>
              <input type="checkbox" checked.bind="profile.newsletter">
              Subscribe to newsletter
            </label>
          </div>
    
          <div class="form-field">
            <label>
              <input type="checkbox" checked.bind="profile.notifications">
              Enable notifications
            </label>
          </div>
    
          <div class="form-field">
            <label for="theme">Theme</label>
            <select id="theme" value.bind="profile.theme">
              <option value="light">Light</option>
              <option value="dark">Dark</option>
            </select>
          </div>
    
          <div class="form-field">
            <label for="language">Language</label>
            <select id="language" value.bind="profile.language">
              <option value="en">English</option>
              <option value="es">Español</option>
              <option value="fr">Français</option>
              <option value="de">Deutsch</option>
            </select>
          </div>
        </div>
    
        <!-- Navigation -->
        <div class="wizard-actions">
          <button
            type="button"
            click.trigger="previous()"
            disabled.bind="isFirstStep"
            class="btn btn-secondary">
            Previous
          </button>
    
          <button
            if.bind="!isLastStep"
            type="button"
            click.trigger="next()"
            class="btn btn-primary">
            Next
          </button>
    
          <button
            if.bind="isLastStep"
            type="button"
            click.trigger="submit()"
            class="btn btn-success">
            Complete
          </button>
        </div>
      </div>
    // src/components/dynamic-contact-form.ts
    import { newInstanceForScope } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController } from '@aurelia/validation-html';
    
    interface EmailEntry {
      id: string;
      address: string;
      label: string;
      isPrimary: boolean;
    }
    
    interface ContactForm {
      name: string;
      company: string;
      emails: EmailEntry[];
      notes: string;
    }
    
    export class DynamicContactForm {
      private form: ContactForm = {
        name: '',
        company: '',
        emails: [this.createEmailEntry(true)],
        notes: ''
      };
    
      private validation = resolve(newInstanceForScope(IValidationController));
      private nextId = 1;
      private validationRules = resolve(IValidationRules);
    
      constructor() {
        this.setupValidation();
      }
    
      private createEmailEntry(isPrimary = false): EmailEntry {
        return {
          id: `email-${this.nextId++}`,
          address: '',
          label: isPrimary ? 'Primary' : 'Secondary',
          isPrimary
        };
      }
    
      private setupValidation() {
        this.validationRules
          .on(this.form)
          .ensure('name')
            .required()
            .minLength(2)
          .ensure('company')
            .required()
          .ensure('emails')
            .required()
            .minItems(1)
            .withMessage('At least one email is required')
            .satisfies((emails: EmailEntry[]) =>
              emails.every(e => this.isValidEmail(e.address))
            )
            .withMessage('All email addresses must be valid')
            .satisfies((emails: EmailEntry[]) =>
              emails.filter(e => e.isPrimary).length === 1
            )
            .withMessage('Exactly one primary email is required');
      }
    
      private isValidEmail(email: string): boolean {
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
      }
    
      addEmail() {
        this.form.emails.push(this.createEmailEntry());
      }
    
      removeEmail(id: string) {
        // Don't allow removing the last email
        if (this.form.emails.length <= 1) {
          return;
        }
    
        const index = this.form.emails.findIndex(e => e.id === id);
        if (index === -1) return;
    
        const wasRemoved = this.form.emails[index];
        this.form.emails.splice(index, 1);
    
        // If we removed the primary, make the first one primary
        if (wasRemoved.isPrimary && this.form.emails.length > 0) {
          this.form.emails[0].isPrimary = true;
          this.form.emails[0].label = 'Primary';
        }
    
        // Revalidate after removal
        this.validation.validate();
      }
    
      setPrimary(id: string) {
        // Only one email can be primary
        this.form.emails.forEach(email => {
          email.isPrimary = email.id === id;
          email.label = email.isPrimary ? 'Primary' : 'Secondary';
        });
      }
    
      async submit() {
        const result = await this.validation.validate();
    
        if (!result.valid) {
          return;
        }
    
        console.log('Form submitted:', this.form);
        // API call here
      }
    
      get canRemoveEmail(): boolean {
        return this.form.emails.length > 1;
      }
    }
    <!-- src/components/dynamic-contact-form.html -->
      <form submit.trigger="submit()">
        <h2>Contact Information</h2>
    
        <div class="form-field">
          <label for="name">Name *</label>
          <input
            type="text"
            id="name"
            value.bind="form.name & validate">
        </div>
    
        <div class="form-field">
          <label for="company">Company *</label>
          <input
            type="text"
            id="company"
            value.bind="form.company & validate">
        </div>
    
        <!-- Dynamic email fields -->
        <div class="form-section">
          <div class="section-header">
            <h3>Email Addresses *</h3>
            <button
              type="button"
              click.trigger="addEmail()"
              class="btn btn-small btn-secondary">
              + Add Email
            </button>
          </div>
    
          <div
            repeat.for="email of form.emails"
            class="email-entry"
            id.bind="email.id">
    
            <div class="form-row">
              <div class="form-field flex-grow">
                <label for="${email.id}-address">${email.label}</label>
                <input
                  type="email"
                  id="${email.id}-address"
                  value.bind="email.address"
                  placeholder="[email protected]">
              </div>
    
              <div class="form-field-actions">
                <label class="radio-label">
                  <input
                    type="radio"
                    name="primaryEmail"
                    checked.bind="email.isPrimary"
                    change.trigger="setPrimary(email.id)">
                  Primary
                </label>
    
                <button
                  type="button"
                  click.trigger="removeEmail(email.id)"
                  disabled.bind="!canRemoveEmail"
                  class="btn btn-small btn-danger"
                  aria-label="Remove email">
                  ×
                </button>
              </div>
            </div>
          </div>
        </div>
    
        <div class="form-field">
          <label for="notes">Notes</label>
          <textarea
            id="notes"
            value.bind="form.notes"
            rows="4"></textarea>
        </div>
    
        <button type="submit" class="btn btn-primary">Save Contact</button>
      </form>
    // src/components/shipping-form.ts
    import { newInstanceForScope } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController } from '@aurelia/validation-html';
    
    interface ShippingForm {
      sameAsBilling: boolean;
    
      // Billing address
      billingStreet: string;
      billingCity: string;
      billingState: string;
      billingZip: string;
      billingCountry: string;
    
      // Shipping address (only required if different)
      shippingStreet: string;
      shippingCity: string;
      shippingState: string;
      shippingZip: string;
      shippingCountry: string;
    
      // Shipping method
      shippingMethod: 'standard' | 'express' | 'overnight' | '';
    
      // Signature required (only for overnight)
      signatureRequired: boolean;
    
      // International customs (only for international)
      customsValue: number;
      customsDescription: string;
    }
    
    export class ShippingFormComponent {
      private form: ShippingForm = {
        sameAsBilling: true,
        billingStreet: '',
        billingCity: '',
        billingState: '',
        billingZip: '',
        billingCountry: 'US',
        shippingStreet: '',
        shippingCity: '',
        shippingState: '',
        shippingZip: '',
        shippingCountry: 'US',
        shippingMethod: '',
        signatureRequired: false,
        customsValue: 0,
        customsDescription: ''
      };
    
      private validation = resolve(newInstanceForScope(IValidationController));
      private validationRules = resolve(IValidationRules);
    
      constructor() {
        this.setupValidation();
      }
    
      private setupValidation() {
        this.validationRules
          .on(this.form)
          // Billing address (always required)
          .ensure('billingStreet')
            .required()
          .ensure('billingCity')
            .required()
          .ensure('billingState')
            .required()
          .ensure('billingZip')
            .required()
            .matches(/^\d{5}(-\d{4})?$/)
            .withMessage('Please enter a valid ZIP code')
          .ensure('billingCountry')
            .required()
    
          // Shipping address (required only if different from billing)
          .ensure('shippingStreet')
            .required()
            .when(() => !this.form.sameAsBilling)
          .ensure('shippingCity')
            .required()
            .when(() => !this.form.sameAsBilling)
          .ensure('shippingState')
            .required()
            .when(() => !this.form.sameAsBilling)
          .ensure('shippingZip')
            .required()
            .when(() => !this.form.sameAsBilling)
            .matches(/^\d{5}(-\d{4})?$/)
            .withMessage('Please enter a valid ZIP code')
            .when(() => !this.form.sameAsBilling)
          .ensure('shippingCountry')
            .required()
            .when(() => !this.form.sameAsBilling)
    
          // Shipping method
          .ensure('shippingMethod')
            .required()
            .withMessage('Please select a shipping method')
    
          // Customs info (required for international shipments)
          .ensure('customsValue')
            .required()
            .min(0.01)
            .withMessage('Customs value must be greater than 0')
            .when(() => this.isInternationalShipment)
          .ensure('customsDescription')
            .required()
            .minLength(10)
            .withMessage('Please provide a detailed description for customs')
            .when(() => this.isInternationalShipment);
      }
    
      get isInternationalShipment(): boolean {
        const destCountry = this.form.sameAsBilling
          ? this.form.billingCountry
          : this.form.shippingCountry;
    
        return destCountry !== 'US';
      }
    
      get isOvernightShipping(): boolean {
        return this.form.shippingMethod === 'overnight';
      }
    
      sameAsBillingChanged() {
        if (this.form.sameAsBilling) {
          // Clear shipping address when using billing address
          this.form.shippingStreet = '';
          this.form.shippingCity = '';
          this.form.shippingState = '';
          this.form.shippingZip = '';
          this.form.shippingCountry = this.form.billingCountry;
        }
    
        // Revalidate after toggling
        this.validation.validate();
      }
    
      async submit() {
        const result = await this.validation.validate();
    
        if (!result.valid) {
          return;
        }
    
        console.log('Shipping form submitted:', this.form);
      }
    }
    <!-- src/components/shipping-form.html -->
      <form submit.trigger="submit()">
        <h2>Billing Address</h2>
    
        <div class="form-field">
          <label for="billingStreet">Street Address *</label>
          <input
            type="text"
            id="billingStreet"
            value.bind="form.billingStreet & validate">
        </div>
    
        <div class="form-row">
          <div class="form-field">
            <label for="billingCity">City *</label>
            <input
              type="text"
              id="billingCity"
              value.bind="form.billingCity & validate">
          </div>
    
          <div class="form-field">
            <label for="billingState">State *</label>
            <input
              type="text"
              id="billingState"
              value.bind="form.billingState & validate">
          </div>
    
          <div class="form-field">
            <label for="billingZip">ZIP Code *</label>
            <input
              type="text"
              id="billingZip"
              value.bind="form.billingZip & validate">
          </div>
        </div>
    
        <div class="form-field">
          <label for="billingCountry">Country *</label>
          <select id="billingCountry" value.bind="form.billingCountry & validate">
            <option value="US">United States</option>
            <option value="CA">Canada</option>
            <option value="MX">Mexico</option>
            <option value="UK">United Kingdom</option>
            <option value="FR">France</option>
          </select>
        </div>
    
        <hr>
    
        <h2>Shipping Address</h2>
    
        <div class="form-field">
          <label>
            <input
              type="checkbox"
              checked.bind="form.sameAsBilling"
              change.trigger="sameAsBillingChanged()">
            Same as billing address
          </label>
        </div>
    
        <!-- Only show shipping address fields if different from billing -->
        <div if.bind="!form.sameAsBilling">
          <div class="form-field">
            <label for="shippingStreet">Street Address *</label>
            <input
              type="text"
              id="shippingStreet"
              value.bind="form.shippingStreet & validate">
          </div>
    
          <div class="form-row">
            <div class="form-field">
              <label for="shippingCity">City *</label>
              <input
                type="text"
                id="shippingCity"
                value.bind="form.shippingCity & validate">
            </div>
    
            <div class="form-field">
              <label for="shippingState">State *</label>
              <input
                type="text"
                id="shippingState"
                value.bind="form.shippingState & validate">
            </div>
    
            <div class="form-field">
              <label for="shippingZip">ZIP Code *</label>
              <input
                type="text"
                id="shippingZip"
                value.bind="form.shippingZip & validate">
            </div>
          </div>
    
          <div class="form-field">
            <label for="shippingCountry">Country *</label>
            <select id="shippingCountry" value.bind="form.shippingCountry & validate">
              <option value="US">United States</option>
              <option value="CA">Canada</option>
              <option value="MX">Mexico</option>
              <option value="UK">United Kingdom</option>
              <option value="FR">France</option>
            </select>
          </div>
        </div>
    
        <hr>
    
        <h2>Shipping Method</h2>
    
        <div class="form-field">
          <label for="shippingMethod">Method *</label>
          <select id="shippingMethod" value.bind="form.shippingMethod & validate">
            <option value="">Select shipping method</option>
            <option value="standard">Standard (5-7 days) - $5.99</option>
            <option value="express">Express (2-3 days) - $14.99</option>
            <option value="overnight">Overnight - $29.99</option>
          </select>
        </div>
    
        <!-- Only show signature option for overnight shipping -->
        <div if.bind="isOvernightShipping" class="form-field">
          <label>
            <input type="checkbox" checked.bind="form.signatureRequired">
            Signature required upon delivery
          </label>
        </div>
    
        <!-- Only show customs fields for international shipments -->
        <div if.bind="isInternationalShipment">
          <hr>
          <h2>Customs Information</h2>
    
          <div class="form-field">
            <label for="customsValue">Declared Value (USD) *</label>
            <input
              type="number"
              id="customsValue"
              value.bind="form.customsValue & validate"
              step="0.01"
              min="0">
          </div>
    
          <div class="form-field">
            <label for="customsDescription">Description *</label>
            <textarea
              id="customsDescription"
              value.bind="form.customsDescription & validate"
              rows="3"
              placeholder="Detailed description of contents for customs"></textarea>
          </div>
        </div>
    
        <button type="submit" class="btn btn-primary">Continue to Payment</button>
      </form>
    // src/components/article-editor.ts
    import { IRouter, RouteNode } from '@aurelia/router';
    import { newInstanceForScope, resolve } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController } from '@aurelia/validation-html';
    
    interface Article {
      id: string | null;
      title: string;
      content: string;
      tags: string[];
      published: boolean;
    }
    
    export class ArticleEditor {
      private article: Article = {
        id: null,
        title: '',
        content: '',
        tags: [],
        published: false
      };
    
      private originalArticle: Article;
      private isDirty = false;
      private isSaving = false;
      private lastSaved: Date | null = null;
      private autosaveTimer: any = null;
    
      private validation = resolve(newInstanceForScope(IValidationController));
      private router = resolve(IRouter);
      private validationRules = resolve(IValidationRules);
    
      constructor() {
        this.validationRules
          .on(this.article)
          .ensure('title')
            .required()
            .minLength(5)
          .ensure('content')
            .required()
            .minLength(50);
    
        // Store original state
        this.originalArticle = JSON.parse(JSON.stringify(this.article));
    
        // Setup autosave
        this.setupAutosave();
      }
    
      binding() {
        // Track changes to mark form as dirty
        this.watchForChanges();
      }
    
      detaching() {
        // Clean up autosave timer
        if (this.autosaveTimer) {
          clearInterval(this.autosaveTimer);
        }
      }
    
      private watchForChanges() {
        // Simple dirty checking - compare current to original
        // In production, consider using a more robust solution
        const checkDirty = () => {
          this.isDirty = JSON.stringify(this.article) !== JSON.stringify(this.originalArticle);
        };
    
        // Check after each property change
        // You could use @observable or watch the properties more elegantly
        setInterval(checkDirty, 500);
      }
    
      private setupAutosave() {
        // Autosave every 30 seconds if dirty
        this.autosaveTimer = setInterval(() => {
          if (this.isDirty && !this.isSaving) {
            this.saveDraft();
          }
        }, 30000);
      }
    
      async saveDraft() {
        if (this.isSaving) return;
    
        this.isSaving = true;
    
        try {
          // Save to localStorage as draft
          localStorage.setItem('article-draft', JSON.stringify(this.article));
          this.lastSaved = new Date();
    
          console.log('Draft saved');
        } catch (error) {
          console.error('Failed to save draft:', error);
        } finally {
          this.isSaving = false;
        }
      }
    
      async publish() {
        const result = await this.validation.validate();
    
        if (!result.valid) {
          return;
        }
    
        this.isSaving = true;
    
        try {
          this.article.published = true;
          await this.saveArticle();
    
          // Update original state
          this.originalArticle = JSON.parse(JSON.stringify(this.article));
          this.isDirty = false;
    
          // Clear draft
          localStorage.removeItem('article-draft');
    
          // Navigate away
          await this.router.load('/articles');
        } catch (error) {
          console.error('Failed to publish:', error);
        } finally {
          this.isSaving = false;
        }
      }
    
      async save() {
        const result = await this.validation.validate();
    
        if (!result.valid) {
          return;
        }
    
        this.isSaving = true;
    
        try {
          this.article.published = false;
          await this.saveArticle();
    
          // Update original state
          this.originalArticle = JSON.parse(JSON.stringify(this.article));
          this.isDirty = false;
    
          // Clear draft
          localStorage.removeItem('article-draft');
        } catch (error) {
          console.error('Failed to save:', error);
        } finally {
          this.isSaving = false;
        }
      }
    
      private async saveArticle(): Promise<void> {
        const response = await fetch('/api/articles', {
          method: this.article.id ? 'PUT' : 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(this.article)
        });
    
        if (!response.ok) {
          throw new Error('Failed to save article');
        }
    
        const saved = await response.json();
        this.article.id = saved.id;
      }
    
      // Router lifecycle hook - prevent navigation if dirty
      canUnload(next: RouteNode | null, current: RouteNode): boolean {
        if (!this.isDirty) {
          return true;
        }
    
        // Show confirmation dialog
        return confirm('You have unsaved changes. Are you sure you want to leave?');
      }
    
      get lastSavedText(): string {
        if (!this.lastSaved) {
          return 'Never saved';
        }
    
        const seconds = Math.floor((Date.now() - this.lastSaved.getTime()) / 1000);
    
        if (seconds < 60) {
          return 'Saved just now';
        } else if (seconds < 3600) {
          const minutes = Math.floor(seconds / 60);
          return `Saved ${minutes} minute${minutes > 1 ? 's' : ''} ago`;
        } else {
          const hours = Math.floor(seconds / 3600);
          return `Saved ${hours} hour${hours > 1 ? 's' : ''} ago`;
        }
      }
    }
    <!-- src/components/article-editor.html -->
      <div class="article-editor">
        <!-- Status bar -->
        <div class="editor-status">
          <span class="status-indicator ${isDirty ? 'dirty' : 'clean'}">
            ${isDirty ? 'Unsaved changes' : 'All changes saved'}
          </span>
          <span class="last-saved">${lastSavedText}</span>
          <span if.bind="isSaving" class="saving-indicator">Saving...</span>
        </div>
    
        <!-- Editor form -->
        <div class="form-field">
          <label for="title">Title *</label>
          <input
            type="text"
            id="title"
            value.bind="article.title & validate"
            placeholder="Enter article title">
        </div>
    
        <div class="form-field">
          <label for="content">Content *</label>
          <textarea
            id="content"
            value.bind="article.content & validate"
            rows="20"
            placeholder="Write your article..."></textarea>
        </div>
    
        <div class="form-field">
          <label for="tags">Tags (comma-separated)</label>
          <input
            type="text"
            id="tags"
            value.bind="article.tags"
            placeholder="javascript, aurelia, web development">
        </div>
    
        <!-- Actions -->
        <div class="editor-actions">
          <button
            type="button"
            click.trigger="saveDraft()"
            disabled.bind="!isDirty || isSaving"
            class="btn btn-secondary">
            Save Draft
          </button>
    
          <button
            type="button"
            click.trigger="save()"
            disabled.bind="isSaving"
            class="btn btn-primary">
            ${isSaving ? 'Saving...' : 'Save'}
          </button>
    
          <button
            type="button"
            click.trigger="publish()"
            disabled.bind="isSaving"
            class="btn btn-success">
            ${isSaving ? 'Publishing...' : 'Publish'}
          </button>
        </div>
      </div>
    // src/components/invoice-form.ts
    import { newInstanceForScope } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController } from '@aurelia/validation-html';
    
    interface LineItem {
      id: string;
      description: string;
      quantity: number;
      unitPrice: number;
      total: number;
    }
    
    interface Invoice {
      invoiceNumber: string;
      customerName: string;
      customerEmail: string;
      invoiceDate: string;
      dueDate: string;
      items: LineItem[];
      subtotal: number;
      tax: number;
      total: number;
    }
    
    export class InvoiceForm {
      private invoice: Invoice = {
        invoiceNumber: this.generateInvoiceNumber(),
        customerName: '',
        customerEmail: '',
        invoiceDate: new Date().toISOString().split('T')[0],
        dueDate: '',
        items: [this.createLineItem()],
        subtotal: 0,
        tax: 0,
        total: 0
      };
    
      private readonly taxRate = 0.08; // 8% tax
      private nextItemId = 1;
    
      private validation = resolve(newInstanceForScope(IValidationController));
      private validationRules = resolve(IValidationRules);
    
      constructor() {
        this.setupValidation();
      }
    
      private generateInvoiceNumber(): string {
        return `INV-${Date.now()}`;
      }
    
      private createLineItem(): LineItem {
        return {
          id: `item-${this.nextItemId++}`,
          description: '',
          quantity: 1,
          unitPrice: 0,
          total: 0
        };
      }
    
      private setupValidation() {
        this.validationRules
          .on(this.invoice)
          .ensure('invoiceNumber')
            .required()
          .ensure('customerName')
            .required()
            .minLength(2)
          .ensure('customerEmail')
            .required()
            .email()
          .ensure('invoiceDate')
            .required()
          .ensure('dueDate')
            .required()
            .satisfies((value: string) => {
              if (!value || !this.invoice.invoiceDate) return true;
              return new Date(value) >= new Date(this.invoice.invoiceDate);
            })
            .withMessage('Due date must be after invoice date')
          .ensure('items')
            .required()
            .minItems(1)
            .withMessage('At least one line item is required')
            .satisfies((items: LineItem[]) =>
              items.every(item =>
                item.description.trim().length > 0 &&
                item.quantity > 0 &&
                item.unitPrice >= 0
              )
            )
            .withMessage('All line items must be complete');
      }
    
      addLineItem() {
        this.invoice.items.push(this.createLineItem());
      }
    
      removeLineItem(id: string) {
        if (this.invoice.items.length <= 1) {
          return; // Must have at least one item
        }
    
        const index = this.invoice.items.findIndex(item => item.id === id);
        if (index !== -1) {
          this.invoice.items.splice(index, 1);
          this.calculateTotals();
        }
      }
    
      duplicateLineItem(id: string) {
        const index = this.invoice.items.findIndex(item => item.id === id);
        if (index !== -1) {
          const original = this.invoice.items[index];
          const duplicate = {
            ...original,
            id: `item-${this.nextItemId++}`
          };
          this.invoice.items.splice(index + 1, 0, duplicate);
          this.calculateTotals();
        }
      }
    
      updateLineItem(item: LineItem) {
        item.total = item.quantity * item.unitPrice;
        this.calculateTotals();
      }
    
      private calculateTotals() {
        this.invoice.subtotal = this.invoice.items.reduce(
          (sum, item) => sum + item.total,
          0
        );
    
        this.invoice.tax = this.invoice.subtotal * this.taxRate;
        this.invoice.total = this.invoice.subtotal + this.invoice.tax;
      }
    
      async submit() {
        const result = await this.validation.validate();
    
        if (!result.valid) {
          return;
        }
    
        console.log('Invoice submitted:', this.invoice);
        // API call to save invoice
      }
    
      get canRemoveItem(): boolean {
        return this.invoice.items.length > 1;
      }
    }
    <!-- src/components/invoice-form.html -->
      <form submit.trigger="submit()" class="invoice-form">
        <h2>Create Invoice</h2>
    
        <!-- Invoice Header -->
        <div class="invoice-header">
          <div class="form-row">
            <div class="form-field">
              <label for="invoiceNumber">Invoice Number *</label>
              <input
                type="text"
                id="invoiceNumber"
                value.bind="invoice.invoiceNumber & validate"
                readonly>
            </div>
    
            <div class="form-field">
              <label for="invoiceDate">Invoice Date *</label>
              <input
                type="date"
                id="invoiceDate"
                value.bind="invoice.invoiceDate & validate">
            </div>
    
            <div class="form-field">
              <label for="dueDate">Due Date *</label>
              <input
                type="date"
                id="dueDate"
                value.bind="invoice.dueDate & validate">
            </div>
          </div>
    
          <div class="form-row">
            <div class="form-field">
              <label for="customerName">Customer Name *</label>
              <input
                type="text"
                id="customerName"
                value.bind="invoice.customerName & validate">
            </div>
    
            <div class="form-field">
              <label for="customerEmail">Customer Email *</label>
              <input
                type="email"
                id="customerEmail"
                value.bind="invoice.customerEmail & validate">
            </div>
          </div>
        </div>
    
        <!-- Line Items -->
        <div class="invoice-items">
          <div class="items-header">
            <h3>Line Items</h3>
            <button
              type="button"
              click.trigger="addLineItem()"
              class="btn btn-secondary btn-small">
              + Add Item
            </button>
          </div>
    
          <div class="items-table">
            <div class="items-table-header">
              <div class="col-description">Description</div>
              <div class="col-quantity">Qty</div>
              <div class="col-price">Unit Price</div>
              <div class="col-total">Total</div>
              <div class="col-actions">Actions</div>
            </div>
    
            <div
              repeat.for="item of invoice.items"
              class="line-item"
              id.bind="item.id">
    
              <div class="col-description">
                <input
                  type="text"
                  value.bind="item.description"
                  change.trigger="updateLineItem(item)"
                  placeholder="Item description"
                  aria-label="Description for item ${$index + 1}">
              </div>
    
              <div class="col-quantity">
                <input
                  type="number"
                  value.bind="item.quantity"
                  change.trigger="updateLineItem(item)"
                  min="1"
                  step="1"
                  aria-label="Quantity for item ${$index + 1}">
              </div>
    
              <div class="col-price">
                <input
                  type="number"
                  value.bind="item.unitPrice"
                  change.trigger="updateLineItem(item)"
                  min="0"
                  step="0.01"
                  aria-label="Unit price for item ${$index + 1}">
              </div>
    
              <div class="col-total">
                $${item.total | numberFormat:'0.00'}
              </div>
    
              <div class="col-actions">
                <button
                  type="button"
                  click.trigger="duplicateLineItem(item.id)"
                  class="btn btn-icon"
                  title="Duplicate"
                  aria-label="Duplicate item ${$index + 1}">
                  📋
                </button>
    
                <button
                  type="button"
                  click.trigger="removeLineItem(item.id)"
                  disabled.bind="!canRemoveItem"
                  class="btn btn-icon btn-danger"
                  title="Remove"
                  aria-label="Remove item ${$index + 1}">
                  ×
                </button>
              </div>
            </div>
          </div>
        </div>
    
        <!-- Totals -->
        <div class="invoice-totals">
          <div class="total-row">
            <span>Subtotal:</span>
            <span>$${invoice.subtotal | numberFormat:'0.00'}</span>
          </div>
          <div class="total-row">
            <span>Tax (${taxRate * 100}%):</span>
            <span>$${invoice.tax | numberFormat:'0.00'}</span>
          </div>
          <div class="total-row total-row-grand">
            <span>Total:</span>
            <span>$${invoice.total | numberFormat:'0.00'}</span>
          </div>
        </div>
    
        <!-- Actions -->
        <div class="form-actions">
          <button type="submit" class="btn btn-primary">
            Save Invoice
          </button>
        </div>
      </form>
    // src/components/image-upload.ts
    import { newInstanceForScope } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController } from '@aurelia/validation-html';
    
    interface UploadedFile {
      id: string;
      file: File;
      preview: string;
      progress: number;
      status: 'pending' | 'uploading' | 'complete' | 'error';
      error?: string;
    }
    
    export class ImageUpload {
      private files: UploadedFile[] = [];
      private dragOver = false;
      private nextId = 1;
    
      private readonly maxFileSize = 5 * 1024 * 1024; // 5MB
      private readonly maxFiles = 10;
      private readonly allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
    
      private validation = resolve(newInstanceForScope(IValidationController));
    
      constructor(
        @IValidationRules validationRules: IValidationRules = resolve(IValidationRules)
      ) {
        validationRules
          .on(this)
          .ensure('files')
            .required()
            .minItems(1)
            .withMessage('Please upload at least one image')
            .satisfies((files: UploadedFile[]) => files.length <= this.maxFiles)
            .withMessage(`Maximum ${this.maxFiles} images allowed`);
      }
    
      handleFileSelect(event: Event) {
        const input = event.target as HTMLInputElement;
        const files = Array.from(input.files || []);
    
        this.addFiles(files);
    
        // Clear input so same file can be selected again
        input.value = '';
      }
    
      handleDrop(event: DragEvent) {
        event.preventDefault();
        this.dragOver = false;
    
        const files = Array.from(event.dataTransfer?.files || []);
        this.addFiles(files);
      }
    
      handleDragOver(event: DragEvent) {
        event.preventDefault();
        this.dragOver = true;
      }
    
      handleDragLeave() {
        this.dragOver = false;
      }
    
      private addFiles(files: File[]) {
        for (const file of files) {
          // Check if we've reached the limit
          if (this.files.length >= this.maxFiles) {
            alert(`Maximum ${this.maxFiles} files allowed`);
            break;
          }
    
          // Validate file type
          if (!this.allowedTypes.includes(file.type)) {
            alert(`${file.name}: Invalid file type. Only images allowed.`);
            continue;
          }
    
          // Validate file size
          if (file.size > this.maxFileSize) {
            alert(`${file.name}: File too large. Maximum ${this.maxFileSize / 1024 / 1024}MB.`);
            continue;
          }
    
          // Create uploaded file entry
          const uploadedFile: UploadedFile = {
            id: `file-${this.nextId++}`,
            file,
            preview: '',
            progress: 0,
            status: 'pending'
          };
    
          this.files.push(uploadedFile);
    
          // Generate preview
          this.generatePreview(uploadedFile);
        }
      }
    
      private generatePreview(uploadedFile: UploadedFile) {
        const reader = new FileReader();
    
        reader.onload = (e) => {
          uploadedFile.preview = e.target?.result as string;
        };
    
        reader.readAsDataURL(uploadedFile.file);
      }
    
      removeFile(id: string) {
        const index = this.files.findIndex(f => f.id === id);
        if (index !== -1) {
          this.files.splice(index, 1);
        }
      }
    
      async uploadFile(uploadedFile: UploadedFile) {
        if (uploadedFile.status === 'uploading' || uploadedFile.status === 'complete') {
          return;
        }
    
        uploadedFile.status = 'uploading';
        uploadedFile.progress = 0;
    
        try {
          const formData = new FormData();
          formData.append('file', uploadedFile.file);
    
          // Simulate upload with progress
          await this.simulateUpload(uploadedFile);
    
          uploadedFile.status = 'complete';
          uploadedFile.progress = 100;
        } catch (error) {
          uploadedFile.status = 'error';
          uploadedFile.error = error.message || 'Upload failed';
        }
      }
    
      private async simulateUpload(uploadedFile: UploadedFile): Promise<void> {
        // In real implementation, use XMLHttpRequest or fetch with progress
        return new Promise((resolve) => {
          const duration = 2000; // 2 seconds
          const interval = 100; // Update every 100ms
          const increment = (interval / duration) * 100;
    
          const timer = setInterval(() => {
            uploadedFile.progress += increment;
    
            if (uploadedFile.progress >= 100) {
              clearInterval(timer);
              uploadedFile.progress = 100;
              resolve();
            }
          }, interval);
        });
      }
    
      async uploadAll() {
        const pending = this.files.filter(f => f.status === 'pending' || f.status === 'error');
    
        for (const file of pending) {
          await this.uploadFile(file);
        }
      }
    
      async submit() {
        const result = await this.validation.validate();
    
        if (!result.valid) {
          return;
        }
    
        // Upload any pending files
        await this.uploadAll();
    
        // Check if all uploaded successfully
        const hasErrors = this.files.some(f => f.status === 'error');
        if (hasErrors) {
          alert('Some files failed to upload. Please try again.');
          return;
        }
    
        console.log('All files uploaded successfully!', this.files);
      }
    
      get uploadedCount(): number {
        return this.files.filter(f => f.status === 'complete').length;
      }
    
      get totalSize(): string {
        const bytes = this.files.reduce((sum, f) => sum + f.file.size, 0);
        const mb = bytes / 1024 / 1024;
        return `${mb.toFixed(2)} MB`;
      }
    }
    <!-- src/components/image-upload.html -->
      <div class="image-upload">
        <h2>Upload Images</h2>
    
        <!-- Drop Zone -->
        <div
          class="drop-zone ${dragOver ? 'drag-over' : ''}"
          drop.trigger="handleDrop($event)"
          dragover.trigger="handleDragOver($event)"
          dragleave.trigger="handleDragLeave()">
    
          <div class="drop-zone-content">
            <p class="drop-zone-icon">📁</p>
            <p class="drop-zone-text">Drag & drop images here</p>
            <p class="drop-zone-or">or</p>
    
            <label for="fileInput" class="btn btn-primary">
              Choose Files
            </label>
            <input
              type="file"
              id="fileInput"
              change.trigger="handleFileSelect($event)"
              multiple
              accept="${allowedTypes.join(',')}"
              style="display: none;">
    
            <p class="drop-zone-hint">
              Maximum ${maxFiles} files, ${maxFileSize / 1024 / 1024}MB each
            </p>
          </div>
        </div>
    
        <!-- File List -->
        <div if.bind="files.length > 0" class="file-list">
          <div class="file-list-header">
            <h3>Selected Files (${files.length}/${maxFiles})</h3>
            <div class="file-list-stats">
              <span>${uploadedCount} uploaded</span>
              <span>${totalSize} total</span>
            </div>
          </div>
    
          <div class="file-grid">
            <div
              repeat.for="file of files"
              class="file-item file-item-${file.status}">
    
              <!-- Preview -->
              <div class="file-preview">
                <img
                  if.bind="file.preview"
                  src.bind="file.preview"
                  alt="${file.file.name}">
                <div if.bind="!file.preview" class="file-preview-loading">
                  Loading...
                </div>
              </div>
    
              <!-- Info -->
              <div class="file-info">
                <div class="file-name" title.bind="file.file.name">
                  ${file.file.name}
                </div>
                <div class="file-size">
                  ${file.file.size / 1024 | numberFormat:'0.0'} KB
                </div>
              </div>
    
              <!-- Progress -->
              <div if.bind="file.status === 'uploading'" class="file-progress">
                <div class="progress-bar">
                  <div
                    class="progress-fill"
                    style="width: ${file.progress}%"></div>
                </div>
                <div class="progress-text">${file.progress | numberFormat:'0'}%</div>
              </div>
    
              <!-- Status -->
              <div class="file-status">
                <span if.bind="file.status === 'pending'" class="status-badge status-pending">
                  Pending
                </span>
                <span if.bind="file.status === 'uploading'" class="status-badge status-uploading">
                  Uploading...
                </span>
                <span if.bind="file.status === 'complete'" class="status-badge status-complete">
                  ✓ Complete
                </span>
                <span if.bind="file.status === 'error'" class="status-badge status-error">
                  ✕ ${file.error}
                </span>
              </div>
    
              <!-- Actions -->
              <div class="file-actions">
                <button
                  if.bind="file.status === 'pending' || file.status === 'error'"
                  type="button"
                  click.trigger="uploadFile(file)"
                  class="btn btn-icon btn-small">
                  ↑
                </button>
    
                <button
                  type="button"
                  click.trigger="removeFile(file.id)"
                  class="btn btn-icon btn-small btn-danger">
                  ×
                </button>
              </div>
            </div>
          </div>
    
          <!-- Bulk Actions -->
          <div class="file-list-actions">
            <button
              type="button"
              click.trigger="uploadAll()"
              class="btn btn-secondary">
              Upload All
            </button>
    
            <button
              type="button"
              click.trigger="submit()"
              class="btn btn-primary">
              Complete Upload
            </button>
          </div>
        </div>
      </div>
    // src/components/location-selector.ts
    import { newInstanceForScope } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController } from '@aurelia/validation-html';
    
    interface Country {
      code: string;
      name: string;
    }
    
    interface State {
      code: string;
      name: string;
      countryCode: string;
    }
    
    interface City {
      id: string;
      name: string;
      stateCode: string;
    }
    
    interface LocationForm {
      country: string;
      state: string;
      city: string;
      address: string;
      zipCode: string;
    }
    
    export class LocationSelector {
      private form: LocationForm = {
        country: '',
        state: '',
        city: '',
        address: '',
        zipCode: ''
      };
    
      // Mock data (in real app, load from API)
      private allCountries: Country[] = [
        { code: 'US', name: 'United States' },
        { code: 'CA', name: 'Canada' },
        { code: 'MX', name: 'Mexico' }
      ];
    
      private allStates: State[] = [
        { code: 'CA', name: 'California', countryCode: 'US' },
        { code: 'NY', name: 'New York', countryCode: 'US' },
        { code: 'TX', name: 'Texas', countryCode: 'US' },
        { code: 'ON', name: 'Ontario', countryCode: 'CA' },
        { code: 'BC', name: 'British Columbia', countryCode: 'CA' },
        { code: 'JA', name: 'Jalisco', countryCode: 'MX' }
      ];
    
      private allCities: City[] = [
        { id: '1', name: 'Los Angeles', stateCode: 'CA' },
        { id: '2', name: 'San Francisco', stateCode: 'CA' },
        { id: '3', name: 'New York City', stateCode: 'NY' },
        { id: '4', name: 'Buffalo', stateCode: 'NY' },
        { id: '5', name: 'Houston', stateCode: 'TX' },
        { id: '6', name: 'Dallas', stateCode: 'TX' },
        { id: '7', name: 'Toronto', stateCode: 'ON' },
        { id: '8', name: 'Vancouver', stateCode: 'BC' },
        { id: '9', name: 'Guadalajara', stateCode: 'JA' }
      ];
    
      private isLoadingStates = false;
      private isLoadingCities = false;
    
      private validation = resolve(newInstanceForScope(IValidationController));
    
      constructor(
        @IValidationRules validationRules: IValidationRules = resolve(IValidationRules)
      ) {
        validationRules
          .on(this.form)
          .ensure('country')
            .required()
          .ensure('state')
            .required()
          .ensure('city')
            .required()
          .ensure('address')
            .required()
            .minLength(5)
          .ensure('zipCode')
            .required()
            .matches(/^\d{5}(-\d{4})?$/)
            .withMessage('Please enter a valid ZIP code');
      }
    
      // Computed: Available states based on selected country
      get availableStates(): State[] {
        if (!this.form.country) return [];
        return this.allStates.filter(s => s.countryCode === this.form.country);
      }
    
      // Computed: Available cities based on selected state
      get availableCities(): City[] {
        if (!this.form.state) return [];
        return this.allCities.filter(c => c.stateCode === this.form.state);
      }
    
      // When country changes, reset dependent fields
      async countryChanged(newValue: string, oldValue: string) {
        if (newValue !== oldValue) {
          this.form.state = '';
          this.form.city = '';
    
          // In real app, load states from API
          if (newValue) {
            this.isLoadingStates = true;
            await this.loadStates(newValue);
            this.isLoadingStates = false;
          }
        }
      }
    
      // When state changes, reset city
      async stateChanged(newValue: string, oldValue: string) {
        if (newValue !== oldValue) {
          this.form.city = '';
    
          // In real app, load cities from API
          if (newValue) {
            this.isLoadingCities = true;
            await this.loadCities(newValue);
            this.isLoadingCities = false;
          }
        }
      }
    
      private async loadStates(countryCode: string): Promise<void> {
        // Simulate API call
        await new Promise(resolve => setTimeout(resolve, 500));
        // States are already filtered by computed property
      }
    
      private async loadCities(stateCode: string): Promise<void> {
        // Simulate API call
        await new Promise(resolve => setTimeout(resolve, 500));
        // Cities are already filtered by computed property
      }
    
      async submit() {
        const result = await this.validation.validate();
    
        if (!result.valid) {
          return;
        }
    
        console.log('Location submitted:', this.form);
      }
    }
    <!-- src/components/location-selector.html -->
      <form submit.trigger="submit()" class="location-form">
        <h2>Enter Your Location</h2>
    
        <div class="form-field">
          <label for="country">Country *</label>
          <select
            id="country"
            value.bind="form.country & validate">
            <option value="">Select a country</option>
            <option
              repeat.for="country of allCountries"
              value.bind="country.code">
              ${country.name}
            </option>
          </select>
        </div>
    
        <div class="form-field">
          <label for="state">State/Province *</label>
          <select
            id="state"
            value.bind="form.state & validate"
            disabled.bind="!form.country || isLoadingStates">
            <option value="">
              ${isLoadingStates ? 'Loading...' : 'Select a state'}
            </option>
            <option
              repeat.for="state of availableStates"
              value.bind="state.code">
              ${state.name}
            </option>
          </select>
          <div if.bind="!form.country" class="field-hint">
            Please select a country first
          </div>
        </div>
    
        <div class="form-field">
          <label for="city">City *</label>
          <select
            id="city"
            value.bind="form.city & validate"
            disabled.bind="!form.state || isLoadingCities">
            <option value="">
              ${isLoadingCities ? 'Loading...' : 'Select a city'}
            </option>
            <option
              repeat.for="city of availableCities"
              value.bind="city.id">
              ${city.name}
            </option>
          </select>
          <div if.bind="!form.state" class="field-hint">
            Please select a state first
          </div>
        </div>
    
        <div class="form-field">
          <label for="address">Street Address *</label>
          <input
            type="text"
            id="address"
            value.bind="form.address & validate"
            placeholder="123 Main St">
        </div>
    
        <div class="form-field">
          <label for="zipCode">ZIP/Postal Code *</label>
          <input
            type="text"
            id="zipCode"
            value.bind="form.zipCode & validate"
            placeholder="12345">
        </div>
    
        <button type="submit" class="btn btn-primary">
          Continue
        </button>
      </form>
    // src/components/validated-field.ts
    import { bindable, INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    import { IValidationController } from '@aurelia/validation-html';
    
    export class ValidatedField {
      @bindable label: string;
      @bindable value: any;
      @bindable type: string = 'text';
      @bindable placeholder: string = '';
      @bindable required: boolean = false;
      @bindable disabled: boolean = false;
      @bindable hint: string = '';
      @bindable validation: IValidationController;
    
      private fieldId: string;
      private inputElement: HTMLInputElement;
      private element = resolve(INode);
    
      constructor() {
        this.fieldId = `field-${Math.random().toString(36).substr(2, 9)}`;
      }
    
      get errors(): string[] {
        if (!this.validation) return [];
    
        const results = this.validation.results || [];
        return results
          .filter(r => !r.valid && r.propertyName === this.getPropertyName())
          .map(r => r.message);
      }
    
      get hasError(): boolean {
        return this.errors.length > 0;
      }
    
      private getPropertyName(): string {
        // Extract property name from binding expression
        // This is a simplified version
        const binding = this.element.getAttribute('value.bind');
        return binding?.split('&')[0].trim() || '';
      }
    
      focus() {
        this.inputElement?.focus();
      }
    }
    <!-- src/components/validated-field.html -->
      <div class="form-field ${hasError ? 'has-error' : ''}">
        <label for.bind="fieldId">
          ${label}
          <span if.bind="required" class="required-indicator">*</span>
        </label>
    
        <input
          ref="inputElement"
          type.bind="type"
          id.bind="fieldId"
          value.bind="value & validate"
          placeholder.bind="placeholder"
          disabled.bind="disabled"
          aria-invalid.bind="hasError"
          aria-describedby="${fieldId}-hint ${hasError ? `${fieldId}-error` : ''}">
    
        <div
          if.bind="hint && !hasError"
          id="${fieldId}-hint"
          class="field-hint">
          ${hint}
        </div>
    
        <div
          if.bind="hasError"
          id="${fieldId}-error"
          class="field-error"
          role="alert">
          ${errors[0]}
        </div>
      </div>
    // src/pages/signup.ts
    import { newInstanceForScope } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController } from '@aurelia/validation-html';
    
    export class Signup {
      private user = {
        username: '',
        email: '',
        password: '',
        confirmPassword: ''
      };
    
      private validation = resolve(newInstanceForScope(IValidationController));
    
      constructor(
        @IValidationRules validationRules: IValidationRules = resolve(IValidationRules)
      ) {
        validationRules
          .on(this.user)
          .ensure('username')
            .required()
            .minLength(3)
            .matches(/^[a-zA-Z0-9_]+$/)
            .withMessage('Username can only contain letters, numbers, and underscores')
          .ensure('email')
            .required()
            .email()
          .ensure('password')
            .required()
            .minLength(8)
          .ensure('confirmPassword')
            .required()
            .satisfies((value: string) => value === this.user.password)
            .withMessage('Passwords must match');
      }
    
      async submit() {
        const result = await this.validation.validate();
        if (!result.valid) return;
    
        console.log('Signup:', this.user);
      }
    }
    <!-- src/pages/signup.html -->
      <form submit.trigger="submit()">
        <h2>Sign Up</h2>
    
        <validated-field
          label="Username"
          value.bind="user.username"
          validation.bind="validation"
          required.bind="true"
          hint="Letters, numbers, and underscores only">
        </validated-field>
    
        <validated-field
          label="Email"
          type="email"
          value.bind="user.email"
          validation.bind="validation"
          required.bind="true">
        </validated-field>
    
        <validated-field
          label="Password"
          type="password"
          value.bind="user.password"
          validation.bind="validation"
          required.bind="true"
          hint="Minimum 8 characters">
        </validated-field>
    
        <validated-field
          label="Confirm Password"
          type="password"
          value.bind="user.confirmPassword"
          validation.bind="validation"
          required.bind="true">
        </validated-field>
    
        <button type="submit" class="btn btn-primary">
          Create Account
        </button>
      </form>

    Comprehensive Reference

    Master Aurelia 2 forms with comprehensive coverage of binding patterns, advanced collections, validation integration, and performance optimization for production applications.

    Forms are the cornerstone of interactive web applications. Whether you're building simple contact forms, complex data-entry systems, or dynamic configuration interfaces, Aurelia provides a comprehensive and performant forms system. This guide covers everything from basic input binding to advanced patterns like collection-based form controls, dynamic form generation, and seamless validation integration.

    Looking for focused guides? This is a comprehensive reference covering all form concepts. For more digestible, task-focused guides, check out:

    • - Text inputs, textareas, number/date inputs

    • - Checkboxes, radio buttons, select elements, Sets, Maps

    • - Handling form submission, state management, auto-save

    • - File input handling, validation, progress tracking

    • - Multi-step wizards, dynamic forms, conditional validation, form state management

    This guide assumes familiarity with Aurelia's binding system and template syntax. For fundamentals, see first.

    Table of Contents


    Understanding Aurelia's Form Architecture

    Aurelia's forms system is built on sophisticated observer patterns that provide automatic synchronization between your view models and form controls. Understanding this architecture helps you build more efficient and maintainable forms.

    Data Flow Architecture

    Key Components:

    1. Observers: Monitor DOM events and property changes

    2. Bindings: Connect observers to view model properties

    3. Collection Observers: Handle arrays, Sets, and Maps efficiently

    4. Mutation Observers: Track dynamic DOM changes

    Automatic Change Detection

    Aurelia automatically observes:

    • Text inputs: input, change, keyup events

    • Checkboxes/Radio: change events with array synchronization

    • Select elements: change

    This means you typically don't need manual event handlers—Aurelia handles the complexity automatically while providing hooks for customization when needed.


    Basic Input Binding

    Aurelia provides intuitive two-way binding for all standard form elements. Let's start with the fundamentals and build toward advanced patterns.

    Simple Text Inputs

    The foundation of most forms is text input binding:

    Textarea Binding

    Textareas work identically to text inputs:

    Number and Date Inputs

    For specialized input types, Aurelia handles type coercion automatically:


    Binding With Text and Textarea Inputs

    Text Input

    Binding to text inputs in Aurelia is straightforward:

    You can also bind other attributes like placeholder:

    Textarea

    Textareas work just like text inputs, with value.bind for two-way binding:

    Any changes to textAreaValue in the view model will show up in the <textarea>, and vice versa.


    Advanced Collection Patterns

    One of Aurelia's most powerful features is its sophisticated support for collection-based form controls. Beyond simple arrays, Aurelia supports Sets, Maps, and custom collection types with optimal performance characteristics.

    Boolean Checkboxes

    The simplest checkbox pattern binds to boolean properties:

    Array-Based Multi-Select

    For traditional multi-select scenarios, bind arrays to checkbox groups:

    Set-Based Collections (Advanced)

    For high-performance scenarios with frequent additions/removals, use Set collections:

    Resource-Keyed Collections (Expert Level)

    Per-Resource Permission Sets (Expert Level)

    For complex key-value selections (e.g., multiple actions per resource), keep a Set per resource so each checkbox can reuse Aurelia's built-in collection handling:

    Performance Considerations

    Choose the right collection type for your use case:

    • Arrays: General purpose, good for small to medium collections

    • Sets: High-performance for frequent additions/removals, O(1) lookups

    • Record/Map of Sets: Model resource → actions (or similar hierarchies) cleanly

    • Custom Matchers: When object identity comparison isn't sufficient

    Performance Tips:

    • Use Set for large collections with frequent changes

    • Implement efficient matcher functions for object comparison

    • Avoid creating new objects in templates—use computed properties

    • Consider virtualization for very large checkbox lists


    Event Handling and Binding Behaviors

    Aurelia provides sophisticated event handling and binding behaviors that give you precise control over when and how form data synchronizes. These features are crucial for building responsive, performant forms.

    Advanced Event Timing with updateTrigger

    By default, Aurelia uses appropriate events for each input type, but you can customize this behavior:

    Rate Limiting with Debounce and Throttle

    Control the frequency of updates to improve performance and user experience:

    Signal-Based Reactive Updates

    Signals provide cache invalidation and coordinated updates across components:


    Dynamic Forms and Performance

    Building performant, dynamic forms requires understanding Aurelia's observation system and applying optimization strategies for complex scenarios.

    Dynamic Field Generation

    Create forms that adapt their structure based on configuration:

    Performance Optimization Strategies

    Implement performance optimizations for large, complex forms:


    Radio Button and Select Element Patterns

    Aurelia provides comprehensive support for single-selection controls with sophisticated object binding and custom matching logic.

    Advanced Radio Button Groups

    Radio buttons with complex object handling and conditional logic:

    Advanced Select Elements with Smart Filtering

    Sophisticated select components with search, grouping, and virtual scrolling:


    Form Submission Patterns

    Modern web applications require sophisticated form submission strategies that handle success, failure, loading states, and complex business logic. Aurelia provides flexible patterns for all scenarios.

    Basic Form Submission with State Management

    Implement comprehensive submission state management for better user experience:

    Multi-Step Form Submission

    Handle complex multi-step forms with progress tracking and validation:


    File Inputs and Upload Handling

    Working with file uploads in Aurelia typically involves using the standard <input type="file"> element and handling file data in your view model. While Aurelia doesn’t provide special bindings for file inputs, you can easily wire up event handlers or use standard properties to capture and upload files.

    Capturing File Data

    In most cases, you’ll want to listen for the change event on a file input:

    • multiple: Allows selecting more than one file.

    • accept="image/*": Restricts file selection to images (this can be changed to fit your needs).

    • change.trigger="handleFileSelect($event)": Calls a method in your view model to handle the file selection event.

    View Model Handling

    You can retrieve the selected files from the event object in your view model:

    Key Points:

    • Reading File Data: input.files returns a FileList; converting it to an array (Array.from) makes it easier to iterate over.

    • FormData: Using FormData to append files is a convenient way to send them to the server (via Fetch).

    • Error Handling: Always check response.ok

    Single File Inputs

    If you only need a single file, omit multiple and simplify your logic:

    Validation and Security

    When handling file uploads, consider adding validation and security measures:

    • Server-side Validation: Even if you filter files by type on the client (accept="image/*"), always verify on the server to ensure the files are valid and safe.

    • File Size Limits: Check file sizes either on the client or server (or both) to prevent excessively large uploads.

    • Progress Indicators: For a better user experience, consider using XMLHttpRequest or the Fetch API with progress events (via third-party solutions or polyfills), so you can display an upload progress bar.


    Validation Integration

    Aurelia's validation system integrates seamlessly with forms through the & validate binding behavior and specialized validation components. This section covers practical validation patterns for production applications.

    Basic Validation with & validate

    The & validate binding behavior automatically integrates form inputs with Aurelia's validation system.

    Validation Controller Scope: Use newInstanceForScope(IValidationController) to create a validation controller scoped to your component. This ensures each component gets its own isolated validation controller, preventing conflicts between different forms or components.

    Advanced Validation Display

    Use validation components for sophisticated error display:

    Dynamic Validation Rules

    Create validation rules that adapt to changing form conditions:

    Real-time Validation Feedback

    Provide immediate feedback with sophisticated validation timing:

    For comprehensive validation documentation, see the dedicated .


    Security and Best Practices

    Security in forms is critical for protecting user data and preventing common web vulnerabilities. Aurelia provides the foundation for secure form implementations, but you must implement security best practices.

    Input Validation and Sanitization

    Always validate and sanitize user input on both client and server sides:

    Rate Limiting and Abuse Prevention

    Implement client-side rate limiting and abuse prevention:

    Content Security Policy (CSP) Considerations

    Implement CSP-friendly form patterns:


    Accessibility Considerations

    Building accessible forms ensures your application works for users with disabilities and meets WCAG guidelines. Aurelia provides excellent support for accessibility features.

    Semantic Form Structure

    Use proper semantic HTML and ARIA attributes:

    Accessible Form Validation

    Implement validation feedback that works with screen readers:

    CSS for Accessibility

    Include essential accessibility styles:

    Testing Accessibility

    Include accessibility testing strategies:

    This comprehensive forms documentation provides production-ready patterns for all aspects of form development in Aurelia 2, from basic binding to advanced security and accessibility considerations. Each section includes real-world examples that you can adapt to your specific use cases.

    Event Handling and Binding Behaviors
  • Validation Integration

  • File Upload Handling

  • Form Submission Patterns

  • Security and Best Practices

  • Accessibility Considerations

  • Value Converters & Binding Behaviors: Transform and control data flow

    events with mutation observation
  • Collections: Array mutations, Set/Map changes

  • Object properties: Deep property observation

  • to handle server or network errors.
  • Disabling the Button: In the HTML, disabled.bind="!selectedFiles.length" keeps the button disabled until at least one file is selected.

  • Form Basics
    Collections
    Form Submission
    File Uploads
    Advanced Patterns
    Template Syntax & Features
    Understanding Aurelia's Form Architecture
    Basic Input Binding
    Advanced Collection Patterns
    Dynamic Forms and Performance
    Validation Guide
    User Input → DOM Event → Observer → Binding → View Model → Reactive Updates
         ↑                                                            ↓
    Form Element ← DOM Update ← Binding ← Property Change ← View Model
    <form submit.trigger="handleSubmit()">
      <div class="form-group">
        <label for="email">Email:</label>
        <input id="email" 
               type="email" 
               value.bind="email" 
               placeholder.bind="emailPlaceholder" />
      </div>
      <div class="form-group">
        <label for="password">Password:</label>
        <input id="password" 
               type="password" 
               value.bind="password" />
      </div>
      <button type="submit" disabled.bind="!isFormValid">Login</button>
    </form>
    export class LoginComponent {
      email = '';
      password = '';
      emailPlaceholder = 'Enter your email address';
    
      get isFormValid(): boolean {
        return this.email.length > 0 && this.password.length >= 8;
      }
    
      handleSubmit() {
        if (this.isFormValid) {
          // Process form submission
          console.log('Submitting:', { email: this.email, password: this.password });
        }
      }
    }
    <div class="form-group">
      <label for="comments">Comments:</label>
      <textarea id="comments" 
                value.bind="comments"
                rows="4"
                maxlength.bind="maxCommentLength"></textarea>
      <small>${comments.length}/${maxCommentLength} characters</small>
    </div>
    export class FeedbackForm {
      comments = '';
      maxCommentLength = 500;
    }
    <div class="form-group">
      <label for="age">Age:</label>
      <input id="age" 
             type="number" 
             value.bind="age" 
             min="18" 
             max="120" />
    </div>
    <div class="form-group">
      <label for="birthdate">Birth Date:</label>
      <input id="birthdate" 
             type="date" 
             value.bind="birthDate" />
    </div>
    export class ProfileForm {
      age: number = 25;
      birthDate: Date = new Date('1998-01-01');
      
      // Computed property demonstrating reactive updates
      get isAdult(): boolean {
        return this.age >= 18;
      }
    }
    <form>
      <label>User value:</label><br />
      <input type="text" value.bind="userValue" />
    </form>
    <form>
      <label>User value:</label><br />
      <input type="text" value.bind="userValue" placeholder.bind="myPlaceholder" />
    </form>
    <form>
      <label>Comments:</label><br />
      <textarea value.bind="textAreaValue"></textarea>
    </form>
    export class PreferencesForm {
      emailNotifications = false;
      smsNotifications = true;
      pushNotifications = false;
      
      // Computed property for form validation
      get hasValidNotificationPrefs(): boolean {
        return this.emailNotifications || this.smsNotifications || this.pushNotifications;
      }
    }
    <form>
      <fieldset>
        <legend>Notification Preferences</legend>
        <label>
          <input type="checkbox" checked.bind="emailNotifications" />
          Email notifications
        </label>
        <label>
          <input type="checkbox" checked.bind="smsNotifications" />
          SMS notifications
        </label>
        <label>
          <input type="checkbox" checked.bind="pushNotifications" />
          Push notifications
        </label>
      </fieldset>
      
      <div if.bind="!hasValidNotificationPrefs" class="warning">
        Please select at least one notification method.
      </div>
    </form>
    interface Product {
      id: number;
      name: string;
      category: string;
      price: number;
    }
    
    export class ProductSelectionForm {
      products: Product[] = [
        { id: 1, name: "Gaming Mouse", category: "Peripherals", price: 89.99 },
        { id: 2, name: "Mechanical Keyboard", category: "Peripherals", price: 159.99 },
        { id: 3, name: "4K Monitor", category: "Display", price: 399.99 },
        { id: 4, name: "Graphics Card", category: "Components", price: 599.99 }
      ];
    
      // Array of selected product IDs
      selectedProductIds: number[] = [];
      
      // Array of selected product objects
      selectedProducts: Product[] = [];
    
      get totalValue(): number {
        return this.selectedProducts.reduce((sum, product) => sum + product.price, 0);
      }
    }
    <form>
      <h3>Select Products</h3>
      
      <!-- ID-based selection -->
      <div class="product-grid">
        <div repeat.for="product of products" class="product-card">
          <label>
            <input type="checkbox" 
                   model.bind="product.id" 
                   checked.bind="selectedProductIds" />
            <strong>${product.name}</strong>
            <span class="category">${product.category}</span>
            <span class="price">$${product.price}</span>
          </label>
        </div>
      </div>
    
      <!-- Object-based selection (more flexible) -->
      <h4>Or select complete product objects:</h4>
      <div class="product-list">
        <label repeat.for="product of products" class="product-item">
          <input type="checkbox" 
                 model.bind="product" 
                 checked.bind="selectedProducts" />
          ${product.name} - $${product.price}
        </label>
      </div>
    
      <div class="summary" if.bind="selectedProducts.length">
        <h4>Selected Items (${selectedProducts.length})</h4>
        <ul>
          <li repeat.for="product of selectedProducts">
            ${product.name} - $${product.price}
          </li>
        </ul>
        <strong>Total: $${totalValue | number:'0.00'}</strong>
      </div>
    </form>
    export class TagSelectionForm {
      availableTags = [
        { id: 'frontend', name: 'Frontend Development', color: '#blue' },
        { id: 'backend', name: 'Backend Development', color: '#green' },
        { id: 'database', name: 'Database Design', color: '#orange' },
        { id: 'devops', name: 'DevOps', color: '#purple' },
        { id: 'mobile', name: 'Mobile Development', color: '#red' }
      ];
    
      // Set-based selection for O(1) lookups
      selectedTags: Set<string> = new Set(['frontend', 'database']);
    
      get selectedTagList() {
        return this.availableTags.filter(tag => this.selectedTags.has(tag.id));
      }
    
      toggleTag(tagId: string) {
        if (this.selectedTags.has(tagId)) {
          this.selectedTags.delete(tagId);
        } else {
          this.selectedTags.add(tagId);
        }
      }
    }
    <form>
      <h3>Select Your Skills</h3>
      <div class="tag-container">
        <label repeat.for="tag of availableTags" 
               class="tag-label" 
               css.bind="{ '--tag-color': tag.color }">
          <input type="checkbox" 
                 model.bind="tag.id" 
                 checked.bind="selectedTags" />
          <span class="tag-text">${tag.name}</span>
        </label>
      </div>
    
      <div if.bind="selectedTags.size > 0" class="selected-tags">
        <h4>Selected Skills (${selectedTags.size})</h4>
        <div class="tag-chips">
          <span repeat.for="tag of selectedTagList" class="tag-chip">
            ${tag.name}
            <button type="button" 
                    click.trigger="toggleTag(tag.id)" 
                    class="remove-tag">×</button>
          </span>
        </div>
      </div>
    </form>
    interface Permission {
      resource: string;
      actions: string[];
      description: string;
    }
    
    export class PermissionForm {
      permissions: Permission[] = [
        {
          resource: 'users',
          actions: ['create', 'read', 'update', 'delete'],
          description: 'User management operations'
        },
        {
          resource: 'posts',
          actions: ['create', 'read', 'update', 'delete', 'publish'],
          description: 'Content management operations'
        },
        {
          resource: 'settings',
          actions: ['read', 'update'],
          description: 'System configuration'
        }
      ];
    
      // Record: resource -> Set<action>
      selectedPermissions: Record<string, Set<string>> = {};
    
      constructor() {
        for (const permission of this.permissions) {
          this.selectedPermissions[permission.resource] = new Set();
        }
        this.selectedPermissions['users'].add('read');
        this.selectedPermissions['posts'].add('read');
        this.selectedPermissions['posts'].add('create');
      }
    
      get permissionSummary() {
        const summary: Array<{ resource: string; actions: string[] }> = [];
        Object.entries(this.selectedPermissions).forEach(([resource, actions]) => {
          if (actions.size > 0) {
            summary.push({ resource, actions: Array.from(actions) });
          }
        });
        return summary;
      }
    }
    <form>
      <h3>Configure Permissions</h3>
      <div class="permission-matrix">
        <div repeat.for="permission of permissions" class="permission-group">
          <h4>${permission.resource | capitalize}</h4>
          <p class="description">${permission.description}</p>
          <div class="action-checkboxes">
            <label repeat.for="action of permission.actions" class="action-label">
              <input type="checkbox" 
                     model.bind="action"
                     checked.bind="selectedPermissions[permission.resource]" />
              ${action | capitalize}
            </label>
          </div>
        </div>
      </div>
    
      <div if.bind="permissionSummary.length > 0" class="permission-summary">
        <h4>Selected Permissions</h4>
        <ul>
          <li repeat.for="perm of permissionSummary">
            <strong>${perm.resource}</strong>: ${perm.actions.join(', ')}
          </li>
        </ul>
      </div>
    </form>
    export class AdvancedForm {
      searchQuery = '';
      username = '';
      description = '';
    
      // Debounced search handler
      performSearch = debounce((query: string) => {
        console.log('Searching for:', query);
        // Perform API call
      }, 300);
    
      searchQueryChanged(newValue: string) {
        this.performSearch(newValue);
      }
    }
    <form>
      <!-- Update on every keystroke (input event) -->
      <input type="text" 
             value.bind="searchQuery & updateTrigger:'input'" 
             placeholder="Real-time search..." />
    
      <!-- Update on focus loss (blur event) -->
      <input type="text" 
             value.bind="username & updateTrigger:'blur'" 
             placeholder="Username (validates on blur)" />
    
      <!-- Multiple events -->
      <textarea value.bind="description & updateTrigger:['input', 'blur']"
                placeholder="Auto-save draft on input and blur"></textarea>
    
      <!-- Custom events -->
      <input type="text" 
             value.bind="customValue & updateTrigger:'keydown':'focus'"
             placeholder="Updates on keydown and focus" />
    </form>
    export class SearchForm {
      searchTerm = '';
      scrollPosition = 0;
      apiCallCount = 0;
    
      // This will be called max once per 300ms
      searchTermChanged(newTerm: string) {
        this.apiCallCount++;
        console.log(`API Call #${this.apiCallCount}: Searching for "${newTerm}"`);
      }
    
      // Throttled scroll tracking
      onScroll(position: number) {
        console.log('Scroll position:', position);
      }
    }
    <form>
      <!-- Debounce: Wait for pause in typing -->
      <input type="search" 
             value.bind="searchTerm & debounce:300"
             placeholder="Search with 300ms debounce..." />
    
      <!-- Throttle: Maximum rate limiting -->
      <input type="range" 
             min="0" max="100" 
             value.bind="scrollPosition & throttle:100"
             input.trigger="onScroll(scrollPosition)" />
    
      <!-- Signal-based cache invalidation -->
      <input type="text" 
             value.bind="searchTerm & debounce:300:'searchSignal'"
             placeholder="Cache-aware search" />
    </form>
    import { resolve } from '@aurelia/kernel';
    import { observable, computed } from '@aurelia/runtime';
    import { ISignaler } from '@aurelia/runtime-html';
    
    export class SignalDrivenForm {
      @observable searchCriteria = {
        term: '',
        category: 'all',
        priceRange: [0, 1000]
      };
    
      // Signal dispatcher
      private signaler = resolve(ISignaler);
    
      updateSearch(criteria: Partial<typeof this.searchCriteria>) {
        Object.assign(this.searchCriteria, criteria);
        // Notify all signal listeners
        this.signaler.dispatchSignal('searchUpdated');
      }
    
      // Expensive computed property with signal-based cache
      @computed('searchCriteria', 'searchUpdated')
      get searchResults() {
        // This will only recompute when 'searchUpdated' signal is dispatched
        return this.performExpensiveSearch(this.searchCriteria);
      }
    
      private performExpensiveSearch(criteria: any) {
        console.log('Performing expensive search operation...');
        // Simulate expensive computation
        return [];
      }
    }
    <form>
      <!-- Signal-coordinated form fields -->
      <input type="search" 
             value.bind="searchCriteria.term & debounce:300:'searchUpdated'" />
    
      <select value.bind="searchCriteria.category & signal:'searchUpdated'">
        <option value="all">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="books">Books</option>
      </select>
    
      <input type="range" 
             min="0" max="1000"
             value.bind="searchCriteria.priceRange[1] & throttle:200:'searchUpdated'" />
    
      <!-- Results update automatically via signal -->
      <div class="results">
        <p>Found ${searchResults.length} results</p>
        <!-- Results rendered here -->
      </div>
    </form>
    import { resolve, newInstanceForScope } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController } from '@aurelia/validation-html';
    
    interface FieldConfig {
      type: 'text' | 'number' | 'select' | 'checkbox' | 'textarea';
      name: string;
      label: string;
      required?: boolean;
      options?: Array<{ value: any; label: string }>;
      validation?: string[];
      placeholder?: string;
      min?: number;
      max?: number;
    }
    
    export class DynamicFormGenerator {
      formConfig: FieldConfig[] = [];
      formData: Record<string, any> = {};
      formSchema: string = '';
    
      private readonly validationRules = resolve(IValidationRules);
      private readonly validationController = resolve(newInstanceForScope(IValidationController));
    
      // Load form configuration from various sources
      async loadFormConfiguration(schemaId: string) {
        try {
          const response = await fetch(`/api/forms/schema/${schemaId}`);
          this.formConfig = await response.json();
          this.initializeFormData();
        } catch (error) {
          console.error('Failed to load form schema:', error);
        }
      }
    
      private initializeFormData() {
        this.formData = {};
        this.formConfig.forEach(field => {
          switch (field.type) {
            case 'checkbox':
              this.formData[field.name] = false;
              break;
            case 'number':
              this.formData[field.name] = field.min || 0;
              break;
            default:
              this.formData[field.name] = '';
          }
        });
      }
    
      // Dynamic validation rule setup
      setupDynamicValidation() {
        
        this.formConfig.forEach(fieldConfig => {
          let rule = this.validationRules.on(this.formData).ensure(fieldConfig.name);
          
          if (fieldConfig.required) {
            rule = rule.required().withMessage(`${fieldConfig.label} is required`);
          }
          
          if (fieldConfig.validation) {
            fieldConfig.validation.forEach(validationType => {
              switch (validationType) {
                case 'email':
                  rule = rule.email().withMessage('Please enter a valid email address');
                  break;
                case 'min-length-5':
                  rule = rule.minLength(5).withMessage(`${fieldConfig.label} must be at least 5 characters`);
                  break;
                // Add more validation types as needed
              }
            });
          }
          
          if (fieldConfig.type === 'number') {
            if (fieldConfig.min !== undefined) {
              rule = rule.min(fieldConfig.min);
            }
            if (fieldConfig.max !== undefined) {
              rule = rule.max(fieldConfig.max);
            }
          }
        });
      }
    
      addField(fieldConfig: FieldConfig) {
        this.formConfig.push(fieldConfig);
        // Initialize form data for new field
        this.formData[fieldConfig.name] = fieldConfig.type === 'checkbox' ? false : '';
        this.setupDynamicValidation();
      }
    
      removeField(fieldName: string) {
        this.formConfig = this.formConfig.filter(field => field.name !== fieldName);
        delete this.formData[fieldName];
      }
    
      async submitDynamicForm() {
        const validationResult = await this.validationController.validate();
        
        if (validationResult.valid) {
          const payload = {
            schemaId: this.formSchema,
            data: this.formData,
            timestamp: new Date().toISOString()
          };
          
          try {
            const response = await fetch('/api/forms/submit', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify(payload)
            });
            
            if (response.ok) {
              console.log('Form submitted successfully');
            }
          } catch (error) {
            console.error('Form submission failed:', error);
          }
        }
      }
    }
    <form submit.trigger="submitDynamicForm()" class="dynamic-form">
      <h2>Dynamic Form Generator</h2>
      
      <div class="form-controls">
        <label for="schema-select">Form Schema:</label>
        <select id="schema-select" 
                value.bind="formSchema" 
                change.trigger="loadFormConfiguration(formSchema)">
          <option value="">Select a form schema...</option>
          <option value="contact">Contact Form</option>
          <option value="survey">Survey Form</option>
          <option value="registration">Registration Form</option>
        </select>
      </div>
    
      <div if.bind="formConfig.length > 0" class="dynamic-fields">
        <div repeat.for="field of formConfig" class="form-group">
          
          <!-- Text Input -->
          <div if.bind="field.type === 'text'" class="field-container">
            <label for.bind="field.name">${field.label}</label>
            <input type="text"
                   id.bind="field.name"
                   value.bind="formData[field.name] & validate"
                   placeholder.bind="field.placeholder"
                   class="form-control" />
          </div>
    
          <!-- Number Input -->
          <div if.bind="field.type === 'number'" class="field-container">
            <label for.bind="field.name">${field.label}</label>
            <input type="number"
                   id.bind="field.name"
                   value.bind="formData[field.name] & validate"
                   min.bind="field.min"
                   max.bind="field.max"
                   class="form-control" />
          </div>
    
          <!-- Select Dropdown -->
          <div if.bind="field.type === 'select'" class="field-container">
            <label for.bind="field.name">${field.label}</label>
            <select id.bind="field.name"
                    value.bind="formData[field.name] & validate"
                    class="form-control">
              <option value="">Choose...</option>
              <option repeat.for="option of field.options" 
                      model.bind="option.value">
                ${option.label}
              </option>
            </select>
          </div>
    
          <!-- Checkbox -->
          <div if.bind="field.type === 'checkbox'" class="field-container">
            <label class="checkbox-label">
              <input type="checkbox"
                     checked.bind="formData[field.name] & validate" />
              ${field.label}
            </label>
          </div>
    
          <!-- Textarea -->
          <div if.bind="field.type === 'textarea'" class="field-container">
            <label for.bind="field.name">${field.label}</label>
            <textarea id.bind="field.name"
                      value.bind="formData[field.name] & validate"
                      placeholder.bind="field.placeholder"
                      rows="4"
                      class="form-control"></textarea>
          </div>
    
          <!-- Field Management (Development Mode) -->
          <div class="field-actions" if.bind="developmentMode">
            <button type="button" 
                    click.trigger="removeField(field.name)"
                    class="btn btn-sm btn-danger">
              Remove Field
            </button>
          </div>
        </div>
      </div>
    
      <div class="form-actions" if.bind="formConfig.length > 0">
        <button type="submit" class="btn btn-primary">Submit Form</button>
        <button type="button" 
                click.trigger="formData = {}"
                class="btn btn-secondary">Clear Form</button>
      </div>
    
      <!-- Debug Information -->
      <div class="debug-panel" if.bind="debugMode">
        <h4>Form Data Debug</h4>
        <pre>${formData | json}</pre>
        
        <h4>Form Configuration Debug</h4>
        <pre>${formConfig | json}</pre>
      </div>
    </form>
    export class PerformantFormComponent {
      // Virtual scrolling for large option lists
      largeDataSet: any[] = [];
      virtualScrollOptions = {
        itemHeight: 40,
        containerHeight: 300,
        buffer: 10
      };
    
      // Lazy loading of form sections
      private loadedSections: Set<string> = new Set();
      
      // Debounced validation for expensive operations
      private debouncedValidations = new Map<string, Function>();
    
      // Efficient collection operations
      selectedItems: Set<any> = new Set();
      
      // Memoized computed properties
      @computed('formData.firstName', 'formData.lastName', 'formData.email')
      get computedSummary() {
        // Expensive computation that only runs when dependencies change
        return this.generateFormSummary();
      }
    
      // Optimize large collection updates
      updateLargeCollection(newItems: any[]) {
        // Use Set for O(1) lookups instead of Array.includes()
        const newItemsSet = new Set(newItems.map(item => item.id));
        
        // Batch updates to minimize observer notifications
        this.startBatch();
        
        this.largeDataSet = this.largeDataSet.filter(item => {
          if (newItemsSet.has(item.id)) {
            // Update existing item efficiently
            Object.assign(item, newItems.find(newItem => newItem.id === item.id));
            return true;
          }
          return false;
        });
        
        // Add new items
        newItems.forEach(item => {
          if (!this.largeDataSet.find(existing => existing.id === item.id)) {
            this.largeDataSet.push(item);
          }
        });
        
        this.endBatch();
      }
    
      // Efficient form section loading
      async loadFormSection(sectionName: string) {
        if (this.loadedSections.has(sectionName)) {
          return; // Already loaded
        }
    
        try {
          const sectionData = await fetch(`/api/forms/sections/${sectionName}`);
          const section = await sectionData.json();
          
          // Load section-specific validation rules
          this.loadSectionValidation(section);
          
          this.loadedSections.add(sectionName);
        } catch (error) {
          console.error(`Failed to load section ${sectionName}:`, error);
        }
      }
    
      // Memory-efficient validation
      createEfficientValidator(fieldName: string, validatorFn: Function, delay: number = 300) {
        if (this.debouncedValidations.has(fieldName)) {
          // Cleanup existing validator
          const existingValidator = this.debouncedValidations.get(fieldName);
          existingValidator.cancel?.();
        }
    
        const debouncedValidator = debounce(validatorFn, delay);
        this.debouncedValidations.set(fieldName, debouncedValidator);
        return debouncedValidator;
      }
    
      // Cleanup on disposal
      dispose() {
        // Cancel all pending validations
        this.debouncedValidations.forEach(validator => {
          validator.cancel?.();
        });
        this.debouncedValidations.clear();
      }
    
      private startBatch() {
        // Implementation depends on your observer system
        // This is a conceptual example
      }
    
      private endBatch() {
        // Implementation depends on your observer system
        // This is a conceptual example
      }
    
      private generateFormSummary() {
        // Expensive computation
        return {
          completionPercentage: this.calculateCompletionPercentage(),
          validationStatus: this.getOverallValidationStatus(),
          estimatedTimeToComplete: this.estimateTimeToComplete()
        };
      }
    
      private calculateCompletionPercentage(): number {
        // Calculate based on filled vs empty fields
        return 85; // Example value
      }
    
      private getOverallValidationStatus(): string {
        // Aggregate validation status
        return 'partial'; // Example value
      }
    
      private estimateTimeToComplete(): number {
        // Estimate in minutes
        return 5; // Example value
      }
    
      private loadSectionValidation(section: any) {
        // Load validation rules specific to the section
      }
    }
    interface PaymentMethod {
      id: string;
      type: 'credit' | 'debit' | 'paypal' | 'crypto';
      name: string;
      fee: number;
      processingTime: string;
      requiresVerification: boolean;
    }
    
    export class PaymentSelectionForm {
      paymentMethods: PaymentMethod[] = [
        {
          id: 'cc-visa',
          type: 'credit',
          name: 'Visa Credit Card',
          fee: 0,
          processingTime: 'Instant',
          requiresVerification: false
        },
        {
          id: 'pp-account',
          type: 'paypal',
          name: 'PayPal Account',
          fee: 2.50,
          processingTime: '1-2 business days',
          requiresVerification: true
        },
        {
          id: 'btc-wallet',
          type: 'crypto',
          name: 'Bitcoin Wallet',
          fee: 0.0001,
          processingTime: '10-60 minutes',
          requiresVerification: true
        }
      ];
    
      selectedPaymentMethod: PaymentMethod | null = null;
      
      // Custom matcher for complex object comparison
      paymentMethodMatcher = (a: PaymentMethod, b: PaymentMethod) => {
        return a?.id === b?.id;
      };
    
      // Computed properties based on selection
      get totalFee(): number {
        return this.selectedPaymentMethod?.fee || 0;
      }
    
      get requiresUserVerification(): boolean {
        return this.selectedPaymentMethod?.requiresVerification || false;
      }
    
      get processingDetails(): string {
        if (!this.selectedPaymentMethod) return '';
        
        return `Processing time: ${this.selectedPaymentMethod.processingTime}
                ${this.totalFee > 0 ? `| Fee: $${this.totalFee.toFixed(2)}` : '| No additional fees'}`;
      }
    
      // Conditional validation based on selection
      validatePaymentSelection(): boolean {
        if (!this.selectedPaymentMethod) return false;
        
        if (this.requiresUserVerification && !this.isUserVerified()) {
          console.warn('User verification required for selected payment method');
          return false;
        }
        
        return true;
      }
    
      private isUserVerified(): boolean {
        // Check user verification status
        return false; // Placeholder
      }
    }
    <form class="payment-selection-form">
      <h3>Select Payment Method</h3>
      
      <div class="payment-options">
        <div repeat.for="method of paymentMethods" class="payment-option">
          <label class="payment-card" 
                 class.bind="{ 'selected': selectedPaymentMethod?.id === method.id }">
            <input type="radio"
                   name="paymentMethod"
                   model.bind="method"
                   checked.bind="selectedPaymentMethod"
                   matcher.bind="paymentMethodMatcher"
                   class="payment-radio" />
            
            <div class="payment-info">
              <div class="payment-header">
                <span class="payment-name">${method.name}</span>
                <span class="payment-type badge" 
                      class.bind="method.type">${method.type}</span>
              </div>
              
              <div class="payment-details">
                <div class="processing-time">
                  <i class="icon-clock"></i>
                  ${method.processingTime}
                </div>
                <div class="fee-info">
                  <i class="icon-dollar"></i>
                  ${method.fee === 0 ? 'No fees' : '$' + method.fee.toFixed(2)}
                </div>
                <div if.bind="method.requiresVerification" class="verification-required">
                  <i class="icon-shield"></i>
                  Verification required
                </div>
              </div>
            </div>
          </label>
        </div>
      </div>
    
      <!-- Selection Summary -->
      <div if.bind="selectedPaymentMethod" class="selection-summary">
        <h4>Payment Summary</h4>
        <div class="summary-details">
          <div class="summary-row">
            <span>Method:</span>
            <span>${selectedPaymentMethod.name}</span>
          </div>
          <div class="summary-row">
            <span>Processing:</span>
            <span>${selectedPaymentMethod.processingTime}</span>
          </div>
          <div class="summary-row">
            <span>Fee:</span>
            <span>${totalFee === 0 ? 'Free' : '$' + totalFee.toFixed(2)}</span>
          </div>
          <div if.bind="requiresUserVerification" class="verification-notice">
            <i class="icon-warning"></i>
            This payment method requires account verification
          </div>
        </div>
      </div>
    </form>
    interface SelectOption {
      value: any;
      label: string;
      group?: string;
      disabled?: boolean;
      metadata?: any;
    }
    
    export class AdvancedSelectComponent {
      countries: SelectOption[] = [];
      filteredCountries: SelectOption[] = [];
      selectedCountry: SelectOption | null = null;
      searchTerm = '';
      isLoading = false;
      showDropdown = false;
      
      // Grouping and filtering
      groupBy = 'region';
      sortBy = 'name';
      
      async loadCountries() {
        this.isLoading = true;
        try {
          const response = await fetch('/api/countries');
          const data = await response.json();
          
          this.countries = data.map(country => ({
            value: country.code,
            label: country.name,
            group: country.region,
            metadata: {
              population: country.population,
              currency: country.currency,
              flag: country.flag
            }
          }));
          
          this.applyFiltering();
        } catch (error) {
          console.error('Failed to load countries:', error);
        } finally {
          this.isLoading = false;
        }
      }
    
      searchTermChanged() {
        this.applyFiltering();
      }
    
      applyFiltering() {
        let filtered = this.countries;
    
        // Apply search filter
        if (this.searchTerm) {
          const term = this.searchTerm.toLowerCase();
          filtered = filtered.filter(country => 
            country.label.toLowerCase().includes(term) ||
            country.group?.toLowerCase().includes(term)
          );
        }
    
        // Apply grouping and sorting
        if (this.groupBy) {
          filtered = this.sortByGroup(filtered, this.groupBy);
        }
    
        this.filteredCountries = filtered;
      }
    
      private sortByGroup(options: SelectOption[], groupField: string): SelectOption[] {
        const groups = new Map<string, SelectOption[]>();
        
        // Group options
        options.forEach(option => {
          const groupKey = option.group || 'Other';
          if (!groups.has(groupKey)) {
            groups.set(groupKey, []);
          }
          groups.get(groupKey)!.push(option);
        });
    
        // Sort within groups and flatten
        const sortedOptions: SelectOption[] = [];
        Array.from(groups.keys()).sort().forEach(groupKey => {
          const groupOptions = groups.get(groupKey)!
            .sort((a, b) => a.label.localeCompare(b.label));
          sortedOptions.push(...groupOptions);
        });
    
        return sortedOptions;
      }
    
      selectOption(option: SelectOption) {
        if (option.disabled) return;
        
        this.selectedCountry = option;
        this.showDropdown = false;
        this.searchTerm = '';
      }
    
      // Custom matcher for complex object comparison
      countryMatcher = (a: SelectOption, b: SelectOption) => {
        return a?.value === b?.value;
      };
    
      // Virtual scrolling configuration for large lists
      virtualScrollConfig = {
        itemHeight: 48,
        containerHeight: 300,
        overscan: 10
      };
    }
    <div class="advanced-select-container">
      <label for="country-select">Select Country</label>
      
      <!-- Custom Select with Search -->
      <div class="custom-select" class.bind="{ 'open': showDropdown }">
        <div class="select-trigger" 
             click.trigger="showDropdown = !showDropdown">
          <span class="selected-value">
            ${selectedCountry ? selectedCountry.label : 'Choose a country...'}
          </span>
          <i class="dropdown-arrow" 
             class.bind="{ 'rotated': showDropdown }"></i>
        </div>
    
        <div class="select-dropdown" if.bind="showDropdown">
          <!-- Search Input -->
          <div class="search-container">
            <input type="text"
                   value.bind="searchTerm & debounce:200"
                   placeholder="Search countries..."
                   class="search-input"
                   focus.bind="showDropdown" />
            <i class="search-icon"></i>
          </div>
    
          <!-- Loading State -->
          <div if.bind="isLoading" class="loading-state">
            <i class="spinner"></i>
            Loading countries...
          </div>
    
          <!-- Options List with Virtual Scrolling -->
          <div class="options-container" if.bind="!isLoading">
            <virtual-repeat.for="option of filteredCountries"
                              virtual-repeat-strategy="virtual-repeat-strategy-array">
              <div class="select-option"
                   class.bind="{ 
                     'disabled': option.disabled,
                     'selected': selectedCountry?.value === option.value 
                   }"
                   click.trigger="selectOption(option)">
                
                <!-- Group Header -->
                <div if.bind="$index === 0 || filteredCountries[$index-1].group !== option.group" 
                     class="group-header">
                  ${option.group || 'Other'}
                </div>
    
                <!-- Option Content -->
                <div class="option-content">
                  <div class="option-main">
                    <span if.bind="option.metadata?.flag" 
                          class="flag">${option.metadata.flag}</span>
                    <span class="option-label">${option.label}</span>
                  </div>
                  <div class="option-details" if.bind="option.metadata">
                    <small class="population">
                      Pop: ${option.metadata.population | number:'0,000'}
                    </small>
                    <small class="currency">
                      ${option.metadata.currency}
                    </small>
                  </div>
                </div>
              </div>
            </virtual-repeat>
          </div>
    
          <!-- No Results State -->
          <div if.bind="!isLoading && filteredCountries.length === 0" 
               class="no-results">
            No countries found matching "${searchTerm}"
          </div>
        </div>
      </div>
    
      <!-- Traditional Select (Fallback) -->
      <select if.bind="!useAdvancedSelect" 
              value.bind="selectedCountry"
              matcher.bind="countryMatcher"
              class="traditional-select">
        <option value="">Choose a country...</option>
        <optgroup repeat.for="group of groupedCountries" 
                  label.bind="group.name">
          <option repeat.for="country of group.options" 
                  model.bind="country"
                  disabled.bind="country.disabled">
            ${country.label}
          </option>
        </optgroup>
      </select>
    </div>
    import { resolve, newInstanceForScope } from '@aurelia/kernel';
    import { IValidationController } from '@aurelia/validation-html';
    
    interface SubmissionState {
      isSubmitting: boolean;
      success: boolean;
      error: string | null;
      attempts: number;
      lastSubmission: Date | null;
    }
    
    export class ComprehensiveFormSubmission {
      formData = {
        firstName: '',
        lastName: '',
        email: '',
        message: ''
      };
    
      submissionState: SubmissionState = {
        isSubmitting: false,
        success: false,
        error: null,
        attempts: 0,
        lastSubmission: null
      };
    
      private readonly validationController = resolve(newInstanceForScope(IValidationController));
    
      // Rate limiting
      private readonly maxAttempts = 3;
      private readonly submissionCooldown = 30000; // 30 seconds
    
      get canSubmit(): boolean {
        return !this.submissionState.isSubmitting 
               && this.submissionState.attempts < this.maxAttempts
               && this.isWithinCooldown();
      }
    
      get submissionMessage(): string {
        if (this.submissionState.isSubmitting) {
          return 'Submitting your form...';
        }
        if (this.submissionState.success) {
          return 'Form submitted successfully!';
        }
        if (this.submissionState.error) {
          return `Submission failed: ${this.submissionState.error}`;
        }
        if (this.submissionState.attempts >= this.maxAttempts) {
          return `Maximum attempts reached. Please try again later.`;
        }
        return '';
      }
    
      async handleSubmit(event: Event) {
        event.preventDefault();
    
        if (!this.canSubmit) {
          return false; // Prevent form submission
        }
    
        // Reset previous state
        this.submissionState.error = null;
        this.submissionState.success = false;
        this.submissionState.isSubmitting = true;
    
        try {
          // Validate form before submission
          const validationResult = await this.validationController.validate();
          
          if (!validationResult.valid) {
            throw new Error('Please fix validation errors before submitting');
          }
    
          // Submit form data
          const response = await this.submitFormData(this.formData);
          
          // Handle success
          this.submissionState.success = true;
          this.submissionState.lastSubmission = new Date();
          
          // Optional: Redirect or show success message
          setTimeout(() => {
            this.resetForm();
          }, 2000);
    
        } catch (error) {
          // Handle submission error
          this.submissionState.error = error instanceof Error 
            ? error.message 
            : 'An unexpected error occurred';
          
          this.submissionState.attempts++;
    
          // Optional: Log error for debugging
          console.error('Form submission failed:', error);
    
        } finally {
          this.submissionState.isSubmitting = false;
        }
    
        return false; // Prevent default browser submission
      }
    
      private async submitFormData(data: any): Promise<any> {
        const response = await fetch('/api/contact', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Accept': 'application/json'
          },
          body: JSON.stringify({
            ...data,
            timestamp: new Date().toISOString(),
            userAgent: navigator.userAgent
          })
        });
    
        if (!response.ok) {
          const errorData = await response.json().catch(() => null);
          throw new Error(errorData?.message || `HTTP ${response.status}: ${response.statusText}`);
        }
    
        return await response.json();
      }
    
      private isWithinCooldown(): boolean {
        if (!this.submissionState.lastSubmission) return true;
        
        const timeSinceLastSubmission = Date.now() - this.submissionState.lastSubmission.getTime();
        return timeSinceLastSubmission > this.submissionCooldown;
      }
    
      resetForm() {
        this.formData = {
          firstName: '',
          lastName: '',
          email: '',
          message: ''
        };
        
        this.submissionState = {
          isSubmitting: false,
          success: false,
          error: null,
          attempts: 0,
          lastSubmission: null
        };
    
        // Clear validation errors
        this.validationController.reset();
      }
    
      retrySubmission() {
        if (this.submissionState.attempts < this.maxAttempts) {
          this.submissionState.error = null;
          // Allow retry by not resetting attempts - they'll try again
        }
      }
    }
    <form submit.trigger="handleSubmit($event)" class="comprehensive-form">
      <h2>Contact Us</h2>
      
      <!-- Form Fields -->
      <div class="form-row">
        <div class="form-group col-md-6">
          <label for="firstName">First Name</label>
          <input id="firstName" 
                 type="text"
                 value.bind="formData.firstName & validate"
                 disabled.bind="submissionState.isSubmitting"
                 class="form-control" />
        </div>
        <div class="form-group col-md-6">
          <label for="lastName">Last Name</label>
          <input id="lastName" 
                 type="text"
                 value.bind="formData.lastName & validate"
                 disabled.bind="submissionState.isSubmitting"
                 class="form-control" />
        </div>
      </div>
    
      <div class="form-group">
        <label for="email">Email Address</label>
        <input id="email" 
               type="email"
               value.bind="formData.email & validate"
               disabled.bind="submissionState.isSubmitting"
               class="form-control" />
      </div>
    
      <div class="form-group">
        <label for="message">Message</label>
        <textarea id="message" 
                  rows="5"
                  value.bind="formData.message & validate"
                  disabled.bind="submissionState.isSubmitting"
                  class="form-control"></textarea>
      </div>
    
      <!-- Submission State Display -->
      <div class="submission-status">
        <div if.bind="submissionState.isSubmitting" class="alert alert-info">
          <div class="loading-spinner"></div>
          ${submissionMessage}
        </div>
    
        <div if.bind="submissionState.success" class="alert alert-success">
          <i class="icon-check"></i>
          ${submissionMessage}
        </div>
    
        <div if.bind="submissionState.error" class="alert alert-danger">
          <i class="icon-warning"></i>
          ${submissionMessage}
          <div class="retry-section">
            <button type="button" 
                    click.trigger="retrySubmission()"
                    class="btn btn-sm btn-outline-danger"
                    if.bind="submissionState.attempts < maxAttempts">
              Try Again (${maxAttempts - submissionState.attempts} attempts remaining)
            </button>
          </div>
        </div>
    
        <div if.bind="submissionState.attempts >= maxAttempts" class="alert alert-warning">
          <i class="icon-clock"></i>
          Too many attempts. Please try again in 
          ${Math.ceil((submissionCooldown - (Date.now() - submissionState.lastSubmission?.getTime())) / 1000)} seconds.
        </div>
      </div>
    
      <!-- Form Actions -->
      <div class="form-actions">
        <button type="submit" 
                disabled.bind="!canSubmit"
                class="btn btn-primary"
                class.bind="{ 'btn-loading': submissionState.isSubmitting }">
          <span if.bind="submissionState.isSubmitting">Submitting...</span>
          <span if.bind="!submissionState.isSubmitting">Send Message</span>
        </button>
        
        <button type="button" 
                click.trigger="resetForm()"
                disabled.bind="submissionState.isSubmitting"
                class="btn btn-secondary">
          Reset Form
        </button>
      </div>
    
      <!-- Submission History (Development) -->
      <div if.bind="showDebugInfo" class="debug-section">
        <h4>Submission Debug Info</h4>
        <ul>
          <li>Attempts: ${submissionState.attempts}/${maxAttempts}</li>
          <li>Last Submission: ${submissionState.lastSubmission?.toLocaleString() || 'Never'}</li>
          <li>Can Submit: ${canSubmit ? 'Yes' : 'No'}</li>
          <li>Is Submitting: ${submissionState.isSubmitting ? 'Yes' : 'No'}</li>
        </ul>
      </div>
    </form>
    interface FormStep {
      id: string;
      title: string;
      component: string;
      isValid: boolean;
      isComplete: boolean;
      data: any;
    }
    
    export class MultiStepFormSubmission {
      currentStepIndex = 0;
      isSubmitting = false;
      submissionProgress = 0;
    
      private readonly validationController = resolve(newInstanceForScope(IValidationController));
    
      steps: FormStep[] = [
        {
          id: 'personal',
          title: 'Personal Information',
          component: 'personal-info-step',
          isValid: false,
          isComplete: false,
          data: {}
        },
        {
          id: 'account',
          title: 'Account Details',
          component: 'account-details-step',
          isValid: false,
          isComplete: false,
          data: {}
        },
        {
          id: 'preferences',
          title: 'Preferences',
          component: 'preferences-step',
          isValid: false,
          isComplete: false,
          data: {}
        },
        {
          id: 'confirmation',
          title: 'Confirmation',
          component: 'confirmation-step',
          isValid: true,
          isComplete: false,
          data: {}
        }
      ];
    
      get currentStep(): FormStep {
        return this.steps[this.currentStepIndex];
      }
    
      get isFirstStep(): boolean {
        return this.currentStepIndex === 0;
      }
    
      get isLastStep(): boolean {
        return this.currentStepIndex === this.steps.length - 1;
      }
    
      get canProceed(): boolean {
        return this.currentStep.isValid && !this.isSubmitting;
      }
    
      get canGoBack(): boolean {
        return !this.isFirstStep && !this.isSubmitting;
      }
    
      get overallProgress(): number {
        const completedSteps = this.steps.filter(step => step.isComplete).length;
        return (completedSteps / this.steps.length) * 100;
      }
    
      async nextStep() {
        if (!this.canProceed) return;
    
        // Validate current step
        const isValid = await this.validateCurrentStep();
        if (!isValid) return;
    
        // Mark current step as complete
        this.currentStep.isComplete = true;
    
        if (this.isLastStep) {
          // Submit the form
          await this.submitCompleteForm();
        } else {
          // Move to next step
          this.currentStepIndex++;
        }
      }
    
      previousStep() {
        if (this.canGoBack) {
          this.currentStepIndex--;
        }
      }
    
      goToStep(stepIndex: number) {
        if (stepIndex >= 0 && stepIndex < this.steps.length) {
          // Only allow going to completed steps or next step
          const targetStep = this.steps[stepIndex];
          const canNavigateToStep = targetStep.isComplete || 
                                   stepIndex === this.currentStepIndex + 1;
          
          if (canNavigateToStep) {
            this.currentStepIndex = stepIndex;
          }
        }
      }
    
      private async validateCurrentStep(): Promise<boolean> {
        const result = await this.validationController.validate();
        
        this.currentStep.isValid = result.valid;
        return result.valid;
      }
    
      private async submitCompleteForm() {
        this.isSubmitting = true;
        this.submissionProgress = 0;
    
        try {
          // Collect all form data
          const formData = this.steps.reduce((acc, step) => {
            return { ...acc, [step.id]: step.data };
          }, {});
    
          // Submit with progress tracking
          await this.submitWithProgress(formData);
    
          // Mark final step as complete
          this.currentStep.isComplete = true;
          this.submissionProgress = 100;
    
          // Show success message
          console.log('Multi-step form submitted successfully!');
    
        } catch (error) {
          console.error('Form submission failed:', error);
          // Handle error appropriately
        } finally {
          this.isSubmitting = false;
        }
      }
    
      private async submitWithProgress(data: any): Promise<void> {
        // Simulate progress updates
        const progressSteps = [
          { message: 'Validating data...', progress: 20 },
          { message: 'Processing payment...', progress: 50 },
          { message: 'Creating account...', progress: 75 },
          { message: 'Sending confirmation...', progress: 90 },
          { message: 'Complete!', progress: 100 }
        ];
    
        for (const step of progressSteps) {
          await new Promise(resolve => setTimeout(resolve, 800));
          this.submissionProgress = step.progress;
          console.log(step.message);
        }
    
        // Actual submission would happen here
        const response = await fetch('/api/register', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(data)
        });
    
        if (!response.ok) {
          throw new Error(`Registration failed: ${response.statusText}`);
        }
      }
    
      updateStepData(data: any) {
        Object.assign(this.currentStep.data, data);
      }
    
      resetForm() {
        this.currentStepIndex = 0;
        this.isSubmitting = false;
        this.submissionProgress = 0;
        
        this.steps.forEach(step => {
          step.isValid = step.id === 'confirmation'; // Only confirmation step is valid by default
          step.isComplete = false;
          step.data = {};
        });
      }
    }
    <div class="multi-step-form-container">
      <div class="form-header">
        <h2>Account Registration</h2>
        
        <!-- Progress Indicator -->
        <div class="progress-container">
          <div class="progress-bar">
            <div class="progress-fill" 
                 style="width: ${overallProgress}%"></div>
          </div>
          <div class="progress-text">${overallProgress.toFixed(0)}% Complete</div>
        </div>
    
        <!-- Step Navigation -->
        <nav class="step-navigation">
          <div repeat.for="step of steps" 
               class="step-indicator"
               class.bind="{
                 'active': $index === currentStepIndex,
                 'completed': step.isComplete,
                 'valid': step.isValid
               }"
               click.trigger="goToStep($index)">
            <div class="step-number">${$index + 1}</div>
            <div class="step-title">${step.title}</div>
          </div>
        </nav>
      </div>
    
      <!-- Dynamic Step Content -->
      <div class="step-content">
        <compose 
          view-model.bind="currentStep.component"
          model.bind="{ 
            data: currentStep.data,
            updateData: updateStepData,
            isValid: currentStep.isValid
          }">
        </compose>
      </div>
    
      <!-- Submission Progress -->
      <div if.bind="isSubmitting" class="submission-progress">
        <h4>Processing Your Registration</h4>
        <div class="progress-bar">
          <div class="progress-fill" 
               style="width: ${submissionProgress}%"></div>
        </div>
        <div class="progress-text">${submissionProgress}% Complete</div>
      </div>
    
      <!-- Navigation Controls -->
      <div class="form-navigation" if.bind="!isSubmitting">
        <button type="button" 
                click.trigger="previousStep()"
                disabled.bind="!canGoBack"
                class="btn btn-secondary">
          ← Previous
        </button>
    
        <button type="button" 
                click.trigger="nextStep()"
                disabled.bind="!canProceed"
                class="btn btn-primary">
          <span if.bind="!isLastStep">Next →</span>
          <span if.bind="isLastStep">Submit Registration</span>
        </button>
      </div>
    
      <!-- Form Actions -->
      <div class="form-actions">
        <button type="button" 
                click.trigger="resetForm()"
                disabled.bind="isSubmitting"
                class="btn btn-outline-secondary">
          Start Over
        </button>
      </div>
    </div>
    file-upload-component.html
    <form>
      <label for="fileUpload">Select files to upload:</label>
      <input
        id="fileUpload"
        type="file"
        multiple
        accept="image/*"
        change.trigger="handleFileSelect($event)"
      />
    
      <button click.trigger="uploadFiles()" disabled.bind="!selectedFiles.length">
        Upload
      </button>
    </form>
    file-upload-component.ts
    export class FileUploadComponent {
      public selectedFiles: File[] = [];
    
      public handleFileSelect(event: Event) {
        const input = event.target as HTMLInputElement;
        if (!input.files?.length) {
          return;
        }
        // Convert the FileList to a real array
        this.selectedFiles = Array.from(input.files);
      }
    
      public async uploadFiles() {
        if (this.selectedFiles.length === 0) {
          return;
        }
    
        const formData = new FormData();
        for (const file of this.selectedFiles) {
          // The first argument (key) matches the field name expected by your backend
          formData.append('files', file, file.name);
        }
    
        try {
          const response = await fetch('/api/upload', {
            method: 'POST',
            body: formData
          });
    
          if (!response.ok) {
            throw new Error(`Upload failed with status ${response.status}`);
          }
    
          const result = await response.json();
          console.log('Upload successful:', result);
          // Optionally, reset selected files
          this.selectedFiles = [];
        } catch (error) {
          console.error('Error uploading files:', error);
        }
      }
    }
    <input type="file" accept="image/*" change.trigger="handleFileSelect($event)" />
    public handleFileSelect(event: Event) {
      const input = event.target as HTMLInputElement;
      this.selectedFiles = input.files?.length ? [input.files[0]] : [];
    }
    import { resolve, newInstanceForScope } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController } from '@aurelia/validation-html';
    
    export class UserRegistrationForm {
      user = {
        email: '',
        password: '',
        confirmPassword: '',
        age: null as number | null,
        terms: false
      };
    
      private readonly validationRules = resolve(IValidationRules);
      private readonly validationController = resolve(newInstanceForScope(IValidationController));
    
      constructor() {
        // Define validation rules
        this.setupValidationRules();
      }
    
      private setupValidationRules() {
        this.validationRules
          .on(this.user)
          .ensure('email')
            .required()
            .email()
            .withMessage('Please enter a valid email address')
          .ensure('password')
            .required()
            .minLength(8)
            .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
            .withMessage('Password must contain lowercase, uppercase, and number')
          .ensure('confirmPassword')
            .required()
            .satisfies((value, object) => value === object.password)
            .withMessage('Passwords must match')
          .ensure('age')
            .required()
            .range(13, 120)
            .withMessage('Age must be between 13 and 120')
          .ensure('terms')
            .satisfies(value => value === true)
            .withMessage('You must accept the terms and conditions');
      }
    
      async handleSubmit() {
        const result = await this.validationController.validate();
        
        if (result.valid) {
          console.log('Form is valid, submitting...', this.user);
          // Submit form
        } else {
          console.log('Validation failed:', result);
        }
      }
    }
    <form submit.trigger="handleSubmit()">
      <div class="form-group">
        <label for="email">Email Address</label>
        <input id="email" 
               type="email" 
               value.bind="user.email & validate" 
               class="form-control" />
      </div>
    
      <div class="form-group">
        <label for="password">Password</label>
        <input id="password" 
               type="password" 
               value.bind="user.password & validate" 
               class="form-control" />
      </div>
    
      <div class="form-group">
        <label for="confirmPassword">Confirm Password</label>
        <input id="confirmPassword" 
               type="password" 
               value.bind="user.confirmPassword & validate" 
               class="form-control" />
      </div>
    
      <div class="form-group">
        <label for="age">Age</label>
        <input id="age" 
               type="number" 
               value.bind="user.age & validate" 
               class="form-control" />
      </div>
    
      <div class="form-group">
        <label>
          <input type="checkbox" checked.bind="user.terms & validate" />
          I accept the terms and conditions
        </label>
      </div>
    
      <button type="submit" class="btn btn-primary">Register</button>
    </form>
    import { resolve, newInstanceForScope } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController, ValidationTrigger } from '@aurelia/validation-html';
    
    export class AdvancedValidationForm {
      contact = {
        firstName: '',
        lastName: '',
        email: '',
        phone: '',
        company: '',
        message: ''
      };
    
      // Validation controller for manual control
      private readonly validationController = resolve(newInstanceForScope(IValidationController));
      private readonly validationRules = resolve(IValidationRules);
    
      // Form-specific error tracking
      nameErrors: any[] = [];
      contactErrors: any[] = [];
      messageErrors: any[] = [];
    
      constructor() {
        this.setupValidation();
      }
    
      private setupValidation() {
        this.validationRules
          .on(this.contact)
          .ensure('firstName')
            .required()
            .minLength(2)
            .withMessage('First name must be at least 2 characters')
          .ensure('lastName')
            .required()
            .minLength(2)
            .withMessage('Last name must be at least 2 characters')
          .ensure('email')
            .required()
            .email()
            .withMessage('Please enter a valid email address')
          .ensure('phone')
            .required()
            .matches(/^[\d\s\-\+\(\)]+$/)
            .withMessage('Please enter a valid phone number')
          .ensure('company')
            .required()
            .withMessage('Company name is required')
          .ensure('message')
            .required()
            .minLength(10)
            .withMessage('Message must be at least 10 characters');
    
        // Configure validation triggers
        this.validationController.validateTrigger = ValidationTrigger.changeOrBlur;
      }
    
      async validateSection(sectionName: 'name' | 'contact' | 'message') {
        let properties: string[] = [];
        
        switch (sectionName) {
          case 'name':
            properties = ['firstName', 'lastName'];
            break;
          case 'contact':
            properties = ['email', 'phone', 'company'];
            break;
          case 'message':
            properties = ['message'];
            break;
        }
    
        const result = await this.validationController.validate({
          object: this.contact,
          propertyName: properties
        });
    
        // Update section-specific errors
        this[`${sectionName}Errors`] = result.results
          .filter(r => !r.valid)
          .map(r => ({ error: r, target: r.target }));
    
        return result.valid;
      }
    
      async submitForm() {
        const result = await this.validationController.validate();
        
        if (result.valid) {
          // Submit the form
          console.log('Submitting:', this.contact);
        }
      }
    }
    <form class="advanced-form">
      <!-- Name Section with Validation Container -->
      <validation-container class="form-section">
        <h3>Personal Information</h3>
        <div validation-errors.bind="nameErrors" class="section-errors">
          <div repeat.for="error of nameErrors" class="alert alert-danger">
            ${error.error.message}
          </div>
        </div>
    
        <div class="form-row">
          <div class="form-group col-md-6">
            <label for="firstName">First Name</label>
            <input id="firstName" 
                   value.bind="contact.firstName & validate" 
                   class="form-control" />
          </div>
          <div class="form-group col-md-6">
            <label for="lastName">Last Name</label>
            <input id="lastName" 
                   value.bind="contact.lastName & validate" 
                   class="form-control" />
          </div>
        </div>
    
        <button type="button" 
                click.trigger="validateSection('name')"
                class="btn btn-outline-primary">
          Validate Name Section
        </button>
      </validation-container>
    
      <!-- Contact Section -->
      <validation-container class="form-section">
        <h3>Contact Information</h3>
        <div validation-errors.bind="contactErrors" class="section-errors">
          <div repeat.for="error of contactErrors" class="alert alert-danger">
            ${error.error.message}
          </div>
        </div>
    
        <div class="form-group">
          <label for="email">Email Address</label>
          <input id="email" 
                 type="email"
                 value.bind="contact.email & validate" 
                 class="form-control" />
        </div>
    
        <div class="form-group">
          <label for="phone">Phone Number</label>
          <input id="phone" 
                 type="tel"
                 value.bind="contact.phone & validate" 
                 class="form-control" />
        </div>
    
        <div class="form-group">
          <label for="company">Company</label>
          <input id="company" 
                 value.bind="contact.company & validate" 
                 class="form-control" />
        </div>
      </validation-container>
    
      <!-- Message Section -->
      <validation-container class="form-section">
        <h3>Message</h3>
        <div validation-errors.bind="messageErrors" class="section-errors">
          <div repeat.for="error of messageErrors" class="alert alert-danger">
            ${error.error.message}
          </div>
        </div>
    
        <div class="form-group">
          <label for="message">Your Message</label>
          <textarea id="message" 
                    rows="5"
                    value.bind="contact.message & validate" 
                    class="form-control"
                    placeholder="Tell us about your needs..."></textarea>
        </div>
      </validation-container>
    
      <!-- Form Actions -->
      <div class="form-actions">
        <button type="button" 
                click.trigger="submitForm()"
                class="btn btn-primary btn-lg">
          Send Message
        </button>
      </div>
    </form>
    import { resolve, newInstanceForScope } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController } from '@aurelia/validation-html';
    
    export class DynamicValidationForm {
      profile = {
        userType: 'individual' as 'individual' | 'business',
        firstName: '',
        lastName: '',
        businessName: '',
        taxId: '',
        email: '',
        phone: ''
      };
    
      private readonly validationRules = resolve(IValidationRules);
      private readonly validationController = resolve(newInstanceForScope(IValidationController));
    
      constructor() {
        this.setupDynamicValidation();
      }
    
      private setupDynamicValidation() {
        this.validationRules
          .on(this.profile)
          .ensure('firstName')
            .required()
            .when(obj => obj.userType === 'individual')
            .withMessage('First name is required for individuals')
          .ensure('lastName')
            .required()
            .when(obj => obj.userType === 'individual')
            .withMessage('Last name is required for individuals')
          .ensure('businessName')
            .required()
            .when(obj => obj.userType === 'business')
            .withMessage('Business name is required for businesses')
          .ensure('taxId')
            .required()
            .matches(/^\d{2}-\d{7}$/)
            .when(obj => obj.userType === 'business')
            .withMessage('Tax ID must be in format XX-XXXXXXX')
          .ensure('email')
            .required()
            .email()
            .withMessage('Valid email address is required')
          .ensure('phone')
            .required()
            .matches(/^[\d\s\-\+\(\)]+$/)
            .withMessage('Valid phone number is required');
      }
    
      userTypeChanged() {
        // Re-validate when user type changes
        this.validationController.validate();
      }
    
      async handleSubmit() {
        const result = await this.validationController.validate();
        
        if (result.valid) {
          console.log('Submitting profile:', this.profile);
          // Handle successful validation
        } else {
          console.log('Validation errors:', result.results.filter(r => !r.valid));
        }
      }
    }
    <form submit.trigger="handleSubmit()" class="dynamic-form">
      <div class="form-group">
        <label>Account Type</label>
        <div class="form-check-container">
          <label class="form-check">
            <input type="radio" 
                   name="userType"
                   model.bind="'individual'"
                   checked.bind="profile.userType"
                   change.trigger="userTypeChanged()" />
            Individual
          </label>
          <label class="form-check">
            <input type="radio" 
                   name="userType"
                   model.bind="'business'"
                   checked.bind="profile.userType"
                   change.trigger="userTypeChanged()" />
            Business
          </label>
        </div>
      </div>
    
      <!-- Individual Fields -->
      <div if.bind="profile.userType === 'individual'" class="user-type-section">
        <h4>Personal Information</h4>
        <div class="form-row">
          <div class="form-group col-md-6">
            <label for="firstName">First Name</label>
            <input id="firstName" 
                   value.bind="profile.firstName & validate" 
                   class="form-control" />
          </div>
          <div class="form-group col-md-6">
            <label for="lastName">Last Name</label>
            <input id="lastName" 
                   value.bind="profile.lastName & validate" 
                   class="form-control" />
          </div>
        </div>
      </div>
    
      <!-- Business Fields -->
      <div if.bind="profile.userType === 'business'" class="user-type-section">
        <h4>Business Information</h4>
        <div class="form-group">
          <label for="businessName">Business Name</label>
          <input id="businessName" 
                 value.bind="profile.businessName & validate" 
                 class="form-control" />
        </div>
        <div class="form-group">
          <label for="taxId">Tax ID (XX-XXXXXXX)</label>
          <input id="taxId" 
                 value.bind="profile.taxId & validate" 
                 class="form-control" 
                 placeholder="12-3456789" />
        </div>
      </div>
    
      <!-- Common Fields -->
      <div class="common-fields">
        <h4>Contact Information</h4>
        <div class="form-group">
          <label for="email">Email Address</label>
          <input id="email" 
                 type="email"
                 value.bind="profile.email & validate" 
                 class="form-control" />
        </div>
        <div class="form-group">
          <label for="phone">Phone Number</label>
          <input id="phone" 
                 type="tel"
                 value.bind="profile.phone & validate" 
                 class="form-control" />
        </div>
      </div>
    
      <button type="submit" class="btn btn-primary">Submit Profile</button>
    </form>
    import { resolve, newInstanceForScope } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    import { IValidationController } from '@aurelia/validation-html';
    
    // Utility function for debouncing (you would typically import this from a utility library)
    function debounce(func: Function, wait: number) {
      let timeout: any;
      return function executedFunction(...args: any[]) {
        const later = () => {
          clearTimeout(timeout);
          func(...args);
        };
        clearTimeout(timeout);
        timeout = setTimeout(later, wait);
      };
    }
    
    export class RealTimeValidationForm {
      user = {
        username: '',
        email: '',
        password: '',
        confirmPassword: ''
      };
    
      validationStates = {
        username: { checking: false, available: false, message: '' },
        email: { checking: false, valid: false, message: '' },
        password: { strength: 0, message: '' },
        confirmPassword: { matches: false, message: '' }
      };
    
      private readonly validationRules = resolve(IValidationRules);
      private readonly validationController = resolve(newInstanceForScope(IValidationController));
      private debounceUsernameCheck = debounce(this.checkUsernameAvailability.bind(this), 500);
      private debounceEmailCheck = debounce(this.validateEmail.bind(this), 300);
    
      constructor() {
        this.setupValidation();
      }
    
      private setupValidation() {
        this.validationRules
          .on(this.user)
          .ensure('username')
            .required()
            .minLength(3)
            .matches(/^[a-zA-Z0-9_]+$/)
            .satisfies(async (username) => {
              if (username.length >= 3) {
                return await this.isUsernameAvailable(username);
              }
              return true;
            })
            .withMessage('Username must be available')
          .ensure('email')
            .required()
            .email()
            .satisfies(async (email) => await this.isEmailValid(email))
            .withMessage('Please enter a valid, verified email address')
          .ensure('password')
            .required()
            .minLength(8)
            .satisfies(password => this.calculatePasswordStrength(password) >= 3)
            .withMessage('Password must be strong (score 3+)')
          .ensure('confirmPassword')
            .required()
            .satisfies((value, obj) => value === obj.password)
            .withMessage('Passwords must match');
      }
    
      usernameChanged(newUsername: string) {
        if (newUsername.length >= 3) {
          this.validationStates.username.checking = true;
          this.debounceUsernameCheck(newUsername);
        }
      }
    
      emailChanged(newEmail: string) {
        if (newEmail.includes('@')) {
          this.validationStates.email.checking = true;
          this.debounceEmailCheck(newEmail);
        }
      }
    
      passwordChanged(newPassword: string) {
        const strength = this.calculatePasswordStrength(newPassword);
        this.validationStates.password.strength = strength;
        this.validationStates.password.message = this.getPasswordStrengthMessage(strength);
        
        // Re-validate confirm password
        if (this.user.confirmPassword) {
          this.confirmPasswordChanged(this.user.confirmPassword);
        }
      }
    
      confirmPasswordChanged(confirmPassword: string) {
        const matches = confirmPassword === this.user.password;
        this.validationStates.confirmPassword.matches = matches;
        this.validationStates.confirmPassword.message = matches 
          ? 'Passwords match' 
          : 'Passwords do not match';
      }
    
      private async checkUsernameAvailability(username: string) {
        try {
          const available = await this.isUsernameAvailable(username);
          this.validationStates.username = {
            checking: false,
            available,
            message: available ? 'Username is available' : 'Username is taken'
          };
        } catch (error) {
          this.validationStates.username = {
            checking: false,
            available: false,
            message: 'Error checking username availability'
          };
        }
      }
    
      private async validateEmail(email: string) {
        try {
          const valid = await this.isEmailValid(email);
          this.validationStates.email = {
            checking: false,
            valid,
            message: valid ? 'Email is valid' : 'Email format is invalid'
          };
        } catch (error) {
          this.validationStates.email = {
            checking: false,
            valid: false,
            message: 'Error validating email'
          };
        }
      }
    
      private calculatePasswordStrength(password: string): number {
        let strength = 0;
        if (password.length >= 8) strength++;
        if (/[a-z]/.test(password)) strength++;
        if (/[A-Z]/.test(password)) strength++;
        if (/\d/.test(password)) strength++;
        if (/[^a-zA-Z\d]/.test(password)) strength++;
        return strength;
      }
    
      private getPasswordStrengthMessage(strength: number): string {
        const messages = [
          'Very weak password',
          'Weak password',
          'Fair password',
          'Good password',
          'Strong password',
          'Very strong password'
        ];
        return messages[strength] || 'No password';
      }
    
      // Mock API calls (replace with real implementations)
      private async isUsernameAvailable(username: string): Promise<boolean> {
        // Simulate API call
        await new Promise(resolve => setTimeout(resolve, 500));
        return !['admin', 'test', 'user'].includes(username.toLowerCase());
      }
    
      private async isEmailValid(email: string): Promise<boolean> {
        // Simulate API call for email verification
        await new Promise(resolve => setTimeout(resolve, 300));
        return email.includes('@') && !email.includes('invalid');
      }
    }
    <form class="realtime-validation-form">
      <div class="form-group">
        <label for="username">Username</label>
        <div class="input-with-feedback">
          <input id="username" 
                 value.bind="user.username & validate & debounce:100"
                 class="form-control"
                 class.bind="{ 
                   'is-valid': validationStates.username.available,
                   'is-invalid': validationStates.username.message && !validationStates.username.available 
                 }" />
          <div class="feedback-icons">
            <i if.bind="validationStates.username.checking" class="spinner"></i>
            <i if.bind="validationStates.username.available" class="success-icon">✓</i>
            <i if.bind="validationStates.username.message && !validationStates.username.available" class="error-icon">✗</i>
          </div>
        </div>
        <div class="feedback-text" 
             class.bind="{ 
               'text-success': validationStates.username.available,
               'text-danger': !validationStates.username.available && validationStates.username.message 
             }">
          ${validationStates.username.message}
        </div>
      </div>
    
      <div class="form-group">
        <label for="email">Email Address</label>
        <div class="input-with-feedback">
          <input id="email" 
                 type="email"
                 value.bind="user.email & validate & debounce:200"
                 class="form-control"
                 class.bind="{ 
                   'is-valid': validationStates.email.valid,
                   'is-invalid': validationStates.email.message && !validationStates.email.valid 
                 }" />
          <div class="feedback-icons">
            <i if.bind="validationStates.email.checking" class="spinner"></i>
            <i if.bind="validationStates.email.valid" class="success-icon">✓</i>
            <i if.bind="validationStates.email.message && !validationStates.email.valid" class="error-icon">✗</i>
          </div>
        </div>
        <div class="feedback-text" 
             class.bind="{ 
               'text-success': validationStates.email.valid,
               'text-danger': !validationStates.email.valid && validationStates.email.message 
             }">
          ${validationStates.email.message}
        </div>
      </div>
    
      <div class="form-group">
        <label for="password">Password</label>
        <input id="password" 
               type="password"
               value.bind="user.password & validate" 
               class="form-control" />
        <div class="password-strength">
          <div class="strength-bar">
            <div repeat.for="i of 5" 
                 class="strength-segment"
                 class.bind="{ 
                   'active': i < validationStates.password.strength,
                   'weak': validationStates.password.strength <= 2,
                   'medium': validationStates.password.strength === 3,
                   'strong': validationStates.password.strength >= 4 
                 }"></div>
          </div>
          <div class="strength-text">${validationStates.password.message}</div>
        </div>
      </div>
    
      <div class="form-group">
        <label for="confirmPassword">Confirm Password</label>
        <div class="input-with-feedback">
          <input id="confirmPassword" 
                 type="password"
                 value.bind="user.confirmPassword & validate" 
                 class="form-control"
                 class.bind="{ 
                   'is-valid': validationStates.confirmPassword.matches && user.confirmPassword,
                   'is-invalid': !validationStates.confirmPassword.matches && user.confirmPassword 
                 }" />
          <div class="feedback-icons">
            <i if.bind="validationStates.confirmPassword.matches && user.confirmPassword" class="success-icon">✓</i>
            <i if.bind="!validationStates.confirmPassword.matches && user.confirmPassword" class="error-icon">✗</i>
          </div>
        </div>
        <div class="feedback-text" 
             class.bind="{ 
               'text-success': validationStates.confirmPassword.matches,
               'text-danger': !validationStates.confirmPassword.matches && user.confirmPassword 
             }">
          ${validationStates.confirmPassword.message}
        </div>
      </div>
    
      <button type="submit" class="btn btn-primary">Create Account</button>
    </form>
    import { resolve } from '@aurelia/kernel';
    import { IValidationRules } from '@aurelia/validation';
    
    export class SecureFormComponent {
      private readonly maxFieldLength = 1000;
      private readonly allowedFileTypes = ['image/jpeg', 'image/png', 'image/webp'];
      private readonly maxFileSize = 5 * 1024 * 1024; // 5MB
    
      userData = {
        username: '',
        email: '',
        bio: '',
        website: ''
      };
    
      private readonly validationRules = resolve(IValidationRules);
    
      constructor() {
        this.setupSecureValidation();
      }
    
      private setupSecureValidation() {
        this.validationRules
          .on(this.userData)
          .ensure('username')
            .required()
            .minLength(3)
            .maxLength(20)
            .matches(/^[a-zA-Z0-9_-]+$/)
            .withMessage('Username can only contain letters, numbers, underscores, and hyphens')
            .satisfies(username => this.isUsernameSafe(username))
            .withMessage('Username contains prohibited content')
          .ensure('email')
            .required()
            .email()
            .maxLength(254) // RFC 5321 limit
            .satisfies(email => this.isEmailDomainAllowed(email))
            .withMessage('Email domain not allowed')
          .ensure('bio')
            .maxLength(this.maxFieldLength)
            .satisfies(bio => this.containsNoMaliciousContent(bio))
            .withMessage('Bio contains prohibited content')
          .ensure('website')
            .satisfies(url => !url || this.isUrlSafe(url))
            .withMessage('Website URL is not allowed');
      }
    
      // Input sanitization
      sanitizeInput(input: string): string {
        // Remove potentially dangerous characters
        let sanitized = input.trim();
        
        // Remove null bytes
        sanitized = sanitized.replace(/\0/g, '');
        
        // Limit length
        sanitized = sanitized.substring(0, this.maxFieldLength);
        
        // HTML encode for display (use a proper HTML sanitizer in production)
        sanitized = sanitized
          .replace(/&/g, '&amp;')
          .replace(/</g, '&lt;')
          .replace(/>/g, '&gt;')
          .replace(/"/g, '&quot;')
          .replace(/'/g, '&#x27;');
        
        return sanitized;
      }
    
      // Security validation methods
      private isUsernameSafe(username: string): boolean {
        // Check against prohibited usernames
        const prohibitedUsernames = ['admin', 'root', 'administrator', 'system'];
        return !prohibitedUsernames.includes(username.toLowerCase());
      }
    
      private isEmailDomainAllowed(email: string): boolean {
        // Example: Block certain domains (implement your own logic)
        const blockedDomains = ['tempmail.com', 'guerrillamail.com'];
        const domain = email.split('@')[1]?.toLowerCase();
        return domain ? !blockedDomains.includes(domain) : false;
      }
    
      private containsNoMaliciousContent(text: string): boolean {
        // Check for common XSS patterns
        const dangerousPatterns = [
          /<script/i,
          /javascript:/i,
          /on\w+\s*=/i,
          /<iframe/i,
          /<object/i,
          /<embed/i
        ];
        
        return !dangerousPatterns.some(pattern => pattern.test(text));
      }
    
      private isUrlSafe(url: string): boolean {
        try {
          const parsedUrl = new URL(url);
          
          // Only allow HTTP and HTTPS
          if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
            return false;
          }
          
          // Check for dangerous domains (implement your own logic)
          const dangerousDomains = ['malicious-site.com'];
          return !dangerousDomains.includes(parsedUrl.hostname.toLowerCase());
          
        } catch {
          return false; // Invalid URL
        }
      }
    
      // Secure file upload validation
      validateFile(file: File): { isValid: boolean; error?: string } {
        // Check file type
        if (!this.allowedFileTypes.includes(file.type)) {
          return {
            isValid: false,
            error: 'File type not allowed. Only JPEG, PNG, and WebP images are permitted.'
          };
        }
    
        // Check file size
        if (file.size > this.maxFileSize) {
          return {
            isValid: false,
            error: `File size exceeds limit. Maximum size is ${this.maxFileSize / (1024 * 1024)}MB.`
          };
        }
    
        // Check filename for dangerous characters
        if (/[<>:"\\|?*\x00-\x1f]/.test(file.name)) {
          return {
            isValid: false,
            error: 'Filename contains invalid characters.'
          };
        }
    
        return { isValid: true };
      }
    
      async handleSecureSubmit() {
        try {
          // Sanitize all inputs before submission
          const sanitizedData = {
            username: this.sanitizeInput(this.userData.username),
            email: this.userData.email.toLowerCase().trim(),
            bio: this.sanitizeInput(this.userData.bio),
            website: this.userData.website.trim()
          };
    
          // Add security headers and CSRF token
          const response = await fetch('/api/secure-form', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'X-CSRF-Token': this.getCSRFToken(),
              'X-Requested-With': 'XMLHttpRequest'
            },
            body: JSON.stringify(sanitizedData)
          });
    
          if (!response.ok) {
            throw new Error('Server validation failed');
          }
    
          const result = await response.json();
          console.log('Form submitted securely:', result);
    
        } catch (error) {
          console.error('Secure submission failed:', error);
          // Don't expose internal error details to user
          throw new Error('Submission failed. Please try again.');
        }
      }
    
      private getCSRFToken(): string {
        // Get CSRF token from meta tag or cookie
        const metaTag = document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement;
        return metaTag?.content || '';
      }
    }
    export class RateLimitedForm {
      private submissionAttempts: number = 0;
      private lastSubmissionTime: number = 0;
      private readonly maxAttempts: number = 5;
      private readonly timeWindow: number = 60000; // 1 minute
      private readonly baseDelay: number = 1000; // 1 second
    
      get currentDelay(): number {
        // Exponential backoff
        return this.baseDelay * Math.pow(2, this.submissionAttempts);
      }
    
      get canSubmit(): boolean {
        const now = Date.now();
        
        // Reset attempts if time window has passed
        if (now - this.lastSubmissionTime > this.timeWindow) {
          this.submissionAttempts = 0;
        }
    
        return this.submissionAttempts < this.maxAttempts;
      }
    
      async handleRateLimitedSubmit() {
        if (!this.canSubmit) {
          const waitTime = Math.ceil(this.currentDelay / 1000);
          throw new Error(`Too many attempts. Please wait ${waitTime} seconds.`);
        }
    
        this.submissionAttempts++;
        this.lastSubmissionTime = Date.now();
    
        try {
          await this.submitWithDelay();
          // Reset on successful submission
          this.submissionAttempts = 0;
        } catch (error) {
          // Keep attempt count for failed submissions
          throw error;
        }
      }
    
      private async submitWithDelay() {
        // Add artificial delay to prevent rapid-fire submissions
        if (this.submissionAttempts > 1) {
          await new Promise(resolve => setTimeout(resolve, this.currentDelay));
        }
    
        // Actual submission logic here
        const response = await fetch('/api/submit', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(this.formData)
        });
    
        if (!response.ok) {
          throw new Error('Submission failed');
        }
    
        return response.json();
      }
    }
    export class CSPFriendlyForm {
      // Avoid inline event handlers - use Aurelia's binding instead
      // BAD: <button onclick="handleClick()">
      // GOOD: <button click.trigger="handleClick()">
    
      // Use nonce for dynamic content if needed
      private nonce: string = this.generateNonce();
    
      private generateNonce(): string {
        const array = new Uint8Array(16);
        crypto.getRandomValues(array);
        return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
      }
    
      // Safe dynamic script injection (if absolutely necessary)
      addScriptSecurely(scriptContent: string) {
        const script = document.createElement('script');
        script.nonce = this.nonce;
        script.textContent = scriptContent;
        document.head.appendChild(script);
      }
    }
    <form class="accessible-form" role="form" aria-labelledby="form-title">
      <h2 id="form-title">Contact Information Form</h2>
      <p id="form-description">Please provide your contact details. Required fields are marked with an asterisk (*).</p>
    
      <!-- Fieldset for grouping related fields -->
      <fieldset>
        <legend>Personal Information</legend>
        
        <div class="form-group">
          <label for="firstName" class="required">
            First Name *
            <span class="visually-hidden">(required)</span>
          </label>
          <input id="firstName"
                 type="text"
                 value.bind="contact.firstName & validate"
                 required
                 aria-describedby="firstName-help firstName-error"
                 aria-invalid.bind="hasFirstNameError"
                 class="form-control" />
          <div id="firstName-help" class="help-text">
            Enter your legal first name as it appears on official documents
          </div>
          <div id="firstName-error" 
               class="error-message"
               role="alert"
               if.bind="hasFirstNameError"
               aria-live="polite">
            ${firstNameError}
          </div>
        </div>
    
        <div class="form-group">
          <label for="email" class="required">
            Email Address *
            <span class="visually-hidden">(required)</span>
          </label>
          <input id="email"
                 type="email"
                 value.bind="contact.email & validate"
                 required
                 aria-describedby="email-help"
                 autocomplete="email"
                 class="form-control" />
          <div id="email-help" class="help-text">
            We'll use this to send you important updates
          </div>
        </div>
    
        <div class="form-group">
          <label for="phone">Phone Number (Optional)</label>
          <input id="phone"
                 type="tel"
                 value.bind="contact.phone & validate"
                 aria-describedby="phone-help"
                 autocomplete="tel"
                 class="form-control" />
          <div id="phone-help" class="help-text">
            Include country code for international numbers
          </div>
        </div>
      </fieldset>
    
      <!-- Radio group with proper ARIA -->
      <fieldset>
        <legend>Preferred Contact Method</legend>
        <div class="radio-group" role="radiogroup" aria-required="true">
          <div class="form-check">
            <input id="contact-email"
                   type="radio"
                   name="contactMethod"
                   model.bind="'email'"
                   checked.bind="contact.preferredMethod"
                   class="form-check-input" />
            <label for="contact-email" class="form-check-label">
              Email
            </label>
          </div>
          <div class="form-check">
            <input id="contact-phone"
                   type="radio"
                   name="contactMethod"
                   model.bind="'phone'"
                   checked.bind="contact.preferredMethod"
                   class="form-check-input" />
            <label for="contact-phone" class="form-check-label">
              Phone
            </label>
          </div>
          <div class="form-check">
            <input id="contact-text"
                   type="radio"
                   name="contactMethod"
                   model.bind="'text'"
                   checked.bind="contact.preferredMethod"
                   class="form-check-input" />
            <label for="contact-text" class="form-check-label">
              Text Message
            </label>
          </div>
        </div>
      </fieldset>
    
      <!-- Accessible checkbox with detailed description -->
      <div class="form-group">
        <div class="form-check">
          <input id="newsletter"
                 type="checkbox"
                 checked.bind="contact.subscribeNewsletter"
                 aria-describedby="newsletter-description"
                 class="form-check-input" />
          <label for="newsletter" class="form-check-label">
            Subscribe to newsletter
          </label>
        </div>
        <div id="newsletter-description" class="form-text">
          Receive weekly updates about new features, tips, and special offers. 
          You can unsubscribe at any time.
        </div>
      </div>
    
      <!-- Form submission with clear feedback -->
      <div class="form-actions">
        <button type="submit" 
                class="btn btn-primary"
                aria-describedby="submit-help">
          <span if.bind="!isSubmitting">Submit Contact Information</span>
          <span if.bind="isSubmitting">
            <span class="visually-hidden">Submitting form, please wait</span>
            <span aria-hidden="true">Submitting...</span>
          </span>
        </button>
        <div id="submit-help" class="form-text">
          Review your information before submitting
        </div>
      </div>
    
      <!-- Live region for dynamic updates -->
      <div aria-live="polite" aria-atomic="true" class="sr-only">
        <span if.bind="submissionMessage">${submissionMessage}</span>
      </div>
    </form>
    import { resolve, newInstanceForScope } from '@aurelia/kernel';
    import { IValidationController } from '@aurelia/validation-html';
    
    export class AccessibleFormValidation {
      contact = {
        firstName: '',
        email: '',
        phone: '',
        preferredMethod: '',
        subscribeNewsletter: false
      };
    
      validationErrors: Map<string, string> = new Map();
      isSubmitting = false;
      submissionMessage = '';
    
      private readonly validationController = resolve(newInstanceForScope(IValidationController));
    
      get hasFirstNameError(): boolean {
        return this.validationErrors.has('firstName');
      }
    
      get firstNameError(): string {
        return this.validationErrors.get('firstName') || '';
      }
    
      // Focus management for form errors
      async handleSubmit() {
        this.validationErrors.clear();
        this.isSubmitting = true;
    
        try {
          const result = await this.validationController.validate();
    
          if (!result.valid) {
            // Collect validation errors
            result.results.forEach(error => {
              if (!error.valid) {
                this.validationErrors.set(error.propertyName, error.message);
              }
            });
    
            // Focus first error field
            this.focusFirstError();
            this.announceErrors();
            return;
          }
    
          // Submit form
          await this.submitForm();
          this.submissionMessage = 'Your contact information has been submitted successfully.';
          
        } catch (error) {
          this.submissionMessage = 'An error occurred. Please try again.';
        } finally {
          this.isSubmitting = false;
        }
      }
    
      private focusFirstError() {
        const firstErrorField = this.validationErrors.keys().next().value;
        if (firstErrorField) {
          const element = document.getElementById(firstErrorField);
          element?.focus();
        }
      }
    
      private announceErrors() {
        const errorCount = this.validationErrors.size;
        const announcement = errorCount === 1 
          ? 'There is 1 error in the form. Please review and correct it.'
          : `There are ${errorCount} errors in the form. Please review and correct them.`;
        
        this.announceToScreenReader(announcement);
      }
    
      private announceToScreenReader(message: string) {
        // Create a temporary live region for immediate announcements
        const announcement = document.createElement('div');
        announcement.setAttribute('aria-live', 'assertive');
        announcement.setAttribute('aria-atomic', 'true');
        announcement.className = 'sr-only';
        announcement.textContent = message;
        
        document.body.appendChild(announcement);
        
        // Remove after announcement
        setTimeout(() => {
          document.body.removeChild(announcement);
        }, 1000);
      }
    
      private async submitForm() {
        // Simulate form submission
        await new Promise(resolve => setTimeout(resolve, 1000));
        console.log('Form submitted:', this.contact);
      }
    }
    /* Screen reader only content */
    .visually-hidden, .sr-only {
      position: absolute !important;
      width: 1px !important;
      height: 1px !important;
      padding: 0 !important;
      margin: -1px !important;
      overflow: hidden !important;
      clip: rect(0, 0, 0, 0) !important;
      white-space: nowrap !important;
      border: 0 !important;
    }
    
    /* Focus indicators */
    .form-control:focus,
    .form-check-input:focus,
    .btn:focus {
      outline: 2px solid #005fcc;
      outline-offset: 2px;
    }
    
    /* High contrast mode support */
    @media (prefers-contrast: high) {
      .form-control,
      .form-check-input {
        border: 2px solid ButtonText;
      }
      
      .form-control:focus,
      .form-check-input:focus {
        outline: 3px solid Highlight;
        outline-offset: 2px;
      }
    }
    
    /* Reduced motion support */
    @media (prefers-reduced-motion: reduce) {
      .loading-spinner,
      .progress-bar .progress-fill {
        animation: none;
      }
    }
    
    /* Error states */
    .form-control[aria-invalid="true"] {
      border-color: #dc3545;
      box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
    }
    
    .error-message {
      color: #dc3545;
      font-size: 0.875rem;
      margin-top: 0.25rem;
    }
    
    /* Required field indicators */
    .required::after {
      content: " *";
      color: #dc3545;
    }
    export class AccessibilityTester {
      // Programmatic accessibility testing
      testFormAccessibility() {
        const errors: string[] = [];
    
        // Check for required labels
        const inputs = document.querySelectorAll('input, textarea, select');
        inputs.forEach((input: HTMLInputElement) => {
          const label = document.querySelector(`label[for="${input.id}"]`);
          const ariaLabel = input.getAttribute('aria-label');
          const ariaLabelledBy = input.getAttribute('aria-labelledby');
          
          if (!label && !ariaLabel && !ariaLabelledBy) {
            errors.push(`Input with id "${input.id}" lacks proper labeling`);
          }
        });
    
        // Check for proper heading structure
        const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
        let previousLevel = 0;
        headings.forEach((heading: HTMLElement) => {
          const level = parseInt(heading.tagName.charAt(1));
          if (level > previousLevel + 1) {
            errors.push(`Heading level skipped: ${heading.tagName} after H${previousLevel}`);
          }
          previousLevel = level;
        });
    
        // Check for live regions
        const liveRegions = document.querySelectorAll('[aria-live]');
        if (liveRegions.length === 0) {
          errors.push('No live regions found for dynamic content announcements');
        }
    
        return {
          isAccessible: errors.length === 0,
          errors
        };
      }
    
      // Keyboard navigation testing
      testKeyboardNavigation() {
        const focusableElements = document.querySelectorAll(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
    
        return {
          focusableCount: focusableElements.length,
          hasTrapFocus: this.checkFocusTrap(),
          hasSkipLinks: !!document.querySelector('[href="#main"], [href="#content"]')
        };
      }
    
      private checkFocusTrap(): boolean {
        // Implementation would check if focus is properly trapped in modals/dialogs
        return true; // Simplified
      }
    }