skip to content

In an Angular directive's `host` object, how do you bind a CSS class, an ARIA attribute and an inline style on the host element?

level: juniorimportance: should knowfreq 50%

answer

  1. same prefixes as template bindings
  2. class dot, attr dot, style dot
  3. units go after the style name
  4. plain keys are static

basics

~20 s

Use template-style keys in the host object: '[class.name]' toggles a class, '[attr.name]' sets or removes an attribute (ARIA names also work without attr. since v20.2), '[style.prop.unit]' sets a style, and a plain key sets a static attribute.

solid answer

~50 s

Host keys use the same binding prefixes as templates. `'[class.is-open]': 'open()'` adds the class when the expression is truthy and removes it otherwise. `'[attr.aria-expanded]': 'open()'` writes the attribute, and a `null` or `undefined` value removes it, which is what you want for optional ARIA attributes. Since v20.2 the shorter `'[aria-expanded]'` compiles to the same attribute binding, but other attributes with no DOM property, such as `data-` attributes, still need `attr.`. `'[style.width.px]': 'width()'` sets `width` with the `px` unit appended; `'[style.--accent]': 'color()'` sets a CSS custom property. A key without brackets, such as `'role': 'menu'`, is a static attribute. If the parent template also binds the same property on the element, the documented rules decide: two static values, the parent's wins; static against dynamic, the dynamic one wins; two dynamic values, the component's host binding wins.

code

ts · 21 lines
ts
import { Directive, signal } from '@angular/core';

@Directive({
  selector: '[appDisclosure]',
  host: {
    'role': 'button',
    '[attr.aria-expanded]': 'open()',
    '[attr.aria-controls]': 'open() ? panelId : null',
    '[class.is-open]': 'open()',
    '[style.--rotation]': "open() ? '90deg' : '0deg'",
    '(click)': 'toggle()',
  },
})
export class DisclosureDirective {
  readonly open = signal(false);
  readonly panelId = 'details-panel';

  toggle(): void {
    this.open.update((v) => !v);
  }
}

go deeper

for a junior

Recall the class., attr. and style. prefixes and that a plain key in the host object is a static attribute.

for a middle

Explain attribute versus property bindings, the ARIA shorthand since v20.2, null removing an attribute, style units and the collision rules with parent bindings.

for a senior

Use host bindings to make accessibility state part of the directive's contract rather than each parent's responsibility.

for a principal

Encourage shared directives that own their ARIA and state classes, so accessibility is enforced by the component library rather than by review.

## Host keys reuse template binding syntax A directive or component can bind to its own **host element** through the `host` object in its metadata. Each key is written exactly like a binding in a template, and each value is a template expression evaluated against the class. If you know template bindings, you already know the key syntax; the only difference is where it lives. ## The four forms you need | Key form | Example | What it does | | --- | --- | --- | | Static attribute | `'role': 'menu'` | Sets the attribute once, to a constant string | | Class binding | `'[class.is-open]': 'open()'` | Adds `is-open` when truthy, removes it when falsy | | Attribute binding | `'[attr.aria-expanded]': 'open()'` | Writes the attribute; `null` or `undefined` removes it | | Style binding | `'[style.width.px]': 'width()'` | Sets `width` and appends the `px` unit | Some related forms: - `'[class]': 'classes()'` binds several classes at once from a string, array or object. - `'[style]': 'styles()'` does the same for several styles from an object. - `'[style.--accent]': 'accent()'` sets a **CSS custom property**, which children can read through `var(--accent)`. - `'[tabIndex]': 'disabled() ? -1 : 0'` binds a **DOM property**, not an attribute. ## Attribute or property? The most common mistake is binding an attribute as if it were a property. A `data-row-id` attribute has no matching DOM property, so `'[data-row-id]'` asks Angular to set a property that does not exist, and the template type checker reports it as an unknown property; `'[attr.data-row-id]'` is correct. Properties such as `tabIndex`, `hidden` or `disabled` (on elements that have it) can be bound directly. **ARIA is the exception since v20.2.** The compiler recognises names starting with `aria-` and turns a property-style binding such as `'[aria-expanded]'` into an attribute binding, so it behaves exactly like `'[attr.aria-expanded]'`. Both forms are correct in Angular 22; the `attr.` form is what most existing code and older answers use. For ARIA, removing an attribute is often better than writing `"false"` or an empty string, and attribute bindings give you that for free: return `null` to remove it. ## Who wins when the parent binds too A parent template may also bind to the same element: `<app-menu role="navigation" [id]="menuId">`, while the component's host object sets `'role': 'menu'` and `'[id]': 'id'`. Angular documents three rules: 1. **Both static**: the parent template's value (the instance) wins. 2. **One static, one dynamic**: the dynamic binding wins, wherever it is declared. 3. **Both dynamic**: the component's host binding wins. Classes are more forgiving: a parent's `class="card"` and a host `'[class.is-open]'` combine, because class bindings toggle individual classes rather than replacing the whole attribute. ## Common mistakes - **Concatenating units.** `'[style.width]': "width() + 'px'"` works, but `'[style.width.px]': 'width()'` is clearer and accepts a number. - **Static keys with expressions.** `'role': 'isMenu ? "menu" : "list"'` is not evaluated; a plain key is a literal string. Use `'[attr.role]'` for a dynamic value. - **Writing `"false"` for ARIA state.** Some attributes, such as `aria-expanded`, are meaningful as `"false"`; others should simply be absent. Decide per attribute whether to return `false` or `null`. - **Binding `class` wholesale by accident.** `'[class]': 'cls()'` manages a set of classes; mixing it with many `[class.x]` keys on the same host is legal but harder to reason about. ## Why this belongs on the host - **Encapsulation.** A menu directive that owns `aria-expanded` guarantees every usage is accessible, instead of relying on each parent to remember the binding. - **Signals and `OnPush`.** Host expressions can call signals, so the bindings update when the signals change, with no manual `markForCheck()`. - **No DOM writes.** Binding through `host` avoids injecting `ElementRef` and writing to `nativeElement`, which Angular cannot track and which bypasses its handling of bindings. ## Worked example A disclosure button directive might combine all of the forms: 1. `'role': 'button'` and `'tabindex': '0'` as static attributes, so a non-button element is focusable and announced correctly. 2. `'[attr.aria-expanded]': 'open()'` so assistive technology knows the state. 3. `'[class.is-open]': 'open()'` for styling. 4. `'[style.--rotation]': "open() ? '90deg' : '0deg'"` to drive an icon's rotation through a custom property. All four update together when the `open` signal changes.

  • In Angular 22, why does `'[aria-label]': 'label()'` work in a host object while `'[data-row-id]': 'id'` is reported as an unknown property?
    Since v20.2 the compiler treats bindings whose name starts with `aria-` as attribute bindings, so `[aria-label]` writes the attribute. Nothing similar exists for `data-` names, so `[data-row-id]` is checked as a DOM property, which does not exist. It needs `[attr.data-row-id]`.
  • If a component's host object sets `'[id]': 'id'` and the parent writes `[id]="otherId"`, which value ends up on the element?
    Both are dynamic, and for two dynamic bindings Angular's documented rule is that the component's host binding wins. A static parent value would lose to a dynamic host binding too; only two static values let the parent's instance value win.

saying these in an interview costs you the question

  • Any attribute, including data- attributes, can be bound without the attr. prefix.
  • Setting an attribute binding to null writes the string null.
  • A class host binding replaces every class the parent put on the element.
  • The parent template's binding always overrides the component's host binding.
  • Units such as px must be concatenated into the expression string.