Dynamic composition

Render components and templates dynamically with Aurelia's au-compose element.

Dynamic composition lets you decide what to render at runtime instead of at compile time. Think of <au-compose> as a placeholder that can become any component or template based on your application's state, user preferences, or data.

This is perfect for:

  • Dashboard widgets that change based on user configuration

  • Conditional components where you need to render different components based on data

  • Plugin architectures where components are loaded dynamically

  • Form builders that render different field types

  • Content management where layout components vary by content type

Before you start: Make sure you are comfortable with components and template controllers; both concepts show up throughout the examples.

Quick Reference

Bindable
Accepts
Default
Purpose

component

Registered element name (string), custom element class/definition, plain object, or Promise resolving to either

undefined

Chooses which component to render; strings must match a globally or locally registered custom element or Aurelia will throw.

template

Literal HTML string or Promise<string>

undefined

Provides markup for template-only composition. Ignored when component resolves to a custom element.

model

Any value

undefined

Passed into the composed component's activate(model) hook. Updating model re-runs activate without recreating the component.

scope-behavior

'auto' | 'scoped'

'auto'

Controls scope inheritance for template-only compositions (component omitted or resolves to null). Has no effect for custom elements.

tag

string | null

null (containerless)

For template-only compositions, provide a tag name when you need a surrounding element; leave as null to keep the default comment boundaries.

composition

ICompositionController (from-view)

undefined

Exposes the controller for the currently composed view so you can call controller.viewModel, update(model), or deactivate().

composing

Promise<void> | void (from-view)

undefined

Surfaces the pending composition promise so parents can show loading states or cancel work when newer compositions queue up.

Tip: Bindings placed on <au-compose> that match bindables on the composed component are forwarded to that component. Attributes that do not match (for example class, style, or event handlers) are applied to the generated host element instead.

Component Composition

Composing with Custom Element Definitions

You can compose any custom element by passing its definition to the component property. Define those elements once at module scope so Aurelia reuses the same definition instead of recreating it for every view-model instance:

// dashboard.ts
import { CustomElement } from '@aurelia/runtime-html';

const ChartWidget = CustomElement.define({
  name: 'chart-widget',
  template: '<div class="chart">Chart: ${title}</div>'
});

const ListWidget = CustomElement.define({
  name: 'list-widget',
  template: '<ul><li repeat.for="item of items">${item}</li></ul>'
});

export class Dashboard {
  selectedWidget = ChartWidget;

  switchToChart() {
    this.selectedWidget = ChartWidget;
  }

  switchToList() {
    this.selectedWidget = ListWidget;
  }
}
<!-- dashboard.html -->
<div class="widget-controls">
  <button click.trigger="switchToChart()">Chart View</button>
  <button click.trigger="switchToList()">List View</button>
</div>

<au-compose component.bind="selectedWidget" title="Sales Data" items.bind="['Q1', 'Q2', 'Q3']"></au-compose>

Composing with Component Names

If you have components registered globally or imported, you can reference them by name:

<!-- These components must be registered or imported -->
<au-compose component="user-profile"></au-compose>
<au-compose component="admin-panel" if.bind="isAdmin"></au-compose>

Template-Only Composition

Sometimes you just need to render dynamic HTML without a full component. Template-only composition is perfect for this:

Basic Template Composition

<!-- Render static HTML -->
<au-compose template="<div class='alert'>Message sent successfully!</div>"></au-compose>

<!-- Render dynamic content from parent scope -->
<au-compose template="<h2>Welcome, ${user.name}!</h2>"></au-compose>

Dynamic Templates with Data

// notification.ts
export class NotificationCenter {
  notifications = [
    { type: 'success', message: 'Profile updated', icon: '✓' },
    { type: 'warning', message: 'Storage almost full', icon: '⚠' },
    { type: 'error', message: 'Connection failed', icon: '✗' }
  ];

  getTemplate(notification) {
    return `<div class="alert alert-${notification.type}">
      <span class="icon">${notification.icon}</span>
      <span class="message">${notification.message}</span>
    </div>`;
  }
}
<!-- notification.html -->
<div class="notifications">
  <au-compose
    repeat.for="notif of notifications"
    template.bind="getTemplate(notif)">
  </au-compose>
</div>

Template with Component Object

You can combine a template with a simple object that provides data and methods:

<!-- Each item gets its own mini-component -->
<au-compose
  repeat.for="item of products"
  template="<div class='product'>
    <h3>${name}</h3>
    <p>${description}</p>
    <button click.trigger='addToCart()'>Add to Cart</button>
  </div>"
  component.bind="{
    name: item.name,
    description: item.description,
    addToCart: () => buyProduct(item)
  }">
</au-compose>

Controlling the Host Element

Default Host Behavior

When you compose a custom element, it creates its own host element. But for template-only compositions, Aurelia doesn't create a wrapper element by default:

<!-- Template-only composition - no wrapper element -->
<au-compose template="<span>Hello</span><span>World</span>"></au-compose>

This renders as comment boundaries around your content:

<!--au-start--><span>Hello</span><span>World</span><!--au-end-->

Creating a Host Element with tag

When you need a wrapper element around your composed content, use the tag property:

<!-- Create a div wrapper -->
<au-compose
  tag="div"
  class="notification-container"
  template="<span class='icon'>✓</span><span class='message'>Success!</span>">
</au-compose>

This renders as:

<div class="notification-container">
  <span class="icon">✓</span>
  <span class="message">Success!</span>
</div>

Any attributes you put on <au-compose> (like class, style, or event handlers) get transferred to the host element.

Practical Host Element Example

// card-layout.ts
export class CardLayout {
  cards = [
    { title: 'Sales', content: 'Revenue: $50,000', theme: 'success' },
    { title: 'Issues', content: '3 open tickets', theme: 'warning' },
    { title: 'Users', content: '1,250 active', theme: 'info' }
  ];

  getCardTemplate(card) {
    return `<h3>${card.title}</h3><p>${card.content}</p>`;
  }
}
<!-- card-layout.html -->
<div class="dashboard">
  <au-compose
    repeat.for="card of cards"
    tag="div"
    class="card card-${card.theme}"
    template.bind="getCardTemplate(card)"
    click.trigger="selectCard(card)">
  </au-compose>
</div>

Passing Data with Models and the Activate Method

Understanding the Activate Lifecycle

Composed components can implement an activate method that runs when the component is created and whenever the model changes. This is perfect for initialization and data updates:

// user-widget.ts
export class UserWidget {
  user = null;
  posts = [];

  // Called when component is first created and when model changes
  async activate(userData) {
    this.user = userData;

    // Load user's posts when activated
    if (userData?.id) {
      this.posts = await this.loadUserPosts(userData.id);
    }
  }

  async loadUserPosts(userId) {
    // Simulate API call
    return fetch(`/api/users/${userId}/posts`).then(r => r.json());
  }
}

Using Models for Data Passing

<!-- user-dashboard.html -->
<div class="user-dashboard">
  <au-compose
    component.bind="userWidget"
    model.bind="selectedUser">
  </au-compose>
</div>
// user-dashboard.ts
import { UserWidget } from './user-widget';

export class UserDashboard {
  userWidget = UserWidget;
  users = [
    { id: 1, name: 'Alice', role: 'admin' },
    { id: 2, name: 'Bob', role: 'user' }
  ];

  selectedUser = this.users[0];

  selectUser(user) {
    // This will trigger activate() in the composed component
    this.selectedUser = user;
  }
}

Model Updates vs Component Changes

Important distinction: changing the model doesn't recreate the component, it just calls activate() again. This is efficient for data updates:

// dashboard.ts
export class Dashboard {
  UserProfile = CustomElement.define({
    name: 'user-profile',
    template: '<div>User: ${user?.name}</div>'
  });

  currentUser = { id: 1, name: 'Alice' };

  switchUser() {
    // This calls activate() on existing component - efficient!
    this.currentUser = { id: 2, name: 'Bob' };
  }

  switchComponent() {
    // This recreates the entire component - more expensive
    this.UserProfile = SomeOtherComponent;
  }
}

Advanced Features

Promise Support

Both template and component properties can accept promises, perfect for lazy loading:

// lazy-dashboard.ts
export class LazyDashboard {
  selectedWidgetType = 'chart';
  pending?: Promise<void> | void;

  get isLoading() {
    return this.pending != null;
  }

  // Lazy load components based on user selection
  get currentComponent() {
    switch (this.selectedWidgetType) {
      case 'chart':
        return import('./widgets/chart-widget').then(m => m.ChartWidget);
      case 'table':
        return import('./widgets/table-widget').then(m => m.TableWidget);
      case 'map':
        return import('./widgets/map-widget').then(m => m.MapWidget);
      default:
        return Promise.resolve(null);
    }
  }

  // Dynamically load templates from server
  get dynamicTemplate() {
    return fetch(`/api/templates/${this.selectedWidgetType}`)
      .then(response => response.text());
  }
}
<!-- lazy-dashboard.html -->
<div class="widget-selector">
  <select value.bind="selectedWidgetType">
    <option value="chart">Chart</option>
    <option value="table">Table</option>
    <option value="map">Map</option>
  </select>
</div>

<div class="loading" if.bind="isLoading">Loading next widget...</div>

<au-compose
  component.bind="currentComponent"
  model.bind="widgetData"
  composing.bind="pending">
</au-compose>

Scope Behavior Control

For template-only compositions, you can control whether they inherit the parent scope:

<!-- Auto scope (default) - inherits parent properties -->
<au-compose
  template="<div>Welcome, ${user.name}!</div>"
  scope-behavior="auto">
</au-compose>

<!-- Scoped - isolated from parent, only uses component object -->
<au-compose
  template="<div>Welcome, ${name}!</div>"
  component.bind="{ name: user.name }"
  scope-behavior="scoped">
</au-compose>

Accessing the Composition Controller

Use the composition property to access the composed component's controller:

// admin-panel.ts
export class AdminPanel {
  composition: ICompositionController;

  async refreshWidget() {
    // Access the composed component directly
    if (this.composition?.controller) {
      const widgetInstance = this.composition.controller.viewModel;
      if (widgetInstance.refresh) {
        await widgetInstance.refresh();
      }
    }
  }

  getComposedData() {
    return this.composition?.controller?.scope?.bindingContext;
  }
}
<!-- admin-panel.html -->
<au-compose
  component.bind="selectedWidget"
  composition.bind="composition">
</au-compose>

<button click.trigger="refreshWidget()">Refresh Widget</button>

Tracking Pending Compositions

Bind to composing whenever you need to surface intermediate loading states. Aurelia assigns the currently pending composition promise to your property, allowing you to show a spinner or cancel older requests when newer compositions queue up.

// widget-shell.ts
import type { ICompositionController } from '@aurelia/runtime-html';

export class WidgetShell {
  composition?: ICompositionController;
  pending?: Promise<void> | void;

  get isLoading() {
    return this.pending != null;
  }
}
<!-- widget-shell.html -->
<div class="widget-shell">
  <div class="loading" if.bind="isLoading">Loading latest widget...</div>

  <au-compose
    component.bind="selectedWidget"
    model.bind="widgetConfig"
    composition.bind="composition"
    composing.bind="pending">
  </au-compose>
</div>

Real-World Examples

Form Builder with Dynamic Fields

// form-builder.ts
import { CustomElement } from '@aurelia/runtime-html';

const TextInput = CustomElement.define({
  name: 'text-input',
  template: '<input type="text" value.bind="value" placeholder.bind="placeholder">'
});

const NumberInput = CustomElement.define({
  name: 'number-input',
  template: '<input type="number" value.bind="value" min.bind="min" max.bind="max">'
});

const SelectInput = CustomElement.define({
  name: 'select-input',
  template: '<select value.bind="value"><option repeat.for="opt of options" value.bind="opt.value">${opt.label}</option></select>'
});

const fieldTypes = {
  text: TextInput,
  number: NumberInput,
  select: SelectInput
};

export class FormBuilder {
  formConfig = [
    { type: 'text', name: 'firstName', placeholder: 'First Name', value: '' },
    { type: 'text', name: 'lastName', placeholder: 'Last Name', value: '' },
    { type: 'number', name: 'age', min: 0, max: 120, value: null },
    { type: 'select', name: 'country', options: [
      { value: 'us', label: 'United States' },
      { value: 'ca', label: 'Canada' }
    ], value: '' }
  ];

  getFieldComponent(field) {
    return fieldTypes[field.type];
  }
}
<!-- form-builder.html -->
<form class="dynamic-form">
  <div repeat.for="field of formConfig" class="field-group">
    <label>${field.name | titleCase}:</label>
    <au-compose
      component.bind="getFieldComponent(field)"
      value.bind="field.value"
      placeholder.bind="field.placeholder"
      min.bind="field.min"
      max.bind="field.max"
      options.bind="field.options">
    </au-compose>
  </div>
</form>

Plugin Architecture with Dynamic Loading

// plugin-host.ts
export class PluginHost {
  availablePlugins = [
    { id: 'weather', name: 'Weather Widget', url: '/plugins/weather.js' },
    { id: 'news', name: 'News Feed', url: '/plugins/news.js' },
    { id: 'calendar', name: 'Calendar', url: '/plugins/calendar.js' }
  ];

  activePlugins = [];

  async loadPlugin(pluginConfig) {
    try {
      // Dynamically import the plugin module
      const module = await import(pluginConfig.url);
      const PluginComponent = module.default;

      this.activePlugins.push({
        id: pluginConfig.id,
        name: pluginConfig.name,
        component: PluginComponent,
        config: await this.loadPluginConfig(pluginConfig.id)
      });
    } catch (error) {
      console.error('Failed to load plugin:', error);
    }
  }

  async loadPluginConfig(pluginId) {
    return fetch(`/api/plugins/${pluginId}/config`).then(r => r.json());
  }

  removePlugin(pluginId) {
    this.activePlugins = this.activePlugins.filter(p => p.id !== pluginId);
  }
}
<!-- plugin-host.html -->
<div class="plugin-dashboard">
  <div class="plugin-loader">
    <h3>Available Plugins</h3>
    <div repeat.for="plugin of availablePlugins">
      <button click.trigger="loadPlugin(plugin)">
        Load ${plugin.name}
      </button>
    </div>
  </div>

  <div class="active-plugins">
    <div repeat.for="plugin of activePlugins" class="plugin-container">
      <div class="plugin-header">
        <h4>${plugin.name}</h4>
        <button click.trigger="removePlugin(plugin.id)">Remove</button>
      </div>

      <au-compose
        component.bind="plugin.component"
        model.bind="plugin.config">
      </au-compose>
    </div>
  </div>
</div>

Content Management with Dynamic Layouts

// cms-renderer.ts
import { CustomElement } from '@aurelia/runtime-html';

const layoutComponents = {
  'hero-section': CustomElement.define({
    name: 'hero-section',
    template: `
      <section class="hero" style="background-image: url(\${backgroundImage})">
        <h1>\${title}</h1>
        <p>\${subtitle}</p>
        <button if.bind="ctaText">\${ctaText}</button>
      </section>
    `
  }),
  'text-block': CustomElement.define({
    name: 'text-block',
    template: '<div class="text-content" innerHTML.bind="content"></div>'
  }),
  'image-gallery': CustomElement.define({
    name: 'image-gallery',
    template: `
      <div class="gallery">
        <img repeat.for="img of images" src.bind="img.url" alt.bind="img.alt">
      </div>
    `
  })
};

export class CmsRenderer {
  pageContent = [
    {
      type: 'hero-section',
      data: {
        title: 'Welcome to Our Site',
        subtitle: 'Building amazing experiences',
        backgroundImage: '/images/hero-bg.jpg',
        ctaText: 'Get Started'
      }
    },
    {
      type: 'text-block',
      data: {
        content: '<h2>About Us</h2><p>We create innovative solutions...</p>'
      }
    },
    {
      type: 'image-gallery',
      data: {
        images: [
          { url: '/images/1.jpg', alt: 'Project 1' },
          { url: '/images/2.jpg', alt: 'Project 2' }
        ]
      }
    }
  ];

  getLayoutComponent(block) {
    return layoutComponents[block.type];
  }
}
<!-- cms-renderer.html -->
<div class="cms-page">
  <au-compose
    repeat.for="block of pageContent"
    component.bind="getLayoutComponent(block)"
    model.bind="block.data">
  </au-compose>
</div>

Migrating from Aurelia 1

If you're upgrading from Aurelia 1, here are the key changes you need to know:

Property Name Changes

Aurelia 1:

<compose view.bind="myTemplate" view-model.bind="myComponent"></compose>

Aurelia 2:

<au-compose template.bind="myTemplate" component.bind="myComponent"></au-compose>

Component Reference Changes

Aurelia 1:

<compose view-model.ref="composerRef"></compose>

Aurelia 2:

<!-- Get the composition controller -->
<au-compose composition.bind="compositionRef"></au-compose>

<!-- Use the controller to reach the composed view model -->
<button click.trigger="compositionRef?.controller?.viewModel?.refresh?.()">
  Refresh composed widget
</button>

Note: component.ref on <au-compose> references the AuCompose element itself, not the composed child. Use composition.controller.viewModel when you need the child instance.


### String Handling Changes

**Aurelia 1:**
```html
<!-- Both worked in v1 -->
<compose view="./my-template.html"></compose>
<compose view-model="./my-component"></compose>

Aurelia 2:

<!-- Template strings are now literal HTML -->
<au-compose template="<div>Hello World</div>"></au-compose>

<!-- Component strings must be registered component names -->
<au-compose component="my-registered-component"></au-compose>

<!-- For dynamic imports, use promises -->
<au-compose component.bind="import('./my-component')"></au-compose>

Scope Inheritance Changes

Aurelia 1:

<!-- Default was isolated scope -->
<compose view.bind="template" model.bind="data"></compose>

Aurelia 2:

<!-- Default is now inherited scope -->
<au-compose template.bind="template" scope-behavior="auto"></au-compose>

<!-- For isolated scope like v1 default -->
<au-compose template.bind="template" scope-behavior="scoped" component.bind="data"></au-compose>

Migration Examples

Before (Aurelia 1):

// aurelia-1-dashboard.ts
export class Dashboard {
  selectedView = './widgets/chart-widget.html';
  selectedViewModel = './widgets/chart-widget';
  widgetData = { title: 'Sales Chart' };
}
<!-- aurelia-1-dashboard.html -->
<compose
  view.bind="selectedView"
  view-model.bind="selectedViewModel"
  model.bind="widgetData">
</compose>

After (Aurelia 2):

// aurelia-2-dashboard.ts
import { CustomElement } from '@aurelia/runtime-html';

export class Dashboard {
  // Define component inline or import it
  ChartWidget = CustomElement.define({
    name: 'chart-widget',
    template: '<div class="chart">Chart: ${title}</div>'
  });

  selectedComponent = this.ChartWidget;
  widgetData = { title: 'Sales Chart' };
}
<!-- aurelia-2-dashboard.html -->
<au-compose
  component.bind="selectedComponent"
  model.bind="widgetData">
</au-compose>

Dynamic Module Loading Migration

Aurelia 1:

// System.js or RequireJS loading
export class Dashboard {
  async loadWidget(widgetName) {
    const viewModel = await System.import(`./widgets/${widgetName}`);
    return viewModel.Widget;
  }
}

Aurelia 2:

// ES6 dynamic imports
export class Dashboard {
  async loadWidget(widgetName) {
    const module = await import(`./widgets/${widgetName}`);
    return module.Widget;
  }

  // Or use promises directly in template
  get currentWidget() {
    return import(`./widgets/${this.selectedWidgetName}`).then(m => m.Widget);
  }
}
<!-- Can bind promises directly -->
<au-compose component.bind="currentWidget" model.bind="widgetData"></au-compose>

Value Converter Pattern for Remote Templates

If you need to load templates from URLs (like in Aurelia 1), create a value converter:

// template-loader.ts
export class TemplateLoaderValueConverter {
  private cache = new Map<string, Promise<string>>();

  toView(url: string): Promise<string> {
    if (!this.cache.has(url)) {
      this.cache.set(url,
        fetch(url).then(response => response.text())
      );
    }
    return this.cache.get(url)!;
  }
}
<!-- Use in template -->
<au-compose template.bind="templateUrl | templateLoader"></au-compose>

Common Migration Gotchas

  1. Binding Transfer: In Aurelia 2, ALL bindings on <au-compose> are passed to the composed component

  2. Activation: The activate method works the same but is now available on any component type

  3. Lifecycle: Custom elements get full lifecycle, plain objects get activate/deactivate only

  4. Performance: Aurelia 2's composition is more efficient with better change detection

Best Practices

  1. Use promises for lazy loading - Only load components when needed to improve performance

  2. Leverage the activate method - Perfect for data initialization and updates

  3. Consider scope behavior - Use scoped when you want isolation, auto for inheritance

  4. Cache component definitions - Call CustomElement.define once per module and reuse the reference instead of redefining inside constructors.

  5. Handle loading states - Bind to composing to show a spinner or disable UI while Aurelia hydrates the next component.

  6. Use models efficiently - Changing models is cheaper than switching components because activate(model) re-runs without rehydration.

Dynamic composition gives you the flexibility to build truly dynamic UIs that adapt to your users' needs, load efficiently, and scale with your application's complexity.

Next steps

  • Explore portalling elements to move DOM across layout boundaries.

  • Combine composition with enhance when progressively upgrading existing markup.

  • Review watching data to react to model changes that drive composition.

Last updated

Was this helpful?