Attribute Directives in Angular
Attribute directives change the appearance or behaviour of an element, component, or other directive — without touching the DOM structure. They look like HTML attributes and are the most common type of custom directive you will write.
Unlike structural directives (*ngIf, *ngFor), attribute directives do not add or remove elements. They decorate the element they are applied to.
Built-in Attribute Directives
Angular ships several useful attribute directives in @angular/common.
Directive | Purpose |
|---|---|
NgClass | Add/remove CSS classes dynamically |
NgStyle | Set inline styles dynamically |
NgModel | Two-way data binding for form inputs (FormsModule) |
NgTemplateOutlet | Render a template reference in place |
NgComponentOutlet | Dynamically render a component |
NgClass
import { Component } from '@angular/core';
import { NgClass } from '@angular/common';
@Component({
selector: 'app-ngclass-demo',
standalone: true,
imports: [NgClass],
template: `
<!-- Object syntax: key=class, value=boolean -->
<div [ngClass]="{
'alert': true,
'alert-success': type === 'success',
'alert-error': type === 'error',
'alert-warning': type === 'warning'
}">{{ message }}</div>
<!-- Array syntax: add multiple classes -->
<button [ngClass]="['btn', sizeClass, variantClass]">
Click me
</button>
<!-- String syntax: just set classes directly -->
<p [ngClass]="computedClass">Paragraph</p>
<!-- Single class toggle shorthand (no NgClass needed) -->
<div [class.active]="isActive">Shorthand toggle</div>
`,
})
export class NgClassDemoComponent {
type = 'success';
message = 'Operation completed successfully!';
sizeClass = 'btn-lg';
variantClass = 'btn-primary';
isActive = true;
get computedClass() {
return this.isActive ? 'text-success font-bold' : 'text-muted';
}
}
NgStyle
import { Component } from '@angular/core';
import { NgStyle } from '@angular/common';
@Component({
selector: 'app-ngstyle-demo',
standalone: true,
imports: [NgStyle],
template: `
<!-- Object map of style properties -->
<div [ngStyle]="{
'background-color': bgColor,
'color': textColor,
'font-size.px': fontSize,
'border-radius.px': 8,
'padding': '12px 16px',
'opacity': isVisible ? 1 : 0.3
}">Styled box</div>
<!-- Single style shorthand (no NgStyle needed) -->
<p [style.color]="textColor">Shorthand color</p>
<p [style.font-size.px]="fontSize">Shorthand size</p>
`,
})
export class NgStyleDemoComponent {
bgColor = '#e8f4fd';
textColor = '#0056b3';
fontSize = 16;
isVisible = true;
}
Creating a Custom Attribute Directive
Writing a custom attribute directive involves three steps:
- Create a class decorated with
@Directive({ selector: '[appMyDirective]' }). - Inject
ElementRefto access the host DOM element. - Use
@HostListenerto respond to events and@HostBindingto set properties.
// tooltip.directive.ts
import {
Directive,
ElementRef,
Input,
OnInit,
OnDestroy,
Renderer2,
HostListener,
} from '@angular/core';
@Directive({
selector: '[appTooltip]',
standalone: true,
})
export class TooltipDirective implements OnInit, OnDestroy {
@Input('appTooltip') text = '';
@Input() tooltipPosition: 'top' | 'bottom' = 'top';
private tooltipEl: HTMLElement | null = null;
constructor(
private el: ElementRef,
private renderer: Renderer2,
) {}
ngOnInit() {
// Set accessible title attribute as a fallback
this.renderer.setAttribute(this.el.nativeElement, 'title', this.text);
}
@HostListener('mouseenter')
show() {
this.tooltipEl = this.renderer.createElement('div');
this.renderer.addClass(this.tooltipEl, 'tooltip');
this.renderer.addClass(this.tooltipEl, `tooltip-${this.tooltipPosition}`);
const textNode = this.renderer.createText(this.text);
this.renderer.appendChild(this.tooltipEl, textNode);
this.renderer.appendChild(document.body, this.tooltipEl);
const rect = this.el.nativeElement.getBoundingClientRect();
this.renderer.setStyle(this.tooltipEl, 'top', `${rect.top - 36}px`);
this.renderer.setStyle(this.tooltipEl, 'left', `${rect.left}px`);
this.renderer.setStyle(this.tooltipEl, 'position', 'fixed');
}
@HostListener('mouseleave')
hide() {
if (this.tooltipEl) {
this.renderer.removeChild(document.body, this.tooltipEl);
this.tooltipEl = null;
}
}
ngOnDestroy() {
this.hide();
}
}
<button [appTooltip]="'Save your changes'" tooltipPosition="top"> Save </button> <span appTooltip="This field is required" class="info-icon">?</span>
HostBinding and HostListener in Depth
@HostBinding binds a directive property to a property of the host element. @HostListener subscribes to events on the host element. Together they are the idiomatic way to react to and modify the host element.
// auto-focus.directive.ts
import {
Directive,
ElementRef,
HostBinding,
HostListener,
OnInit,
Input,
} from '@angular/core';
@Directive({
selector: 'input[appAutoFocus]',
standalone: true,
})
export class AutoFocusDirective implements OnInit {
@Input() selectOnFocus = false;
// Bind to the host element's tabIndex property
@HostBinding('tabIndex') tabIndex = 0;
// Bind to a CSS class on the host element
@HostBinding('class.focused') isFocused = false;
// Bind to an aria attribute
@HostBinding('attr.aria-label')
@Input() label = '';
constructor(private el: ElementRef<HTMLInputElement>) {}
ngOnInit() {
setTimeout(() => this.el.nativeElement.focus(), 0);
}
@HostListener('focus')
onFocus() {
this.isFocused = true;
if (this.selectOnFocus) {
this.el.nativeElement.select();
}
}
@HostListener('blur')
onBlur() {
this.isFocused = false;
}
}
<!-- Focuses on page load, selects text when re-focused --> <input appAutoFocus [selectOnFocus]="true" label="Search field" placeholder="Search..." />
Directive with @Input Using the Directive Name
A common and clean pattern is to use the directive's selector as the @Input alias, so configuring the directive value and applying it happen in one attribute.
// badge-color.directive.ts
import { Directive, Input, HostBinding } from '@angular/core';
type BadgeColor = 'primary' | 'success' | 'warning' | 'danger';
@Directive({
selector: '[appBadge]',
standalone: true,
})
export class BadgeDirective {
// Using the selector as the @Input alias
@Input('appBadge') color: BadgeColor = 'primary';
@HostBinding('class')
get badgeClasses() {
return `badge badge-${this.color}`;
}
}
<!-- The directive selector doubles as the input binding -->
<span appBadge="success">Active</span>
<span appBadge="danger">Banned</span>
<span [appBadge]="userStatusColor">{{ userStatus }}</span>
Reading Host Properties from a Directive
// char-limit.directive.ts
import { Directive, ElementRef, HostListener, Input } from '@angular/core';
@Directive({
selector: 'textarea[appCharLimit]',
standalone: true,
})
export class CharLimitDirective {
@Input('appCharLimit') limit = 200;
constructor(private el: ElementRef<HTMLTextAreaElement>) {}
@HostListener('input')
onInput() {
const textarea = this.el.nativeElement;
const value = textarea.value;
const remaining = this.limit - value.length;
// Visual feedback: red border when near/over limit
textarea.style.borderColor = remaining < 0 ? 'red' : remaining < 20 ? 'orange' : '';
// Hard truncation at limit
if (value.length > this.limit) {
textarea.value = value.slice(0, this.limit);
}
}
}
<textarea [appCharLimit]="280" rows="4" placeholder="Tweet (max 280 chars)"></textarea>
Best Practices
Use Renderer2 instead of direct DOM manipulation for SSR compatibility.
Always clean up in ngOnDestroy — remove event listeners, DOM nodes, subscriptions.
Use @HostBinding / @HostListener instead of ElementRef where possible — cleaner and testable.
Prefer CSS classes over inline [ngStyle] — easier to maintain and theme.
Name custom directives with an app prefix (appTooltip, appHighlight) to avoid collisions.
Make directives configurable with @Input so they can be reused across projects.
Keep directives focused on a single responsibility — split large directives into smaller ones.