Skip to content

Aurelia Custom Attributes — Enhancing HTML Elements

DodaTech Updated 2026-06-28 6 min read

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

Aurelia custom attributes add behavior to existing HTML elements without creating new tags. They enhance elements with tooltips, validation, animations, and Accessibility features. Custom attributes use the same lifecycle and Dependency Injection as components.

What You'll Learn

You will learn how to create custom attributes, bind values, handle attribute lifecycle, and add reusable behaviors to elements.

Why It Matters

Custom attributes keep HTML semantic while adding rich behavior. A <button tooltip="Save"> is cleaner than wrapping every button in a custom element. Attributes compose naturally with existing HTML.

Real-World Use

A form library uses custom attributes for validation, tooltips, auto-focus, click-outside handling, and keyboard shortcuts. Each attribute adds a specific behavior without changing the element structure.

flowchart LR
    A[Custom Attribute] --> B[Bind to element]
    B --> C[Lifecycle hooks]
    B --> D[DOM manipulation]
    B --> E[Event handling]
    D --> F[CSS classes]
    D --> G[DOM properties]
    E --> H[Keyboard, mouse, scroll]

Basic Custom Attribute

// src/resources/attributes/tooltip.ts
import { inject, DOM } from 'aurelia-framework';

@inject(DOM.Element)
export class TooltipCustomAttribute {
  constructor(element) {
    this.element = element;
    this.tooltipElement = null;
  }

  // Called when value changes
  valueChanged(newValue, oldValue) {
    if (this.tooltipElement) {
      this.tooltipElement.textContent = newValue;
    }
  }

  // Attached to DOM
  attached() {
    this.element.classList.add('has-tooltip');
    this.tooltipElement = document.createElement('span');
    this.tooltipElement.className = 'tooltip-text';
    this.tooltipElement.textContent = this.value;
    this.element.appendChild(this.tooltipElement);
  }

  // Removed from DOM
  detached() {
    this.element.classList.remove('has-tooltip');
    if (this.tooltipElement) {
      this.element.removeChild(this.tooltipElement);
    }
  }
}
<!-- Usage -->
<button tooltip="Save your changes">Save</button>
<span tooltip.bind="helpText">?</span>

Click Outside Attribute

// src/resources/attributes/click-outside.ts
import { inject, DOM } from 'aurelia-framework';

@inject(DOM.Element)
export class ClickOutsideCustomAttribute {
  constructor(element) {
    this.element = element;
    this.handler = this.onClick.bind(this);
  }

  // The callback function passed to the attribute
  value;

  attached() {
    setTimeout(() => {
      document.addEventListener('click', this.handler);
    }, 0);
  }

  detached() {
    document.removeEventListener('click', this.handler);
  }

  onClick(event) {
    if (!this.element.contains(event.target) && this.value) {
      this.value();
    }
  }
}
<div click-outside.call="closeDropdown()" class="dropdown">
  Dropdown content
</div>

Auto-Focus Attribute

// src/resources/attributes/auto-focus.ts
import { inject, DOM } from 'aurelia-framework';

@inject(DOM.Element)
export class AutoFocusCustomAttribute {
  constructor(element) {
    this.element = element;
  }

  attached() {
    setTimeout(() => {
      this.element.focus();
      this.element.select?.();
    }, 100);
  }
}
<input type="text" auto-focus placeholder="Search..." />

Debounce Attribute

// src/resources/attributes/debounce.ts
import { inject, DOM } from 'aurelia-framework';

@inject(DOM.Element)
export class DebounceCustomAttribute {
  constructor(element) {
    this.element = element;
    this.timer = null;
    this.originalHandler = null;
  }

  value = 300; // Default debounce time

  attached() {
    let eventName = 'input';
    this.originalHandler = (event) => {
      if (this.timer) clearTimeout(this.timer);
      this.timer = setTimeout(() => {
        this.element.dispatchEvent(
          new CustomEvent('debounced-input', { detail: event.target.value })
        );
      }, this.value);
    };
    this.element.addEventListener(eventName, this.originalHandler);
  }

  detached() {
    if (this.originalHandler) {
      this.element.removeEventListener('input', this.originalHandler);
    }
  }
}
<input type="text" debounce="500" debounced-input.delegate="search($event.detail)" />

Intersection Observer Attribute

// src/resources/attributes/intersection.ts
import { inject, DOM } from 'aurelia-framework';

@inject(DOM.Element)
export class IntersectionCustomAttribute {
  constructor(element) {
    this.element = element;
    this.observer = null;
  }

  value; // Callback when element becomes visible

  attached() {
    this.observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting && this.value) {
          this.value(entry);
          // Optionally unobserve after first visibility
          // this.observer.unobserve(this.element);
        }
      });
    });

    this.observer.observe(this.element);
  }

  detached() {
    if (this.observer) {
      this.observer.disconnect();
      this.observer = null;
    }
  }
}
<div intersection.call="onVisible($event)">
  <img data-src="large.jpg" />
</div>

Dynamic CSS Class Attribute

// src/resources/attributes/css-class.ts
import { inject, DOM } from 'aurelia-framework';

@inject(DOM.Element)
export class CssClassCustomAttribute {
  constructor(element) {
    this.element = element;
  }

  valueChanged(newValue) {
    if (this.previousValue) {
      this.element.classList.remove(...this.previousValue.split(' '));
    }
    if (newValue) {
      this.element.classList.add(...newValue.split(' '));
    }
    this.previousValue = newValue;
  }

  detached() {
    if (this.previousValue) {
      this.element.classList.remove(...this.previousValue.split(' '));
    }
  }
}
<div css-class.bind="isActive ? 'active highlighted' : 'inactive'">
  Conditional classes
</div>

Validation Attribute

// src/resources/attributes/validate.ts
import { inject, DOM } from 'aurelia-framework';

@inject(DOM.Element)
export class ValidateCustomAttribute {
  constructor(element) {
    this.element = element;
  }

  value; // Validation rules object

  valueChanged() {
    this.validate();
  }

  attached() {
    this.element.addEventListener('blur', () => this.validate());
    this.element.addEventListener('input', () => this.validate());
  }

  validate() {
    let rules = this.value || {};
    let value = this.element.value;

    if (rules.required && !value) {
      this.setError(`${rules.label || 'This field'} is required`);
      return false;
    }

    if (rules.minLength && value.length < rules.minLength) {
      this.setError(`Minimum ${rules.minLength} characters`);
      return false;
    }

    if (rules.pattern && !rules.pattern.test(value)) {
      this.setError(rules.message || 'Invalid format');
      return false;
    }

    this.clearError();
    return true;
  }

  setError(message) {
    this.element.classList.add('invalid');
    let errorEl = this.element.nextElementSibling;
    if (errorEl?.classList.contains('validation-error')) {
      errorEl.textContent = message;
    }
  }

  clearError() {
    this.element.classList.remove('invalid');
    let errorEl = this.element.nextElementSibling;
    if (errorEl?.classList.contains('validation-error')) {
      errorEl.textContent = '';
    }
  }

  detached() {
    this.element.removeEventListener('blur', () => this.validate());
    this.element.removeEventListener('input', () => this.validate());
  }
}

Attribute Options Pattern

// src/resources/attributes/toggle-class.ts
import { inject, DOM } from 'aurelia-framework';

@inject(DOM.Element)
export class ToggleClassCustomAttribute {
  constructor(element) {
    this.element = element;
  }

  // Primary value: class name to toggle
  value;

  // Options
  target;      // Selector for target element
  event = 'click';  // Trigger event

  attached() {
    let target = this.target
      ? this.element.querySelector(this.target)
      : this.element;

    this.listener = () => {
      target.classList.toggle(this.value);
    };

    this.element.addEventListener(this.event, this.listener);
  }

  detached() {
    if (this.listener) {
      this.element.removeEventListener(this.event, this.listener);
    }
  }
}
<button toggle-class="active" target=".menu">
  Toggle Menu
</button>
<button toggle-class="dark-mode" event="dblclick">
  Double-click for dark mode
</button>

Common Mistakes

  1. Not injecting DOM.Element. The element the attribute is placed on must be injected via @inject(DOM.Element).
  2. Forgetting detached() cleanup. Event listeners added in attached() must be removed in detached().
  3. Using .call instead of .bind for function attributes. Use call when passing a function reference. bind is for property binding.
  4. Mutating the element's innerHTML carelessly. Attribute values often come from user input. Sanitize before setting innerHTML.
  5. Not handling valueChanged for dynamic updates. If the attribute value changes, the attribute must respond. Implement valueChanged or use bind.

Practice Questions

  1. What is the difference between a custom attribute and a custom element?
  2. How do you access the host element inside a custom attribute?
  3. What lifecycle hooks are available for custom attributes?
  4. How do you pass options to a custom attribute?
  5. Challenge: Create a custom attribute infinite-scroll that detects when the user scrolls near the bottom of an element and calls a callback. Support options for threshold distance and debounce time.

FAQ

Can custom attributes receive multiple values?

Yes. Use the @bindable decorator for multiple bindable properties.

Can custom attributes work with SVG elements?

Yes. Aurelia custom attributes work with any DOM element.

{{< faq "How do I pass a callback to a custom attribute?" "Use `.call` binding: `click-outside.call=\"close()\"`" >}}
Do custom attributes have access to the ViewModel?

Not directly. Communicate through the element or EventAggregator.

Can a custom attribute modify the element's template?

Attributes should not replace templates. Use custom elements for template modification.

Mini Project

Create a library of 6 custom attributes: (1) tooltip — shows a tooltip on hover, (2) click-outside — detects clicks outside the element, (3) long-press — detects press-and-hold, (4) debounce — debounces input events, (5) copy-to-clipboard — copies value on click, (6) lazy-img — loads images when visible. Demonstrate each in a demo page.

What's Next

Now that you understand custom attributes, learn Aurelia Templating for advanced template features. Then explore Aurelia Value Converters for data transformation.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro