Custom Attribute Directives
Attribute directives let you attach reusable behavior to existing DOM elements without owning their template. Where structural directives add or remove elements, attribute directives reshape what an element already shows: its styling, its DOM attributes, or its response to events. They are the idiomatic Angular answer to the question “how do I make any element do X?” and they keep that logic out of your components, where it would otherwise pile up as one-off event handlers.
Anatomy of an attribute directive
A custom attribute directive is a class decorated with @Directive and registered through a CSS attribute selector. When Angular finds an element matching that selector, it instantiates the directive and hands it a reference to the host element. In modern Angular (17+) directives are standalone by default, so you simply import them where you need them.
The two services you reach for constantly are ElementRef, which exposes the underlying host element, and Renderer2, which mutates that element in a platform-safe way. You should prefer Renderer2 over touching nativeElement directly: it keeps your directive working under server-side rendering and Web Workers, where the DOM may not exist.
import { Directive, ElementRef, Renderer2, inject } from '@angular/core';
@Directive({
selector: '[appHighlight]',
})
export class HighlightDirective {
private el = inject(ElementRef<HTMLElement>);
private renderer = inject(Renderer2);
constructor() {
this.renderer.setStyle(this.el.nativeElement, 'backgroundColor', 'yellow');
}
}
Apply it by adding the selector as an attribute on any element:
<p appHighlight>This paragraph is highlighted on render.</p>
The selector is wrapped in square brackets (
[appHighlight]) so it matches an attribute, not an element or class. Always prefix selectors with a short namespace such asappto avoid clashing with native HTML attributes.
Reacting to user events
A directive that only runs once in its constructor is rarely useful. Most behavior responds to interaction. The @HostListener decorator binds a method to a DOM event on the host element, and @HostBinding binds a class property to a host property, attribute, or class. Together they let the directive both listen and react without a template.
import { Directive, ElementRef, Renderer2, HostListener, inject } from '@angular/core';
@Directive({
selector: '[appHighlight]',
})
export class HighlightDirective {
private el = inject(ElementRef<HTMLElement>);
private renderer = inject(Renderer2);
@HostListener('mouseenter')
onEnter(): void {
this.setColor('yellow');
}
@HostListener('mouseleave')
onLeave(): void {
this.setColor(null);
}
private setColor(color: string | null): void {
if (color) {
this.renderer.setStyle(this.el.nativeElement, 'backgroundColor', color);
} else {
this.renderer.removeStyle(this.el.nativeElement, 'backgroundColor');
}
}
}
Passing configuration with inputs
Hard-coding the colour limits reuse. Expose an input() signal so callers can configure the directive. When the input name matches the selector, Angular supports a compact one-attribute binding syntax.
import { Directive, ElementRef, Renderer2, HostListener, input, inject } from '@angular/core';
@Directive({
selector: '[appHighlight]',
})
export class HighlightDirective {
private el = inject(ElementRef<HTMLElement>);
private renderer = inject(Renderer2);
// Aliased to the selector for the compact binding form.
appHighlight = input<string>('yellow');
@HostListener('mouseenter')
onEnter(): void {
this.renderer.setStyle(this.el.nativeElement, 'backgroundColor', this.appHighlight());
}
@HostListener('mouseleave')
onLeave(): void {
this.renderer.removeStyle(this.el.nativeElement, 'backgroundColor');
}
}
<!-- Default yellow -->
<p appHighlight>Hover me</p>
<!-- Compact binding: the value is passed straight to appHighlight() -->
<p [appHighlight]="'cyan'">Hover me too</p>
Import the directive in the component that uses it:
import { Component } from '@angular/core';
import { HighlightDirective } from './highlight.directive';
@Component({
selector: 'app-demo',
standalone: true,
imports: [HighlightDirective],
template: `<p [appHighlight]="'cyan'">Hover for cyan</p>`,
})
export class DemoComponent {}
ElementRef vs Renderer2 vs HostBinding
These three approaches overlap, so it helps to know when to reach for each.
| Approach | Best for | SSR-safe | Notes |
|---|---|---|---|
Renderer2 | Imperative DOM mutation (styles, classes, attributes) | Yes | The recommended default for dynamic changes |
@HostBinding | Declarative binding tied to a property/signal | Yes | Cleanest when state maps directly to a host property |
el.nativeElement | Reading geometry, focus, third-party libs | No | Avoid for writes; breaks under SSR |
A @HostBinding version of a class toggle is often the tidiest:
import { Directive, HostBinding, HostListener } from '@angular/core';
@Directive({ selector: '[appActiveOnHover]' })
export class ActiveOnHoverDirective {
@HostBinding('class.is-active') isActive = false;
@HostListener('mouseenter') enter(): void { this.isActive = true; }
@HostListener('mouseleave') leave(): void { this.isActive = false; }
}
Output: hovering toggles the CSS class on the element:
<button class="is-active">Save</button> <!-- while hovered -->
<button class="">Save</button> <!-- after mouseleave -->
Best Practices
- Mutate the DOM through
Renderer2rather thannativeElementso directives stay safe under SSR and hydration. - Prefix selectors with an app-specific namespace (for example
app) to avoid colliding with native attributes. - Prefer
@HostBindingand@HostListenerover manualaddEventListener; Angular cleans them up automatically. - Expose configuration through
input()signals and provide sensible defaults so the directive works with zero attributes. - Keep directives focused on a single behavior; compose multiple small directives rather than one that does everything.
- Type your
ElementRef<HTMLElement>so the element API is statically checked.