Only this pageAll pages
Powered by GitBook
Couldn't generate the PDF for 598 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...

Loading...

Loading...

Loading...

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...

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...

Forms

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

Real-World Recipes

Startup & enhancement

Component Recipes

Introduction

Template compilation

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.

👋 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.

  • Direct DOM manipulation - no virtual DOM overhead

  • Batched rendering for optimal browser performance

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

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

  • Modern JavaScript/TypeScript - no proprietary abstractions

  • Seamless third-party integration - works with any library

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

  • Two-way data binding with unidirectional safety

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

  • Used by enterprise companies worldwide

  • MIT licensed open-source with active development

  • Comprehensive testing tools built-in

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

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

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

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

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.

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.

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

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.

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

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.

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.

Step 1: Project setup + app shell

Set up Project Pulse, enable the router, and build the shared app shell.

In this step you will create the project, enable the router, and build a shared layout component.

1. Create the project

npx makes aurelia
# Name: project-pulse
# Select TypeScript
cd project-pulse
npm run dev

2. Enable the router and active link styling

Update src/main.ts to register the router and configure an active class for navigation links:

import Aurelia from 'aurelia';
import { RouterConfiguration } from '@aurelia/router';
import { MyApp } from './my-app';

Aurelia
  .register(RouterConfiguration.customize({ activeClass: 'is-active' }))
  .app(MyApp)
  .start();

Any <a load="..."> link will now get the is-active class when the route is active.

3. Create the app shell

Create the folders src/components and src/pages if you do not have them yet.

Create src/components/app-shell.ts:

Create src/components/app-shell.html:

Next step:

with.bind (scope binding)

Change the binding context for a section of a template using Aurelia's built-in with template controller.

with is a template controller that creates a new binding scope for its content. It’s useful when you want to “zoom in” on an object so you don’t have to repeat a prefix like user. everywhere.

Basic usage

<template with.bind="user">
  <h2>${firstName} ${lastName}</h2>
  <p>${email}</p>
</template>

The inner template’s binding context becomes user, so firstName resolves as user.firstName, etc.

You can also put with.bind on a real element:

<section with.bind="user">
  <h2>${firstName} ${lastName}</h2>
</section>

“Shape” a small context object

Sometimes you don’t want to pass the whole object through — just a few values (or renamed values):

  • with does not add/remove DOM like if or repeat.

  • When the with value changes, Aurelia re-binds the existing view to the new scope (it does not recreate the view).

  • with expects an object. If your value can be null/undefined, guard it:

  • Prefer <let> when you only need a couple of local aliases and don’t need to re-scope a whole block.

focus custom attribute

Bind an element's focus state with Aurelia's built-in focus custom attribute.

focus is a built-in custom attribute that lets you bind an element’s focus state to a view-model property.

By default it’s two-way:

  • setting the property focuses/blurs the element

  • focusing/blurring the element updates the property

Basic usage (two-way)

<input focus.bind="isFocused">

<button click.trigger="isFocused = true">Focus the input</button>
<button click.trigger="isFocused = false">Blur the input</button>
export class MyPage {
  public isFocused = false;
}

One-way focus (recommended for “open a thing, focus an input”)

Two-way focus can be surprising if you don’t want a blur to change your state. In that case, make it one-way:

Now the input won’t write back to shouldFocusSearch when it blurs.

  • The target must be focusable. For non-input elements, you may need tabindex="0".

  • Focusing can only happen once the element is connected to the document; focus handles this automatically (it will apply focus after attach if needed).

Step 5: Router events + polish

Add router event listeners and polish the navigation experience.

In this step you will listen to router events and surface navigation state in the UI.

Update src/pages/projects-page.ts:

Update src/pages/projects-page.html:

You now have:

  • Root routes and child routes

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

Local templates (inline templates)

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

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


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:

Optimized bundle sizes - smart code splitting and async loading
Future-proof architecture - leverages browser capabilities
API stability - no breaking changes since 2.0, stable production platform
Strong TypeScript support from day one
Quality ecosystem - carefully curated tools and plugins that work seamlessly together
Better battery life - efficient resource usage on mobile devices
Standards-aligned - leverage browser capabilities instead of fighting them

Choose Your Path

Why Aurelia Outperforms

Performance That Matters

Standards-First Development

Developer Experience

Production Ready

Built for the Future of Web Development

Direct DOM: Maximum Performance, Minimum Overhead

Why Direct DOM Wins

Your Learning Journey

Join the Aurelia Community

Ready to Start Building?

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

Core Concepts

Components

Templates

Dependency Injection

Reactivity

Next Steps

Quick Start Guide
Templates
Components
Getting to Know Aurelia
Tutorials
Developer Guides
Step 2: Routing + nested layouts

Updates and lifecycle

Gotchas

Common pattern: focus when showing a panel

Notes

Active navigation styling via activeClass

  • Query params synced from the UI

  • Programmatic navigation via IRouter.load

  • Router events via IRouterEvents

  • Guards (canLoad and canUnload) for real-world UX

  • If you want, expand this app by adding child routes under projects/detail/:id, or persist notes to storage.

    Next step: Step 6: Route data + auth roles

    1. Listen to router events in the Projects layout

    2. Recap the routing features used

    • 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.


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

    Introduction

    Why Use Local Templates?

    Syntax and Basic Usage

    export class AppShell {}
    <div class="shell">
      <header class="shell__header">
        <div class="shell__title">
          <au-slot name="title">Project Pulse</au-slot>
        </div>
        <div class="shell__actions">
          <au-slot name="actions"></au-slot>
        </div>
      </header>
    
      <main class="shell__body">
        <au-slot></au-slot>
      </main>
    </div>
    <template with.bind="{ profile: user.profile, canEdit: permissions.admin }">
      <user-profile profile.bind="profile"></user-profile>
      <button disabled.bind="!canEdit">Edit</button>
    </template>
    <template with.bind="user || {}">
      ${firstName}
    </template>
    <input focus.to-view="shouldFocusSearch">
    <button click.trigger="isOpen = !isOpen">Toggle</button>
    
    <div if.bind="isOpen">
      <input focus.to-view="isOpen" placeholder="Type to search...">
      <!-- Focuses when opened; does not auto-close on blur -->
    </div>
    import { IDisposable, resolve } from '@aurelia/kernel';
    import { IRouterEvents, NavigationEndEvent, route } from '@aurelia/router';
    import { ProjectsActivityPage } from './projects-activity-page';
    import { ProjectsOverviewPage } from './projects-overview-page';
    
    @route({
      routes: [
        { path: ['', 'overview'], component: ProjectsOverviewPage, title: 'Overview' },
        { path: 'activity', component: ProjectsActivityPage, title: 'Activity' }
      ]
    })
    export class ProjectsPage {
      lastNavigation = '';
    
      private readonly events = resolve(IRouterEvents);
      private subscription?: IDisposable;
    
      bound(): void {
        this.subscription = this.events.subscribe(
          'au:router:navigation-end',
          (event: NavigationEndEvent) => {
            this.lastNavigation = event.instructions.toPath();
          }
        );
      }
    
      unbinding(): void {
        this.subscription?.dispose();
      }
    }
    <import from="../components/app-shell"></import>
    
    <app-shell>
      <h1 au-slot="title">Projects</h1>
      <a au-slot="actions" load="../dashboard">Back to Dashboard</a>
    
      <nav class="projects-subnav">
        <a load="./">Overview</a>
        <a load="activity">Activity</a>
      </nav>
    
      <p class="nav-meta" if.bind="lastNavigation">
        Last navigation: ${lastNavigation}
      </p>
    
      <au-viewport></au-viewport>
    </app-shell>
    <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) {}
    }
    for tailoring DOM events.

    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.

    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

    Key Features of Aurelia Templating

    Learn the Syntax by Topic

    event modifier catalog

    Step 2: Routing + nested layouts

    Add primary routes, a Projects layout, and nested child routes.

    In this step you will create the root pages and a Projects layout that hosts child routes.

    1. Create the dashboard

    Create src/pages/dashboard-page.ts:

    export class DashboardPage {}

    Create src/pages/dashboard-page.html:

    <import from="../components/app-shell"></import>
    
    <app-shell>
      <h1 au-slot="title">Dashboard</h1>
      <a au-slot="actions" load="../projects">View Projects</a>
    
      <p>Welcome to Project Pulse. Use the Projects page to manage tasks.</p>
    </app-shell>

    Note: Links inside routed components are resolved relative to the current route. Use ../ when you want to navigate to a sibling at the parent level.

    2. Create placeholder child pages

    We will build these pages in later steps, but we need the files now so the routes can resolve.

    Create src/pages/projects-overview-page.ts:

    Create src/pages/projects-overview-page.html:

    Create src/pages/projects-activity-page.ts:

    Create src/pages/projects-activity-page.html:

    Create src/pages/projects-page.ts:

    Create src/pages/projects-page.html:

    The nested <au-viewport> is where the Overview and Activity pages render.

    Update src/my-app.ts:

    Update src/my-app.html:

    Notice the is-active class when you navigate between Dashboard and Projects.

    Next step:

    Step 4: Detail route + guards

    Add a detail route with parameters and protect it with router guards.

    In this step you will add a parameterized detail route and guard it with canLoad and canUnload.

    1. Add the detail route

    Update src/pages/projects-page.ts to add the detail child route:

    import { route } from '@aurelia/router';
    import { ProjectDetailPage } from './project-detail-page';
    import { ProjectsActivityPage } from './projects-activity-page';
    import { ProjectsOverviewPage } from './projects-overview-page';
    
    @route({
      routes: [
        { path: ['', 'overview'], component: ProjectsOverviewPage, title: 'Overview' },
        { path: 'activity', component: ProjectsActivityPage, title: 'Activity' },
        { id: 'project-detail', path: 'detail/:id', component: ProjectDetailPage, title: 'Project Detail' }
      ]
    })
    export class ProjectsPage {}

    We keep detail in the URL to keep the child routes explicit and readable. The id: 'project-detail' value is what the Overview page uses in its route: instruction, with a ../ prefix to resolve against the parent context.

    Create src/pages/project-detail-page.ts:

    Create src/pages/project-detail-page.html:

    Guard recap:

    • canLoad prevents invalid IDs and redirects to projects.

    • canUnload prompts when there is unsaved user input.

    Next step:

    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

    • : 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

    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

    These recipes assume you're familiar with:

    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

    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!

    Extended Tutorial

    Multi-step Project Pulse tutorial that introduces routing, component communication, and real-world app patterns.

    Build a small but realistic app while learning the Aurelia router, component communication, and real-world navigation patterns.

    What you will build

    • A Dashboard and a Projects area

    • Nested routes for Projects (Overview + Activity)

    • A project detail view with route parameters

    • Filterable lists with query params you can share

    • Router events and active navigation styling

    • Multi-locale internationalization (i18n)

    • Form validation with localized error messages

    • Modal dialogs for confirmations and editing

    • Centralized state management

    • Completed the

    • Basic TypeScript and HTML

    If you are new to Aurelia, start with steps 1–4 to learn routing fundamentals. Steps 5–6 cover advanced routing patterns. Steps 7–10 introduce additional Aurelia packages for building production-ready applications.

    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.

    • A code editor of your choice

    • (latest version recommended) for the scaffolded app path

    Want to try Aurelia immediately without installing anything? Use . That page is the canonical no-build setup and covers module scripts, CDN imports, version pinning, CodePen usage, and package registration.

    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.

    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.

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

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

    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.

    Display dynamic content using string interpolation:

    Bind to element properties and attributes:

    Create two-way data flow with .bind:

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

    Handle user interactions with .trigger:

    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.

    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.

    • Recent version of installed

    • Basic knowledge of JavaScript, HTML, and CSS

    Spread operators

    Aurelia supports two different spread features in templates:

    1. Bindables spreading: bind multiple properties from an object onto a custom element’s bindable properties.

    2. Attribute transferring: forward captured attributes/bindings from a custom element usage to an element inside its template.

    This page focuses on the template syntax and common gotchas. For the deeper conceptual guide, see:

    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?

    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.

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

    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.

    template reference walkthrough
    template variables guide
    template promises article
    recipes collection
    forms guide
    Tooltip: Position-aware tooltips with smart placement
  • Accordion: Collapsible content panels with smooth animations

  • Styling: Base CSS to get you started
  • Testing: How to test the component

  • Enhancements: Ideas for extending the component

  • Proper cleanup in detaching() lifecycle hooks

  • Accessible markup with ARIA attributes

  • Clear explanations

    Available Recipes

    UI Components

    How to Use These Recipes

    Prerequisites

    Code Standards

    Contributing

    Dropdown Menu
    Modal Dialog
    Tabs Component
    Aurelia components
    Bindable properties
    Template syntax
    Dependency injection

    3. Create the Projects layout with child routes

    4. Wire up the root routes

    Step 3: Overview page + filters + events

    2. Build the detail page

    Step 5: Router events + polish
    Show or hide content based on conditions:

    Display dynamic lists with repeat.for:

    Access DOM elements directly:

    Create local variables within templates:

    • Explore the complete template syntax overview

    • Learn about value converters for data transformation

    • Discover custom attributes for reusable behaviors

    Data Binding

    Text Interpolation

    Property Binding

    Two-way Binding

    Event Handling

    Conditional Rendering

    List Rendering

    Template References

    Template Variables

    What's Next

    Bindable Properties (Bindables spreading, Attributes Transferring)

  • Attribute Transferring


  • Bindables spreading is for custom element usage. It creates one-way (to-view) bindings from an object’s properties to matching bindable properties on the custom element.

    Only keys that match bindable property names (name, email, avatarUrl) create bindings. Extra keys are ignored.

    • Keys are property names, not attribute names: firstName matches @bindable firstName, but first-name does not.

    • Shorthand is HTML-case-sensitive in a bad way: HTML lowercases attribute names, so ...firstName becomes ...firstname and won’t match your view-model property. Prefer ...$bindables="firstName" when case matters.

    • No spaces in shorthand: ...a + b becomes multiple attributes (...a, +, b) and will not work. Use ...$bindables="a + b".

    • Direction is always one-way (to-view): bindables spreading does not support two-way/from-view. Binding behaviors can’t change direction, but can still affect evaluation (e.g. ...$bindables="user & debounce:200").

    • Bindings are based on existing keys: if the object did not have a key at the time it was evaluated, no binding is created for that property. To “add” bindings, assign a new object.

    You can technically combine spread syntax with explicit bindings, but it creates multiple bindings targeting the same property and can be confusing.

    Prefer one approach, or ensure the intent is obvious and well-documented. If you do mix them, the last declared binding typically “wins” at update time.


    Attribute transferring is for inside a component template: it forwards “captured” attributes and bindings from the component’s usage to an element inside the component.

    Enable capturing on the custom element definition:

    Use it like a normal input:

    Everything that is captured from <form-input ...> will be applied to the inner <input ...$attrs>.

    When capture: true is enabled, Aurelia captures (for later spreading) everything except:

    • custom element bindables (those are handled as bindables, not captured)

    • template controllers (like if, repeat.for, with, portal, etc.)

    You can also supply a capture filter function if you only want to capture certain attributes.

    • You cannot transfer template controllers via ...$attrs (you’ll get a compile error).

    • Don’t overuse deep “pass-through” chains; one or two layers is usually plenty.

    1) Bindables spreading

    Syntax options

    Example

    Important gotchas

    Avoid double-binding the same property

    2) Attribute transferring (...$attrs)

    Minimal example

    What gets captured?

    Restrictions

    This is the mechanism that lets you write <input value.bind="message"> 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.

    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.

    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.

    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:

    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.

    • 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.

    When to extend IAttrMapper

    How the mapper decides

    Registering mappings during startup

    Enabling implicit two-way bindings

    Troubleshooting and best practices

    export class ProjectsOverviewPage {}
    <p>Projects overview goes here.</p>
    export class ProjectsActivityPage {}
    <p>Activity summary goes here.</p>
    import { route } from '@aurelia/router';
    import { ProjectsActivityPage } from './projects-activity-page';
    import { ProjectsOverviewPage } from './projects-overview-page';
    
    @route({
      routes: [
        { path: ['', 'overview'], component: ProjectsOverviewPage, title: 'Overview' },
        { path: 'activity', component: ProjectsActivityPage, title: 'Activity' }
      ]
    })
    export class ProjectsPage {}
    <import from="../components/app-shell"></import>
    
    <app-shell>
      <h1 au-slot="title">Projects</h1>
      <a au-slot="actions" load="../dashboard">Back to Dashboard</a>
    
      <nav class="projects-subnav">
        <a load="">Overview</a>
        <a load="activity">Activity</a>
      </nav>
    
      <au-viewport></au-viewport>
    </app-shell>
    import { route } from '@aurelia/router';
    import { DashboardPage } from './pages/dashboard-page';
    import { ProjectsPage } from './pages/projects-page';
    
    @route({
      routes: [
        { path: ['', 'dashboard'], component: DashboardPage, title: 'Dashboard' },
        { path: 'projects', component: ProjectsPage, title: 'Projects' }
      ]
    })
    export class MyApp {}
    <nav class="main-nav">
      <a load="dashboard">Dashboard</a>
      <a load="projects">Projects</a>
    </nav>
    
    <au-viewport></au-viewport>
    import { IRouteViewModel, Params } from '@aurelia/router';
    import { Project } from '../models';
    import { PROJECTS } from '../project-data';
    
    export class ProjectDetailPage implements IRouteViewModel {
      projectId = '';
      project: Project | null = null;
      noteDraft = '';
    
      canLoad(params: Params): boolean | string {
        const candidate = PROJECTS.find(item => item.id === params.id);
        return candidate ? true : 'projects';
      }
    
      loading(params: Params): void {
        this.projectId = params.id ?? '';
        this.project = PROJECTS.find(item => item.id === this.projectId) ?? null;
      }
    
      canUnload(): boolean {
        if (!this.noteDraft.trim()) return true;
        return confirm('You have an unsaved note. Leave this page?');
      }
    
      saveNote(): void {
        this.noteDraft = '';
      }
    }
    <import from="../components/app-shell"></import>
    
    <app-shell>
      <h1 au-slot="title">${project?.name ?? 'Project'}</h1>
      <a au-slot="actions" load="../overview">Back to Projects</a>
    
      <section if.bind="project">
        <p class="project-meta">Project id: ${projectId}</p>
    
        <label class="note">
          <span>Note</span>
          <textarea value.bind="noteDraft" placeholder="Add a quick note"></textarea>
        </label>
        <button if.bind="noteDraft" click.trigger="saveNote()">Save Note</button>
    
        <ul class="task-list">
          <li repeat.for="task of project.tasks">
            <span class.bind="task.done ? 'task-item__done' : ''">${task.title}</span>
          </li>
        </ul>
      </section>
    
      <p if.bind="!project">Project not found.</p>
    </app-shell>
    <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>
    <!-- Recommended: put the expression in the attribute value -->
    <user-card ...$bindables="user"></user-card>
    
    <!-- Equivalent: explicit binding command form -->
    <user-card $bindables.spread="user"></user-card>
    
    <!-- Shorthand: put the expression in the attribute name (no spaces!) -->
    <user-card ...user></user-card>
    import { bindable } from 'aurelia';
    
    export class UserCard {
      @bindable name!: string;
      @bindable email!: string;
      @bindable avatarUrl!: string;
    }
    export class MyApp {
      user = {
        name: 'Jane Doe',
        email: 'jane@example.com',
        avatarUrl: 'https://example.com/avatar.jpg',
        extra: 'ignored'
      };
    }
    <user-card ...$bindables="user"></user-card>
    import { customElement, bindable } from 'aurelia';
    
    @customElement({
      name: 'form-input',
      capture: true,
      template: `
        <label>
          \${label}
          <input ...$attrs>
        </label>
      `
    })
    export class FormInput {
      @bindable label!: string;
      @bindable value!: string;
    }
    <form-input
      label="Email"
      value.bind="email"
      placeholder="name@example.com"
      input.trigger="validate($event)">
    </form-input>
    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');
      })
    );
    For a more complete no-build application, see the realworld-vanilla example, which demonstrates a full application with routing.

    Aurelia uses the Makes scaffolding tool. No global installs required.

    When prompted:

    • Project name: Enter your project name

    • Setup: Choose TypeScript (recommended) or ESNext

    • Install dependencies: Select "Yes"

    Navigate to your project and start the development server:

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

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

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

    • Ready for more? Explore our developer guides and tutorials

    • Need help? Check out troubleshooting

    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 Component essentials.

    • Project structure & conventions – See how the scaffolded files fit together in the Complete guide's project structure section.

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

    • Dependency injection – Learn how Aurelia's DI container works and when to call resolve() in the .

    • Routing – When you need multiple pages, follow the to add navigation.

    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

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

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

    Prerequisites

    Option 1: Try Aurelia in the browser

    Node.js
    Run Aurelia in the browser without a build step
    npx makes aurelia
    cd your-project-name
    npm start

    Option 2: Create Your App

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

    Run Your App

    Verify Everything Works

    What's Next?

    Recommended Reading

    Core Concepts (Optional Reading)

    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.

    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.

    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.

    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.

    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!

    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.

    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.

    Getting started

    Download a code editor

    here
    Visual Studio Code
    npx makes aurelia
    npm run build

    Create a new Aurelia project

    Step 1. Name your project

    Step 2. Choose your options

    Step 3. Install the dependencies

    Step 4. Run the sample app

    Building your app

    Keep Learning

    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.

    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.

    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.

    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.

    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

    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

    Ready to dive deeper? Explore:

    • Template Syntax - loops, conditionals, and more bindings

    • Component Lifecycle - hooks for advanced behavior

    • Dependency Injection - sharing services between components

    • - multi-page applications

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

    What You'll Build

    Prerequisites

    Step 1: Create Your Project

    Node.js
    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>

    Step 2: Understand the Basics

    Step 3: Create an Interactive Component

    Step 4: Use Your Component

    Step 5: Test Your App

    Key Concepts You Learned

    Next Steps

    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.

    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

    class.bind="someString"

    string

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

    class="${someString}"

    string

    col-md-4 ${someString}

    Dynamically set CSS styles on elements in your view templates.

    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:

    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.

    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.

    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.

    <p selected.class="isSelected">I am selected (I think)</p>

    Binding to the class attribute

    Binding to a single class

    <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>
    

    Binding to multiple classes

    Binding to the style attribute

    Binding to a single style

    Binding to multiple styles

    Style binding using strings

    Style binding using objects

    Prerequisites

    Steps

    Core (Steps 1–4)

    Advanced routing (Steps 5–6)

    Aurelia packages (Steps 7–10)

    Hello World Tutorial
    Step 1: Project setup + app shell
    Step 2: Routing + nested layouts
    Step 3: Overview page + filters + events
    Step 4: Detail route + guards
    Step 5: Router events + polish
    Step 6: Route data + auth roles
    Step 7: Internationalization (i18n)
    Step 8: Form validation with i18n
    Step 9: Dialogs
    Step 10: State management

    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.

    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.

    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.

    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.

    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.

    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.

    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.

    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.

    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.

    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.

    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.

    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.

    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.

    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).

    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:

    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.

    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.

    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.

    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.

    • 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.

    Theme
    Read this first
    Follow with
    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.

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

    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.

    The <let> element provides a straightforward syntax for declaring variables directly within your templates. The basic structure is as follows:

    • <let>

    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.


    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

    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.

    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

    Web Standards, Enhanced

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

    Convention Over Configuration

    Intuitive Syntax That Reads Like You Think

    Built for Testing

    Stability Over the Latest Hotness

    Architecture for Growing Applications

    Popular Is Overrated

    We Trust You With Power Tools

    Complete, Not Cobbled Together

    No Surprises

    Progress Through Evolution, Not Revolution

    DI overview
    router getting started guide
    templates overview
    template syntax deep dive
    dependency injection overview
    debugging and troubleshooting
    : 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.

  • 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:

    <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.

    <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.

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

    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.

    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.

    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.

    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.

    • 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.

    Declaring Template Variables with <let>

    Basic String Assignment

    Binding Expressions for Dynamic Values

    Scoping of Template Variables

    Practical Use Cases for <let>

    1. Simplifying Complex Expressions

    2. Conditional Rendering Logic

    3. Data Transformation within Templates

    4. Creating Reusable Template Snippets

    Considerations when Using <let>

    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


    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.

    Serialize an object for debugging or quick display:

    Perform simple or complex calculations:

    Use global numeric checks to conditionally display elements:

    Construct inline regular expressions for quick validation:

    Use Object methods for reflection or retrieval:

    De-duplicate arrays or combine sets inline:

    Leverage encodeURI / decodeURI for safe link construction:

    Localize numbers, currency, or dates easily:

    Filter, map, and transform arrays:


    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.

    Why Limit Global Access?

    Example Usages of Built-In Globals

    1. Working with JSON

    2. Mathematical Operations with Math

    3. Conditional Rendering with isNaN

    4. Regular Expressions with RegExp

    5. Dynamic Property Access with Object

    6. Set Operations with Set

    7. Encoding & Decoding URLs

    8. Number Formatting with Intl.NumberFormat

    9. Complex Array Manipulations

    Best Practices and Considerations

    <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>
    <pre>${JSON.stringify(user, null, 2)}</pre>
    <p>The square root of 16 is: ${Math.sqrt(16)}</p>
    <input type="text" value.bind="value" />
    <p if.bind="isNaN(value)">This is not a valid number!</p>
    <input value.bind="email" placeholder="Enter email" />
    <p if.bind="new RegExp('^\\S+@\\S+\\.\\S+$').test(email)">
      Valid Email Address
    </p>
    <p>Property Value: ${Object.getOwnPropertyDescriptor(user, selectedProp)?.value}</p>
    <p>Unique Values: ${[...new Set(numbersArray)]}</p>
    <a href.bind="encodeURI(externalLink)">Visit External Site</a>
    <p>Original URL: ${decodeURI(externalLink)}</p>
    <p>Price: ${new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price)}</p>
    <p>Active Items: ${Array.from(dataSet).filter(i => i.active).map(i => i.name).join(', ')}</p>

    Navigation

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

    Composition patterns

    ,

    State & observation

    ,

    Services & runtime hooks

    , , ,

    Deep dives

    None

    Explore composition and state tools to keep components small and expressive.
  • Wire in services and lifecycle hooks as the app grows in complexity.

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

  • Templates & syntax

    Built-in template features

    Class & style binding, Attribute transferring

    Bootstrapping

    App configuration & startup

    Enhance

    Topic Map

    Suggested Learning Path

    decorator from the template compiler.

    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 attribute patterns 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.

    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 type IAttrMapper is used to determine the binding mode, the target property name, etc. (for more information, refer to the documentation). 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.

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

    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.

    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:

    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.

    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
    -->

    Binding command

    Creating a custom binding command

    Registering the custom binding command

    Why ignoreAttr = true?

    Debugging custom binding commands

    Live example

    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.

    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.

    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.

    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.

    <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') }))
      }
    }
    

    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:

    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.

    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.

    • $middle - In a repeat template, is true if the item is neither first nor last.

    • $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.

    • $previous - In a repeat template, returns the previous item in the collection (or undefined for the first item).

    • $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.

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

    Adding or removing an element using if.bind

    <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>
    <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>

    Showing or hiding an element using show.bind

    Conditionally add or remove elements using switch.bind

    Using promises in templates with promise.bind

    Looping over collections with repeat.for

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

    Creating ranges with repeat.for

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

    <p selected.class="isSelected">I am selected (I think)</p>
    <div alert,alert-danger,fade-in,bold-text.class="hasError">Something went wrong!</div>

    class.bind="someString"

    string

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

    class="${someString}"

    string

    col-md-4 ${someString}

    <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>

    Binding to multiple classes

    Binding Inline Styles

    Binding to a single style

    Binding to multiple styles

    Style binding using strings

    Style binding using objects

    Router

    Step 6: Route data + auth roles

    Use route data for roles and enforce access with a router hook.

    Optional step: This adds route data for role requirements and enforces it with a router hook. You will also add a small auth status widget to toggle the admin role in the UI. If you want to keep the tutorial beginner-friendly, you can stop after Step 5.

    1. Create a tiny auth service

    Create src/services/auth-service.ts:

    import { DI } from '@aurelia/kernel';
    
    export type User = {
      name: string;
      roles: string[];
    };
    
    export const IAuthService = DI.createInterface<IAuthService>(
      'IAuthService',
      x => x.singleton(AuthService)
    );
    
    export interface IAuthService extends AuthService {}
    
    export class AuthService {
      private user: User | null = { name: 'Taylor', roles: ['member'] };
    
      getCurrentUser(): User | null {
        return this.user;
      }
    
      hasRole(role: string): boolean {
        return !!this.user?.roles.includes(role);
      }
    
      toggleRole(role: string): void {
        if (!this.user) return;
    
        if (this.user.roles.includes(role)) {
          this.user = { ...this.user, roles: this.user.roles.filter(item => item !== role) };
          return;
        }
    
        this.user = { ...this.user, roles: [...this.user.roles, role] };
      }
    }

    2. Create a role-based router hook

    Create src/role-hook.ts:

    import { resolve } from '@aurelia/kernel';
    import { lifecycleHooks } from '@aurelia/runtime-html';
    import { IRouteViewModel, Params, RouteNode } from '@aurelia/router';
    import { IAuthService } from './services/auth-service';
    
    @lifecycleHooks()
    export class RoleHook {
      private readonly auth = resolve(IAuthService);
    
      canLoad(_viewModel: IRouteViewModel, _params: Params, next: RouteNode): boolean | string {
        const requiredRoles = next.data?.roles as string[] | undefined;
        const fallbackRoute = (next.data?.fallbackRoute as string | undefined) ?? 'forbidden';
    
        if (!requiredRoles || requiredRoles.length === 0) {
          return true;
        }
    
        const hasRequiredRole = requiredRoles.some(role => this.auth.hasRole(role));
        return hasRequiredRole ? true : fallbackRoute;
      }
    }

    Register the hook in src/main.ts:

    import Aurelia from 'aurelia';
    import { RouterConfiguration } from '@aurelia/router';
    import { MyApp } from './my-app';
    import { RoleHook } from './role-hook';
    
    Aurelia
      .register(
        RouterConfiguration.customize({ activeClass: 'is-active' }),
        RoleHook
      )
      .app(MyApp)
      .start();

    3. Add protected routes with route data

    Create src/pages/admin-page.ts:

    Create src/pages/admin-page.html:

    Create src/pages/forbidden-page.ts:

    Create src/pages/forbidden-page.html:

    Update src/my-app.ts to add route data:

    Create src/components/auth-status.ts:

    Create src/components/auth-status.html:

    Update src/pages/dashboard-page.html to show it:

    Finally, add the Admin link in src/my-app.html:

    • Route data carries the roles requirement.

    • The RoleHook reads next.data and redirects to forbidden if roles are missing.

    Back to:

    File Uploads

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

    Basic File Input

    <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);
        }
      }
    }

    Single File Upload

    <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]] : [];
    }

    File Preview

    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>

    Validation

    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;
      }
    }

    Progress Tracking

    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>

    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

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

    • Check file sizes to prevent DoS attacks

    • Scan uploaded files for malware

    Routing

    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 Choosing the right Aurelia router.

    Highlights

    • 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: .

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

    1. Fundamentals

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

    Capability
    How to use it
    Related doc
    • Explore targeted recipes in the .

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

    • Review the when upgrading between versions.

    Browser without a build step

    Run Aurelia 2 directly in the browser with a module script and CDN imports.

    You can run Aurelia from a plain HTML page by importing the framework as a browser ES module. This works well for CodePen demos, reduced bug reproductions, documentation examples, and small pages where a Vite or Webpack setup would be more ceremony than value.

    For application development, the scaffolded Vite setup is still the recommended path. A build tool gives you TypeScript, file-based templates, CSS imports, optimized output, and local package management.

    What to include

    A no-build Aurelia page needs these pieces:

    • A host element, such as <app-root></app-root>.

    • A <script type="module"> block. Static import statements only work in module scripts.

    • An ESM CDN URL for aurelia.

    • CustomElement.define(...) for inline component definitions.

    • new Aurelia().app({ component, host }).start() to attach Aurelia to the host element.

    The smallest useful page looks like this:

    In CodePen, put the host element and the <script type="module"> block in the HTML panel. If you put the JavaScript in a normal script panel, the browser will reject static import statements unless that panel is explicitly configured to emit a module script.

    Use @latest only for quick local experiments. For bug reports, examples in issues, CodePen links, and documentation, pin every Aurelia package to the same version:

    When you update one Aurelia package URL, update the others in the same page. Mixing versions can produce duplicate containers, missing registrations, or subtle binding/runtime mismatches.

    Avoid bundling each Aurelia package URL independently in a multi-package browser demo. For example, do not add ?bundle to both aurelia and @aurelia/i18n unless you have confirmed the CDN keeps a single shared copy of Aurelia's internals. Separate bundles can hide duplicated framework modules.

    The examples on this page use esm.sh. The same pattern also works with other CDNs that serve browser-ready ES modules. For example, jsDelivr exposes Aurelia's ESM build at https://cdn.jsdelivr.net/npm/aurelia@2.0.0-rc.1/+esm.

    Import each package from the CDN and register its configuration before calling .app(...).start(). This i18n example shows the pattern:

    The t attribute writes translated text by default. Use t="[html]key" only when the translated value is trusted HTML that you intentionally want inserted as markup.

    Use enhance(...) when the page already contains the markup you want Aurelia to bind. This is useful for progressive enhancement, server-rendered HTML, or a small demo that does not need a custom root element definition.

    Use .app(...) when Aurelia owns a component host such as <app-root>. Use .enhance(...) when Aurelia should bind markup that is already in the document.

    No-build examples run as plain browser JavaScript. Keep these constraints in mind:

    • Browsers do not run TypeScript directly. Use JavaScript in the page, or use a build tool for TypeScript examples.

    • Decorator syntax needs transpilation. Prefer CustomElement.define(...) for no-build examples.

    • Local .html, .css, and

    When an example grows beyond a single page, move it to the normal project setup from the .

    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.

    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.

    Directive
    Use Case
    DOM Behavior
    Performance

    App configuration and startup

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

    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 for context on how this guide fits with enhancement, routing, and composition topics.

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

    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:

    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.

    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>

    SVG

    A developer guide for enabling SVG binding in Aurelia 2.

    Learn about enabling SVG binding in Aurelia templates.

    By default, Aurelia won't work with SVG elements since SVG elements and their attributes require different parsing rules (SVG uses XML namespaces and has different attribute handling than HTML). To teach Aurelia how to handle SVG element bindings, register the SVGAnalyzer:

    After adding this registration, bindings on SVG elements will work as expected.

    Once SVGAnalyzer is registered, you can bind to SVG attributes just like HTML attributes:

    Create reactive charts with data binding:

    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.

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

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

    Store files outside web root
  • Use unique filenames to prevent overwrites

  • Implement rate limiting

  • Security Considerations

    Related

    Form Basics
    Form Submission
    Form Examples
    Choosing a router
    Template controllers
    Dynamic composition
    Portalling elements
    Understanding the binding system
    Observation overview
    Watching data
    Dependency injection primer
    App tasks
    Task queue
    Event aggregator
    Logging
    Framework internals
    repeat.for examples
    https://codesandbox.io/p/devbox/bold-cache-cyn542?embed=1codesandbox.io

    activeClass keeps the main nav styled for the active route.

    4. Add an auth status widget

    Recap

    Extended Tutorial overview
    .json
    imports are bundler features in most Aurelia apps. Inline small templates in the component definition, or fetch external data over HTTP.
  • If your example fetches local files, serve it from http://localhost instead of opening it with file://. Browser security rules block many file:// fetches.

  • Keep CDN package versions aligned across aurelia, @aurelia/* packages, and any plugin packages used by those integrations.

  • Pin package versions

    Add official Aurelia packages

    Enhance existing HTML

    Browser-only constraints

    Quick Install Guide

    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

    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.

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

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

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

    hide is an alias of show with inverted logic:

    This is equivalent to:

    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.

    Handle multiple values with a single case:

    Enable fall-through to show multiple cases:

    Use computed expressions with switch.bind:

    Combine switch.bind with slots for dynamic content projection:

    Handle complex conditional logic with nested switches:

    • 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

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

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

    if.bind

    Quick Reference

    Simple true/false conditions

    Using if.bind

    Basic Usage

    If/Else Structures

    Caching Behavior

    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

    Basic Usage

    hide.bind (inverse of show.bind)

    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

    Basic Usage

    Grouping Cases

    Fall-Through Behavior

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

    Advanced Techniques

    Dynamic Switch Expressions

    Conditional Slot Projection

    Nested Switches

    Performance Guidelines

    Choosing the Right Directive

    Optimization Tips

    Important Restrictions

    Case Usage Rules

    Default Case Placement

    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

    • DOM event handling and delegation

    • Shadow DOM and CSS module support

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

    Group related components into resource modules for better organization.

    src/components/index.ts:

    src/main.ts:

    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 Enhance article.

    • Continue with Enhance for progressive integration scenarios.

    • Wire services using dependency injection once your shell is running.

    • Explore choosing a router to add navigation after the app is bootstrapped.

    Application Startup

    Quick startup

    Verbose Startup

    section overview

    Registering Global Resources

    Registering a single custom element

    Registering multiple resources

    Registering other resource types

    Advanced Configuration

    Custom DI registrations

    Environment-specific configuration

    Enhancement Mode

    Next steps

    Bind to path data for dynamic shapes:

    Handle events on SVG elements:

    Apply dynamic classes to SVG elements:

    Create dynamic gradients:

    The SVGAnalyzer supports all standard SVG elements including:

    • Basic shapes: circle, rect, ellipse, line, polyline, polygon, path

    • Text: text, tspan, textPath

    • Containers: g, svg, defs, symbol, use

    • Gradients: linearGradient, radialGradient, stop

    • Filters: filter, feBlend, feColorMatrix, feGaussianBlur, and more

    • Animation: animate, animateTransform, animateMotion

    • Always register SVGAnalyzer before using SVG bindings

    • SVG attributes are case-sensitive (e.g., viewBox, not viewbox)

    • Use xlink:href for older SVG references, or just href for modern browsers

    • For better performance with many SVG elements, consider using one-time bindings for static values

    • Attribute Binding - Basic binding syntax

    • Class and Style Binding - Dynamic styling

    • Repeat Rendering - Iterating over data

    Adding SVG Registration

    Basic SVG Bindings

    Dynamic SVG Charts

    SVG Path Animations

    Interactive SVG Elements

    SVG with CSS Classes

    SVG Gradients with Bindings

    Supported SVG Elements

    Important Notes

    Related Documentation

    Data Table with Sorting, Filtering & Pagination - Complete data table with sorting, filtering, pagination, row selection
    • Notification/Toast System - Global notifications with auto-dismiss, multiple types, and queue management

    • Search with Autocomplete - Typeahead search with keyboard navigation and highlighting

    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

    Looking to contribute a recipe? Follow this structure:

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

    Available Recipes

    E-Commerce & Shopping

    Data Display & Tables

    Product Catalog with Search & Filters
    Shopping Cart

    UI Components

    How to Use These Recipes

    Recipe Template

    Contributing

    export class AdminPage {}
    <import from="../components/app-shell"></import>
    
    <app-shell>
      <h1 au-slot="title">Admin</h1>
      <a au-slot="actions" load="../dashboard">Back to Dashboard</a>
    
      <p>Only admins can access this page.</p>
    </app-shell>
    export class ForbiddenPage {}
    <import from="../components/app-shell"></import>
    
    <app-shell>
      <h1 au-slot="title">Access denied</h1>
      <a au-slot="actions" load="../dashboard">Back to Dashboard</a>
    
      <p>You do not have permission to view that page.</p>
    </app-shell>
    import { route } from '@aurelia/router';
    import { AdminPage } from './pages/admin-page';
    import { DashboardPage } from './pages/dashboard-page';
    import { ForbiddenPage } from './pages/forbidden-page';
    import { ProjectsPage } from './pages/projects-page';
    
    @route({
      routes: [
        { path: ['', 'dashboard'], component: DashboardPage, title: 'Dashboard' },
        { path: 'projects', component: ProjectsPage, title: 'Projects' },
        {
          path: 'admin',
          component: AdminPage,
          title: 'Admin',
          data: {
            roles: ['admin'],
            fallbackRoute: 'forbidden'
          }
        },
        { path: 'forbidden', component: ForbiddenPage, title: 'Access denied' }
      ]
    })
    export class MyApp {}
    import { resolve } from '@aurelia/kernel';
    import { IAuthService, User } from '../services/auth-service';
    
    export class AuthStatus {
      private readonly auth = resolve(IAuthService);
    
      user: User | null = this.auth.getCurrentUser();
    
      toggleAdmin(): void {
        this.auth.toggleRole('admin');
        this.user = this.auth.getCurrentUser();
      }
    }
    <section class="auth-status">
      <p if.bind="user">
        Signed in as ${user.name}. Roles: ${user.roles.join(', ')}
      </p>
      <p if.bind="!user">Signed out.</p>
    
      <button click.trigger="toggleAdmin()">Toggle Admin Role</button>
    </section>
    <import from="../components/app-shell"></import>
    <import from="../components/auth-status"></import>
    
    <app-shell>
      <h1 au-slot="title">Dashboard</h1>
      <a au-slot="actions" load="../projects">View Projects</a>
    
      <p>Welcome to Project Pulse. Use the Projects page to manage tasks.</p>
    
      <auth-status></auth-status>
    </app-shell>
    <nav class="main-nav">
      <a load="dashboard">Dashboard</a>
      <a load="projects">Projects</a>
      <a load="admin">Admin</a>
    </nav>
    
    <au-viewport></au-viewport>
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Aurelia without a build step</title>
        <link rel="modulepreload" href="https://esm.sh/aurelia@2.0.0-rc.1" crossorigin>
      </head>
      <body>
        <app-root></app-root>
    
        <script type="module">
          import { Aurelia, CustomElement } from 'https://esm.sh/aurelia@2.0.0-rc.1';
    
          const AppRoot = CustomElement.define({
            name: 'app-root',
            template: `
              <h1>\${message}</h1>
              <input value.bind="message">
              <p>You typed: \${message}</p>
            `,
          }, class {
            message = 'Hello from Aurelia';
          });
    
          await new Aurelia()
            .app({
              component: AppRoot,
              host: document.querySelector('app-root'),
            })
            .start();
        </script>
      </body>
    </html>
    import { Aurelia, CustomElement } from 'https://esm.sh/aurelia@2.0.0-rc.1';
    import { I18nConfiguration } from 'https://esm.sh/@aurelia/i18n@2.0.0-rc.1';
    <app-root></app-root>
    
    <script type="module">
      import { Aurelia, CustomElement } from 'https://esm.sh/aurelia@2.0.0-rc.1';
      import { I18nConfiguration } from 'https://esm.sh/@aurelia/i18n@2.0.0-rc.1';
    
      const AppRoot = CustomElement.define({
        name: 'app-root',
        template: `
          <h1 t="heading"></h1>
          <p t="[text]intro"></p>
        `,
      }, class {});
    
      await new Aurelia()
        .register(
          I18nConfiguration.customize((options) => {
            options.initOptions = {
              lng: 'en',
              resources: {
                en: {
                  translation: {
                    heading: 'Hello from i18n',
                    intro: 'Text translations update textContent.',
                  },
                },
              },
            };
          }),
        )
        .app({
          component: AppRoot,
          host: document.querySelector('app-root'),
        })
        .start();
    </script>
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Aurelia without a build step</title>
      </head>
      <body>
        <p>${message}</p>
    
        <script type="module">
          import { Aurelia } from 'https://esm.sh/aurelia@2.0.0-rc.1';
    
          await new Aurelia()
            .enhance({
              component: { message: 'Hello from enhanced HTML' },
              host: document.body,
            })
            .start();
        </script>
      </body>
    </html>
    <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>
    <div hide.bind="isHidden">Hidden when true</div>
    <div show.bind="!isHidden">Hidden when true</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>
    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!' }
    });
    import { SVGAnalyzer } from '@aurelia/runtime-html';
    import { Aurelia } from 'aurelia';
    
    Aurelia
      .register(SVGAnalyzer) // <-- add this line
      .app(MyApp)
      .start();
    <svg width="200" height="200">
      <!-- Bind to standard SVG attributes -->
      <circle cx.bind="circleX"
              cy.bind="circleY"
              r.bind="radius"
              fill.bind="fillColor" />
    
      <!-- Use interpolation for transform -->
      <rect x="10" y="10"
            width.bind="rectWidth"
            height.bind="rectHeight"
            transform="rotate(${rotation} 50 50)" />
    </svg>
    export class SvgDemo {
      circleX = 100;
      circleY = 100;
      radius = 50;
      fillColor = '#3498db';
      rectWidth = 80;
      rectHeight = 60;
      rotation = 45;
    }
    <svg width="400" height="300" class="bar-chart">
      <!-- Y-axis -->
      <line x1="50" y1="10" x2="50" y2="250" stroke="#333" />
      <!-- X-axis -->
      <line x1="50" y1="250" x2="390" y2="250" stroke="#333" />
    
      <!-- Dynamic bars -->
      <g repeat.for="item of chartData; let i = $index">
        <rect x.bind="60 + i * 70"
              y.bind="250 - item.value * 2"
              width="50"
              height.bind="item.value * 2"
              fill.bind="item.color" />
        <text x.bind="85 + i * 70"
              y="270"
              text-anchor="middle"
              font-size="12">
          ${item.label}
        </text>
      </g>
    </svg>
    export class BarChart {
      chartData = [
        { label: 'Jan', value: 65, color: '#3498db' },
        { label: 'Feb', value: 89, color: '#2ecc71' },
        { label: 'Mar', value: 72, color: '#e74c3c' },
        { label: 'Apr', value: 95, color: '#9b59b6' }
      ];
    }
    <svg width="300" height="200">
      <path d.bind="pathData"
            fill="none"
            stroke.bind="strokeColor"
            stroke-width.bind="strokeWidth" />
    </svg>
    export class PathDemo {
      strokeColor = '#e74c3c';
      strokeWidth = 2;
    
      get pathData(): string {
        return `M 10 80 Q 95 10 180 80 T 350 80`;
      }
    }
    <svg width="400" height="300">
      <circle repeat.for="circle of circles"
              cx.bind="circle.x"
              cy.bind="circle.y"
              r.bind="circle.r"
              fill.bind="circle.color"
              class.bind="circle.selected ? 'selected' : ''"
              click.trigger="selectCircle(circle)"
              mouseenter.trigger="highlightCircle(circle)"
              mouseleave.trigger="unhighlightCircle(circle)"
              style="cursor: pointer;" />
    </svg>
    <svg width="200" height="200">
      <rect x="50" y="50"
            width="100" height="100"
            class.bind="isActive ? 'shape-active' : 'shape-inactive'" />
    </svg>
    .shape-active {
      fill: #2ecc71;
      stroke: #27ae60;
      stroke-width: 3;
    }
    
    .shape-inactive {
      fill: #bdc3c7;
      stroke: #95a5a6;
      stroke-width: 1;
    }
    <svg width="200" height="200">
      <defs>
        <linearGradient id="dynamicGradient" x1="0%" y1="0%" x2="100%" y2="0%">
          <stop offset="0%" stop-color.bind="startColor" />
          <stop offset="100%" stop-color.bind="endColor" />
        </linearGradient>
      </defs>
      <rect x="10" y="10" width="180" height="180" fill="url(#dynamicGradient)" />
    </svg>
    # 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)
    Defining routes and viewports · Viewports in depth
  • Navigation patterns

    • Imperative navigation

    • Build menus with the navigation model

    • Accessing the active route

  • Lifecycle, hooks, and events

    • Routing lifecycle

    • Router hooks

    • Router events

  • Advanced scenarios

    • Transition plans

    • Router state management

    • Error handling

  • Support resources

    • Testing guide

    • Troubleshooting

  • Compose multiple viewports or named layouts

    <au-viewport> and named viewports

    Control navigation flow

    Lifecycle hooks (canLoad, loading, canUnload, unloading)

    Listen for navigation changes

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

    Persist and observe route state

    Inject ICurrentRoute / IRouter

    Customize transitions

    Provide a transitionPlan or set per-route strategies

    Configure base path, hash vs pushState, title building

    RouterConfiguration.customize and RouterOptions

    Router configuration

    Map URLs to components with strong typing

    @route decorator inside your component

    Defining routes

    Choose the right guide

    Feature map

    Where to go next

    packages/router/README.md
    Getting started
    Router configuration
    live StackBlitz examples
    developer guides
    the store plugin
    router package CHANGELOG
    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.

    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.

    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.

    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.

    // 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);
      }
    }

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

    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 .

    element directly accessible.

    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.

    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.

    • 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.

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

    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.

    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.

    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.

    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.).

      • 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.

    • 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.

    • 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.

    <input type="text" ref="firstNameInput" placeholder="First name">

    Declaring Template References

    Basic Usage: Referencing DOM Elements

    <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
        }
      }
    }

    Accessing References in Templates

    Accessing References in View Models

    Advanced Usage: Referencing Components and Controllers

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

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

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

    Practical Applications and Benefits

    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.

    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)

    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?

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

    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.

    Every scope in Aurelia has three main parts:

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

    When Aurelia evaluates ${message} in the template, it looks at the binding context (your component instance) and finds the message property.

    The override context holds special values that aren't part of your component but are available in templates. The most common example is loop variables:

    In this example:

    • item is added to a child scope's binding context

    • $index, $first, $last, etc. are added to the override context

    Scopes form a hierarchy. Each component creates its own scope, and child components can access parent data using $parent:

    Component boundaries are important to understand. When you create a custom element, Aurelia marks that scope as a boundary. This affects how property resolution works:

    • Within a component, Aurelia searches up the scope chain automatically

    • At component boundaries, you must use $parent explicitly to access parent component data

    In slot projections, $host gives you access to the component that defines the slot:

    When you need to debug scope issues, you can access the scope in the browser console:

    Then in the browser console:

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

    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.

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

    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

    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

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

    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:

    <input value.bind="message">

    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.

    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

    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

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

    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

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

    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).

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

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

    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:

    • 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.

    Step 3: Overview page + filters + events

    Add real data, reusable components, filters with query params, and event-driven updates.

    In this step you will add real data, reusable components, query param syncing, and deep child-to-parent communication with the Event Aggregator.

    Create src/models.ts:

    Create src/project-data.ts:

    Create src/components/project-card.ts:

    Create src/components/project-card.html:

    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 .

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

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

    You can also use ternary operations:

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

    Dependency Injection

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

    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():

    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.

    Use the resolve()

    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.

    The promise.bind

    Building more complex and encapsulated component structures.

    Improved maintainability and refactoring capabilities.

    Advanced API reference
    Viewports
    Routing lifecycle
    Router events
    Router state management
    Transition plans
    documentation
    Slotted content
    Are there any special contextual values (like $index from a repeat.for)?
    )
    usage
    $parent = explicit parent access
  • $host = slot projection context

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

  • When and why component boundaries matter

  • The magic behind $parent, $host, and other special keywords

  • How to debug those tricky scope-related issues

  • The JavaScript Analogy

    Anatomy of a Scope

    1. Binding Context: Your Data Home

    2. Override Context: Special Contextual Values

    3. Scope Hierarchy and Parent Access

    4. Component Boundaries

    5. The $host Keyword

    Debugging Scopes

    Best Practices

    1. Keep Component Boundaries in Mind

    2. Use Override Context Sparingly

    3. Set Up Override Context Early

    4. Debug Scope Issues Systematically

    Summary

    Want to learn more? Check out our guides on Custom Attributes, Template Controllers, and Shadow DOM and Slots to see scope and binding context in action.

    element:
  • and many more...

  • really means
    value.two-way
    , 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.).

  • to behave like
    .two-way
    for certain
    (element, attrName)
    pairs.
    attrName
    is the kebab-case attribute name; return
    true
    to enable two-way.
    default?: unknown — fallback value when a binding sets 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.

  • Examples

    Using the Attribute Syntax Mapper

    Combining the attribute syntax mapper with the node observer locator

    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

    Troubleshooting and best practices

    https://explore.fast.design/components/fast-text-field
    https://ionicframework.com/docs/api/input
    Attribute Patterns
    Microsoft FAST
    https://www.webcomponents.org/element/@polymer/paper-input
    The route: instruction uses a route id. Because this link lives inside the Overview child route, we prefix with ../ so the router resolves the id from the parent (Projects) route context.

    Create src/components/task-list.ts:

    Create src/components/task-list.html:

    Create src/components/task-item.ts:

    Create src/components/task-item.html:

    Update src/pages/projects-overview-page.ts:

    Update src/pages/projects-overview-page.html:

    The loading hook reads the query params from next.queryParams. The Share Filter button uses IRouter.load() to update the URL.

    Update src/pages/projects-activity-page.ts:

    Update src/pages/projects-activity-page.html:

    Next step: Step 4: Detail route + guards

    1. Define shared models and data

    2. Build reusable components

    3. Replace the Overview page with real behavior

    4. Replace the Activity page with real data

    You can use more sophisticated expressions for dynamic content:

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

    • ??

    • ?.

    • ?.()

    • ?.[]

    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.

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

    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.

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

    When interpolating HTMLElements, be mindful of security implications:

    This feature is particularly useful for dynamic content scenarios:

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

    1. Aurelia parses expressions, not statements (so things like if, for, return, and function declarations are not supported inside ${...}).

    2. Some JavaScript tokens are repurposed: | is reserved for value converters and & is reserved for binding behaviors (so bitwise | / & are not available).

    3. The comma operator (,) is not supported.

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

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

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

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

    Some expressions can throw runtime errors. Use defensive patterns:

    Interpolation converts values to strings following JavaScript coercion rules:

    When interpolating HTMLElements, be aware of these behaviors:

    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.

    Template expressions

    Calling functions

    Using ternaries

    Complex expressions

    JavaScript template literals

    Optional Syntax

    Note that ??= is not supported.

    HTMLElement Interpolation

    Creating elements with document.createElement()

    Parsing HTML strings

    Be cautious about the source of your HTML strings to avoid XSS vulnerabilities. Only do this with trusted content, or sanitize it first.

    Security Considerations

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

    Dynamic element creation

    Notes on syntax

    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

    Array Observation Performance

    Memory Considerations

    Error Handling and Edge Cases

    Handling Null and Undefined Values

    Error-Prone Expressions

    Type Coercion Behavior

    HTMLElement Edge Cases

    Advanced Example: Dynamic Content with Observer Updates

    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
    └─────────────────────────┘
    export class MyComponent {
      message = 'Hello, World!';
      count = 42;
    
      greet() {
        return `The count is ${this.count}`;
      }
    }
    <div repeat.for="item of items">
      ${$index}: ${item.name}  <!-- $index comes from override context -->
    </div>
    <!-- parent-component.html -->
    <div>
      <child-component></child-component>
    </div>
    
    <!-- child-component.html -->
    <p>Parent's title: ${$parent.title}</p>
    <!-- This works within the same component -->
    <div with.bind="user">
      ${name}  <!-- Finds user.name, then falls back to component properties -->
    </div>
    
    <!-- To access parent component data, use $parent -->
    <child-component>
      <!-- Inside child, to get parent's data: -->
      ${$parent.parentProperty}
    </child-component>
    <!-- my-card.html (defines slots) -->
    <div class="card">
      <slot></slot>
    </div>
    
    <!-- Usage with projected content -->
    <my-card>
      <p>Card title: ${$host.title}</p>  <!-- Accesses my-card's title -->
    </my-card>
    // In your component, expose the scope for debugging
    export class DebugComponent {
      created() {
        (window as any).debugScope = this.$controller.scope;
      }
    }
    // Explore the scope structure
    debugScope.bindingContext      // Your component instance
    debugScope.overrideContext     // Special contextual values
    debugScope.parent              // Parent scope (if any)
    
    // Check if it's a component boundary
    debugScope.isBoundary
    <!-- 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';
    }
    <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' ] });
    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>
    nodeObserverLocator.useConfig({
      'FAST-TEXT-FIELD': {
        value: { events: ['change'] }
      },
      'ION-INPUT': {
        value: { events: ['change'] }
      },
      'PAPER-INPUT': {
        value: { events: ['change'] }
      }
    })
    export type Task = {
      id: string;
      title: string;
      done: boolean;
    };
    
    export type Project = {
      id: string;
      name: string;
      tasks: Task[];
    };
    import { Project } from './models';
    
    export const PROJECTS: Project[] = [
      {
        id: 'alpha',
        name: 'Onboarding',
        tasks: [
          { id: 'alpha-1', title: 'Create welcome pack', done: false },
          { id: 'alpha-2', title: 'Schedule kickoff', done: false }
        ]
      },
      {
        id: 'beta',
        name: 'Release prep',
        tasks: [
          { id: 'beta-1', title: 'Finalize changelog', done: true },
          { id: 'beta-2', title: 'QA smoke test', done: false }
        ]
      }
    ];
    import { bindable } from 'aurelia';
    import { Project } from '../models';
    
    export class ProjectCard {
      @bindable project!: Project;
      @bindable onRemove?: (project: Project) => void;
    
      remove(): void {
        this.onRemove?.(this.project);
      }
    }
    <import from="./task-list"></import>
    
    <section class="project-card">
      <header class="project-card__header">
        <h3>${project.name}</h3>
        <span class="project-card__count">
          ${project.tasks.length} tasks
        </span>
      </header>
    
      <task-list tasks.bind="project.tasks" project-id.bind="project.id"></task-list>
    
      <footer class="project-card__footer">
        <a load="route: ../project-detail; params.bind: { id: project.id }">
          Open project
        </a>
        <button class="project-card__remove" click.trigger="remove()">
          Remove
        </button>
      </footer>
    </section>
    import { bindable } from 'aurelia';
    import { Task } from '../models';
    
    export class TaskList {
      @bindable tasks: Task[] = [];
      @bindable projectId = '';
    }
    <import from="./task-item"></import>
    
    <ul class="task-list">
      <li repeat.for="task of tasks">
        <task-item task.bind="task" project-id.bind="projectId"></task-item>
      </li>
    </ul>
    import { IEventAggregator, resolve } from '@aurelia/kernel';
    import { bindable } from 'aurelia';
    import { Task } from '../models';
    
    export class TaskItem {
      @bindable task!: Task;
      @bindable projectId = '';
    
      private readonly ea = resolve(IEventAggregator);
    
      notifyToggle(): void {
        this.ea.publish('task:toggled', {
          projectId: this.projectId,
          taskId: this.task.id,
          done: this.task.done
        });
      }
    }
    <label class="task-item">
      <input type="checkbox" checked.bind="task.done" change.trigger="notifyToggle()" />
      <span class.bind="task.done ? 'task-item__done' : ''">
        ${task.title}
      </span>
    </label>
    import { IEventAggregator, IDisposable, resolve } from '@aurelia/kernel';
    import { IRouter, IRouteViewModel, Params, RouteNode } from '@aurelia/router';
    import { observable } from 'aurelia';
    import { Project } from '../models';
    import { PROJECTS } from '../project-data';
    
    export class ProjectsOverviewPage implements IRouteViewModel {
      @observable searchQuery = '';
    
      projects: Project[] = structuredClone(PROJECTS);
      filteredProjects: Project[] = this.projects;
      recentActivity: string[] = [];
      newProjectName = '';
    
      private readonly ea = resolve(IEventAggregator);
      private readonly router = resolve(IRouter);
      private subscription?: IDisposable;
    
      loading(_params: Params, next: RouteNode): void {
        const query = next.queryParams.get('q');
        this.searchQuery = query ?? '';
        this.applyFilter();
      }
    
      bound(): void {
        this.subscription = this.ea.subscribe('task:toggled', ({ projectId, taskId, done }) => {
          const project = this.projects.find(item => item.id === projectId);
          const task = project?.tasks.find(item => item.id === taskId);
    
          if (!project || !task) return;
    
          const status = done ? 'completed' : 'reopened';
          this.recentActivity.unshift(`${project.name}: ${task.title} ${status}`);
          this.recentActivity = this.recentActivity.slice(0, 5);
        });
      }
    
      unbinding(): void {
        this.subscription?.dispose();
      }
    
      searchQueryChanged(): void {
        this.applyFilter();
      }
    
      clearSearch(): void {
        this.searchQuery = '';
        this.applyFilter();
        this.syncQueryToUrl();
      }
    
      syncQueryToUrl(): void {
        void this.router.load('overview', {
          context: this,
          queryParams: this.searchQuery ? { q: this.searchQuery } : {}
        });
      }
    
      handleNewProjectKeydown(event: KeyboardEvent): void {
        if (event.key === 'Enter') {
          this.addProject();
        }
      }
    
      addProject(): void {
        const name = this.newProjectName.trim();
        if (!name) return;
    
        const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
        this.projects = [
          ...this.projects,
          { id, name, tasks: [] }
        ];
        this.newProjectName = '';
        this.applyFilter();
      }
    
      removeProject(project: Project): void {
        this.projects = this.projects.filter(item => item !== project);
        this.applyFilter();
      }
    
      private applyFilter(): void {
        const term = this.searchQuery.trim().toLowerCase();
        this.filteredProjects = term
          ? this.projects.filter(project => project.name.toLowerCase().includes(term))
          : this.projects;
      }
    }
    <import from="../components/project-card"></import>
    
    <section class="toolbar">
      <input
        value.bind="searchQuery"
        placeholder="Search projects" />
      <button if.bind="searchQuery" click.trigger="clearSearch()">
        Clear
      </button>
      <button if.bind="searchQuery" click.trigger="syncQueryToUrl()">
        Share Filter
      </button>
    </section>
    
    <section class="project-create">
      <input
        value.bind="newProjectName"
        placeholder="New project name"
        keydown.trigger="handleNewProjectKeydown($event)" />
      <button disabled.bind="!newProjectName.trim()" click.trigger="addProject()">
        Add project
      </button>
    </section>
    
    <div class="project-grid">
      <project-card
        repeat.for="project of filteredProjects"
        project.bind="project"
        on-remove.bind="(project) => removeProject(project)">
      </project-card>
    </div>
    
    <aside class="activity" if.bind="recentActivity.length">
      <h2>Recent activity</h2>
      <ul>
        <li repeat.for="entry of recentActivity">${entry}</li>
      </ul>
    </aside>
    import { PROJECTS } from '../project-data';
    
    export class ProjectsActivityPage {
      totalProjects = PROJECTS.length;
    
      get totalTasks(): number {
        return PROJECTS.reduce((total, project) => total + project.tasks.length, 0);
      }
    
      get completedTasks(): number {
        return PROJECTS.reduce(
          (total, project) => total + project.tasks.filter(task => task.done).length,
          0
        );
      }
    }
    <section class="activity-summary">
      <h2>Activity Summary</h2>
      <p>Total projects: ${totalProjects}</p>
      <p>Tasks completed: ${completedTasks} / ${totalTasks}</p>
    </section>
    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 = (() => {
        const tpl = document.createElement('template');
        tpl.innerHTML = '<button>Parsed Button</button>';
        return tpl.content.firstElementChild as HTMLElement;
      })();
    }
    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>
    function to inject services into components:

    Why resolve()?

    • Clean, modern syntax

    • No decorators needed

    • Better TypeScript inference

    • Easier to test

    Services can depend on other services using resolve():

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

    Control how services are instantiated:

    Create configuration objects for your services:

    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

    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.

    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.

    DI makes testing straightforward by allowing easy mocking:

    • Learn more about dependency injection concepts

    • Explore service creation patterns

    • Understand DI resolvers for advanced scenarios

    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;
      }
    }
    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));

    Creating Services

    Service Registration

    Using Services in Components

    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';
    import { IApiConfig } from './services/api-config';
    
    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);

    Service Dependencies

    Service Lifetimes

    Configuration Services

    Resolver toolbox

    Creating custom resolvers

    Testing with DI

    What's Next

    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).

    • <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.

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

    <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);
        });
      }
    }

    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.

    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:

    <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
        });
      }
    }

    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 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.

    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").

    • 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.

    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 then (Response): When initialFetchPromise resolves, the resolved value (initialResponse) becomes available in the then template.

    4. 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.

    5. 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.

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

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

    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 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.

    You may also see promise.resolve="..." in older examples. It’s an alias for promise.bind="...".

    Basic Usage

    <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 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
        });
      }
    }

    Simple Example with Different Promise States

    Promise Binding with Functions and Parameters

    Isolated Promise Binding Scope

    Nested Promise Bindings

    Promise Bindings in repeat.for Loops

    Form Submission

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

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

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

    2. Disable submit button - Prevent multiple submissions

    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.

    Use one-way bindings for read-only flows or whenever the DOM only needs to reflect state.

    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 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.

    if.bind creates and disposes the DOM subtree. It frees memory and automatically detaches listeners any time the condition flips back to false

    lazy(key)

    resolve(lazy(IHttpClient))

    Injects a function that resolves the dependency on demand.

    optional(key) / own(key)

    resolve(optional(IMaybeService))

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

    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.

    Handle errors gracefully - Show user-friendly error messages

  • Validate before submitting - Client-side validation for UX

  • 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

    • Form Basics

    • Validation

    • File Uploads

    • Form Examples

    Basic Form Submission

    Preventing Default Submission

    Validation Before Submission

    Submission State Management

    Optimistic UI Updates

    Debounced Auto-Save

    Rate Limiting

    Multi-Step Forms

    Best Practices

    Template Recipes collection

    Common Patterns

    Submit on Enter Key

    Confirm Before Submit

    Redirect After Success

    Related

    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
    }
    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');
      }
    }
    .

    show.bind toggles display: none without touching the DOM tree. It is ideal for frequently toggled sections that should keep their internal state alive.

    Capability

    if.bind

    show.bind

    DOM manipulation

    Create/destroy nodes

    Toggle CSS display

    Memory

    Released when hidden

    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.

    Property
    Description
    Example Values (list of 3)

    $index

    Zero-based index

    0, 1, 2

    $first

    True only for the first item

    .trigger listens during the bubble phase as the event travels from the target back toward the window.

    .capture intercepts the event on its way down the DOM tree before child handlers run.

    Use .bind to pass a callback reference to the child now that the deprecated .call binding command is gone.

    When the user checks "Keyboard", Aurelia pushes 2 into selectedIds. Unchecking "Mouse" removes 1, keeping the array aligned with the checked boxes.

    Binding Mode
    Setup Cost
    Updates
    Memory Footprint
    Typical Use

    .one-time

    Set value once

    Never updates

    No observers hooked up

    Internals note: the PropertyBinding.bind implementation wires observers based on the binding mode flags. .one-time evaluates the expression once without connecting, .to-view 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.

    Aurelia re-runs getters whenever any accessed dependency (the array itself or a member property) mutates, then propagates the new value into the DOM.

    • Template Syntax Overview

    • Cheat Sheet

    • Conditional Rendering

    • List Rendering

    Data Binding Flow

    One-Way Binding (View Model → View)

    Two-Way Binding (View Model ↔ View)

    From-View Binding (View → View Model)

    Binding Mode Decision Tree

    Conditional Rendering: if vs show

    if.bind – Adds/Removes from the DOM

    <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);
    }

    show.bind – CSS Display Toggle

    Decision Matrix

    List Rendering with repeat.for

    Basic Flow

    With Keys for Efficient Updates

    Contextual Properties

    Event Binding: Trigger vs Capture

    Bubbling Phase (.trigger)

    Capturing Phase (.capture)

    Event Flow Complete Picture

    Value Converters Pipeline

    Converter Flow Detail

    Component Communication

    Parent → Child (Bindable Properties)

    Child → Parent (Callback Binding)

    Form Checkbox Collections

    Template Lifecycle

    Performance: Binding Modes Comparison

    Computed Properties Reactivity

    Related Documentation

    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

    // 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.bind="() => onUserClick(user)">
      </user-card>
    </div>

    Result: 30-50% faster rendering with smaller bundle sizes.

    ✨ Cleaner Component Code

    // 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>

    No hooks complexity. No re-render cycles. Just clean, maintainable code.

    🎯 TypeScript-First Development

    // 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
    }

    🔥 Better Developer Experience

    Feature
    React
    Aurelia

    Create your first Aurelia component using familiar React patterns:

    • No build step complexity - just works with any bundler

    • No prop drilling - dependency injection handles state

    • No re-render debugging - direct DOM updates

    • Faster initial load - no virtual DOM library overhead

    • Faster updates - direct DOM manipulation

    • Smaller bundles - efficient code splitting

    • Less boilerplate - no prop interfaces, no memo wrapping

    • Intuitive templates - HTML that looks like HTML

    • Simpler state management - class properties instead of hooks

    • 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

    Next Steps:

    1. - Build your first app in 15 minutes

    2. - Master Aurelia's component model

    3. - Learn the templating system

    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

    import Aurelia, { TemplateCompilerHooks } from 'aurelia';
    
    Aurelia
      .register(TemplateCompilerHooks.define(class {
        compiling(template: HTMLElement) {
          template.querySelector('table')?.setAttribute(someAttribute, someValue);
        }
      }));

    With decorator

    import Aurelia, { templateCompilerHooks } from 'aurelia';
    
    @templateCompilerHooks
    class MyTableHook1 {
      compiling(template) {...}
    }
    // paren ok too
    @templateCompilerHooks()
    class MyTableHook1 {
      compiling(template) {...}
    }
    
    Aurelia.register(MyTableHook1);

    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.

    All hooks from local and global registrations will be invoked: local first, then global.

    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

    Now that we understand how the template compiler works let's create fun scenarios showcasing how you might use it in your Aurelia applications.

    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.

    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.

    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.

    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.

    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.

    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.

    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.

    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

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

    • 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.

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

    • Runs synchronously, parent before children.

    • 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.

    • 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.

    • 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.

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

    • Executes child ➞ parent.

    • 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

    • 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.

    • 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.

    • Runs after detaching finishes and bindings have been disconnected.

    • Executes child ➞ parent.

    • 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.

    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.

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

    • Router hooks such as canLoad, loading, canUnload,

    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).

    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.

    New to Aurelia templates? Start here:

    1. - Quick syntax reference for all template features

    2. - Core concepts and features

    No hooks confusion - simple class properties and methods
    Better mobile performance - less JavaScript execution
    Better separation of concerns - HTML, CSS, and TypeScript in separate files
    Excellent debugging - inspect actual DOM, not virtual representations
    - See more migration patterns

    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

    Forms

    Controlled components + validation libs

    Two-way binding + built-in validation

    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)

    3. Experience the Differences (5 minutes)

    What You'll Gain

    📈 Performance Benefits

    🧹 Cleaner Codebase

    🚀 Better Developer Experience

    Ready to Make the Switch?

    Complete Getting Started Guide
    Component Guide
    Templates Deep Dive
    Migration Examples
    Discord community
    Start building with Aurelia now
    attr with binding expression (attr.command="...")
  • 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

  • Hooks invocation order

    Compilation Behavior

    Scenarios

    Feature Flagging in Templates

    Auto-Generating Form Field IDs for Label Association

    Automatic ARIA Role Assignment

    Content Security Policy (CSP) Compliance

    Lazy Loading Image Optimization

    Dynamic Theme Class Injection

    Node APIs used

    // 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, { 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();
    .
    unloading
    , etc., are documented in the
    and are available even if you do not use the router.

    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

    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

    –

    –

    import { resolve } from '@aurelia/kernel';
    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`);
      }
    }

    Detailed walkthrough

    1. Constructor

    2. Define

    3. Hydrating

    4. Hydrated

    5. Created

    6. Binding

    7. Bound

    8. Attaching

    9. Attached

    10. Detaching

    11. Unbinding

    12. Dispose

    Lifecycle hooks decorator (@lifecycleHooks)

    Special cases

    Best practices

    dynamic composition guide
    routing lifecycle section

    Real-World Recipes - Complete working examples

    Find what you need quickly with this task-based guide:

    • Show dynamic text? → Text Interpolation - Use ${property}

    • Bind to element properties? → Attribute Binding - Use .bind, .to-view, .two-way

    • Format data for display? → - Use ${value | converter}

    • Show/hide elements? → - Use if.bind or show.bind

    • 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? → - Combine if.bind="items.length === 0" with else

    • Optimize large lists? → - Use key.bind or key:

    • 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"

    • 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? → - Use else after if.bind

    • Apply dynamic CSS classes? → Class & Style Binding

    • Bind inline styles? → Class & Style Binding

    • Toggle classes conditionally? → Class & Style Binding

    • 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? →

    • Build a form? → Forms: Basic Inputs

    • Validate form input? → Validation Plugin

    • Handle form submission? → Forms: Submission

    • Work with checkboxes? →

    • Handle file uploads? →

    • Create reusable template behaviors? → Custom Attributes

    • Transform data in templates? → Value Converters

    • Control binding behavior? → Binding Behaviors

    • Work with promises? → - Use promise.bind

    • Create local template variables? → - Use <let>

    • Change binding context for a section? →

    • Bind element focus state? → - Use focus.bind

    • Spread config objects / forward captured attrs? → - Use ...$bindables / ...$attrs

    • Render markup elsewhere in the DOM? → - Use portal

    • Compose components dynamically? → - Use <au-compose>

    • Work with SVG? →

    • Use lambda expressions? →

    • Template Syntax Overview - Start here for the big picture

    • Cheat Sheet - Quick reference for all syntax

    • Text Interpolation - Display data with ${}

    • Attribute Binding - Bind to element properties and attributes

    • Event Binding - Handle user interactions

    • - Access DOM elements with ref

    • - Create local variables with <let>

    • - Re-scope a section to an object

    • - Handle async data with promise.bind

    • - Bindables spreading and attribute transferring

    • - Built-in global functions and values

    • Conditional Rendering - Show/hide content with if, show, switch

    • List Rendering - Loop over data with repeat.for

    • - Dynamic CSS

    • Value Converters - Format and transform data (like pipes)

    • Binding Behaviors - Control binding flow (debounce, throttle, etc.)

    • Forms Overview - Working with form inputs

    • Form Collections - Checkboxes, radios, multi-select

    • Form Submission - Submit forms and handle user feedback

    • - Handle file inputs and uploads

    • - Validate user input

    • Custom Attributes - Create reusable template behaviors

    • Advanced Custom Attributes - Complex attribute patterns

    • Lambda Expressions - Arrow functions in templates

    • Local Templates - Inline component definitions

    • SVG - Working with SVG elements

    • 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!

    Not sure where to start? Follow this path:

    1. Read Template Syntax Overview

    2. Learn Text Interpolation and Attribute Binding

    3. Try Event Binding for interactivity

    4. Practice with Product Catalog Recipe

    1. Master Conditional Rendering (if, show, switch)

    2. Learn List Rendering (repeat.for)

    3. Explore for data formatting

    4. Build forms with

    5. Try

    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

    • Use .to-view 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

    • 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 .to-view

    • 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

    • 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

    • Components - Build reusable UI components

    • Essentials - Core Aurelia concepts

    • Router - Navigation and routing

    • - Share services between components

    Quick Start

    Cheat Sheet
    Template Syntax Overview

    How Do I...?

    Display Data

    Work with Lists

    Handle User Input

    Conditional Logic

    Styling

    Components

    Forms

    Advanced

    Documentation Structure

    Core Concepts

    Template Syntax

    Display Logic

    Data Transformation

    Forms & Input

    Extensibility

    Other Features

    Real-World Examples

    Learning Path

    Beginner

    Intermediate

    Advanced

    Performance Tips

    Common Pitfalls

    Need Help?

    Related Documentation

    Always allocated

    Toggle speed

    Slightly slower

    Instant

    Event cleanup

    Automatic

    Handled manually if needed

    Component init

    Runs every attach

    Runs once

    Best for

    Rare toggles

    Frequent toggles

    true, false, false

    $last

    True only for the last item

    false, false, true

    $even

    True when $index % 2 === 0

    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'

    Static text that never changes

    .to-view

    Set value + observer

    Whenever property changes

    One source observer

    Displaying reactive state

    .bind (.two-way)

    Bidirectional observers

    View ↔ ViewModel

    Source observer + DOM listener

    Form controls that read/write

    Event Binding
    Value Converters

    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 first.

    Quick Navigation

    • Basic Inputs - Text, textarea, number, date inputs

    • Collections - Checkboxes, radios, multi-select, arrays

    • - Submit forms, handle events

    • - Handle file inputs and uploads

    • - Integrate with @aurelia/validation

    Aurelia's forms system is built on sophisticated observer patterns that provide automatic synchronization between your view models and form controls.

    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

    Aurelia automatically observes:

    • Text inputs: input, change, keyup events

    • Checkboxes/Radio: change events with array synchronization

    This means you typically don't need manual event handlers—Aurelia handles the complexity automatically while providing hooks for customization when needed.

    Aurelia provides intuitive two-way binding for all standard form elements. Let's start with the fundamentals.

    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

    Textareas work identically to text inputs:

    Browser form controls always provide string values unless you bind to their typed DOM properties. Use Aurelia's value-as-* bindings when you need numbers or dates in your view-model.

    value-as-number binds to the input's valueAsNumber, so age is a number (or NaN when the field is empty/invalid). value-as-date binds to valueAsDate, giving you a Date | null. If you keep value.bind, the value remains a string—convert it before serializing to JSON for APIs.

    Aurelia supports all HTML5 input types:

    Type
    Value
    Common Use

    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

    • .to-view

    Here's a complete user registration form:

    Now that you understand basic form inputs, explore:

    • - Checkboxes, radio buttons, and multi-select

    • - Integrate form validation

    • - Handle file inputs

    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

    • 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

    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.

    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.

    You can enhance with three different component types:

    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

    Always clean up enhanced content to prevent memory leaks:

    Suppose your server renders this HTML:

    You can enhance it to make it interactive:

    Create interactive widgets within existing pages:

    Enhancement is perfect for content that gets added to the page after initial load.

    When you need specific services or configurations for enhanced content:

    Enhanced components support all standard Aurelia lifecycle hooks:

    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?

    • 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

    • 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.

    • 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.

    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.

    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

    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.

    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.

    Before creating a component, consider these guidelines:

    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 allow you to modify templates during the compilation process:

    Hooks can be registered globally or at the component level:

    The attribute pattern system allows you to create custom binding syntax:

    Support for multi-part patterns with custom symbols:

    Binding commands can take full control of attribute processing:

    Commands can process multiple attributes for complex scenarios:

    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.

    An accordion that supports:

    • Expand/collapse panels

    • Single or multiple panels open

    Value Converters
    Conditional Rendering
    Conditional Rendering
    List Rendering: Performance
    Event Binding: Modifiers
    Event Binding: Binding Behaviors
    Conditional Rendering: else
    Template References
    Slotted Content
    Forms: Collections
    Forms: File Uploads
    Template Promises
    Template Variables
    with.bind
    focus
    Spread operators
    Portalling elements
    Dynamic composition
    SVG
    Lambda Expressions
    Template References
    Template Variables
    with.bind
    Template Promises
    Spread operators
    Globals
    Class & Style Bindings
    File Uploads
    Validation Plugin
    Value Converters
    Forms Guide
    Shopping Cart Recipe
    Template Recipes collection
    Aurelia Discourse
    GitHub Issues
    Dependency Injection
    https://stackblitz.com/edit/au2-promise-binding-using-functions-improved?ctl=1&embed=1&file=src/my-app.tsstackblitz.com
    Mutation Observers: Track dynamic DOM changes
  • Value Converters & Binding Behaviors: Transform and control data flow

  • Select elements: change events with mutation observation
  • Collections: Array mutations, Set/Map changes

  • Object properties: Deep property observation

  • password

    string

    Password fields

    number

    string (use value-as-number for number)

    Numeric input

    tel

    string

    Phone numbers

    url

    string

    Website URLs

    search

    string

    Search queries

    date

    string (use value-as-date for Date)

    Date selection

    time

    string

    Time selection

    datetime-local

    string (use value-as-date for Date)

    Date and time

    color

    string

    Color picker

    range

    string (use value-as-number for number)

    Slider input

    - Read-only inputs, display-only values
  • .from-view - Capture input without updating view

  • .one-time - Static initial values

  • - Submit and process forms
  • Template Recipes - Real-world examples

  • Use computed properties - Let Aurelia handle form state reactively

  • Keep it simple - Don't overcomplicate with manual DOM manipulation

  • , not
    form[prop] = value
  • Missing labels - Always include labels for accessibility

  • text

    string

    General text input

    email

    string

    Email addresses

    Understanding Aurelia's Form Architecture

    Data Flow Architecture

    Automatic Change Detection

    Basic Input Binding

    Simple Text Inputs

    Textarea Binding

    Number and Date Inputs

    Input Types and Binding

    Binding Modes

    Real-World Example

    Next Steps

    Quick Tips

    Common Pitfalls

    Related Documentation

    Form Submission
    File Uploads
    Validation Plugin
    Collections
    Validation Plugin
    File Uploads
    Template Syntax Overview
    Attribute Binding
    Event Binding
    Validation Plugin
    Template Syntax & Features
    Form Submission

    Template compilation: Aurelia compiles the existing HTML for bindings and directives

    Handle cleanup: Always deactivate when done
  • Test without JavaScript: Ensure basic functionality works without enhancement

  • Document what gets enhanced: Make it clear to other developers
  • Consider performance: Don't enhance too many elements at once

  • Performance critical sections: Enhancement has overhead compared to pre-compiled templates

    Understanding What Gets Enhanced

    Basic Enhancement Syntax

    Component Types: Classes, Instances, or Objects

    Key Enhancement Concepts

    Proper Cleanup

    Practical Enhancement Examples

    Server-Rendered Content Enhancement

    Widget Integration Example

    Dynamic Content Enhancement

    Enhancing Dynamically Loaded Content

    Enhancing Modal or Dialog Content

    Advanced Enhancement Patterns

    Using Custom Containers

    Lifecycle Hooks in Enhanced Components

    Common Enhancement Patterns

    Progressive Enhancement Checklist

    Best Practices

    When NOT to Use Enhancement

    Next steps

    startup sections
    App configuration and startup
    dynamic composition
    watching data
    portalling elements
  • 🎯 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.

    You'll need:

    • Node.js 18+ (recommended latest LTS) (Download here)

    • A code editor (VS Code recommended)

    • Basic knowledge of HTML, CSS, and JavaScript

    Want to see Aurelia in action before creating a project? Use Run Aurelia in the browser without a build step. That page is the maintained no-install path for plain HTML files, CodePen demos, plugin imports, and reduced bug reproductions.

    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!"

    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)

    Aurelia apps are built with components. Each component has two parts:

    src/my-app.ts:

    src/my-app.html:

    The ${} syntax binds data from your view-model to the template. When message changes, the <h1> automatically updates!

    Let's transform the hello world app into a task manager. We'll build it step by step.

    Replace contents of src/my-app.html:

    Replace contents of src/my-app.ts:

    Replace contents of src/my-app.css:

    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

    Aurelia automatically keeps your HTML in sync with your TypeScript properties.

    Connect user interactions to your methods seamlessly.

    Show or hide elements based on conditions.

    Display dynamic lists that update automatically.

    Derived values that update automatically when dependencies change.

    Congratulations! You've built a real Aurelia application. Here's what to explore next:

    • Components Guide - Create reusable components

    • Templates Deep Dive - Master Aurelia's templating

    • Dependency Injection - Manage services and data

    • Router - Add navigation between pages

    • Forms - Handle complex user input

    • HTTP Client - Connect to APIs

    • Build Tools - Optimize your development setup

    • Testing - Test your applications

    • Debugging - Debug effectively

    TypeScript is recommended for better development experience, error catching, and IntelliSense. But JavaScript works perfectly fine too.

    Aurelia focuses on standards-based development with minimal learning curve. If you know HTML, CSS, and JavaScript, you already know most of Aurelia.

    Absolutely! Aurelia 2 is production-ready and used by companies worldwide. The framework is stable, performant, and well-tested.

    • Documentation - Comprehensive guides and API docs

    • GitHub Discussions - Community Q&A

    • Discord - Real-time chat with the community

    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!

    What You'll Discover

    Prerequisites

    Quick Try (No Installation)

    Create Your First Project

    Step 1: Initialize Project

    Step 2: Project Structure

    Understanding Aurelia Components

    View-Model (Logic)

    View (Template)

    Build Your Task App

    Step 3: Update the Template

    Step 4: Update the Logic

    Step 5: Add Styles

    Step 6: See It Work!

    Key Concepts You Just Learned

    1. Data Binding

    2. Event Handling

    3. Conditional Rendering

    4. List Rendering

    5. Computed Properties

    Next Steps

    Immediate Next Steps

    Building Real Apps

    Development Workflow

    Common Questions

    "Should I use TypeScript or JavaScript?"

    "How does this compare to React/Vue/Angular?"

    "Can I use this in production?"

    "What if I get stuck?"

    You're Ready!

    ✅ 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>)

    • ✅ 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

    • ✅ Examples: <button tooltip="Save changes">, <div draggable sortable>

    • ✅ You're just formatting data for display

    • ✅ The transformation is pure (same input → same output)

    • ✅ Examples: ${date | dateFormat}, ${price | currency}

    To create reusable custom elements, use the @customElement decorator:

    After creating a component, you need to make it available for use. There are two ways to do this:

    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

    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

    Make component properties configurable from the outside using @bindable:

    Use the component by binding values to its properties:

    Components have lifecycle hooks for initialization and cleanup:

    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.

    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.

    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.

    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.

    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.

    • ✅ 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

    • ✅ Use slots and component composition

    • ✅ Build complex UIs from simple, reusable pieces

    • ❌ Avoid deep inheritance hierarchies

    • ✅ Use bindable properties for inputs

    • ✅ Use callbacks or events for outputs

    • ✅ Document what bindables are required vs optional

    • ❌ Don't manipulate parent state directly

    • ✅ Presenter components are easy to test (just props)

    • ✅ Keep business logic in services, not components

    • ✅ Use dependency injection for testability

    • Learn more about component lifecycles

    • Explore bindable properties in detail

    • Understand shadow DOM and slots for advanced composition

    Basic Component Structure

    When to Create a Component?

    Create a component when:

    Use a custom attribute instead when:

    Use a value converter when:

    Custom Elements

    Using Components

    Option 1: Import in Templates (Recommended for Most Cases)

    Option 2: Global Registration

    Bindable Properties

    Component Lifecycle

    Common Component Patterns

    Pattern: Container/Presenter (Smart/Dumb Components)

    Pattern: Composition with Slots

    Pattern: Form Components with Two-Way Binding

    Pattern: Stateful UI Components

    Pattern: Event-Emitting Components

    Best Practices

    Keep Components Focused

    Favor Composition Over Inheritance

    Make Components Predictable

    Test-Friendly Components

    What's Next

    The template element factory supports custom caching strategies:

    Customize how templates are wrapped for proper compilation:

    Implement custom resource resolution for dynamic components:

    Optimize bindable resolution with custom caching:

    Create complex local element hierarchies:

    Create local templates programmatically:

    Work with compilation contexts for advanced scenarios:

    Customize DI container behavior during compilation:

    Implement aggressive template caching for performance:

    Configure compilation for different environments:

    • Register global hooks early in application bootstrap

    • Use component-level hooks for specific customizations

    • Keep hooks lightweight to avoid compilation performance impact

    • Design patterns to be intuitive and consistent with Aurelia conventions

    • Use descriptive names and clear syntax

    • Provide good error messages for invalid usage

    • Cache expensive resource lookups

    • Implement fallback mechanisms for missing resources

    • Use lazy loading for dynamic components

    • Profile template compilation in development

    • Use AOT compilation for production builds

    • Implement smart caching strategies

    • Monitor memory usage with large template caches

    • 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.

    Template Compiler Hooks

    Registering Compilation Hooks

    Global vs Component-Level Hooks

    Advanced Attribute Pattern System

    Creating Custom Attribute Syntax

    Complex Pattern Matching

    Custom Binding Commands

    Advanced Binding Command Features

    Multi-Attribute Processing

    Template Element Factory Customization

    Custom Template Caching

    Template Wrapping Detection

    Advanced Resource Resolution

    Custom Resource Discovery

    Bindables Information Caching

    Local Template System

    Advanced Local Element Definitions

    Dynamic Local Template Creation

    Compilation Context System

    Hierarchical Resource Resolution

    Custom Dependency Injection

    Performance Optimization

    Template Compilation Caching

    Compilation Mode Optimization

    Best Practices

    1. Hook Registration

    2. Pattern and Command Design

    3. Resource Resolution

    4. Performance Considerations

    5. Testing Extensions

    Smooth animations

  • Keyboard navigation

  • Accessible with ARIA attributes

  • Customizable styling

  • 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

    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

    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!

    What We're Building

    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

    Enhancements

    1. Add Animation Callbacks

    2. Add Icons

    3. Add Lazy Loading

    Best Practices

    Summary

    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-as-number.bind="age"
             min="18"
             max="120" />
    </div>
    
    <div class="form-group">
      <label for="birthdate">Birth Date:</label>
      <input id="birthdate"
             type="date"
             value-as-date.bind="birthDate" />
    </div>
    
    <div class="form-group">
      <label for="appointment">Appointment Time:</label>
      <input id="appointment"
             type="datetime-local"
             value-as-date.bind="appointmentTime" />
    </div>
    export class ProfileForm {
      age = 25;
      birthDate = new Date('1998-01-01');
      appointmentTime = new Date();
    
      get isAdult(): boolean {
        return this.age >= 18;
      }
    }
    <!-- Two-way binding (default for inputs) -->
    <input value.two-way="username">
    
    <!-- To-view (view model → view) -->
    <input value.to-view="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.trigger="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.trigger="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.trigger="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.
        }
      }
    });
    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;
    }
    export class MyComponent {
      message = 'Hello from Aurelia!';
    }
    <h1>${message}</h1>
    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 starts
      }
    
      bound() {
        // Data binding completed
      }
    
      attaching() {
        // Before DOM attachment
      }
    
      attached() {
        // Component attached to DOM
      }
    
      detaching() {
        // Before DOM removal
      }
    
      unbinding() {
        // Data binding being removed
      }
    }
    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.bind="(user) => viewUser(user)"
        on-delete.bind="(user) => deleteUser(user)">
      </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.bind="(query) => performSearch(query)"></search-box>
    
    <!-- Or listen via event aggregator -->
    export class ProductCatalog {
      private ea = resolve(IEventAggregator);
    
      binding() {
        this.ea.subscribe('search:query', query => {
          this.performSearch(query);
        });
      }
    }
    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;
      }
    }
    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];
    
        // Initially closed
        expect(panel.classList.contains('accordion-panel--open')).toBe(false);
    
        // Click to open
        trigger.click('.accordion-panel__header');
        expect(panel.classList.contains('accordion-panel--open')).toBe(true);
    
        // Click to close
        trigger.click('.accordion-panel__header');
        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;
    
        // Open first panel
        trigger.click('.accordion-panel:first-child .accordion-panel__header');
        expect(component.openPanels).toEqual([0]);
    
        // Open second panel
        trigger.click('.accordion-panel:nth-child(2) .accordion-panel__header');
        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;
    
        // Open first panel
        trigger.click('.accordion-panel:first-child .accordion-panel__header');
        expect(component.openPanels).toEqual([0]);
    
        // Open second panel
        trigger.click('.accordion-panel:nth-child(2) .accordion-panel__header');
        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;
        }
      }
    }

    @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

    • 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.

    Usage:

    Use a CSS selector to filter which slotted elements are tracked:

    Usage:

    When your component has multiple named slots, you can target specific slots:

    Usage:

    Use '*' as the slot name to watch all slots simultaneously:

    The @slotted decorator automatically looks for a callback method following the naming convention {propertyName}Changed:

    You can specify a custom callback method name:

    The @slotted decorator accepts a configuration object for fine-grained control:

    Property
    Type
    Default
    Description

    By default, @slotted only tracks element nodes. To include text nodes, use the special query '$all':

    The query parameter accepts any valid CSS selector:

    Here's a comprehensive example showing how to build a tab component using @slotted:

    Usage:

    The property decorated with @slotted has a special getObserver() method that returns a subscriber collection:

    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

    Both @slotted and @children decorators watch for changes in child elements, but they serve different purposes:

    Feature
    @slotted
    @children

    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)

    • 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

    • - Overview of slots in Aurelia

    • - Alternative for watching child elements

    • - Building custom elements

    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

    The simplest way to create a component is with convention-based files:

    Aurelia automatically pairs user-card.ts with user-card.html by convention, creating a <user-card> element you can use in templates.

    Use the @customElement decorator for explicit configuration:

    For simple naming, use the shorthand syntax:

    Key @customElement options:

    Template Configuration:

    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

    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:

    Static Configuration:

    Programmatic (mainly for testing):

    Create simple components with just HTML:

    Usage:

    Components that handle DOM manipulation through third-party libraries:

    Global Registration (in main.ts):

    Local Import (in templates):

    Render component content without wrapper tags:

    Or configure inline:

    Components follow a predictable lifecycle. Implement only the hooks you need:

    Components accept data through bindable properties:

    See for complete configuration options.

    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

    See the for detailed configuration, styling patterns, and best practices.

    Transform markup before compilation:

    Apply Aurelia to existing elements:

    Watch for property changes:

    Attribute Capture:

    Aliases:

    • Single Responsibility: Each component should have one clear purpose

    • Type Safety: Use interfaces for complex data structures

    • Composition: Favor composition over inheritance

    • Use attached() for DOM-dependent initialization

    • Clean up subscriptions in detaching()

    • Prefer @watch over polling for reactive updates

    • Mock dependencies properly

    • Test lifecycle hooks and bindable properties

    • Write tests for error scenarios

    See 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.

    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

    export class FormInput {
      @bindable label
      @bindable value
    }

    with the template

    <label>${label}
      <input value.bind="value">
    </label>

    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:

    export class FormInput {
      @bindable label
      @bindable value
      @bindable type
      @bindable tooltip
      @bindable arias
      @bindable etc
    }

    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 provides several spread operators for different use cases:

    Operator
    Purpose
    Example

    Each operator serves a specific purpose in component composition and data flow.

    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:

    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)"

    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.

    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.

    Spread operators support complex expressions:

    You can conditionally spread attributes based on expressions:

    Aurelia can automatically infer property names in certain binding scenarios:

    • 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.)

    Spread operators include several performance optimizations:

    Spread operations are optimized to prevent unnecessary binding updates:

    • 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

    Spread operators handle null and undefined values gracefully:

    TypeScript provides compile-time validation for spread operations:

    Filter which attributes are captured using a function:

    Spread operators work with template controllers:

    Usage:

    This comprehensive documentation now covers all the advanced patterns and edge cases developers might encounter when working with Aurelia's spread operators.

    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

    • What Are Lambda Expressions?

    • Basic Syntax

    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

    Aurelia supports these arrow function patterns:

    Real Template Usage:

    Lambda expressions work in multiple binding contexts:

    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"

    Lambda expressions work with all standard JavaScript array methods. Aurelia automatically observes array changes and property access within lambda expressions.

    Lambda expressions work with all standard JavaScript array methods:

    Combine multiple array methods for complex transformations:

    Aurelia automatically tracks property access in lambda expressions:

    Lambda expressions work with all Aurelia event bindings:

    Custom Attributes with Lambdas:

    Use lambda expressions in text interpolation for inline calculations:

    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

    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

    Create responsive search interfaces:

    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

    Aurelia automatically observes properties accessed within lambda expressions:

    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

    • 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

    • 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

    AST Structure:

    • Lambda expressions compile to ArrowFunction AST nodes

    • Support rest parameters via boolean flag

    • Body must be an expression (IsAssign type)

    Error Codes:

    • AUR0173: Invalid arrow parameter list

    • AUR0174: Default parameters not supported

    • AUR0175: Destructuring parameters not supported

    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

    With these guidelines, lambda expressions can significantly improve your template code's clarity and maintainability.

    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner

    'default'

    Name of the slot to watch. Use '*' to watch all slots

    callback

    PropertyKey

    '{property}Changed'

    Name of the callback method to invoke when slotted content changes

    with the initial slotted elements
  • Updates: The callback is invoked whenever slotted content changes (elements added, removed, or reordered)

  • Filters by

    CSS selector + slot name

    CSS selector only

    Tracks

    Content from parent component

    Direct children only

    Best for

    Content projection scenarios

    Observing component's immediate children

    , 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

  • - Component lifecycle integration

    query

    string

    '*'

    CSS selector to filter slotted elements. Use '*' to match all elements, '$all' to include text nodes

    slotName

    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

    Basic Usage

    Filtering with CSS Selectors

    Targeting Specific Slots

    Watching All Slots

    Change Callbacks

    Custom Callback Names

    Advanced Configuration

    Configuration Options

    Querying All Nodes Including Text Nodes

    Complex Selectors

    Complete Example: Dynamic Tab Component

    Subscribing to Changes Programmatically

    Lifecycle and Timing

    Comparison with @children Decorator

    Important Notes

    See Also

    Slotted Content
    @children Decorator
    Custom Elements

    string

    Lifecycle Hooks

    ...expression

    Shorthand for bindable spreading

    <my-component ...user>

    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">

    Aurelia Spread Operators Overview

    Usage

    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

    How will attributes be applied in ...$attrs

    Advanced Spread Patterns

    Mixed Binding Patterns

    Complex Member Access

    Conditional Spreading

    Automatic Expression Inference

    Shorthand Binding Syntax

    Inference Rules

    Performance Considerations

    Binding Creation Optimization

    One-time Change Detection

    Memory Usage Guidelines

    Error Handling & Edge Cases

    Null and Undefined Handling

    Invalid Expressions

    Type Safety with TypeScript

    Advanced Capture Patterns

    Capture Filtering

    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

    Integration Examples

    Component Composition

    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

    Reactive Updates: Automatically re-evaluate when dependencies change
  • Maintains reactivity: Changes to referenced properties trigger updates

  • Profile when needed: Monitor performance in large lists with complex transformations
    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

  • Parameters are stored as BindingIdentifier[]

    AUR0176: Rest parameter must be last

  • AUR0178: Function body (block statements) not supported

  • Move complex logic to view model methods when needed
  • Use slice() before mutating operations like sort() for safety

  • What Are Lambda Expressions?

    Basic Syntax

    Supported Patterns

    Restrictions and Limitations

    Array Operations

    Basic Array Operations

    Chaining Operations

    Reactive Property Access

    Event Handling

    Text Interpolation

    Accessing Template Context

    Advanced Use Cases

    Nested Array Processing

    Dynamic Search and Filtering

    Immediate Invoked Arrow Functions (IIFE)

    Performance and Best Practices

    Automatic Property Observation

    Array Mutation Handling

    Performance Guidelines

    Common Pitfalls

    Mutation vs. Immutable Operations

    Expression Complexity

    Debugging Tips

    Framework Implementation Notes

    Summary

    Supported Patterns
    Restrictions and Limitations
    Array Operations
    Event Handling
    Text Interpolation
    Advanced Use Cases
    Performance and Best Practices
    Common Pitfalls
    (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).

  • Building design systems and component libraries

    Consider Shadow DOM for style isolation

    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

    Component Configuration

    Configuration Options

    Importing external HTML templates with bundlers

    Alternative Creation Methods

    HTML-Only Components

    Viewless Components

    Using Components

    Containerless Components

    Use Sparingly

    Containerless components lose their wrapper element, which can complicate styling, testing, and third-party library integration.

    Component Lifecycle

    See Component Lifecycles for comprehensive lifecycle documentation.

    Bindable Properties

    Advanced Features

    Shadow DOM

    Template Processing

    Enhancing Existing DOM

    Reactive Properties

    Child Element Observation

    Component Configuration

    Best Practices

    Component Design

    Performance

    Testing

    Bindable Properties
    Shadow DOM guide
    Testing Components
    export class UserCard {
      name = 'John Doe';
      email = 'john@example.com';
    }
    <div class="user-card">
      <h3>${name}</h3>
      <p>${email}</p>
    </div>
    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');
      }
    }
    <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
    })
    <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: 'john@example.com', 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">
    <!-- 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)}
    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 = 'john@example.com';
    }
    @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 template from './product-name-search.html?raw';
    declare module '*.html?raw' {
      const content: string;
      export default content;
    }
    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 });
      }
    }
    // 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;
    }

    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

    <!-- 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.bind="handleEdit">
      </user-card>
    </div>

    Result: Same clean code style, but with direct DOM updates and no proxy overhead.

    ✨ Better TypeScript Integration

    <!-- 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() {
        // Pass as callback via .bind, e.g. on-edit.bind="() => edit()"
      }
      
      delete() {
        // Type-safe event handling
      }
    }

    🚀 Standards-Based Architecture

    <!-- 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>

    Aurelia's approach: Build on web standards instead of creating new syntax.

    Your Vue Knowledge Transfers Perfectly

    Template Syntax Comparison

    Vue
    Aurelia
    Benefit

    Let's convert a typical Vue component:

    • No ref() wrappers - plain JavaScript properties

    • No .value everywhere - direct property access

    • Better TypeScript - no generic complications

    • Direct DOM updates instead of virtual DOM reconciliation

    • Smaller runtime - no proxy reactivity overhead

    • Better tree shaking - more efficient bundling

    • 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

    • Built-in dependency injection - no need for provide/inject complexity

    • Powerful templating - lambda expressions and better binding

    • Shadow DOM support - true component encapsulation

    Feature
    Vue 3
    Aurelia 2
    Winner

    Next Steps:

    1. - Build a real app in 15 minutes

    2. - Master component patterns

    3. - Advanced templating

    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.

    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

    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.

    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.

    The notifications array is a reactive property. When notifications are added or removed, Aurelia's binding system automatically updates the DOM.

    The progress bar uses a computed property (getProgressWidth) that calculates the percentage remaining based on elapsed time. This creates a smooth countdown animation.

    Current implementation stacks notifications. For "replacing" behavior (only show one at a time):

    Make position configurable:

    Add action buttons to notifications:

    Pause the auto-dismiss timer when hovering:

    • - Singleton services

    • - Alternative global communication

    • - if.bind documentation

    Same event handling

    :class="{ active: isActive }"

    active.class="isActive"

    Cleaner conditional classes

    Automatic CSS loading - matching CSS files load automatically
    Faster startup - less framework initialization code
    Standards-based - closer to web platform APIs
    Better routing - type-safe, more powerful navigation

    Excellent

    Aurelia

    Performance

    Good

    Better

    Aurelia

    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

    - Powerful DI system

    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"

    Learning Curve

    Easy

    Easy

    Tie

    TypeScript Support

    Component Structure

    Reactivity Comparison

    Component Communication

    Migration Path: Vue → Aurelia

    1. Quick Start (5 minutes)

    2. Convert Your First Vue Component (10 minutes)

    3. Experience the Differences

    What You'll Gain Moving from Vue

    📈 Performance Improvements

    🧹 Cleaner Development Experience

    🚀 Enhanced Capabilities

    Vue vs Aurelia: Feature Comparison

    Ready to Experience the Upgrade?

    Complete Getting Started Guide
    Component Guide
    Templates Deep Dive
    Dependency Injection
    Discord community
    Start building with Aurelia

    click.trigger="handler"

    Good

    List Rendering - repeat.for documentation

    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

    Auto-Dismiss Timer

    Reactive Array

    Progress Bar Animation

    Variations

    Stacking vs Replacing

    Position Options

    Action Buttons

    Pause on Hover

    Related

    Dependency Injection
    Event Aggregator
    Conditional Rendering
    <!-- 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.bind="(item) => 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
    // 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>

    Search Autocomplete

    A complete autocomplete/typeahead search component with keyboard navigation, highlighting, and debouncing.

    Features Demonstrated

    • Two-way data binding - Search input

    • 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

    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.

    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.

    A global click listener detects clicks outside the component and closes the dropdown. The listener is added in attached() and cleaned up in detaching().

    The highlightMatch method uses regex to wrap matching text in <mark> tags. The result is bound with innerhtml.bind to render the HTML.

    • role="combobox" on input

    • role="listbox" on dropdown

    • role="option" on results

    Store and show recent searches when input is focused but empty:

    Group results by category:

    Load more results as user scrolls:

    • - Keyboard events

    • - if.bind documentation

    • - ref attribute

    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

    • Keyboard navigation (Arrow keys, Enter, Escape)

    • Click outside to close

    • Accessible with ARIA attributes

    • Customizable trigger and content

    • Positioning options

    Test your dropdown component:

    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

    Nest another dropdown-menu inside:

    Use a library like Floating UI to automatically position the menu to avoid viewport overflow:

    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

    You've built a fully-featured dropdown menu with:

    • ✅ Click and keyboard interactions

    • ✅ Accessibility built-in

    • ✅ Click-outside detection

    • ✅ Customizable trigger and content

    This dropdown is production-ready and can be extended with search, submenus, and intelligent positioning!

    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 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.

    All template controllers follow this pattern:

    Usage:

    aria-expanded indicates dropdown state

  • aria-activedescendant points to selected item

  • Keyboard navigation follows ARIA practices

  • Bindable Properties - Component inputs

  • Value Converters - sanitize for HTML binding

  • Code

    Component (search-autocomplete.ts)

    Template (search-autocomplete.html)

    Styles (search-autocomplete.css)

    Usage Example

    How It Works

    Debouncing

    Keyboard Navigation

    Click Outside

    Highlighting Matches

    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

    Variations

    Recent Searches

    Grouped Results

    Infinite Scroll

    Related

    Event Binding
    Conditional Rendering
    Template References
    ✅ 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

  • Accessibility: Test with keyboard only and screen readers

  • Portal Rendering: For complex layouts, render menu in a portal to avoid z-index issues

  • ✅ Comprehensive tests

    Component Code

    dropdown-menu.ts

    dropdown-menu.html

    dropdown-menu.css

    Usage Examples

    Basic Dropdown

    With Custom Trigger

    Programmatic Control

    Disabled State

    Testing

    Accessibility Features

    Enhancements

    1. Add Icons to Menu Items

    2. Add Submenus

    3. Add Search/Filter

    4. Add Positioning Intelligence

    Best Practices

    Summary

    A practical template controller that shows/hides content based on user permissions:

    Usage:

    A template controller that manages loading states with caching:

    Usage:

    Create attributes that can both read and write data:

    Usage:

    Handle multiple bindable properties with complex interactions:

    Usage:

    Defer expensive operations until needed:

    Minimize DOM operations by batching updates:

    Handle errors gracefully without breaking the application:

    Validate inputs before applying them:

    Always clean up resources in detaching():

    • Use requestAnimationFrame for DOM updates

    • Batch operations when possible

    • Avoid frequent DOM queries

    • Validate inputs before applying changes

    • Provide fallback behaviors

    • Log errors for debugging

    • 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.

    Template Controllers

    Basic Template Controller Structure

    Real-World Example: Visibility Controller

    Advanced Template Controller: Loading States

    Complex Two-Way Binding Attributes

    Bi-directional Data Synchronization

    Multi-Value Binding

    Performance Optimization Patterns

    Lazy Initialization

    Batch Updates

    Error Handling in Custom Attributes

    Graceful Degradation

    Validation and Sanitization

    Testing Custom Attributes

    Unit Testing Template Controllers

    Best Practices

    1. Resource Management

    2. Performance Considerations

    3. Error Handling

    4. Type Safety

    // 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();
      }
    }
    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`
      });
    }
    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 detaching(): 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 detaching(): 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 detaching(): 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 detaching(): void {
      if (this.observer) {
        this.observer.disconnect();
      }
      if (this.subscription) {
        this.subscription.dispose();
      }
    }

    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.

    Single Class Binding: The .class Syntax

    The .class binding is the foundation of dynamic styling in Aurelia. The syntax is straightforward:

    <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
    }

    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.

    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.

    Aurelia provides multiple approaches for binding CSS styles, from individual properties to complex style objects.

    To bind individual CSS properties dynamically, use the .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.

    Aurelia fully supports CSS custom properties (CSS variables), enabling powerful theming capabilities:

    Aurelia supports vendor-prefixed CSS properties for cross-browser compatibility:

    Aurelia automatically handles the !important CSS declaration when included in style values:

    Advanced class binding techniques provide greater flexibility for complex styling scenarios.

    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 techniques enable sophisticated styling patterns and better code organization.

    For complex styling scenarios, bind an entire style object:

    Combine static and dynamic styles using template interpolation:

    Create dynamic styles based on component state:

    Beyond template bindings, Aurelia provides several approaches for styling components themselves.

    Aurelia automatically imports stylesheets that match your component names:

    This means you can focus on writing CSS without worrying about imports:

    For complete style isolation, use Shadow DOM:

    Shadow DOM Configuration Options:

    Shadow DOM provides special CSS selectors for enhanced styling control:

    To share styles across Shadow DOM components, configure shared styles in your application:

    CSS Modules provide scoped styling by transforming class names to unique identifiers at build time. Aurelia provides the cssModules() helper to integrate CSS Modules with your components:

    The cssModules() helper transforms class names in your template at compile time. In the example above, class="title" becomes class="title_abc123".

    Key features:

    • Works with static classes, class.bind, and interpolation (class="some ${myClass}")

    • Supports multi-class binding syntax (class1,class2.class="condition")

    • Each component must register its own cssModules()

    For more details on using CSS Modules with Shadow DOM, see the .

    The following examples demonstrate practical applications of class and style binding techniques in common scenarios.

    ✅ DO:

    • Use .class for simple boolean toggling

    • Use CSS custom properties for theming

    • Prefer computed getters for complex style calculations

    ❌ 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

    Problem: Styles don't change when data changes. Solution: Make sure you're using proper binding syntax and that your properties are observable.

    Problem: Using CSS Modules and seeing transformed class names. Solution: This is expected behavior! CSS Modules transform class names to ensure uniqueness.

    Problem: Global CSS frameworks aren't working inside Shadow DOM components. Why: Shadow DOM isolates styles; open/closed mode does not change CSS encapsulation. Solutions:

    • Use Light DOM for components that should inherit global framework styles

    • Register framework CSS as shared styles with StyleConfiguration.shadowDOM({ sharedStyles: [...] })

    • Use CSS variables or ::part to expose safe customization points

    Learn more:

    The syntax is mostly the same, with some improvements:

    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)

    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

    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 . To understand when styles are applied, refer to the documentation.

    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)

    3. Stack-Based Coordination


    How lifecycle hooks execute when activating a parent with children:


    How lifecycle hooks execute when deactivating:


    How Aurelia ensures correct timing using activation/deactivation stacks:


    How async hooks affect timing:


    Real-world mistakes and how to avoid them:


    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

    For more details, see the main documentation.

    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

    🎯 All the Power, None of the Complexity

    // 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.bind="() => userEdit(user)">
      </user-card>
    </div>

    Result: 70% less code with the same functionality and better performance.

    🚀 Dependency Injection Without the Complexity

    // 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
    }

    ✨ Better TypeScript Integration

    // 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>

    Your Angular Knowledge Transfers

    Template Syntax Translation

    Angular
    Aurelia
    Benefit
    • 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

    • Less boilerplate - no modules, less ceremony

    • Simpler testing - no TestBed setup complexity

    • Better debugging - inspect actual DOM, not framework abstractions

    • Built-in hot reload - better development experience

    • Automatic CSS loading - no need to import stylesheets

    • Shadow DOM support - true component encapsulation

    Take any Angular component and follow this pattern:

    • No change detection cycles - updates happen directly

    • No modules to configure - components work immediately

    • Better TypeScript support - everything is typed by default

    Feature
    Angular
    Aurelia
    Winner

    ✅ 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

    Next Steps:

    1. - Build a real app in 15 minutes

    2. - Master Aurelia's DI system

    3. - Type-safe navigation

    Questions? Join our where developers discuss enterprise framework experiences and architectural decisions.

    Ready to experience Angular without the complexity? today.

    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.

    The attribute pattern system consists of several core components:

    • AttributePatternDefinition: Defines pattern structure with pattern and symbols

    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.

    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.

    The general syntax for event binding in Aurelia 2 follows this pattern:

    • <element>

    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.

    • Two-way data binding - Search input with instant updates

    • Computed properties - Filtered product list based on search and filters

    Stacks coordinate timing between parent and children
  • Always clean up in the opposite hook (attached ↔ detaching)

  • Use attached() for DOM work, not binding()

  • 1. Activation Sequence (Parent-Child)

    2. Deactivation Sequence (Parent-Child)

    3. Stack-Based Coordination

    4. Async Lifecycle Behavior

    5. Common Pitfalls

    Summary

    Async Lifecycle Behavior
    Common Pitfalls
    Component Lifecycles
    Better runtime performance - efficient batched updates
    Cleaner templates - HTML that looks like HTML
    Standards-based - closer to web platform APIs
    Cleaner templates - HTML without framework-specific syntax

    Powerful and simple

    Aurelia

    Performance

    Good with OnPush

    Better by default

    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

    - Test your applications

    [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

    *ngFor="let item of items"

    repeat.for="item of items"

    Same iteration, better performance

    [class.active]="isActive"

    active.class="isActive"

    More intuitive class binding

    TypeScript Support

    Excellent

    Excellent

    Tie

    Dependency Injection

    Component Architecture

    Services and DI Comparison

    Migration Benefits for Angular Developers

    📈 Performance Gains

    🧹 Development Experience Improvements

    🚀 Modern Development Features

    Quick Migration Path

    1. Set Up Your Aurelia Environment (5 minutes)

    2. Convert Your First Angular Component (15 minutes)

    3. Experience the Improvements

    Angular vs Aurelia: Feature Comparison

    What Angular Concepts Work in Aurelia

    Ready for a Better Angular Experience?

    Complete Getting Started Guide
    Dependency Injection Guide
    Router Guide
    Testing Guide
    Discord community
    Start building with Aurelia

    Powerful but complex

    - mappings do not inherit to child components
    Use Shadow DOM for true component isolation
  • Cache complex style objects when possible

  • Mix too many styling approaches in one component

    Advanced techniques - Implement complex styling with objects, interpolation, and CSS variables

  • Component styling - Choose appropriate encapsulation strategies for your use case

  • 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".

    TailwindCSS note: Tailwind’s content scanner won’t pick up class names that only appear in attribute names. For Tailwind classes that include special characters (for example width-[360px]), prefer the object form with class.bind so the class token appears in an attribute value:

    Multiple Classes: Comma-Separated Syntax

    Style Binding

    Single Style Properties

    Alternative Style Syntax

    CSS Custom Properties

    Vendor Prefixes

    The !important Declaration

    Advanced Class Binding Techniques

    String-Based Class Binding

    Advanced Style Binding

    Object-Based Style Binding

    String Interpolation

    Computed Style Properties

    Component Styling Strategies

    Convention-Based Styling

    Shadow DOM

    Shadow DOM Special Selectors

    Global Shared Styles in Shadow DOM

    CSS Modules

    Real-World Examples and Patterns

    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

    Performance Optimization

    Troubleshooting Common Issues

    "My styles aren't updating!"

    "My CSS classes have weird names!"

    "Shadow DOM is blocking my global styles!"

    Migration and Compatibility

    Coming from Aurelia 1?

    Browser Support

    Summary

    Shadow DOM documentation
    Shadow DOM guide
    template syntax guide
    component lifecycles
    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
    // 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
    <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 { }
    
    // To use Light DOM (no Shadow DOM), simply don't use the decorator
    export class LightDomComponent { }
    /* 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();
    import { customElement, cssModules } from 'aurelia';
    
    // Import the CSS module (bundler provides the class mapping)
    import styles from './my-component.module.css';
    // styles = { title: 'title_abc123', button: 'button_def456' }
    
    @customElement({
      name: 'my-component',
      template: `
        <h1 class="title">My Title</h1>
        <button class="button">Click Me</button>
      `,
      dependencies: [cssModules(styles)]
    })
    export class MyComponent {}
    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>
    <div class.bind="{ 'width-[360px]': condition }"></div>
    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

  • 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.

    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]+).

    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)

    Note: AttrSyntax must be imported from @aurelia/template-compiler, not from the main aurelia package, as it's not currently re-exported there.

    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

    The AttrSyntax class has the following constructor signature:

    Parameter
    Description
    Example

    rawName

    Original attribute name from template

    "[(value)]"

    rawValue

    Original attribute value

    "message"

    • '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

    Register patterns globally at application startup:

    Register patterns for specific components:

    For simple patterns, you can define them inline:

    A single class can handle multiple related patterns:

    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:

    Aurelia includes several built-in patterns you can reference:

    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.

    • 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

    Patterns must be registered before template compilation begins. Late registration after the application starts may not take effect for already-compiled templates.

    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

    Test your patterns with various attribute combinations:

    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

    • 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.

    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

    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

    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

    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.

    Here's a corrected cheatsheet with working examples:

    • 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.

    Architecture Overview

    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();

    When to reach for attribute patterns

    Basic Pattern Definition

    AttributePatternDefinition Interface

    The PART Keyword

    Symbols Behavior

    Pattern Class Structure

    Basic Pattern Class

    Pattern Method Signature

    AttrSyntax Constructor

    AttrSyntax Parameters Explained

    Common Binding Commands

    Pattern Registration

    Global Registration

    Local Registration

    Inline Pattern Definition

    Multiple Patterns per Class

    Pattern Priority System

    Advanced Pattern Examples

    Event Modifiers

    Static Patterns (No PART)

    Complex Multi-PART Patterns

    Built-in Pattern Examples

    Dot-Separated Patterns

    Shorthand Binding Patterns

    Framework Syntax Examples

    Angular-Style Patterns

    Vue-Style Patterns

    Performance Considerations

    Caching System

    Pattern Optimization

    Registration Timing

    Debugging and Error Handling

    Common Pattern Errors

    Debugging Tips

    Pattern Testing

    Integration with Template Compiler

    Working alongside binding commands and the attribute mapper

    Best Practices

    Pattern Design

    Registration Strategy

    Error Recovery

    Complete Examples

    Custom Framework Integration

    Advanced Component System

    Quick Reference Cheatsheet

    Next steps

    : 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).

  • .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.

  • 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.

    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.

    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.

    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:

    Aurelia 2 supports binding to all standard DOM events. Here are some frequently used events in web development:

    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.

    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.

    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.

    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.

    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.

    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.

    Aurelia 2 provides capabilities beyond basic event binding, allowing for performance optimization and handling specific scenarios.

    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.

    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:

    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.

    To solidify your understanding, let's explore practical examples showcasing different event binding scenarios in Aurelia 2.

    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.

    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.

    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 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.

    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.

    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 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

    prevent

    Any event

    Calls event.preventDefault() before running your handler.

    stop

    Any event

    Calls event.stopPropagation() before running your handler.

    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.

    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 +.

    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.

    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.

    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.

    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.

    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.

    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.

    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".

    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.

    1. Console logging: Add console.log statements to verify event firing:

      handleClick(event: MouseEvent) {
        console.log('Click handler called', event);
        // Your logic here
      }
    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:

      handleEvent(event: Event) {
        console.log('Event details:', {
          type: event.type,
          target: event.target,
          currentTarget: event.currentTarget
        });
      }

    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.

    <element event.command="methodName(argument1, argument2, ...)">

    Understanding Event Binding

    Event Binding Syntax

    <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)
    );

    Event Binding Commands: .trigger and .capture

    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

    Shorthand syntax for events (@event)

    Passing Event Data to Handlers

    Common DOM Events

    click

    input

    change

    mouseover and mouseout

    keydown, keyup, and keypress

    Controlling Event Propagation

    Advanced Event Binding Techniques

    Throttling and Debouncing Event Handlers

    Performance Considerations

    Custom Events

    Event Binding Examples and Use Cases

    Self-Delegating Events with .self

    Checkbox change Event and Two-Way Binding

    Handling Keyboard Events for Specific Keys

    Event Delegation for Dynamic Lists

    Custom Event Communication Between Components

    Autocomplete with Debounced Input

    Event Modifiers: Enhancing Event Handling

    Mouse and Keyboard Event Modifiers

    prevent and stop Modifiers

    Mouse Button Modifiers: left, middle, right

    Keyboard Key Mappings and Custom Modifiers

    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

    Common Pitfalls and Troubleshooting

    Event Handler Issues

    Performance Issues

    Binding Behavior Conflicts

    Event Modifier Issues

    Debugging Tips

    Conclusion

    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

  • 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.

    The filteredProducts getter automatically recalculates when any filter changes:

    Category filters use array binding:

    Aurelia automatically adds/removes items from the selectedCategories array.

    Using key: id tells Aurelia to track products by ID, enabling efficient DOM updates when sorting or filtering:

    The active sort button and out-of-stock cards use class binding:

    Use the router to save filter state:

    • Shopping Cart Recipe

    • Data Table Recipe

    • List Rendering Guide

    • Conditional Rendering

    Features Demonstrated

    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 }
        });
      }
    }

    Code

    View Model (product-catalog.ts)

    Template (product-catalog.html)

    Styles (product-catalog.css)

    How It Works

    1. Search with Debouncing

    2. Reactive Filtering

    3. Multiple Checkbox Selection

    4. Efficient List Rendering

    5. Dynamic CSS Classes

    Variations

    Add Price Range Filter

    Add to Cart Functionality

    Persist Filters in URL

    Related

    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:

    <div attribute-name.bind="value"></div>
    • attribute-name.bind="value": The binding declaration.

      • attribute-name: The target HTML attribute you want to bind to.

      • .bind: The default binding command (uses Aurelia's default binding mode for that target).

      • value: The expression or property from the view model to bind.

    You can bind to virtually any attribute listed in the .

    Result: The div will have a title attribute with the value "This is a tooltip". Hovering over the div will display the tooltip.

    Aurelia provides multiple methods for attribute binding, each tailored for specific use cases and offering different levels of data flow control.

    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".

    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: Continuously updates the view from the view model.

    • .from-view

    Result: The input fields and links will reflect the bound properties with varying degrees of reactivity based on the binding keyword used.

    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 .

    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.

    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.

    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.

    Explore more sophisticated binding scenarios to handle complex data interactions and ensure seamless attribute management.

    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>

    This makes as-element a handy compatibility feature when integrating Aurelia components into environments with strict HTML requirements.

    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.

    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.

    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".

    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

    Example: Using the attr Binding Behavior

    Result: All bindings will target their respective HTML attributes directly, ensuring proper DOM attribute manipulation.

    Aurelia includes a built-in attribute mapper that handles common HTML attribute-to-property mappings automatically. For example:

    • maxlength → maxLength

    • readonly → readOnly

    • tabindex

    You can extend this mapping for custom elements or third-party components:

    Aurelia provides specialized handling for certain attributes:

    To better illustrate attribute bindings, here are several practical scenarios showcasing different binding techniques.

    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.

    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.

    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.

    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.

    Binding with a Value Converter

    Result: Displays the totalPrice formatted as currency, e.g., "$199.99".

    Error: AUR9994 - Invalid Binding Type for 'attr' Binding Behavior

    This error occurs when the attr binding behavior is used with non-property bindings:

    When bound properties are null or undefined, attributes are removed from the DOM:

    When using .attr binding command, Aurelia bypasses custom attribute detection:

    HTML boolean attributes (like disabled, checked, readonly) have special handling:

    SVG attributes may require the attr binding behavior for proper functionality:

    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

    Keep binding expressions simple for better performance:

    Use computed properties with proper dependencies for efficient updates:

    You can also use the shorthand syntax for simple dependencies:

    Use consistent naming conventions for attributes and properties:

    Leverage TypeScript for better development experience:

    Handle potential errors in binding expressions:

    Ensure proper accessibility attributes:

    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

    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

    • 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

    • Completed the

    • Basic understanding of

    • Familiarity with TypeScript

    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

    Create src/models.ts:

    Create src/storage-service.ts:

    The service is automatically registered as a singleton via DI.createInterface.

    Update src/my-app.ts:

    Create src/todo-form.ts:

    Create src/todo-form.html:

    Create src/todo-item.ts:

    Create src/todo-item.html:

    Update src/my-app.html:

    Update src/my-app.css:

    • Component Composition - Created reusable TodoForm and TodoItem components

    • Component Communication - Used @bindable and callback functions for parent-child communication

    • Dependency Injection - Created and injected StorageService

    Enhance your app with:

    • Drag-and-drop reordering

    • Edit mode for todos

    • Due dates and reminders

    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.

    Checkboxes

    Boolean Checkboxes

    The simplest checkbox pattern binds to boolean properties:

    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>

    Key Points:

    • Use checked.bind for boolean checkboxes

    • Works with any boolean property

    • Great for independent on/off toggles

    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)

    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

    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 are for single-selection from multiple options.

    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

    Choose the right collection type for your use case:

    Collection Type
    Best For
    Performance

    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

    By default, Aurelia compares values with strict equality (===). That works for primitives and for objects that are the exact same instance. It does not work when your selected value and your option values are different instances that represent the same logical entity (for example, data reloaded from an API). A matcher lets you define what "equal" means so selections stay in sync.

    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

    • - Basic form inputs

    • - Validate form inputs

    • - Complete examples

    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.

    A modal dialog that supports:

    • Open/close with smooth animations

    • Backdrop click to close (optional)

    target

    The target property, element, or identifier

    "value"

    command

    Binding command type

    "two-way", "bind", "trigger", "ref"

    parts

    Additional parts for complex patterns

    For event modifiers, extended syntax

    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.

    left, middle, right

    Mouse events

    Filters mouse buttons.

    Value Converters

    📊 Task statistics

    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

  • Search
    functionality
  • Dark mode toggle

  • Export/import todos

  • Form Handling

  • List Rendering

  • Prerequisites

    The App We're Building

    Step 1: Project Setup

    Step 2: Data Models

    Step 3: Storage Service

    Step 4: Main App Component

    Step 5: Create Todo Form Component

    Step 6: Create Todo Item Component

    Step 7: Main App Template

    Step 8: Styling

    What You've Learned

    Next Steps

    Related Documentation

    Hello World Tutorial
    Templates
    Templates Overview
    Component Basics
    Extended Tutorial
    Dependency Injection
    Better performance for frequent add/remove operations

    Use matcher.bind for complex object comparison

    Map

    Key-value pairs, nested selections

    Excellent (O(1) lookups)

    - Using repeat.for

    Array

    General purpose, small-medium collections

    Good

    Set

    Frequent additions/removals, uniqueness

    Excellent (O(1) lookups)

    Array-Based Multi-Select

    Set-Based Collections

    Map-Based Collections

    Radio Buttons

    Basic Radio Buttons

    Radio Buttons with Objects

    Select Elements

    Basic Select

    Select with Objects

    Select with Optgroups

    Multi-Select

    Performance Considerations

    Matchers Explained

    Common Patterns

    Select All / Deselect All

    Conditional Options

    Related

    Form Basics
    Validation
    Form Examples
    List Rendering

    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

  • 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

    Use Aurelia's animation system for smoother transitions:

    Create a global modal service for programmatic modals:

    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

    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!

    What We're Building

    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

    Enhancements

    1. Add Transition Animations

    2. Add Confirmation Before Close

    3. Add Modal Service

    Best Practices

    Summary

    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.bind="(data) => addTodo(data)"></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.bind="(todo) => toggleTodo(todo)"
              on-delete.bind="(todo) => deleteTodo(todo)">
            </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;
      }
    }
    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>
    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);
      }
    }
    : 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.

  • surrogate (putting it there triggers AUR0702).
    SVG attributes
  • Cases where you need to ensure attribute-specific behavior

  • →
    tabIndex
  • contenteditable → contentEditable

  • Restricted Primitives and Operators: Certain JavaScript primitives and operators cannot be used within interpolation expressions. These include:

    • Boolean

    • String

    • instanceof

    • typeof

    • Bitwise operators (except for the pipe | used with value converters)

  • Usage of Pipe |: The pipe character | is reserved exclusively for Aurelia's value converters within bindings and cannot be used as a bitwise operator.

  • 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.

    • Example: Binding id Using Property and Attribute

      Result:

      • Using .bind, Aurelia binds to the id property of the input element.

  • 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.

  • Use .from-view for write-only scenarios

    Handle null/undefined values gracefully
  • Consider performance implications of complex expressions

  • Follow accessibility best practices

  • Use TypeScript for better development experience

  • <!-- 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">
    
    <!-- To-view binding: changes in 'lastName' update the input, but not vice versa -->
    <input type="text" value.to-view="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({ deps: ['items'] })
      get itemCount() {
        return this.items.length;
      }
    }
    @computed('items')
    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>

    Example: Binding the title Attribute

    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

    1. Interpolation Binding

    2. Keyword Binding

    Examples of Keyword Binding

    3. Vue-style shorthand for .bind

    3. Binding to Images

    4. Disabling Elements

    5. Binding innerHTML and textContent

    Advanced Binding Techniques

    0. Treating existing markup as a custom element with as-element

    1. How Attribute Binding Works

    Property vs. Attribute Targeting

    2. Using the .attr Binding Command

    3. The attr Binding Behavior

    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

    5. Special Attribute Handling

    Class Attributes

    Style Attributes

    Practical Use Cases

    1. Dynamic Class Binding

    2. Styling Elements Dynamically

    3. Conditional Attribute Rendering

    Notes on Syntax

    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

    Troubleshooting and Common Issues

    1. Binding Behavior Errors

    2. Null and Undefined Values

    3. Custom Attribute Conflicts

    4. Boolean Attribute Handling

    5. SVG Attributes

    Performance Considerations

    1. Binding Mode Selection

    2. Expression Complexity

    3. Computed Properties

    Best Practices

    1. Consistent Naming

    2. Type Safety

    3. Error Boundaries

    4. Accessibility

    Summary

    HTML Attributes Reference
    event binding guide

    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"

    • ✅ Value depends on other properties and calculation is cheap

    • ✅ Automatic dependency tracking - no manual configuration needed

    • ✅ Example: get fullName() { return this.firstName + ' ' + this.lastName; }

    • ✅ Expensive calculations that should be cached

    • ✅ You want to explicitly control dependencies (not automatic)

    • ✅ Deep observation needed for nested objects

    • A method call in template needs dependency tracking

    • You want explicit tracked dependencies instead of proxy-based tracking

    • You want to disable tracking for a specific method call

    • ✅ You need to run code when a property changes (side effects)

    • ✅ You want the propertyChanged(newValue, oldValue) callback

    • ✅ Examples: Validation, analytics tracking, syncing data

    • ✅ Complex expressions - watching multiple properties or nested values

    • ✅ Need more flexibility than @observable

    • ✅ Examples: @watch('user.address.city'), @watch(vm => vm.total > 100)

    • ✅ Building libraries or advanced features

    • ✅ Need fine-grained control over subscription lifecycle

    • ✅ Performance critical code requiring optimization

    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.

    Getter properties automatically become reactive when their dependencies change:

    For some reason, when 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.

    When you call a method from a template (not a getter), use @computed to control dependency tracking for that call. Observation only activates when the method is called from an observation context (e.g. a template binding or another computed observation). A normal function call will not trigger any observation.

    Note: Using @computed on methods is currently experimental. The syntax and behavior may change before the final release.

    Behavior:

    • deps omitted (or undefined) uses proxy-based tracking.

    • deps: [] explicitly disables tracking for the decorated method/getter.

    • deps

    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:

    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.

    Aurelia can observe nested object changes:

    Arrays are automatically observed for mutations:

    The @observable decorator is only needed when you want to react to property changes in your view-model code (not just the template):

    Create side effects that run when observed data changes:

    For advanced scenarios, manually control observation:

    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

    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.

    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.

    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.

    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.

    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.

    • ✅ Start simple - Use plain properties and getters first

    • ✅ Add @observable only when you need side effects

    • ✅ Use @computed for expensive operations, not simple getters

    • ✅ 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

    • ✅ Use @computed with explicit dependencies for expensive calculations

    • ✅ Debounce rapid changes (user input, scroll events)

    • ✅ Batch related updates together

    • ✅ Use @watch or propertyChanged callbacks for async side effects

    • ✅ Track loading states during async operations

    • ✅ Handle errors gracefully

    • Learn about in detail

    • Explore for advanced reactive patterns

    • Understand strategies

    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.

    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:

    Aurelia automatically observes collection changes and updates the DOM efficiently:

    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.

    • Array manipulation - Add, remove, update cart items

    • Lambda expressions - Complex calculations directly in templates using reduce, filter, etc.

    Quick Reference ("How Do I...")

    Navigate your Aurelia 2 application with confidence using this task-focused quick reference.

    Using .attr, Aurelia binds directly to the id attribute in the DOM.

    ✅ Example: Complex filtering, heavy aggregations
    Observation only activates when the method is called from an observation context (template, computed). Normal calls are unaffected
  • Example: complex filtering or formatting methods used directly from templates

  • with strings enables explicit string-based dependency tracking.
  • deps with a getter function enables function-based dependency tracking.

  • Strings and functions cannot be mixed in the same deps declaration.

  • Stacking @computed on the same method overrides prior tracking metadata (last applied wins).

  • No Virtual DOM: Direct DOM manipulation eliminates virtual DOM overhead

    ❌ Don't over-engineer - most scenarios don't need @watch or manual observation

    ❌ Don't create unnecessary watchers

    ❌ Don't make computed properties async

    Use getters (computed) when:

    Use @computed decorator when:

    Use @computed on methods when:

    Use @observable when:

    Use watch() when:

    Use manual observation when:

    Automatic Change Detection

    Computed Properties

    Decorator computed

    Method tracking with @computed

    Flush timing with flush

    Deep observation with deep

    Deep Observation

    Array Observation

    When You Need @observable

    Effect Observation

    Manual Observation Control

    Performance Considerations

    Common Reactivity Patterns

    Pattern: Form Validation with @observable

    Pattern: Computed Filtering and Sorting

    Pattern: Syncing Data with @watch

    Pattern: Dependent Computations

    Pattern: Optimized List Updates with @computed

    Best Practices

    Choose the Right Tool

    Keep Computations Pure

    Optimize Performance

    Handle Async Operations

    What's Next

    observing property changes
    effect observation
    watching data
    <!-- 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 ProductList {
      filter = '';
      products: Product[] = [];
      nested = { prop: '' };
      prop = '';
      prop2 = '';
    
      // proxy-based tracking (default)
      @computed
      matches(product: Product) {
        return product.name.includes(this.filter);
      }
    
      // explicit dependency paths
      @computed('filter', 'nested.prop')
      matches2(product: Product) {
        return product.name.includes(this.filter);
      }
    
      // explicit dependency function
      @computed((instance: ProductList) => instance.prop + instance.prop2)
      matches3(product: Product) {
        return product.name.includes(this.filter);
      }
    
      // options form with string deps
      @computed({ deps: ['filter', 'nested.prop'] })
      matches4(product: Product) {
        return product.name.includes(this.filter);
      }
    
      // options form with getter function dep
      @computed({ deps: (instance: ProductList) => instance.prop + instance.prop2 })
      matches5(product: Product) {
        return product.name.includes(this.filter);
      }
    }
    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: 0.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 { /* ... */ }
    }
    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.

    Without keys, Aurelia recreates DOM elements when collections change. With keys, it reuses existing elements:

    Property-based keys (recommended):

    Literal property keys (more efficient):

    Expression-based keys (flexible but slower):

    • 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

    • Sortable/filterable lists

    Avoid keys when:

    • Collection is static or append-only

    • Items are simple primitives without DOM state

    • Performance testing shows no benefit

    Every repeat iteration provides rich contextual information:

    Property
    Type
    Description

    $index

    number

    Zero-based index (0, 1, 2...)

    $first

    boolean

    true for the first item

    Access parent contexts in nested structures:

    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)

    • Works with all collection types (arrays, Maps, Sets, etc.)

    • Compatible with keyed repeats

    A common use case is rendering section headers only when data changes:

    Output:

    Highlight changes from previous values:

    $previous works seamlessly with keyed repeats:

    Control contextual computed properties (including $previous) based on view model properties:

    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

    The most common and optimized collection type:

    Useful for unique collections:

    Perfect for key-value pairs:

    Generate sequences quickly:

    Extract multiple values in the repeat declaration:

    Conditional rendering within repeats:

    Nested conditionals and repeats:

    Handle loading states and async operations:

    Use value converters for non-standard collections:

    Use keyed iteration:

    Consider virtual scrolling for very large lists:

    This requires using the virtual repeat plugin.

    Avoid memory leaks in complex scenarios:

    Aurelia includes handlers for:

    • Arrays (Array, [])

    • Sets (Set)

    • Maps (Map)

    • Numbers (5 → creates range 0-4)

    • Array-like objects (NodeList, HTMLCollection, etc.)

    • Null/undefined (renders nothing)

    For specialized collections:

    Create reactive custom collections:

    Problem: Direct array index assignment doesn't trigger updates

    Solution: Use array methods or replace the array

    Problem: Input values disappear when list is reordered

    Solution: Use stable keys

    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

    Problem: Components not disposing properly

    Solution: Clean up in lifecycle hooks

    <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' };
      }
    }

    Core Concepts

    The repeat.for Binding

    Change Detection and Updates

    <!-- 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>

    Performance Optimization with Keys

    Why Keys Matter

    Key Strategies

    When to Use Keys

    Contextual Properties

    Complete Property Reference

    Nested Repeats and $parent

    Accessing Previous Items with $previous

    Section Headers and Dividers

    Comparison and Change Indicators

    Combining with Keys

    Conditional Contextual Properties

    Performance Considerations

    Data Types and Collections

    Arrays

    Sets

    Maps

    Number Ranges

    Advanced Patterns

    Destructuring Declarations

    Integration with Other Template Controllers

    Working with Async Data

    Complex Object Iteration

    Performance Best Practices

    Optimizing Large Lists

    Memory Management

    Custom Collection Handlers

    Built-in Handlers

    Creating Custom Handlers

    Observable Collections

    Troubleshooting Common Issues

    Issue: Changes Not Reflecting

    Issue: Form State Lost on Reorder

    Issue: Performance with Large Lists

    Issue: Memory Leaks

    Real-World Examples

    Dynamic Product Catalog

    Data Table with Sorting

    TypeScript Integration

    Type-Safe Repeats

    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

  • 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.

    Using array methods ensures change detection:

    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.

    Using key: id allows Aurelia to track items efficiently:

    When items are removed or reordered, Aurelia reuses DOM elements.

    Multiple ways to update quantity with validation:

    Show different UI based on cart state:

    Lambda expressions work seamlessly with conditionals: if.bind="cartItems.length" or if.bind="!cartItems.length".

    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.

    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.

    Make the cart available throughout the app:

    • Product Catalog Recipe

    • Lambda Expressions Guide

    • List Rendering Guide

    • Computed Properties

    Features Demonstrated

    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);
      }
    }

    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

    2. Array Manipulation

    3. Benefits of Lambda Expressions

    4. Efficient List Updates

    5. Quantity Validation

    6. Conditional Rendering

    7. Currency Formatting with Value Converter

    8. When to Use Lambda Expressions vs Computed Properties

    Variations

    Persist Cart to LocalStorage

    Add Discount Codes

    Cart as a Service

    Related

    Route Protection

  • Lifecycle Hooks

  • Advanced Topics

  • Troubleshooting


  • Full configuration options →

    Configuring routes →

    Viewports documentation →

    Hash vs PushState routing →


    Navigation methods →

    Using the Router API →

    Active CSS class →

    Ancestor navigation →

    Query parameters →

    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 →


    Path and parameters →

    Lifecycle hooks →

    Aggregate parameters → Route parameters guide →

    Constrained parameters →


    Router hooks →

    Router hooks example →

    canUnload hook →

    Redirect from canLoad →


    loading hook →

    loaded hook →

    Hook
    When
    Use For

    canLoad

    Before activation

    Guards, redirects, param validation

    loading

    After approval, before render

    Data fetching, state setup

    Hook summary →

    • Component hooks (IRouteViewModel): Implemented on the component itself

    • Router hooks (@lifecycleHooks()): Shared across multiple components

    Router hooks vs component hooks →


    Fallback configuration →

    Redirects →

    Sibling viewports →

    Hierarchical routing → Child routing playbook →

    Using inline import() →

    Setting titles → | Customizing titles →

    Path generation →

    Base path configuration →

    History strategy →

    Current route →


    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 →

    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 →

    Problem: Links to sibling routes not working

    Solution: Use ../ prefix for parent context

    Ancestor navigation →

    Problem: canLoad, loading, etc. not executing

    Solution: Implement the IRouteViewModel interface

    Lifecycle hooks →

    Problem: Navigating from /users/1 to /users/2 doesn't update component

    Solution: Configure transition plan

    Transition plans →

    Router events →


    • Getting Started

    • Router Configuration

    • Configuring Routes

    • Route Parameters Guide

    • Route Expression Syntax

    • Navigation

    • Lifecycle Hooks

    • Router Hooks

    • Transition Plans

    • Current Route

    • Navigation Model

    • Router Events

    • Error Handling

    • Testing Guide

    • API Reference

    • Troubleshooting

    Table of Contents

    Getting Started
    Navigation
    Route Parameters
    // 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:test@example.com">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') }
      ]
    })
    // Set a route node title part in route configuration
    {
      path: 'about',
      component: About,
      title: 'About Us'
    }
    
    // Set a document title override for one navigation
    router.load('about', { title: 'Custom Title' });
    
    // Set a route node title part after loading data
    loading(params, next) {
      next.title = `About ${params.id}`;
    }
    
    // Custom title building
    RouterConfiguration.customize({
      buildTitle(transition) {
        const title = transition.routeTree.root.getTitle(' - ');
        return title ? `${title} | My App` : '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);
    
      // Use in bindings or read it after navigation (for example in event handlers).
      logCurrentRoute() {
        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);
        });
      }
    }

    Getting Started

    How do I install and configure the router?

    How do I define routes?

    How do I set up a viewport?

    How do I use hash-based routing instead of clean URLs?

    Navigation

    How do I create navigation links?

    How do I navigate programmatically?

    How do I highlight the active link?

    How do I navigate to parent routes from nested components?

    How do I pass query parameters?

    How do I handle external links?

    Route Parameters

    How do I define route parameters?

    How do I access route parameters in my component?

    How do I get all parameters including from parent routes?

    How do I constrain parameters with regex?

    Route Protection

    How do I protect routes (authentication)?

    How do I implement authorization (role-based access)?

    How do I prevent navigation away from unsaved forms?

    How do I redirect based on conditions?

    Lifecycle Hooks

    How do I load data before showing a component?

    How do I run code after a component is fully loaded?

    When do lifecycle hooks run?

    What's the difference between component hooks and router hooks?

    Advanced Topics

    How do I handle 404 / unknown routes?

    How do I create route aliases / redirects?

    How do I work with multiple viewports (sibling routes)?

    How do I implement nested/child routes?

    How do I lazy load routes?

    How do I set/change the page title?

    How do I generate URLs without navigating?

    How do I work with base paths (multi-tenant apps)?

    How do I handle browser back/forward buttons?

    How do I access the current route information?

    Troubleshooting

    My routes don't work with clean URLs (no hash)

    External links are triggering the router (rare)

    Navigation isn't working from nested components

    My lifecycle hooks aren't being called

    Route parameters aren't updating when navigating between same routes

    How do I debug routing issues?

    Complete Documentation

    Getting Started

    Routes and Navigation

    Lifecycle and Guards

    State and Events

    Advanced Topics

    Reference

    Cheat Sheet

    Quick reference guide for Aurelia 2 templating syntax and common patterns.

    Data Binding

    Binding Modes

    Syntax
    Direction
    Use Case

    .bind

    Auto (two-way for form elements, to-view otherwise)

    Default choice for most scenarios

    Category
    Modifiers

    Combine with +: keydown.trigger:ctrl+shift+enter="handler()"

    Property
    Type
    Description
    Ref Type
    Returns
    Use Case
    Hook
    When Called
    Common Use
    1. Use appropriate binding modes: .to-view 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

    Issue
    Problem
    Solution

    Data Table

    A complete, production-ready data table with sorting, filtering, pagination, row selection, and responsive design.

    • Two-way data binding - Search input, filters, page size

    • Computed properties - Filtered, sorted, and paginated data

    $last

    boolean

    true for the last item

    $middle

    boolean

    true for items that aren't first or last

    $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

    Value Converters

    loaded

    After render

    Analytics, scroll, post-render effects

    canUnload

    Before deactivation

    Unsaved changes warnings

    unloading

    Before removal

    Cleanup, save drafts

    Viewports
    Child Routing Playbook

    Letters

    a-z (case-insensitive)

    Numbers

    0-9

    Event control

    prevent, stop

    Mouse buttons

    left, middle, right

    Resources

    Cleans up events/bindings

    Keeps everything in memory

    Use When

    Content rarely changes

    Content toggles frequently

    $last

    boolean

    True for last item

    $middle

    boolean

    True for middle items

    $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)

    controller.ref

    Controller instance

    Advanced lifecycle access

    my-attr.ref

    Custom attribute instance

    Access attribute methods

    binding

    Before bindings evaluated

    Initialize from bindables

    bound

    Bindings connected

    Setup dependent on bound values

    attaching

    Before DOM attachment

    Async setup work

    attached

    In DOM

    DOM manipulation, third-party libs

    detaching

    Before DOM removal

    Cleanup, save state

    unbinding

    Bindings disconnecting

    Cleanup subscriptions

    Use if.bind for infrequent changes: 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

  • Style not applied

    CSS property name

    Use kebab-case: background-color not backgroundColor

    Async data undefined

    Template renders before data

    Use promise.bind or if.bind="data"

    $parent undefined

    Not in a repeat

    Only available inside repeat.for

    List Rendering

  • Value Converters

  • Binding Behaviors

  • Form Inputs

  • Class & Style Binding

  • Spread Binding

  • Local Templates

  • Lambda Expressions

  • .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

    Meta keys

    ctrl, alt, shift, meta

    Special keys

    enter, escape, space, tab

    Arrow keys

    arrowup, arrowdown, arrowleft, arrowright

    Feature

    if.bind

    show.bind / hide.bind

    DOM Manipulation

    Adds/removes from DOM

    Shows/hides (display: none)

    Performance

    Better for infrequent changes

    $index

    number

    Zero-based index (0, 1, 2...)

    $first

    boolean

    True for first item

    ref

    HTMLElement

    DOM manipulation

    component.ref

    View model instance

    Call component methods

    constructor

    Instance created

    Inject dependencies with resolve()

    created

    After constructor

    Access $controller

    Binding not updating

    Object/array mutation

    Use splice(), reassign, or use observable

    Event not firing

    Wrong event name

    Check: click.trigger not onclick.trigger

    Common Bindings

    String Interpolation

    Event Binding

    Basic Events

    Event Modifiers

    Available Key Modifiers

    Binding Behaviors

    Conditional Rendering

    if.bind vs show.bind vs hide.bind

    hide.bind is an alias for show.bind with inverted logic. hide.bind="true" is equivalent to show.bind="false".

    switch.bind

    List Rendering

    Basic Syntax

    Contextual Properties

    Advanced Collection Types

    Value Converters

    Syntax

    Common Built-in Patterns

    Template References

    Template Variables

    By default, <let> creates template-local variables. Add to-binding-context to assign values directly to the view model instead.

    Class & Style Binding

    Attribute Binding

    Promises in Templates

    Spread Binding

    Spread binding is always one-way (to-view). Only properties that exist in the object at evaluation time create bindings.

    Custom Attributes

    Component Import & Usage

    Dynamic Composition

    Focus Management

    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

    Component Lifecycle (Quick Reference)

    Performance Tips

    Common Gotchas

    Accessibility Reminders

    Related Documentation

    Template Syntax Overview
    Attribute Binding
    Event Binding
    Conditional Rendering

    Better for frequent toggles

    <!-- 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="handleSelfClick() & self">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">
    
    <!-- Update trigger - control when binding updates -->
    <input value.bind="name & updateTrigger:'blur'">
    <input value.bind="name & updateTrigger:'blur':'paste'">
    
    <!-- Signal - manual update triggering -->
    <span>${expensiveComputation | format & signal:'refresh'}</span>
    <!-- Then in code: signaler.dispatchSignal('refresh') -->
    
    <!-- Self - only handle events from the element itself -->
    <div click.trigger="onClick() & self">
      <button>Click me</button> <!-- Won't trigger onClick -->
    </div>
    <!-- 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>
    
    <!-- hide.bind - Inverse of show.bind -->
    <div hide.bind="isHidden">Hidden when true</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 (view-model) Reference -->
    <my-component component.ref="myComponent"></my-component>
    <button click.trigger="myComponent.refresh()">Refresh</button>
    
    <!-- Controller Reference (for advanced use) -->
    <my-component controller.ref="myComponentController"></my-component>
    
    <!-- Custom Attribute Reference -->
    <div my-tooltip.ref="tooltipInstance" my-tooltip="Hello"></div>
    <!-- let - Local Variables (kebab-case converts to camelCase) -->
    <let full-name.bind="firstName + ' ' + lastName"></let>
    <h1>Hello, ${fullName}</h1>
    
    <!-- let with to-binding-context - Assigns to view model -->
    <let to-binding-context computed-value.bind="items.length * 2"></let>
    <!-- Now this.computedValue is available in the view model -->
    
    <!-- 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>
    
    <!-- Single class toggle -->
    <div active.class="isActive"></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>
    <!-- Force attribute (not property) binding with & attr -->
    <img src.bind="imageUrl & attr">
    
    <!-- Useful for ARIA and data attributes -->
    <button aria-label.bind="label & attr" aria-pressed.bind="isPressed & attr">
    <div data-id.bind="item.id & attr">
    
    <!-- Without & attr, bindings target DOM properties by default -->
    <div promise.bind="fetchData()">
      <span pending>Loading...</span>
      <span then="data">
        Loaded: ${data.title}
      </span>
      <span catch="error">
        Error: ${error.message}
      </span>
    </div>
    <!-- Spread all bindable properties from an object -->
    <user-card ...$bindables="user"></user-card>
    
    <!-- Equivalent explicit syntax -->
    <user-card $bindables.spread="user"></user-card>
    
    <!-- Shorthand (expression in attribute name) -->
    <user-card ...user></user-card>
    // If user = { name: 'Jane', email: 'jane@example.com', avatarUrl: '...' }
    // And UserCard has @bindable name, email, avatarUrl
    // Then spread passes all matching properties automatically
    <!-- 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" on-save.bind="handleSave"></my-component>
    
    <!-- Inline Component -->
    <template as-custom-element="inline-component">
      <bindable name="title"></bindable>
      <h1>${title}</h1>
    </template>
    
    <inline-component title="Hello"></inline-component>
    <!-- Compose with component reference -->
    <au-compose component.bind="MyComponent"></au-compose>
    
    <!-- Compose with view model and view -->
    <au-compose view-model.bind="dynamicViewModel" view.bind="dynamicView"></au-compose>
    
    <!-- Compose with model data -->
    <au-compose component.bind="CardComponent" model.bind="{ title: 'Hello', content: cardContent }"></au-compose>
    
    <!-- Compose with string path -->
    <au-compose view-model="./components/dynamic-panel"></au-compose>
    <!-- Two-way focus binding -->
    <input focus.bind="isInputFocused">
    
    <!-- Focus on condition -->
    <input focus.bind="shouldFocus">
    
    <!-- Programmatic focus via ref -->
    <input ref="nameInput">
    <button click.trigger="nameInput.focus()">Focus Input</button>
    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 .to-view (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)
    <!-- Using switch for multiple states -->
    <template switch.bind="state">
      <div case="loading">Loading...</div>
      <div case="error">Error: ${error.message}</div>
      <div case="empty">No items found</div>
      <div default-case>
        <div repeat.for="item of items; key: id">${item.name}</div>
      </div>
    </template>
    
    <!-- Or using nested if/else -->
    <div if.bind="isLoading">Loading...</div>
    <template else>
      <div if.bind="error">Error: ${error.message}</div>
      <template else>
        <div if.bind="items.length === 0">No items found</div>
        <div else>
          <div repeat.for="item of items; key: id">${item.name}</div>
        </div>
      </template>
    </template>
    <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>
    import { resolve } from '@aurelia/kernel';
    
    export class MyComponent {
      // Properties injected via DI
      private api = resolve(IApiService);
    
      // Lifecycle methods
      binding() { /* Called first */ }
      attached() { /* DOM is ready */ }
      detaching() { /* Cleanup */ }
    }
    <!-- 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>
    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

  • 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.

    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.

    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.

    • 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

    • Debounced search (300ms) prevents excessive filtering

    • Keyed repeat ensures efficient DOM updates

    • Computed properties cache results until dependencies change

    For large datasets, move filtering/sorting to the server:

    Add edit mode for quick updates:

    Let users show/hide columns:

    • Product Catalog - Another filtering/sorting example

    • List Rendering - repeat.for documentation

    • Conditional Rendering - if.bind and show.bind

    • - Date/number formatting

    Features Demonstrated

    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: 'alice@example.com',
          role: 'Admin',
          status: 'active',
          lastLogin: new Date('2025-01-08'),
          tasksCompleted: 127
        },
        {
          id: 2,
          name: 'Bob Smith',
          email: 'bob@example.com',
          role: 'User',
          status: 'active',
          lastLogin: new Date('2025-01-09'),
          tasksCompleted: 89
        },
        {
          id: 3,
          name: 'Carol Williams',
          email: 'carol@example.com',
          role: 'Manager',
          status: 'inactive',
          lastLogin: new Date('2024-12-15'),
          tasksCompleted: 203
        },
        {
          id: 4,
          name: 'David Brown',
          email: 'david@example.com',
          role: 'User',
          status: 'pending',
          lastLogin: new Date('2025-01-07'),
          tasksCompleted: 45
        },
        {
          id: 5,
          name: 'Eve Davis',
          email: 'eve@example.com',
          role: 'User',
          status: 'active',
          lastLogin: new Date('2025-01-09'),
          tasksCompleted: 156
        },
        // Add more sample data...
        {
          id: 6,
          name: 'Frank Miller',
          email: 'frank@example.com',
          role: 'Admin',
          status: 'active',
          lastLogin: new Date('2025-01-08'),
          tasksCompleted: 312
        },
        {
          id: 7,
          name: 'Grace Wilson',
          email: 'grace@example.com',
          role: 'Manager',
          status: 'active',
          lastLogin: new Date('2025-01-09'),
          tasksCompleted: 178
        },
        {
          id: 8,
          name: 'Henry Moore',
          email: 'henry@example.com',
          role: 'User',
          status: 'inactive',
          lastLogin: new Date('2024-11-20'),
          tasksCompleted: 67
        },
        {
          id: 9,
          name: 'Iris Taylor',
          email: 'iris@example.com',
          role: 'User',
          status: 'active',
          lastLogin: new Date('2025-01-09'),
          tasksCompleted: 234
        },
        {
          id: 10,
          name: 'Jack Anderson',
          email: 'jack@example.com',
          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>

    Code

    View Model (data-table.ts)

    Template (data-table.html)

    Styles (data-table.css)

    How It Works

    Filtering Pipeline

    Sorting

    Pagination

    Selection

    Performance

    Variations

    Server-Side Pagination

    Inline Editing

    Column Visibility Toggle

    Related

    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

    • 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.

    • node: Node - The DOM node for which to retrieve the controller

    • opts?: object - Optional configuration object with the following properties:

      • optional?: boolean

    Registers a class as a custom element in Aurelia. This method can be called directly or is used internally by the @customElement decorator.

    • 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)

    Retrieves the CustomElementDefinition for a custom element class, providing access to all metadata about the element.

    • Type: Constructable - The custom element class

    Returns a CustomElementDefinition object containing all metadata about the custom element.

    Searches for a custom element definition by name within a specific container's registry.

    • container: IContainer - The dependency injection container to search in

    • name: string - The name of the custom element to find

    Returns the CustomElementDefinition if found, or null if no element with the specified name is registered.

    Checks whether a given value is a custom element type (class decorated with @customElement or defined via CustomElement.define).

    • value: any - The value to check

    Returns true if the value is a custom element type, false otherwise.

    Attaches metadata to a custom element class. This is typically used internally by decorators and the framework.

    • 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

    Retrieves metadata that was previously attached to a custom element class.

    • Type: Constructable - The custom element class

    • prop: string - The property key to retrieve

    Returns the annotation value, or undefined if not found.

    Generates a unique name for a custom element, useful for anonymous or dynamically created elements.

    A string representing a unique name (typically in the format unnamed-{number}).

    Dynamically generates a CustomElementType with a given name and prototype properties.

    • name: string - The name for the generated type

    • proto?: object - An optional object containing properties and methods to add to the prototype

    A CustomElementType that can be used with CustomElement.define.

    Creates an InjectableToken for dependency injection scenarios.

    An InterfaceSymbol that can be used as a dependency injection token.

    Generates the registry key used internally to store and retrieve custom element definitions.

    • name: string - The custom element name

    A string representing the internal registry key.

    The primary decorator for marking a class as a custom element.

    Enables Shadow DOM for the custom element.

    Renders the custom element without its element container.

    Enables capturing of all attributes and bindings that are not explicitly defined as bindables or template controllers.

    Defines a hook that processes the element's content before compilation.

    An object that describes a custom element's configuration. All properties are optional.

    The complete, resolved definition of a custom element (read-only).

    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.

    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.

    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.

    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.

    Quick decision guide

    Start with Light DOM unless you have a specific reason to enable Shadow DOM. Shadow DOM isolates styles by design, so global CSS frameworks and app-wide themes do not flow into components unless you explicitly share them. You can mix Light DOM and Shadow DOM in the same app—use Shadow DOM only on components that need strict isolation or native web component features.

    Enabling Shadow DOM

    Using the @useShadowDOM Decorator

    The simplest way to enable Shadow DOM is with the @useShadowDOM decorator:

    import { customElement, useShadowDOM } from 'aurelia';
    
    @customElement('my-card')
    @useShadowDOM()
    export class MyCard {
      message = 'Hello from Shadow DOM';
    }

    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:

    Mode only controls JavaScript access to the shadow root. It does not change CSS encapsulation—global styles still will not cross the shadow boundary in open mode.

    You can also configure Shadow DOM using the @customElement decorator's configuration object:

    Or using a static property:

    Shadow DOM provides complete CSS isolation. Styles defined outside the component won't affect elements inside, and styles inside won't leak out.

    If styles or slots are not behaving as expected, check these first:

    • Confirm the component actually uses Shadow DOM: @useShadowDOM() or shadowOptions must be set.

    • Global CSS won’t cross the boundary: Use Light DOM for framework styles, or register shared styles with StyleConfiguration.shadowDOM({ sharedStyles: [...] }).

    Use the shadowCSS helper to register styles for your component:

    For better performance and reusability, you can pass CSSStyleSheet instances:

    Configure styles that apply to all Shadow DOM components in your application:

    Global styles are applied first, followed by component-local styles.

    Shared styles only apply to components that actually use Shadow DOM. They do not affect Light DOM components, and selectors like html or body still cannot reach into a shadow root. If you want a global CSS framework to style Shadow DOM components, import that stylesheet and include it in sharedStyles, or keep those components in Light DOM.

    Shadow DOM provides special CSS selectors for enhanced styling control:

    Style the component's host element from within the shadow root:

    Style the host based on an ancestor's context:

    Style content that has been projected into a slot:

    Expose specific elements for external styling using part attributes:

    The most flexible way to style Shadow DOM components from outside is using CSS custom properties (CSS variables):

    CSS Modules provide class name transformation for avoiding naming conflicts. You can combine cssModules() with Shadow DOM for both style encapsulation and class name scoping:

    Native <slot> elements require Shadow DOM. Attempting to use <slot> without Shadow DOM will throw a compilation error.

    Usage:

    Usage:

    Slots can have default content when nothing is projected:

    React to changes in slotted content:

    For more advanced slot usage, including the @children decorator and component view model retrieval, see the .

    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.

    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:

    • 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

    • Easy styling is important: Parent components or application styles should easily affect the component

    • Working with global styles: You rely on application-wide styles or CSS frameworks (Bulma/Bootstrap/Tailwind) to flow into components

    • SEO is a concern: Search engines can more easily index light DOM content

    Allow users to customize your components through CSS variables with sensible defaults:

    Give users a good default experience even when they don't provide slot content:

    Prevent naming conflicts by prefixing your component's CSS variables:

    For optimal performance, Aurelia uses when supported by the browser, falling back to <style> elements otherwise.

    Automatic caching: When you pass CSS strings to shadowCSS(), Aurelia automatically caches the compiled CSSStyleSheet instances. This means multiple instances of the same component share the same stylesheet object in memory.

    For maximum control, you can create CSSStyleSheet objects directly:

    Closed mode prevents useful debugging and testing. Use open mode by default:

    If your component supports theming, document the available CSS variables:

    Aurelia's convention-based CSS loading (where my-component.css is auto-imported alongside my-component.ts) does not automatically inject styles into Shadow DOM. For Shadow DOM components, you must explicitly use shadowCSS():

    • - Deep dive into slots, @children, and @slotted decorators

    • - Using Aurelia components as web components

    • - Complete API documentation including Shadow DOM options

    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.

    Binding behaviors enable you to:

    • Control timing - throttle, debounce, or trigger updates at specific intervals

    • Modify binding modes - force one-way, two-way, or one-time binding behavior

    Value Converters
  • CustomElement.keyFrom

  • @capture

  • @processContent

  • - 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

  • Core Methods

    CustomElement.for

    Method Signatures

    Parameters

    Examples

    CustomElement.define

    Method Signatures

    Parameters

    Examples

    CustomElement.getDefinition

    Method Signature

    Parameters

    Return Value

    Example

    CustomElement.find

    Method Signature

    Parameters

    Return Value

    Example

    CustomElement.isType

    Method Signature

    Parameters

    Return Value

    Example

    Metadata Methods

    CustomElement.annotate

    Method Signature

    Parameters

    Example

    CustomElement.getAnnotation

    Method Signature

    Parameters

    Return Value

    Example

    Utility Methods

    CustomElement.generateName

    Method Signature

    Return Value

    Example

    CustomElement.generateType

    Method Signature

    Parameters

    Return Value

    Example

    CustomElement.createInjectable

    Method Signature

    Return Value

    Example

    CustomElement.keyFrom

    Method Signature

    Parameters

    Return Value

    Example

    Decorators

    @customElement Decorator

    Syntax

    Examples

    @useShadowDOM Decorator

    Syntax

    Example

    @containerless Decorator

    Syntax

    Example

    @capture Decorator

    Syntax

    Example

    @processContent Decorator

    Syntax

    Example

    Definition Objects

    PartialCustomElementDefinition

    Properties

    Example

    CustomElementDefinition

    Key Properties

    Programmatic Resource Aliases

    alias(...aliases) decorator

    registerAliases(...)

    Best Practices

    1. Use Decorators Over Direct API Calls

    2. Type Your Controllers

    3. Handle Errors Gracefully

    4. Leverage Definition Objects for Complex Elements

    CustomElement.define
    CustomElement.getDefinition
    CustomElement.find
    CustomElement.isType
    Metadata Methods
    CustomElement.annotate
    CustomElement.getAnnotation
    Utility Methods
    CustomElement.generateName
    CustomElement.generateType
    CustomElement.createInjectable
    Decorators
    @customElement
    @useShadowDOM
    @containerless
    Definition Objects
    PartialCustomElementDefinition
    CustomElementDefinition
    Co-located CSS is not auto-injected: Import CSS as a string and pass it to shadowCSS() for Shadow DOM components.
  • Use Shadow DOM selectors: :host, :host-context(), and ::slotted() apply inside the shadow root. Use CSS variables or ::part for safe theming.

  • Slots require Shadow DOM: Native <slot> only works with Shadow DOM; use <au-slot> if you stay in Light DOM.

  • Containerless is incompatible: You cannot use Shadow DOM and @containerless together.

  • Debugging needs open mode: mode: 'open' makes it easier to inspect and tweak styles in DevTools.

  • ::part
  • Creating a design system: Components should maintain consistent appearance regardless of environment

  • Using <au-slot>: You need Aurelia's slot features like $host scope access

    Using the Configuration Object

    Styling Shadow DOM Components

    Troubleshooting checklist

    Component-Local Styles

    Using Constructable Stylesheets

    Global Shared Styles

    Shadow DOM CSS Selectors

    The :host Selector

    The :host-context() Selector

    The ::slotted() Selector

    Important: The ::slotted() selector only works on direct children of the slot. It cannot select nested descendants within slotted content.

    The ::part() Selector

    The ::part() selector is the recommended way to create styling hooks for consumers of your components. It provides explicit control over which internal elements can be styled externally.

    Styling from Outside: CSS Custom Properties

    Using CSS Modules with Shadow DOM

    How it works: cssModules() transforms class names in your template at compile time, while shadowCSS() injects the actual CSS into the shadow root. When using CSS Modules with Shadow DOM, ensure your CSS rules use the transformed class names.

    Note: CSS Modules mappings do not inherit to child components. Each component must register its own cssModules() dependency.

    Shadow DOM and Slots

    Basic Slot Usage

    Named Slots

    Fallback Content

    Listening to Slot Changes

    Constraints and Limitations

    Cannot Combine with @containerless

    Native Slots Require Shadow DOM

    Choosing Between Shadow DOM and Light DOM

    Use Shadow DOM When:

    Use Light DOM (no Shadow DOM) When:

    Practical Examples

    Themed Button Component

    Card with Multiple Slots

    Component with Dynamic Styles

    Best Practices

    1. Use CSS Custom Properties for Theming

    2. Provide Fallback Content for Slots

    3. Namespace Your CSS Variables

    4. Consider Performance with Constructable Stylesheets

    Using pre-created CSSStyleSheet objects is slightly more efficient than CSS strings because it skips the string-to-stylesheet conversion step, though Aurelia's caching makes this difference minimal for most applications.

    5. Use Open Mode Unless You Have a Reason Not To

    6. Document Your CSS Custom Properties

    7. Convention-Based CSS Does Not Auto-Inject into Shadow DOM

    Convention-based CSS loading works well for Light DOM components where styles are added to the document. For Shadow DOM components, always use shadowCSS() to ensure styles are properly scoped within the shadow root.

    Additional Resources

    Slotted Content documentation
    Constructable Stylesheets
    Slotted Content Documentation
    Web Components Documentation
    CustomElement API Reference
    // 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, data: Record<PropertyKey, unknown>): 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, data: Record<PropertyKey, unknown>): 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 {}
    @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();
    /* Inside your component's shadow DOM styles */
    :host {
      display: block;
      border: 1px solid #e1e1e1;
      padding: 16px;
    }
    
    /* Style the host when it has a specific class */
    :host(.highlighted) {
      background-color: #fff3cd;
      border-color: #ffc107;
    }
    
    /* Style the host when it has a specific attribute */
    :host([disabled]) {
      opacity: 0.5;
      pointer-events: none;
    }
    /* When the component is inside a dark theme container */
    :host-context(.dark-theme) {
      background-color: #2d3748;
      color: #ffffff;
    }
    
    /* When the component is inside a specific page */
    :host-context(.admin-page) {
      border-left: 4px solid #dc3545;
    }
    /* Style all slotted elements */
    ::slotted(*) {
      margin: 8px 0;
    }
    
    /* Style specific slotted elements */
    ::slotted(p) {
      font-size: 14px;
      line-height: 1.6;
    }
    
    /* Style slotted elements with a specific class */
    ::slotted(.highlight) {
      background-color: yellow;
    }
    import { customElement, useShadowDOM, shadowCSS } from 'aurelia';
    
    @customElement({
      name: 'my-card',
      template: `
        <div part="container" class="card">
          <header part="header" class="card-header">
            <slot name="header"></slot>
          </header>
          <div part="body" class="card-body">
            <slot></slot>
          </div>
          <footer part="footer" class="card-footer">
            <slot name="footer"></slot>
          </footer>
        </div>
      `,
      dependencies: [
        shadowCSS(`
          .card { border: 1px solid #ddd; border-radius: 8px; }
          .card-header { padding: 16px; background: #f8f9fa; }
          .card-body { padding: 16px; }
          .card-footer { padding: 12px 16px; background: #f8f9fa; }
        `)
      ]
    })
    @useShadowDOM()
    export class MyCard {}
    /* Style the exposed parts from outside the component */
    my-card::part(header) {
      background: linear-gradient(135deg, #667eea, #764ba2);
      color: white;
    }
    
    my-card::part(body) {
      min-height: 100px;
    }
    
    my-card::part(footer) {
      border-top: 1px solid #ddd;
    }
    
    /* Combine with pseudo-classes */
    my-card::part(header):hover {
      background: linear-gradient(135deg, #764ba2, #667eea);
    }
    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, shadowCSS, cssModules } from 'aurelia';
    
    // CSS Module mapping (typically imported from a .module.css file via your bundler)
    const styles = {
      card: 'card_abc123',
      header: 'header_def456',
      body: 'body_ghi789'
    };
    
    @customElement({
      name: 'module-card',
      template: `
        <div class="card">
          <header class="header"><slot name="header"></slot></header>
          <div class="body"><slot></slot></div>
        </div>
      `,
      dependencies: [
        cssModules(styles),
        shadowCSS(`
          .card_abc123 { border: 1px solid #ddd; }
          .header_def456 { background: #f5f5f5; padding: 12px; }
          .body_ghi789 { padding: 16px; }
        `)
      ]
    })
    @useShadowDOM()
    export class ModuleCard {}
    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> john@example.com</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 at module level, reuse across all component instances
    const cardStyles = new CSSStyleSheet();
    cardStyles.replaceSync(`
      .card { border: 1px solid #ddd; padding: 16px; }
      .card-header { font-weight: bold; }
    `);
    
    @customElement({
      name: 'my-card',
      template: '<div class="card"><slot></slot></div>',
      dependencies: [shadowCSS(cardStyles)] // Same CSSStyleSheet instance is reused
    })
    @useShadowDOM()
    export class MyCard {}
    @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 {}
    // my-card.ts
    import { customElement, useShadowDOM, shadowCSS } from 'aurelia';
    import styles from './my-card.css?inline'; // Import CSS as string (bundler-specific)
    
    @customElement({
      name: 'my-card',
      template: '<div class="card"><slot></slot></div>',
      dependencies: [shadowCSS(styles)] // Explicitly inject into shadow root
    })
    @useShadowDOM()
    export class MyCard {}

    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

  • Binding behaviors use the & operator and follow similar syntax to value converters:

    Parameter syntax flexibility:

    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.

    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.

    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

    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:

    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.

    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.

    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, .to-view, .two-way) are more commonly used, these behaviors offer programmatic control over binding modes.

    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

    Forces one-way data flow from view-model to view only.

    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

    Forces bidirectional data synchronization between view and view-model.

    Behavior
    Direction
    Use Case
    Command Equivalent

    oneTime

    None (static)

    Static content, performance

    N/A

    toView

    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.

    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:

    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.

    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.

    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.

    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.

    Behavior
    Purpose
    Default
    Parameters
    Signals

    throttle

    Limit update frequency

    200ms

    delay, signal

    Behavior
    Direction
    Use Case

    oneTime

    None

    Static content, performance

    toView

    VM → View

    Display-only data

    Behavior
    Purpose
    Use Case

    self

    Filter event source

    Prevent event bubbling

    updateTrigger

    Custom DOM events

    Control when updates occur

    Behavior
    Purpose
    Use Case

    signal

    Manual refresh

    Dynamic content, translations

    Rate limiting for expensive operations:

    Static content optimization:

    Proper cleanup in custom behaviors:

    Progressive enhancement approach:

    Form handling:

    Search functionality:

    Dynamic content:

    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.

    Overview

    <!-- 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.to-view="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; // Set via ref="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, type IBinding } from '@aurelia/runtime-html';
    import type { Scope } from '@aurelia/runtime';
    
    @bindingBehavior('logBindingContext')
    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);
        }
      }
    }
    <import from="./log-binding-context-behavior.ts"></import>
    <input value.bind="userName & logBindingContext">
    import { bindingBehavior, type IBinding } from '@aurelia/runtime-html';
    import type { Scope } from '@aurelia/runtime';
    
    @bindingBehavior('inspect')
    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 = '';
        }
      }
    }
    <import from="./inspect-binding-behavior.ts"></import>
    <input value.bind="itemName & inspect">
    import { bindingBehavior, type IBinding } from '@aurelia/runtime-html';
    import type { Scope } from '@aurelia/runtime';
    
    @bindingBehavior('highlightUpdates')
    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 = '';
        }
      }
    }
    <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>

    Syntax

    Throttle

    Flushing Pending Throttled Updates

    Debounce

    Flushing Pending Debounced Calls

    UpdateTrigger

    Signal

    Binding Mode Behaviors

    oneTime

    toView (One-Way)

    fromView

    twoWay

    Binding Mode Summary

    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

    Attr

    Custom Binding Behaviors

    Log Binding Context Behavior

    Inspect Value Binding Behavior (Tooltip)

    Highlight Updates Binding Behavior

    Built-in Behaviors Reference

    Rate Limiting

    Binding Modes

    Event & DOM

    Utility

    Best Practices

    Performance Considerations

    Memory Management

    Debugging and Development

    Common Patterns

    Summary

    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

    VM → View

    Display-only data

    .to-view

    fromView

    View → VM

    Input-only scenarios

    .from-view

    twoWay

    VM ↔ View

    Interactive forms

    .two-way

    ✅

    debounce

    Delay until input stops

    200ms

    delay, signal

    ✅

    fromView

    View → VM

    Input-only scenarios

    twoWay

    VM ↔ View

    Interactive forms

    attr

    Force attribute access

    Custom attributes, SEO

    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.

    The <slot> element is the browser-native way to project content into Shadow DOM components.

    Before using <slot>, enable Shadow DOM on your component:

    import { customElement, useShadowDOM } 
    
    <div class="modal">
    

    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?

    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:

    A slot can display default content when nothing is explicitly projected into it. Fallback content works for default and named slot elements.

    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:

    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

    Usage
    Meaning

    @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

    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.

    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.

    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.

    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.

    <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>

    The following would be logged to the console for the instances of my-element.

    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.

    To further explain how these rules apply, these rules are explained with the following examples.

    Let's consider the following example with interpolation.

    <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';
    }

    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.

    <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;
    }

    Let's consider the following example with interpolation. This is the same example as before, but this time without projection.

    <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';
    }

    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.

    <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;
    }

    The outer custom element can access the inner custom element's scope using the $host keyword, as shown in the following example.

    <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';
    }

    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.

    <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;
    }

    Let's consider another example of $host which highlights the communication between the inside and outside of a custom element that employs <au-slot>

    <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,
      ) { }
    }

    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.

    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.

    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 here.

    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.

    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 {% %}

    The @slotted decorator can be used in multiple forms:

    Usage
    Meaning

    @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

    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. {% %}

    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

    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

    An example of using slotchange behavior may look like the following:

    <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>
    // my_element_instance_1
    ['default', 's1']
    
    // my_element_instance_2
    []
    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);
      }
    }

    Native Slots (<slot>)

    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

    Named slots

    Fallback content

    Listening to projection change

    At the projection target (<slot> element), with the

    At the projection source (custom element host), with the @children decorator

    @children decorator usage

    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

    Au-slot

    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

    Using [au-slot] with template controllers

    Binding scope

    Examples

    Projection uses the outer scope by defaultthe

    Fallback uses the inner scope by default

    Access the inner scope with $host

    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

    Duplicate slots

    Listening to <au-slot> change

    With @slotted decorator

    @slotted usage

    With slotchange binding

    • The callback will not be called upon the initial rendering, it's only called when there's a mutation after the initial rendering.

    • 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.

  • from
    '
    aurelia
    '
    ;
    @customElement('au-modal')
    @useShadowDOM()
    export class AuModal {}
    <div class="modal-inner">
    <slot></slot>
    </div>
    </div>

    @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('$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

    @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

    slotchange callback reminders

    <au-slot>
    slotchange event

    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.

    loader-component.ts
    import { bindable } from 'aurelia';
    
    export class LoaderComponent {
        @bindable loading = false;
    }

    This will allow our component to be passed in values. Our specified bindable property here is called loading and can be used like this:

    loader-component.html
    <loader loading.bind="true"></loader>

    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.

    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.

    You can then use the component in this way,`<name-component first-name="John" last-name="Smith"></name-component>

    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.

    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:

    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:

    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.

    Like almost everything in Aurelia, you can configure how bindable properties work.

    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

    You can change the name of the callback that is fired when a change is made @bindable({ callback: 'propChanged' })

    By default, Aurelia converts camelCase property names to kebab-case attribute names (e.g., firstName becomes first-name). You can override this with the attribute option:

    This allows the component to be used with a custom attribute name:

    When creating custom attributes, you can specify which property receives the value when the attribute is used without explicitly naming a property. Use the defaultProperty option on the @customAttribute decorator:

    This allows a simpler usage syntax:

    Bindable properties support many different binding modes determining the direction the data is bound in and how it is bound.

    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).

    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.

    The one-time binding mode binds a value once and never updates it again, even if the source value changes. This is useful for static values that won't change after initial binding.

    The from-view binding mode allows data to flow from the view (target) to the view model (source), but not the other way. This is the opposite of toView.

    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.

    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.

    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.

    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.

    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.

    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 section).

    Additionally, depending on whether you are using TypeScript or JavaScript for your app, there can be several ways to use automatic type coercion.

    You need to specify the explicit type in the @bindable definition.

    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).

    It is also possible to coerce values into instances of classes. There are two ways how that can be done.

    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).

    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.

    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.

    It is important to note that an explicit set (see ) 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.

    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.

    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:

    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:

    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.

    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:

    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.

    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.

    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.

    Aurelia conventions enable the setting of capture metadata from the template via <capture> tag, like the following example:

    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.

    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)"

    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.

    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

    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.

    Spread syntax supports complex expressions and transformations:

    ✅ 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

    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

    1. HTML Case Sensitivity

    2. Property Observation Not Working

    3. Binding Mode Conflicts

    Understanding these patterns and considerations will help you use Aurelia's spread syntax effectively while maintaining good performance and code maintainability.

    "true": When the navigator property set to "true".

    tooltip="Hello, ${tooltip}"

    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.

    Calling a change function when bindable is modified

    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.

    Configuring bindable properties

    Change the binding mode using mode

    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

    Change the attribute name using attribute

    Set the default property for custom attributes

    If defaultProperty is not specified, it defaults to 'value'. This allows custom attributes without any bindables defined to automatically receive values through a value property.

    One way binding

    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

    One-time binding

    From-view binding

    Working with two-way binding

    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

    Bindable & getter/setter

    Bindable coercion

    Automatic type coercion

    Specify type in @bindable

    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

    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

    Using a static coerce method

    Using the @coercer decorator

    Coercing nullable values

    set and auto-coercion

    Aurelia2 already exposes a noop function saving your effort to write such boring functions.

    Union types

    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

    Shorthand syntax

    • 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

    • 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

    • 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.

    Attributes Transferring

    Usage

    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

    Attribute filtering

    How it works

    Performance Considerations

    Spread vs Individual Bindings

    Memory and Observation Overhead

    Advanced Patterns

    Complex Expression Patterns

    Combining Spread with Other Binding Features

    Error Handling Patterns

    Best Practices

    When to Use Spread Syntax

    Maintainability Guidelines

    Type Safety Considerations

    Common Patterns and Anti-Patterns

    ✅ Recommended Patterns

    ❌ Anti-Patterns to Avoid

    Debugging and Troubleshooting

    Common Issues

    Debugging Tips

    Coercing nullable values
    bindable setter
    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>
    loader-component.html
    <loader loading.bind="loadingVal"></loader>
    <loader loading="true"></loader>
    import { bindable } from 'aurelia';
    
    export class NameComponent {
        @bindable firstName = '';
        @bindable lastName  = '';
    
        bound() {
            this.firstNameChanged(this.firstName, undefined);
        }
    
        firstNameChanged(newVal, oldVal) {
            console.log('Value changed');
        }
    }
    export class NameComponent {
        @bindable firstName = '';
        @bindable lastName  = '';
    
        propertyChanged(key, newVal, oldVal) {
          if (key === 'firstName') {
            // Handle firstName change
          } else if (key === 'lastName') {
            // Handle lastName change
          }
        }
    }
    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 NameComponent {
        @bindable({ mode: BindingMode.twoWay}) firstName = '';
        @bindable({ callback: 'lnameChanged' }) lastName  = '';
    
        lnameChanged(val) {}
    }
    import { bindable } from 'aurelia';
    
    export class UserCard {
        @bindable({ attribute: 'user-id' }) id = '';
    }
    <user-card user-id="123"></user-card>
    import { bindable, customAttribute } from 'aurelia';
    
    @customAttribute({ name: 'tooltip', defaultProperty: 'message' })
    export class TooltipCustomAttribute {
        @bindable message = '';
        @bindable position = 'top';
    }
    <!-- The value "Hello" goes to the default property (message) -->
    <div tooltip="Hello"></div>
    
    <!-- Equivalent explicit syntax -->
    <div tooltip="message: Hello"></div>
    
    <!-- Using multiple bindables -->
    <div tooltip="message: Hello; position: bottom"></div>
    import { bindable, BindingMode } from 'aurelia';
    
    export class Loader {
        @bindable({ mode: BindingMode.toView }) loading = false;
    }
    import { bindable, BindingMode } from 'aurelia';
    
    export class Loader {
        @bindable({ mode: BindingMode.twoWay }) loading = false;
    }
    import { bindable, BindingMode } from 'aurelia';
    
    export class Loader {
        @bindable({ mode: BindingMode.oneTime }) config = {};
    }
    import { bindable, BindingMode } from 'aurelia';
    
    export class Loader {
        @bindable({ mode: BindingMode.fromView }) userInput = '';
    }
    <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));
      }
    }
    @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 { }
    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;
    }
    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 { }
    @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.spread="customer1 | mapDetails">
    <name-tag $bindables.spread="customer.details | simplify">
    <name-tag $bindables.spread="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>
    <!-- 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">
    // Enable detailed binding information in development
    @customElement({ 
      name: 'debug-component',
      capture: (attr: string) => {
        if (__DEV__) {
          console.log('Capturing attribute:', attr);
        }
        return !attr.startsWith('debug-');
      }
    })

    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

    • Pure functions - predictable, testable transformations

    • Reusable - use the same converter across multiple components

    • Composable - chain multiple converters for complex transformations

    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.

    Use the pipe symbol (|) to apply a converter in templates:

    Usage in template:

    Converters accept parameters using colons (:) for configuration:

    Chain multiple converters for complex transformations:

    Chain execution order: Left to right, where each converter receives the output of the previous one.

    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

    • 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.

    Once withContext: true is enabled, your converter receives a caller parameter with direct access to the view model and binding information:

    • 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

    Usage in template:

    Aurelia 2 provides flexible registration patterns for different use cases and architectural preferences.

    The most common and straightforward approach:

    For advanced options including aliases:

    Usage with aliases:

    Using the static $au property (alternative registration approach):

    For dynamic or runtime registration scenarios:

    Register converters based on environment or feature flags:

    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

    Custom value converters are classes that implement transformation logic. They provide a clean way to handle data formatting throughout your application.

    Perfect for form inputs that need both display formatting and input parsing:

    Usage with two-way binding:

    Usage:

    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.

    Aurelia 2 includes several built-in converters that leverage signals:

    Aurelia 2 includes several built-in converters ready for use:

    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.

    This converter formats dates based on locale:

    Import it in your view:

    Usage examples:

    View this in action on .

    Convert bytes to human-readable file sizes:

    Display time relative to now (e.g., "2 hours ago"):

    Truncate text with full text available on hover:

    Convert markdown text to HTML (using marked library):

    Highlight search terms in text:

    Sort arrays by property or custom function:

    Convert between color formats:

    Implement intelligent caching for expensive operations:

    Defer expensive operations until actually needed:

    Prevent memory leaks in converters:

    Use performance measurement for optimization:

    Single Responsibility

    Pure Functions

    Strong Typing

    Generic Constraints

    Graceful Degradation

    Unit Testing

    Problem: Template shows error "No ValueConverter named 'myConverter' was found"

    Solutions:

    1. Import the converter:

    2. Check decorator name:

    3. Global registration:

    Problem: Page becomes slow with converters in loops

    Solutions:

    1. Implement caching:

    2. Use signals for global updates:

    3. Optimize template usage:

    Problem: caller parameter is undefined in toView

    Solutions:

    1. Enable context access:

    2. Correct parameter order:

    Problem: Converter doesn't update when signal is dispatched

    Solutions:

    1. Declare signals array:

    2. Dispatch signals correctly:

    Converter
    Purpose
    Package
    Parameters
    Converter
    Purpose
    Parameters
    Example

    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

    Use value converters to keep your templates clean and maintainable while providing rich data formatting capabilities throughout your application.

    Framework integration - seamless integration with Aurelia's binding system
  • TypeScript support - full type safety and intellisense

  • Other Bindings: the caller corresponds to the specific binding type in use

    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, InterpolationPartBinding, etc.

  • Document aliases - Make alternative names clear to team members
  • Avoid global pollution - Use local registration for component-specific logic

  • options

    `${date

    nf

    Number formatting

    options

    `${price

    rt

    Relative time

    None

    `${timestamp

    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

  • sanitize

    HTML sanitization

    @aurelia/runtime-html

    None (requires ISanitizer implementation)

    t

    Translation

    key, options

    `${'hello'

    df

    Key Advantages

    Data Flow

    Example Methods

    Basic Usage

    Template Syntax

    Simple Converter Example

    Parameter Passing

    Static Parameters

    Bound Parameters

    Object Parameters

    Chaining Converters

    Advanced Template Patterns

    Conditional Formatting

    Dynamic Parameter Selection

    Nested Object Access

    Receiving the Caller Context

    Common Use Cases

    Accessing the View Model and Binding Context

    Caller Context Properties

    Real-World Example: User Permission Converter

    Registration Patterns

    1. Decorator Registration (Recommended)

    2. Configuration Object Registration

    3. Static Definition

    4. Manual Registration

    5. Local vs Global Registration

    Global Registration (Application-wide)

    Local Registration (Component-specific)

    Scoped Registration (Feature Module)

    6. Conditional Registration

    Best Practices for Registration

    Creating Custom Value Converters

    Basic Structure

    TypeScript Best Practices

    Strong Typing

    Generic Converters

    Bidirectional Converters (Two-Way Binding)

    Phone Number Formatter

    Credit Card Formatter

    Error Handling and Validation

    Performance Optimization

    Memoized Converter

    Utility Converters

    Null-Safe Converter

    Debug Converter

    Signals-Based Reactivity

    Built-in Signal-Aware Converters

    Built-in Value Converters

    Sanitize Converter

    I18n Converters (when @aurelia/i18n is installed)

    Advanced Configuration Options

    Date Formatter Example

    Real-World Converter Examples

    File Size Converter

    Relative Time Converter

    Truncate with Tooltip Converter

    Markdown to HTML Converter

    Search Highlight Converter

    Sort Array Converter

    Color Converter

    Performance Optimization

    Caching Strategies

    Lazy Evaluation

    Memory Management

    Benchmark and Profile

    Best Practices

    1. Design Principles

    2. TypeScript Integration

    3. Error Handling

    4. Testing Strategies

    Troubleshooting Common Issues

    Issue: Converter Not Found

    Issue: Performance Problems

    Issue: Context Access Not Working

    Issue: Signals Not Triggering

    Built-in Converters Reference

    Core Converters

    I18n Converters (when @aurelia/i18n is installed)

    Usage Examples

    Summary

    StackBlitz

    Date formatting

    // 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 { 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>
    <p>${ someValue | myConverter }</p>
    import { valueConverter, type ICallerContext } from '@aurelia/runtime-html';
    
    @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})`;
      }
    }
    import { valueConverter, type ICallerContext } from '@aurelia/runtime-html';
    
    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);
      });
    });
    <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-html';
    
    private signaler = resolve(ISignaler);
    
    updateData(): void {
      // Update data first
      this.signaler.dispatchSignal('my-signal');
    }
    <!-- 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>

    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

    1. Introduction

    2. Creating a Basic Custom Attribute


    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.


    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.

    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.


    Aurelia 2 provides multiple approaches for defining custom attributes. For most user scenarios, you'll use either the convention-based or decorator-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).

    Use the @customAttribute decorator for explicit control and better IDE support:

    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:


    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.

    By default, the class name might be used to infer the attribute name. However, you can explicitly set a custom name:

    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:


    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:


    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.

    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

    You can specify which property receives the value when the attribute is used with shorthand syntax (without explicitly naming a property). Use the defaultProperty option on the @customAttribute decorator:

    With a default property defined, you can bind directly:

    You can intercept and transform values being set on bindable properties using the set option, or leverage Aurelia's built-in type coercion system. Coercion is opt-in: enable it at startup with config.coercingOptions.enableCoercion = true, and be sure each bindable exposes a runtime type (for example type: Number).

    Built-in Type Coercers:

    • Number: Converts strings to numbers ("123" → 123)

    • String: Converts values to strings (123 → "123")

    Advanced Coercion Example:

    You can specify custom callback names for change handlers:


    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:

    Use backslashes to escape colons in URLs or other values:

    For attributes that need to handle complex strings without parsing:


    You can also define bindables in the static definition or decorator:

    Or using the static $au approach:


    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 when data binding begins

    • bound(initiator, parent): Called after data binding is complete


    Custom attributes provide powerful batching capabilities for handling multiple property changes efficiently:


    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).


    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.

    If you want to search based on the attribute's constructor (for stronger typing), you can do so:

    Usage:

    • 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


    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.

    Usage:

    You can also use the static definition approach:


    Custom attributes support several advanced configuration options:

    By default, custom attributes support multiple bindings (attr="prop1: value1; prop2: value2"). You can disable this:

    You can specify dependencies that should be registered when the attribute is used:

    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

    When to Use Container Isolation:

    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

    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:

    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.


    Custom attributes can integrate with Aurelia's @watch decorator for advanced property observation:


    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.

    • DOM Manipulation: Many libraries require direct access to the DOM element for initialization.

    • Lifecycle Management: You can leverage Aurelia's lifecycle hooks (attached() and detaching()) 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.

    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.


    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.

    • 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

    • 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

    • Graceful degradation: Handle initialization failures gracefully

    • Validation: Validate bindable property values

    • Logging: Use Aurelia's logging system for debugging

    Write comprehensive unit tests covering lifecycle hooks, property changes, and edge cases:

    • 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

    Custom attributes support getter-based bindables for computed properties:

    Bindable properties properly inherit from parent classes:

  • Custom Change Callbacks

  • Creating template controllers that control the rendering of content.

    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

  • 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 when data binding is being removed

  • Performance: Efficient DOM traversal, but cache results if called frequently

  • Type Safety: Constructor-based searches provide better TypeScript support

  • Faster view creation

  • '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

  • bindables

    Record<string, PartialBindableDefinition> or array

    Declares bindable inputs. You can mix shorthand strings with full objects { name, mode, callback, attribute }.

    defaultProperty

    string

    The name of the property that receives the value when using shorthand syntax (e.g., my-attr="value"). Defaults to 'value'.

    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

  • Type everything: Use strong TypeScript typing for better IDE support

    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.

    Introduction

    Creating a Basic Custom Attribute

    Example: Red Square Attribute

    Custom Attribute Definition Approaches

    Convention-Based Approach

    Decorator-Based Approach (Recommended)

    Static Definition Approach (Framework Internal)

    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

    • Static definition: Advanced scenarios, framework extensions, or when you need to avoid decorators for tooling reasons

    Explicit Custom Attributes

    Explicit Attribute Naming

    Attribute Aliases

    Single Value Binding

    Bindable Properties and Change Detection

    Binding Modes

    Default Property

    Bindable Interceptors and Type Coercion

    Custom Change Callbacks

    Options Binding for Multiple Properties

    Basic Options Binding

    Advanced Options Binding Features

    Escaping Special Characters

    Disabling Multi-Binding Parsing

    Advanced Bindable Configuration

    Lifecycle Hooks

    Example: Using Lifecycle Hooks

    Aggregated Change Callbacks

    Accessing the Host Element

    Finding Related Custom Attributes

    Example: Searching by Attribute Name

    Example: Searching by Constructor (Type-Safe)

    Practical Use Case: Coordinated Form Validation

    Important Notes

    Template Controller Custom Attributes

    Creating a Template Controller

    Advanced Configuration Options

    No Multi-Bindings

    Dependencies

    Container Strategy (Template Controllers Only)

    Definition Metadata Reference

    Inspecting definitions at runtime

    Creating attributes without decorators

    Watch Integration

    Integrating Third-Party Libraries

    When to Use Custom Attributes for Integration

    Example: Integrating a Hypothetical Slider Library

    Best Practices

    Separation of Concerns

    Performance

    Memory Management

    Error Handling

    Testing

    Documentation and Maintainability

    Type Safety Best Practices

    Advanced Features Summary

    Computed Bindables with Getters

    Bindable Inheritance

    Error Handling and Lifecycle Management

    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
    Default 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, type CustomAttributeStaticAuDefinition } from '@aurelia/runtime-html';
    import { resolve } 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';
      }
    
      binding() {
        // 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, customAttribute, INode } from '@aurelia/runtime-html';
    import { resolve } from '@aurelia/kernel';
    
    @customAttribute({ name: 'color-square', defaultProperty: 'color' })
    export class ColorSquareCustomAttribute {
      @bindable() 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({ type: Number }) 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: 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[] = [];
    }
    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, type CustomAttributeStaticAuDefinition } from '@aurelia/runtime-html';
    import { resolve } 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 when binding begins - good for setup
        console.log('Starting to bind');
        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 when unbinding - good for final cleanup
        console.log('About to unbind');
        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);
          }
        }
      }
    
      unbinding() {
        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, type CustomAttributeStaticAuDefinition } from '@aurelia/runtime-html';
    import { resolve } 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
    }
    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);
        }
      }
    
      detaching() {
        // 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)

    Prerequisites

    All examples assume you have the validation plugin installed and configured:

    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();

    See Validation documentation 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

    // 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="you@example.com">
          </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>

    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

    • Accessible navigation buttons


    Forms where users can add or remove fields at runtime, like adding multiple email addresses or phone numbers.

    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


    Validation rules that change based on the values of other fields.

    Key Features:

    • Conditional field visibility with if.bind

    • Conditional validation with .when()

    • Fields depend on checkbox state


    Track whether forms have been modified and warn users before losing changes.

    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


    Form arrays allow users to add/remove entire groups of fields, like invoice line items or multiple addresses.

    Key Features:

    • Add/remove line items dynamically

    • Duplicate line items

    • Auto-calculate line totals and invoice totals

    • Validate entire array of items


    Handle multiple file uploads with image previews, progress tracking, and validation.

    Key Features:

    • Drag & drop support

    • Image preview generation

    • Progress tracking for each file

    • File type and size validation


    Dropdowns where options depend on previous selections, like country → state → city.

    Key Features:

    • Cascading selects (country → state → city)

    • Computed properties for filtered options

    • Auto-reset dependent fields when parent changes

    • Loading states while fetching options


    Create reusable form field components that encapsulate label, input, validation, and error display.

    Key Features:

    • Encapsulates label, input, validation, error display

    • Reusable across entire application

    • Consistent styling and behavior

    • Accessible (proper ARIA attributes)


    • - Basic form concepts

    • - Complete validation guide

    • - Handling form submission


    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

    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.

    Unique IDs for each entry (accessibility and key binding)

    Fields depend on select values

  • Automatic revalidation when dependencies change

  • router hook
  • 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

    - Working with form collections
  • - File upload patterns

  • Form state management - Track changes, prevent data loss, implement autosave with router guards
  • 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

  • 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)

  • // 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@example.com">
              </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>

    Dynamic Forms (Add/Remove Fields)

    Complete Example: Contact Form with Dynamic Emails

    Conditional Validation (Field Dependencies)

    Complete Example: Shipping Form

    Form State Management (Dirty, Pristine, Touched)

    Complete Example: Article Editor with Unsaved Changes Warning

    Form Arrays (Repeating Field Groups)

    Complete Example: Invoice Form with Line Items

    Complex File Uploads with Preview & Progress

    Complete Example: Image Gallery Upload

    Dependent Dropdowns (Cascading Selects)

    Complete Example: Location Selector

    Reusable Form Field Components

    Complete Example: Validated Text Field Component

    Usage Example

    Related Documentation

    Summary

    Form Basics
    Validation Plugin
    Form Submission
    Collections (Checkboxes, Radios, Select)
    File Uploads

    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

    This guide assumes familiarity with Aurelia's binding system and template syntax. For fundamentals, see first.

    Table of Contents

    1. Understanding Aurelia's Form Architecture

    2. Basic Input Binding


    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.

    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

    Aurelia automatically observes:

    • Text inputs: input, change, keyup events

    • Checkboxes/Radio: change events with array synchronization

    This means you typically don't need manual event handlers—Aurelia handles the complexity automatically while providing hooks for customization when needed.


    Aurelia provides intuitive two-way binding for all standard form elements. Let's start with the fundamentals and build toward advanced patterns.

    The foundation of most forms is text input binding:

    Textareas work identically to text inputs:

    Browser form controls always provide string values unless you bind to their typed DOM properties. Use Aurelia's value-as-* bindings when you need numbers or dates in your view-model.

    value-as-number binds to the input's valueAsNumber, so age is a number (or NaN when the field is empty/invalid). value-as-date binds to valueAsDate, giving you a Date | null. If you keep value.bind, the value remains a string—be sure to convert it before serializing to JSON for APIs.


    Binding to text inputs in Aurelia is straightforward:

    You can also bind other attributes like placeholder:

    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.


    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.

    The simplest checkbox pattern binds to boolean properties:

    For traditional multi-select scenarios, bind arrays to checkbox groups:

    For high-performance scenarios with frequent additions/removals, use Set collections:

    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:

    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

    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


    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.

    By default, Aurelia uses appropriate events for each input type, but you can customize this behavior:

    Control the frequency of updates to improve performance and user experience:

    Signals provide cache invalidation and coordinated updates across components:


    Building performant, dynamic forms requires understanding Aurelia's observation system and applying optimization strategies for complex scenarios.

    Create forms that adapt their structure based on configuration:

    Implement performance optimizations for large, complex forms:


    Aurelia provides comprehensive support for single-selection controls with sophisticated object binding and custom matching logic.

    Radio buttons with complex object handling and conditional logic:

    Sophisticated select components with search, grouping, and virtual scrolling:


    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.

    Implement comprehensive submission state management for better user experience:

    Handle complex multi-step forms with progress tracking and validation:


    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.

    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.

    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).

    If you only need a single file, omit multiple and simplify your logic:

    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.


    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.

    The & validate binding behavior automatically integrates form inputs with Aurelia's validation system.

    Use validation components for sophisticated error display:

    Create validation rules that adapt to changing form conditions:

    Provide immediate feedback with sophisticated validation timing:

    For comprehensive validation documentation, see the dedicated .


    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.

    Always validate and sanitize user input on both client and server sides:

    Implement client-side rate limiting and abuse prevention:

    Implement CSP-friendly form patterns:


    Building accessible forms ensures your application works for users with disabilities and meets WCAG guidelines. Aurelia provides excellent support for accessibility features.

    Use proper semantic HTML and ARIA attributes:

    Implement validation feedback that works with screen readers:

    Include essential accessibility styles:

    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.

    Mutation Observers: Track dynamic DOM changes
  • Value Converters & Binding Behaviors: Transform and control data flow

  • Select elements: change events with mutation observation
  • Collections: Array mutations, Set/Map changes

  • Object properties: Deep property observation

  • Custom Matchers: When object identity comparison isn't sufficient
    Error Handling: Always check response.ok 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.

  • - 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

  • 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-as-number.bind="age"
             min="18"
             max="120" />
    </div>
    <div class="form-group">
      <label for="birthdate">Birth Date:</label>
      <input id="birthdate"
             type="date"
             value-as-date.bind="birthDate" />
    </div>
    <div class="form-group">
      <label for="appointment">Appointment Time:</label>
      <input id="appointment"
             type="datetime-local"
             value-as-date.bind="appointmentTime" />
    </div>
    export class ProfileForm {
      age = 25;
      birthDate = new Date('1998-01-01');
      appointmentTime = new Date();
      
      // 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">
        <au-compose 
          component.bind="currentStep.component"
          model.bind="{ 
            data: currentStep.data,
            updateData: updateStepData,
            isValid: currentStep.isValid
          }">
        </au-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
      }
    }

    Understanding Aurelia's Form Architecture

    Data Flow Architecture

    Automatic Change Detection

    Basic Input Binding

    Simple Text Inputs

    Textarea Binding

    Number and Date Inputs

    Binding With Text and Textarea Inputs

    Text Input

    Textarea

    Advanced Collection Patterns

    Boolean Checkboxes

    Array-Based Multi-Select

    Set-Based Collections (Advanced)

    Resource-Keyed Collections (Expert Level)

    Per-Resource Permission Sets (Expert Level)

    Performance Considerations

    Event Handling and Binding Behaviors

    Advanced Event Timing with updateTrigger

    Rate Limiting with Debounce and Throttle

    Signal-Based Reactive Updates

    Dynamic Forms and Performance

    Dynamic Field Generation

    Performance Optimization Strategies

    Radio Button and Select Element Patterns

    Advanced Radio Button Groups

    Advanced Select Elements with Smart Filtering

    Form Submission Patterns

    Basic Form Submission with State Management

    Multi-Step Form Submission

    File Inputs and Upload Handling

    Capturing File Data

    View Model Handling

    Single File Inputs

    Validation and Security

    Validation Integration

    Basic Validation with & validate

    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

    Dynamic Validation Rules

    Real-time Validation Feedback

    Security and Best Practices

    Input Validation and Sanitization

    Rate Limiting and Abuse Prevention

    Content Security Policy (CSP) Considerations

    Accessibility Considerations

    Semantic Form Structure

    Accessible Form Validation

    CSS for Accessibility

    Testing Accessibility

    Advanced Collection Patterns
    Dynamic Forms and Performance
    Event Handling and Binding Behaviors
    Validation Integration
    File Upload Handling
    Form Submission Patterns
    Security and Best Practices
    Accessibility Considerations
    Validation Guide
    Form Basics
    Template Syntax & Features
    Collections
    Form Submission
    File Uploads
    Advanced Patterns