Skip to content

Aurelia Custom Elements — Reusable UI Components

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about Aurelia Custom Elements. We cover key concepts, practical examples, and best practices to help you master this topic.

Aurelia custom elements are reusable HTML tags that encapsulate template, logic, and styles. They accept bindable properties, emit custom events, and project content. Custom elements form the basis of component libraries and design systems.

What You'll Learn

You will learn how to create custom elements with bindable properties, handle options, project content, and build reusable UI components.

Why It Matters

Custom elements abstract repeated UI patterns into single tags. A <data-table> element with 20 lines replaces 100+ lines of repetitive table markup. Design systems are built entirely from custom elements.

Real-World Use

A design system includes custom elements like <ui-button>, <ui-card>, <ui-modal>, <ui-table>, and <ui-form-field>. Each element has documented bindable properties and is used across dozens of application pages.

flowchart LR
    A[Custom Element] --> B[Bindable Properties]
    A --> C[Template]
    A --> D[Events]
    A --> E[Content Slots]
    B --> F[Type]
    B --> G[Default]
    B --> H[Two-way]

Basic Custom Element

// src/resources/elements/status-badge.ts
import { bindable } from 'aurelia-framework';

export class StatusBadge {
  @bindable status = 'info';
  @bindable text;

  statusChanged(newValue, oldValue) {
    console.log(`Status changed from ${oldValue} to ${newValue}`);
  }

  get badgeClass() {
    return `badge-${this.status}`;
  }
}
<!-- src/resources/elements/status-badge.html -->
<template>
  <span class="badge ${badgeClass}">
    ${text || status}
  </span>
</template>

Usage:

<status-badge status.bind="alert.severity" text.bind="alert.message">
</status-badge>
<status-badge status="success" text="Completed"></status-badge>

Bindable Property Options

import { bindable } from 'aurelia-framework';

export class InputField {
  // Default binding mode (one-way)
  @bindable label;

  // Two-way binding for form inputs
  @bindable({ defaultBindingMode: bindingMode.twoWay }) value;

  // One-time binding (static, never updates)
  @bindable({ defaultBindingMode: bindingMode.oneTime }) placeholder;

  // With default value
  @bindable type = 'text';
  @bindable required = false;
  @bindable disabled = false;

  // Attribute with change handler
  @bindable minLength;
  minLengthChanged(newValue, oldValue) {
    this.validate();
  }

  // Optional attribute
  @bindable helpText;
}

Button Component Example

// src/resources/elements/ui-button.ts
import { bindable } from 'aurelia-framework';

export class UiButton {
  @bindable variant = 'primary';
  @bindable size = 'md';
  @bindable disabled = false;
  @bindable loading = false;
  @bindable type = 'button';
  @bindable icon;

  get buttonClass() {
    let classes = [`btn-${this.variant}`, `btn-${this.size}`];
    if (this.loading) classes.push('btn-loading');
    return classes.join(' ');
  }
}
<!-- src/resources/elements/ui-button.html -->
<template>
  <button
    class="btn ${buttonClass}"
    type.bind="type"
    disabled.bind="disabled || loading"
    click.delegate="$event"
  >
    <i if.bind="icon" class="icon-${icon}"></i>
    <span class="btn-text"><slot></slot></span>
    <span if.bind="loading" class="spinner"></span>
  </button>
</template>

Content Projection with Slots

Aurelia supports content projection using <slot> (Aurelia 2) or <content> (Aurelia 1).

<!-- ui-card.html — Using slot for content projection -->
<template>
  <div class="card">
    <div class="card-header">
      <slot name="header">
        <h3>${title}</h3>
      </slot>
    </div>

    <div class="card-body">
      <slot></slot>
    </div>

    <div class="card-footer" if.bind="showFooter">
      <slot name="footer"></slot>
    </div>
  </div>
</template>
// ui-card.ts
import { bindable } from 'aurelia-framework';

export class UiCard {
  @bindable title;
  @bindable showFooter = true;
}

Usage with slots:

<ui-card title="User Profile">
  <div slot="header">
    <h2>Custom Header</h2>
    <p class="subtitle">Profile settings</p>
  </div>

  <!-- Main content (default slot) -->
  <form>
    <label>Name:</label>
    <input value.bind="user.name" />
  </form>

  <div slot="footer">
    <button click.delegate="save()">Save</button>
    <button click.delegate="cancel()">Cancel</button>
  </div>
</ui-card>

Event Emission from Custom Elements

Custom elements emit events using EventAggregator or DOM custom events.

import { bindable, inject, DOM } from 'aurelia-framework';
import { EventAggregator } from 'aurelia-event-aggregator';

@inject(EventAggregator)
export class DropdownSelect {
  @bindable options;
  @bindable selected;

  constructor(eventAggregator) {
    this.ea = eventAggregator;
  }

  selectOption(option) {
    this.selected = option;
    // Option 1: Event Aggregator
    this.ea.publish('dropdown:selected', option);

    // Option 2: DOM Custom Event
    this.element.dispatchEvent(DOM.createCustomEvent('select', {
      detail: option,
      bubbles: true
    }));
  }
}
// src/resources/elements/modal-dialog.ts
import { bindable } from 'aurelia-framework';

export class ModalDialog {
  @bindable show = false;
  @bindable title;
  @bindable size = 'md';
  @bindable closeOnBackdrop = true;

  showChanged() {
    if (this.show) {
      document.body.classList.add('modal-open');
    } else {
      document.body.classList.remove('modal-open');
    }
  }

  close() {
    this.show = false;
  }

  onBackdropClick() {
    if (this.closeOnBackdrop) {
      this.close();
    }
  }

  detached() {
    document.body.classList.remove('modal-open');
  }
}
<!-- src/resources/elements/modal-dialog.html -->
<template>
  <div if.bind="show" class="modal-backdrop" click.delegate="closeOnBackdrop ? close() : ''">
    <div class="modal modal-${size}" click.delegate="$event.stopPropagation()">
      <div class="modal-header">
        <h2>${title}</h2>
        <button class="modal-close" click.delegate="close()">&times;</button>
      </div>
      <div class="modal-body">
        <slot></slot>
      </div>
      <div class="modal-footer">
        <slot name="footer"></slot>
      </div>
    </div>
  </div>
</template>

Data Table Component

// src/resources/elements/data-table.ts
import { bindable } from 'aurelia-framework';

export class DataTable {
  @bindable columns;
  @bindable rows;
  @bindable pageSize = 10;
  @bindable sortable = true;

  currentPage = 1;
  sortColumn = null;
  sortDirection = 'asc';

  get totalPages() {
    return Math.ceil(this.rows.length / this.pageSize);
  }

  get pagedRows() {
    let start = (this.currentPage - 1) * this.pageSize;
    return this.rows.slice(start, start + this.pageSize);
  }

  sort(column) {
    if (!this.sortable) return;
    if (this.sortColumn === column) {
      this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
    } else {
      this.sortColumn = column;
      this.sortDirection = 'asc';
    }
    this.updateRows();
  }

  updateRows() {
    if (this.sortColumn) {
      this.rows.sort((a, b) => {
        let valA = a[this.sortColumn];
        let valB = b[this.sortColumn];
        if (valA < valB) return this.sortDirection === 'asc' ? -1 : 1;
        if (valA > valB) return this.sortDirection === 'asc' ? 1 : -1;
        return 0;
      });
    }
  }

  changePage(page) {
    this.currentPage = page;
  }
}

Common Mistakes

  1. Not using @bindable decorator on properties that need binding. Without @bindable, the property is not accessible from the template as an attribute.
  2. Forgetting to register custom elements globally. Without global registration, each template needs a require statement.
  3. Modifying bound parent data directly. Custom elements should not mutate bound objects. Use events to notify the parent of changes.
  4. Not implementing detached cleanup. Set up DOM listeners and subscriptions in attached and clean them up in detached.
  5. Over-complicating element APIs. A custom element should have a focused purpose with minimal bindable properties.

Practice Questions

  1. What does @bindable do?
  2. How do you pass content into a custom element?
  3. How do custom elements communicate changes to parent components?
  4. What is the purpose of the changed callback pattern?
  5. Challenge: Create a <star-rating> custom element with bindable properties for value (number of stars), max (maximum stars), readonly, and size. Emit a change event when the user clicks a star. Support half-star display.

FAQ

Can custom elements use dependency injection?

Yes. Use @inject to inject services into custom element ViewModels.

How do I make a custom attribute vs a custom element?

Custom elements create new HTML tags. Custom attributes add behavior to existing elements.

Can I nest custom elements?

Yes. Custom elements can contain other custom elements.

How do I test custom elements?

Use Aurelia's testing library to instantiate elements with bindable properties and verify their behavior.

What is the naming convention for custom elements?

Use kebab-case in templates (my-element) and PascalCase in code (MyElement).

Mini Project

Build a component library with these custom elements: (1) <ui-alert> — variants (success, error, warning, info), dismissible, auto-close, icon support. (2) <ui-badge> — color variants, size variants, removable. (3) <ui-avatar> — image, initials fallback, size, status indicator. (4) <ui-progress> — value, max, label, color, animated. Register all globally and create a demo page.

What's Next

Now that you understand custom elements, learn Aurelia Custom Attributes for element behavior. Then explore Aurelia Templating for advanced template features.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro