Angular Directives: Attribute Directives, Host Bindings, and Building Your Own
Components get the spotlight, but directives are Angular’s quieter superpower: behavior you attach to existing elements. Where a component owns a template, a directive decorates someone else’s — adding autofocus, tooltips, permissions-based hiding, drag handles, analytics tracking — without wrapping everything in yet another component layer.
Custom directives are also where many Angular developers plateau: they use built-ins forever but never write their own, missing the cleanest tool for cross-cutting UI behavior. This guide fixes that — built-ins first, then writing real directives, then the composition API that makes them stack.
The Directive Family Tree
Three kinds, one mental model:
- Components — directives with a template. You know these.
- Attribute directives — attach to an element/component and modify its behavior or appearance. This guide’s subject.
- Structural directives — add/remove DOM (
*ngIf,*ngFor). Their job has largely moved into the built-in control flow blocks; you’ll read them in legacy code, rarely write new ones.
So in modern Angular, “writing a directive” almost always means an attribute directive: a TypeScript class attached to elements via a selector, with access to the element it sits on.
The Built-Ins You’ll Actually Use
Beyond the control-flow story, two everyday attribute directives ship with the framework:
<!-- NgClass / NgStyle — multi-class and multi-style versions of [class.x]/[style.x] --><div [ngClass]="{ active: isActive(), error: hasError(), 'is-admin': user().admin }"><div [ngStyle]="{ 'width.%': progress(), color: statusColor() }">Honest guidance: for one or two conditional classes, the single-binding forms ([class.active]="...") read better and need no import; NgClass earns its place when the class set is genuinely dynamic (computed maps). And [(ngModel)] — technically a directive too — you’ve already met. RouterLink, the navigation directive, arrives with routing.
The deeper point of this short section: most of what old Angular used utility directives for has migrated into template syntax. The remaining directive value is in the ones you write yourself.
Writing Your First Directive: Autofocus Done Right
The perennial need — focus an input when it appears — makes a perfect first directive:
import { Directive, ElementRef, inject, afterNextRender } from '@angular/core';
@Directive({ selector: '[appAutofocus]',})export class Autofocus { private el = inject(ElementRef<HTMLElement>);
constructor() { afterNextRender(() => this.el.nativeElement.focus()); }}<input appAutofocus placeholder="Search…" />Reading it closely:
selector: '[appAutofocus]'— the brackets mean attribute selector: this class activates on any element carrying that attribute. Theappprefix convention applies to directives as much as components.inject(ElementRef)— the directive asks dependency injection for a reference to the element it’s attached to. Every directive gets its host element this way;nativeElementis the raw DOM node.afterNextRender— run after the DOM is actually painted (focus before render does nothing). Directives use the same lifecycle machinery as components.
Ten lines, reusable on every input in the app, and the ceremony-free usage — one attribute — is the whole aesthetic of directives: behaviors as vocabulary.
Host Bindings and Listeners: The Directive’s Real Toolkit
A directive’s power tools are declaring bindings and listeners on its host element via the host metadata:
@Directive({ selector: '[appHighlightOnHover]',})export class HighlightOnHover { color = input('#fff8d0'); // configurable via the same attribute private hovering = signal(false);
@HostBinding('style.backgroundColor') get bg() { return this.hovering() ? this.color() : ''; }
@HostListener('mouseenter') onEnter() { this.hovering.set(true); } @HostListener('mouseleave') onLeave() { this.hovering.set(false); }}<tr appHighlightOnHover [color]="'#eef'">…</tr>(You’ll see both the decorator style above and the equivalent host: { '(mouseenter)': '...', '[style.backgroundColor]': '...' } metadata object — current style guidance leans toward the host object; both are current.)
What’s happening: @HostListener subscribes to events on the host with automatic cleanup when the directive dies — no manual listener bookkeeping. @HostBinding binds a host property/class/style to directive state, reactively. And note input() working in directives exactly as in components — the attribute that activates the directive can also carry configuration.
The design rule that keeps directives healthy: prefer host bindings over direct DOM mutation. @HostBinding('class.active') declares intent and lets Angular manage the DOM (SSR-safe, testable); nativeElement.classList.add(...) works but reintroduces imperative DOM management with its cleanup obligations. Touch nativeElement when you truly need APIs bindings can’t express (focus, measurement, canvas), and prefer Renderer2 if your app targets server rendering.
A production-grade example: permissions
The pattern that shows up in every enterprise app — hide/disable UI by role:
@Directive({ selector: '[appRequiresRole]',})export class RequiresRole { private auth = inject(AuthService); role = input.required<string>({ alias: 'appRequiresRole' });
@HostBinding('style.display') get display() { return this.auth.hasRole(this.role()) ? '' : 'none'; }}<button appRequiresRole="admin" (click)="deleteUser()">Delete</button>One attribute encodes the policy; the directive centralizes how denial manifests (hide vs disable vs tooltip), and changing that decision later touches one file. Cross-cutting concerns — permissions, feature flags, analytics click-tracking, external-link handling — are the directive sweet spot: behavior that belongs to many elements but no particular component.
Directive Composition: hostDirectives
The modern API that made directives compositional — components (and directives) can bake in other directives:
@Component({ selector: 'app-menu-item', hostDirectives: [ { directive: HighlightOnHover, inputs: ['color'] }, RequiresRole, ], template: `<ng-content />`,})export class MenuItem { ... }Every <app-menu-item> now is hoverable-highlighted and role-gated — no attribute repetition at call sites, no inheritance hierarchy. This is composition over inheritance landing in the view layer: shared element behaviors become stackable units instead of base-class methods. Design-system teams lean on this hard (a Button composing Ripple, Disableable, TooltipHost), and it’s the answer to “how do I share behavior between components without a common parent?”
Judgment: Directive vs Component vs Service
The decision triangle, since all three “share logic”:
- Needs its own template/markup? → Component. (A tooltip bubble is a component; the trigger behavior on the host is a directive — real tooltip libraries are exactly this pair.)
- Behavior attached to existing elements, DOM-adjacent? → Directive. Focus, hover, gestures, visibility policies, measurement.
- Logic with no element at all? → Service. Data, state, business rules. A directive that’s mostly non-DOM logic should be a thin adapter over a service.
And the restraint clause: don’t directive-ify what a CSS :hover or a one-line binding already does. The highlight example above is pedagogy — real hover styling belongs in CSS; the directive form earns its place when behavior involves state, configuration, or JS-only APIs.
One More Worked Directive: Click-Outside
A second production staple, because it exercises the parts autofocus didn’t — document-level listening, output events, and cleanup:
@Directive({ selector: '[appClickOutside]',})export class ClickOutside { private el = inject(ElementRef<HTMLElement>); private destroyRef = inject(DestroyRef);
appClickOutside = output<void>();
constructor() { const handler = (e: Event) => { if (!this.el.nativeElement.contains(e.target as Node)) { this.appClickOutside.emit(); } }; // capture phase + document level: catches clicks anywhere document.addEventListener('click', handler, true); this.destroyRef.onDestroy(() => document.removeEventListener('click', handler, true)); }}<div class="dropdown" (appClickOutside)="close()"> <button (click)="toggle()">Filters ▾</button> @if (open()) { <div class="menu">…</div> }</div>The instructive details: the listener must be document-level (host listeners can’t see clicks elsewhere), which makes manual registration necessary — and manual registration makes the DestroyRef teardown non-negotiable (the leak pattern this series keeps flagging, defused at the acquisition site). The contains check is DOM tree logic doing the actual work. And naming the output the same as the selector lets usage read as one attribute doing one thing. Dropdowns, modals, popovers, and date-pickers all need exactly this behavior — which is the directive argument in one line: write it once, and “closes on outside click” becomes a word your templates know.
Frequently Asked Questions
How do directives and components interact on the same element? They coexist — a directive on a component’s tag receives the component’s host element, and can even inject the component instance (inject(MenuItem)) to collaborate. DI on an element resolves across everything attached to it — one more DI elegance.
Can a directive have a selector matching all elements of a type — like every <a>? Yes: selector: 'a[href]' activates on every anchor with an href — the mechanism behind “all external links open in new tabs” directives. Broad selectors are powerful and worth using deliberately (and documenting loudly).
What are exportAs and template refs on directives? exportAs: 'appDropdown' lets templates grab the directive instance: <div appDropdown #dd="appDropdown"> then (click)="dd.toggle()" — a public API for template-side control. Common in UI libraries; occasionally exactly right in app code.
Do I still need to learn to write structural directives? Reading, yes — the * sugar and ng-template mechanics appear in legacy code and library docs. Writing new ones: rarely; @if/@for/@defer plus attribute directives cover nearly everything. If you genuinely need custom DOM-shaping (a virtualized repeater), you’ll be reading library source anyway.
How do I test a directive? Mount it on a tiny host component in the test, then assert on the rendered DOM/behavior — the standard pattern in Angular testing docs. Directives designed as thin layers over services (per the judgment section) keep the DOM-touching test surface small.
When Libraries Hand You Directives
A fluency note that saves onboarding time: mature Angular libraries deliver much of their API as directives, and recognizing the pattern makes their docs instantly readable. Angular Material’s matTooltip, cdkDrag/cdkDropList, matRipple; router’s routerLink/routerLinkActive; CDK’s cdkTrapFocus for dialogs — each is exactly the shape this guide taught: an attribute selector, inputs for configuration on the same element, occasionally outputs for events and exportAs for template handles. <div cdkDrag [cdkDragDisabled]="locked()" (cdkDragEnded)="onDrop($event)"> reads as: directive activation, input, output — no new concepts, just vocabulary. This is also the argument for building your cross-cutting behaviors the same way: the team already knows how to read them. The corollary skill is knowing where to look when behavior seems to come from nowhere — an element doing something its component doesn’t explain usually carries a directive in its attribute list, and the element-injector rules mean DevTools’ element inspection will list every directive attached.
And a note on selector discipline for the long run: attribute directives compose, which means elements accumulate them — <button appRipple appTrackClick appRequiresRole="admin"> is healthy usage, but each directive must stay independent to keep it so. Directives that secretly assume a sibling directive’s presence, or fight over the same host binding (two directives both driving style.display — last write wins, nondeterministically from the reader’s perspective), turn composition into coupling. The test before shipping a directive: does its README-in-your-head need to mention any other directive? If yes, either merge them or introduce an explicit contract (hostDirectives, or one injecting the other).
Self-Check
selector: '[appTrackClick]'vsselector: 'app-track-click'— what’s the practical difference?- Why does the autofocus directive use
afterNextRenderinstead of focusing in the constructor body directly? - A directive adds
document.addEventListener('scroll', ...)in its constructor. What’s missing, and which tool avoids the problem entirely for host events? - Your design system wants every card component draggable and elevation-shadowed on hover. Which API composes these without repeating attributes at every usage?
- A “directive” grew to 300 lines of pricing logic with two lines touching the DOM. What refactor is due?
(Answers: attribute selector attaches behavior to existing elements; tag selector would make it an element requiring its own usage — behaviors want attributes. The element exists but isn’t rendered/painted yet at construction; focus needs the real DOM. Cleanup on destroy is missing — document-level listeners leak; @HostListener handles host events with automatic teardown (document listeners need DestroyRef/OnDestroy cleanup). hostDirectives on the card component. Extract the logic into a service; the directive stays as the thin DOM adapter.)
That completes the basics arc. Next section: components in depth, starting with Component Communication — signal inputs, outputs, model inputs, and choosing patterns for parent-child versus distant components.