A directive adds behaviour to an existing element, without a template of its own: tooltip, ripple, drag, autofocus, trap focus, highlight. Components are directives with a template. With the Directive Composition API (hostDirectives) you can “attach” directives to a component, like mixins — without class inheritance.
An attribute directive
import { Directive, input, signal } from '@angular/core';
@Directive({
selector: '[appHighlight]',
host: {
'[style.backgroundColor]': 'active() ? color() : null',
'(mouseenter)': 'active.set(true)',
'(mouseleave)': 'active.set(false)',
},
})
export class Highlight {
// alias = the selector name → <span appHighlight="yellow">
readonly color = input('var(--accent-soft)', { alias: 'appHighlight' });
protected readonly active = signal(false);
}host: {} instead of @HostBinding / @HostListener
// ✗ Old style
@Directive({ selector: '[appHighlight]' })
export class Highlight {
@Input('appHighlight') color = 'yellow';
@HostBinding('style.backgroundColor') bg: string | null = null;
@HostListener('mouseenter') onEnter() { this.bg = this.color; }
@HostListener('mouseleave') onLeave() { this.bg = null; }
}
// ✓ Current style: the `host` metadata (recommended by the style guide; type-checked from v21)
@Directive({
selector: '[appHighlight]',
host: {
'[style.backgroundColor]': 'active() ? color() : null',
'(mouseenter)': 'active.set(true)',
'(mouseleave)': 'active.set(false)',
'(document:keydown.escape)': 'active.set(false)', // global events: window:, document:
},
})
export class Highlight { /* ... */ }A directive with DOM and cleanup
@Directive({
selector: '[appTooltip]',
host: { '(mouseenter)': 'show()', '(mouseleave)': 'hide()', '(focusin)': 'show()', '(focusout)': 'hide()' },
})
export class Tooltip {
readonly text = input.required<string>({ alias: 'appTooltip' });
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly document = inject(DOCUMENT);
private tip: HTMLElement | null = null;
constructor() {
inject(DestroyRef).onDestroy(() => this.hide()); // don’t leave orphan elements in <body>
}
show() { /* creates a <div> positioned next to the host */ }
hide() { this.tip?.remove(); this.tip = null; }
}Directive Composition API
Do you have three different buttons that all need a ripple and a tooltip? You don’t add them by hand each time and you don’t create a base class. You declare them in hostDirectives, and the directives’ inputs/outputs can be exposed (and renamed) on the component.
// Directive Composition API: reusable behaviours “attached” to a component
@Component({
selector: 'app-action-button',
hostDirectives: [
Ripple, // no inputs exposed
{ directive: Tooltip, inputs: ['appTooltip: hint'] }, // expose the input under another name
// { directive: Draggable, inputs: ['axis'], outputs: ['dragEnd'] },
],
host: { role: 'button', tabindex: '0', '[class]': 'variant()' },
template: `<ng-content />`,
})
export class ActionButton {
readonly variant = input<'primary' | 'ghost'>('primary');
}
// <app-action-button hint="Save" variant="primary">Save</app-action-button>// The component can inject the directives from hostDirectives (they are on the same element)
export class ActionButton {
private readonly tooltip = inject(Tooltip);
showHelp() { this.tooltip.show(); }
}
// v22: if the same directive appears several times in the hostDirectives chain,
// Angular de-duplicates it (a single instance).Component or directive?
- You add behaviour to an element you don’t control (a native input, a button from another library).
- The behaviour is orthogonal to the look: tooltip, autofocus, click-outside, lazy image, permissions (
*appHasRole). - You want to combine it freely with other behaviours.
- You need your own markup (a template).
- You need encapsulated styles.
- It is a self-contained visual “thing” (card, badge, dialog).
Classes and styles set from host are explained in Class & style bindings; a directive that turns an element into a form control is covered in Custom form controls.
Common pitfalls
- DOM manipulation without cleanup: any element added to
<body>must be removed inDestroyRef.onDestroy. - Using
document/windowdirectly: useinject(DOCUMENT)— it works with SSR and in tests too. - Host binding conflicts: if both the component and a host directive set the same class/attribute, the order matters. Keep the responsibilities separate.
- Too many hostDirectives: each one is an extra instance on every element. For lists of thousands of elements, measure.
Cheat sheet
@Directive({ selector: '[appX]', host: { '[attr.x]': 'y()', '(click)': 'on($event)' } })
input('', { alias: 'appX' }) // <div appX="value">
inject(ElementRef) / inject(DOCUMENT) / inject(DestroyRef)
hostDirectives: [Dir, { directive: Dir2, inputs: ['a: b'], outputs: ['c'] }]
// Angular CDK has many ready-made directives: cdkDrag, cdkTrapFocus, cdkObserveContent…