angular-directives

Custom directives for DOM manipulation, behavior extension, and component composition in Angular v20+. Attribute directives modify element appearance and behavior using the host property for event handling, class binding, and ARIA attributes; includes patterns for tooltips, highlights, buttons, click-outside detection, and keyboard shortcuts Structural directives handle portal rendering, lazy initialization, and template context injection for DOM manipulation beyond native @if , @for , @switch control flow Host directives compose reusable behaviors across components and other directives, exposing inputs and outputs for flexible behavior composition Built on Angular v20+ signals, inputs, outputs, and effects for reactive, type-safe directive development

INSTALLATION
npx skills add https://github.com/analogjs/angular-skills --skill angular-directives
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

$2b

constructor() {

effect(() => {

this.el.nativeElement.style.backgroundColor = this.color();

});

}

}

// Usage: Highlighted text

// Usage: Default yellow highlight

### Using host Property

Prefer `host` over `@HostBinding`/`@HostListener`:

@Directive({

selector: '[appTooltip]',

host: {

'(mouseenter)': 'show()',

'(mouseleave)': 'hide()',

'[attr.aria-describedby]': 'tooltipId',

},

})

export class Tooltip {

text = input.required<string>({ alias: 'appTooltip' });

position = input<'top' | 'bottom' | 'left' | 'right'>('top');

tooltipId = tooltip-${crypto.randomUUID()};

private tooltipEl: HTMLElement | null = null;

private el = inject(ElementRef<HTMLElement>);

show() {

this.tooltipEl = document.createElement('div');

this.tooltipEl.id = this.tooltipId;

this.tooltipEl.className = tooltip tooltip-${this.position()};

this.tooltipEl.textContent = this.text();

this.tooltipEl.setAttribute('role', 'tooltip');

document.body.appendChild(this.tooltipEl);

this.positionTooltip();

}

hide() {

this.tooltipEl?.remove();

this.tooltipEl = null;

}

private positionTooltip() {

// Position logic based on this.position() and this.el

}

}

// Usage: <button appTooltip="Click to save" position="bottom">Save</button>


### Class and Style Manipulation

@Directive({

selector: '[appButton]',

host: {

'class': 'btn',

'[class.btn-primary]': 'variant() === "primary"',

'[class.btn-secondary]': 'variant() === "secondary"',

'[class.btn-sm]': 'size() === "small"',

'[class.btn-lg]': 'size() === "large"',

'[class.disabled]': 'disabled()',

'[attr.disabled]': 'disabled() || null',

},

})

export class Button {

variant = input<'primary' | 'secondary'>('primary');

size = input<'small' | 'medium' | 'large'>('medium');

disabled = input(false, { transform: booleanAttribute });

}

// Usage: <button appButton variant="primary" size="large">Click</button>


### Event Handling

@Directive({

selector: '[appClickOutside]',

host: {

'(document:click)': 'onDocumentClick($event)',

},

})

export class ClickOutside {

private el = inject(ElementRef<HTMLElement>);

clickOutside = output<void>();

onDocumentClick(event: MouseEvent) {

if (!this.el.nativeElement.contains(event.target as Node)) {

this.clickOutside.emit();

}

}

}

// Usage: <div appClickOutside (clickOutside)="closeMenu()">...</div>


### Keyboard Shortcuts

@Directive({

selector: '[appShortcut]',

host: {

'(document:keydown)': 'onKeydown($event)',

},

})

export class Shortcut {

key = input.required<string>({ alias: 'appShortcut' });

ctrl = input(false, { transform: booleanAttribute });

shift = input(false, { transform: booleanAttribute });

alt = input(false, { transform: booleanAttribute });

triggered = output<KeyboardEvent>();

onKeydown(event: KeyboardEvent) {

const keyMatch = event.key.toLowerCase() === this.key().toLowerCase();

const ctrlMatch = this.ctrl() ? event.ctrlKey || event.metaKey : !event.ctrlKey &#x26;&#x26; !event.metaKey;

const shiftMatch = this.shift() ? event.shiftKey : !event.shiftKey;

const altMatch = this.alt() ? event.altKey : !event.altKey;

if (keyMatch &#x26;&#x26; ctrlMatch &#x26;&#x26; shiftMatch &#x26;&#x26; altMatch) {

event.preventDefault();

this.triggered.emit(event);

}

}

}

// Usage: <button appShortcut="s" ctrl (triggered)="save()">Save (Ctrl+S)</button>


## Structural Directives

Use structural directives for DOM manipulation beyond control flow (portals, overlays, dynamic insertion points). For conditionals and loops, use native `@if`, `@for`, `@switch`.

### Portal Directive

Render content in a different DOM location:

import { Directive, inject, TemplateRef, ViewContainerRef, OnInit, OnDestroy, input } from '@angular/core';

@Directive({

selector: '[appPortal]',

})

export class Portal implements OnInit, OnDestroy {

private templateRef = inject(TemplateRef<any>);

private viewContainerRef = inject(ViewContainerRef);

private viewRef: EmbeddedViewRef<any> | null = null;

// Target container selector or element

target = input<string | HTMLElement>('body', { alias: 'appPortal' });

ngOnInit() {

const container = this.getContainer();

if (container) {

this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);

this.viewRef.rootNodes.forEach(node => container.appendChild(node));

}

}

ngOnDestroy() {

this.viewRef?.destroy();

}

private getContainer(): HTMLElement | null {

const target = this.target();

if (typeof target === 'string') {

return document.querySelector(target);

}

return target;

}

}

// Usage: Render modal at body level

// <div *appPortal="'body'">

// <div class="modal">Modal content</div>

// </div>


### Lazy Render Directive

Defer rendering until condition is met (one-time):

@Directive({

selector: '[appLazyRender]',

})

export class LazyRender {

private templateRef = inject(TemplateRef<any>);

private viewContainer = inject(ViewContainerRef);

private rendered = false;

condition = input.required<boolean>({ alias: 'appLazyRender' });

constructor() {

effect(() => {

// Only render once when condition becomes true

if (this.condition() &#x26;&#x26; !this.rendered) {

this.viewContainer.createEmbeddedView(this.templateRef);

this.rendered = true;

}

});

}

}

// Usage: Render heavy component only when tab is first activated

// <div *appLazyRender="activeTab() === 'reports'">

// <app-heavy-reports />

// </div>


### Template Outlet with Context

interface TemplateContext<T> {

$implicit: T;

item: T;

index: number;

}

@Directive({

selector: '[appTemplateOutlet]',

})

export class TemplateOutlet<T> {

private viewContainer = inject(ViewContainerRef);

private currentView: EmbeddedViewRef<TemplateContext<T>> | null = null;

template = input.required<TemplateRef<TemplateContext<T>>>({ alias: 'appTemplateOutlet' });

context = input.required<T>({ alias: 'appTemplateOutletContext' });

index = input(0, { alias: 'appTemplateOutletIndex' });

constructor() {

effect(() => {

const template = this.template();

const context = this.context();

const index = this.index();

if (this.currentView) {

this.currentView.context.$implicit = context;

this.currentView.context.item = context;

this.currentView.context.index = index;

this.currentView.markForCheck();

} else {

this.currentView = this.viewContainer.createEmbeddedView(template, {

$implicit: context,

item: context,

index,

});

}

});

}

}

// Usage: Custom list with template

// <ng-template #itemTemplate let-item let-i="index">

// <div>{{ i }}: {{ item.name }}</div>

// </ng-template>

// <ng-container

// *appTemplateOutlet="itemTemplate; context: item; index: i"

// />


## Host Directives

Compose directives on components or other directives:

// Reusable behavior directives

@Directive({

selector: '[focusable]',

host: {

'tabindex': '0',

'(focus)': 'onFocus()',

'(blur)': 'onBlur()',

'[class.focused]': 'isFocused()',

},

})

export class Focusable {

isFocused = signal(false);

onFocus() { this.isFocused.set(true); }

onBlur() { this.isFocused.set(false); }

}

@Directive({

selector: '[disableable]',

host: {

'[class.disabled]': 'disabled()',

'[attr.aria-disabled]': 'disabled()',

},

})

export class Disableable {

disabled = input(false, { transform: booleanAttribute });

}

// Component using host directives

@Component({

selector: 'app-custom-button',

hostDirectives: [

Focusable,

{

directive: Disableable,

inputs: ['disabled'],

},

],

host: {

'role': 'button',

'(click)': 'onClick($event)',

'(keydown.enter)': 'onClick($event)',

'(keydown.space)': 'onClick($event)',

},

template: <ng-content />,

})

export class CustomButton {

private disableable = inject(Disableable);

clicked = output<void>();

onClick(event: Event) {

if (!this.disableable.disabled()) {

this.clicked.emit();

}

}

}

// Usage: <app-custom-button disabled>Click me</app-custom-button>


### Exposing Host Directive Outputs

@Directive({

selector: '[hoverable]',

host: {

'(mouseenter)': 'onEnter()',

'(mouseleave)': 'onLeave()',

'[class.hovered]': 'isHovered()',

},

})

export class Hoverable {

isHovered = signal(false);

hoverChange = output<boolean>();

onEnter() {

this.isHovered.set(true);

this.hoverChange.emit(true);

}

onLeave() {

this.isHovered.set(false);

this.hoverChange.emit(false);

}

}

@Component({

selector: 'app-card',

hostDirectives: [

{

directive: Hoverable,

outputs: ['hoverChange'],

},

],

template: <ng-content />,

})

export class Card {}

// Usage: <app-card (hoverChange)="onHover($event)">...</app-card>


## Directive Composition API

Combine multiple behaviors:

// Base directives

@Directive({ selector: '[withRipple]' })

export class Ripple {

// Ripple effect implementation

}

@Directive({ selector: '[withElevation]' })

export class Elevation {

elevation = input(2);

}

// Composed component

@Component({

selector: 'app-material-button',

hostDirectives: [

Ripple,

{

directive: Elevation,

inputs: ['elevation'],

},

{

directive: Disableable,

inputs: ['disabled'],

},

],

template: <ng-content />,

})

export class MaterialButton {}

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card