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?
answer
- same prefixes as template bindings
- class dot, attr dot, style dot
- units go after the style name
- plain keys are static
basics
~20 sUse 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 sHost 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 linesimport { 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
Recall the class., attr. and style. prefixes and that a plain key in the host object is a static attribute.
Explain attribute versus property bindings, the ARIA shorthand since v20.2, null removing an attribute, style units and the collision rules with parent bindings.
Use host bindings to make accessibility state part of the directive's contract rather than each parent's responsibility.
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.