Dynamic classes on host element using Angular

Sep 1, 2023 β€’ Guillaume Nury

Introduction

At Lucca, we develop our own Design System: Prisme. Its implementation is split into 3 main packages:

  • @lucca-front/icons contains our 200+ icons
  • @lucca-front/scss contains many visual components
  • @lucca-front/ng contains Angular components that use @lucca-front/scss

In @lucca-front/scss, we have a lot of components that consist of a set of classes and a corresponding HTML structure. We wrap these structures into Angular components to ease their integration inside Lucca's applications.

As a result we often have to add CSS classes to our Angular components.

The simple use case

To avoid adding unnecessary DOM levels, we can add classes on the host element itself.

What is an unnecessary level? Imagine having to insert an HTML element with the class form-field. The easiest way to do this is to add a div in your component's template:

@Component({
  selector: 'app-form-field',
  template: '<div class="form-field">I am a form field</div>',
})
export class FormFieldComponent {}

When we use our component, we indeed add a .form-field element, but we also add a app-form-field element:

<!-- When using FormFieldComponent1 -->
<app-form-field />

<!-- it renders: -->
<app-form-field>
  <div class="form-field"> <!-- πŸ‘ˆ ❌ this level is unnecessary -->
    I am a form field
  </div>
<app-form-field>

<!-- what we could have -->
<app-form-field class="form-field"> <!-- πŸ‘ˆ βœ… CSS class is directly on host element -->
  I am a form field
<app-form-field>

Angular offers multiple ways of doing this:

  • Using @HostBinding:
    @Component({
      selector: 'app-form-field',
      template: 'I am a form field',
    })
    export class FormFieldComponent {
      @HostBinding('class.form-field') formFieldClass = true
    }
  • Using host property of @Component decorator:
    @Component({
      selector: 'app-form-field',
      template: 'I am a form field',
      host: {
        '[class.form-field]': 'true',
      },
    })
    export class FormFieldComponent {}
  • Using the low-level class Renderer2 and ElementRef
    @Component({
      selector: 'app-form-field',
      template: 'I am a form field'
    })
    export class FormFieldComponent {
      constructor(elementRef: ElementRef<HTMLElement>, renderer: Renderer2) {
        renderer.addClass(elementRef.nativeElement, 'form-field');
      }
    }

When you only want to bind simple CSS classes on the host element, @HostBinding and host attribute are fine. But what if we want to add classes with dynamic names?

Dynamic classes

On our FormFieldComponent, we need CSS classes based on 3 inputs:

type LuPalette = 'primary' | 'secondary' | 'tertiary';
type LuSize = 'small' | 'medium' | 'large';
type LuStatus = 'success' | 'warning' | 'error';

@Component({
  selector: 'app-form-field',
  template: 'I am a form field',
})
export class FormFieldComponent {
  // If we have a status, we want to add the CSS class `is-${status}`
  @Input() public status?: LuStatus

  // If we have a palette, we want to add the CSS class `palette-${palette}`
  @Input() public palette?: LuPalette;

  // If we have a size, we want to add the CSS class `size-${size}`
  @Input() public size?: LuSize;
}

Using previous solutions

We can use and exhaustive @HostBinding decorator, but it is (very) verbose:

@Component({
  selector: 'app-form-field',
  template: 'I am a form field',
})
export class FormFieldComponent {
  @Input() public status?: LuStatus
  @Input() public palette?: LuPalette;
  @Input() public size?: LuSize;

  @HostBinding('class.is-success') get isSuccess() { return this.status === 'success'; }
  @HostBinding('class.is-warning') get isWarning() { return this.status === 'warning'; }
  @HostBinding('class.is-error') get isError() { return this.status === 'error'; }

  @HostBinding('class.palette-primary') get isPrimary() { return this.palette === 'primary'; }
  @HostBinding('class.palette-secondary') get isSecondary() { return this.palette === 'secondary'; }
  @HostBinding('class.palette-tertiary') get isTertiary() { return this.palette === 'tertiary'; }

  @HostBinding('class.size-small') get isSmall() { return this.size === 'small'; }
  @HostBinding('class.size-medium') get isMedium() { return this.size === 'medium'; }
  @HostBinding('class.size-large') get isLarge() { return this.size === 'large'; }
}

Or host in @Component but it is an unsafe code (strings are interpreted at runtime, not compilation time):

@Component({
  selector: 'app-form-field',
  template: 'I am a form field',
  host: {
    '[class.is-success]': 'status === "success"',
    '[class.is-warning]': 'status === "warning"',
    '[class.is-error]': 'status === "error"',
    '[class.palette-primary]': 'palette === "primary"',
    '[class.palette-secondary]': 'palette === "secondary"',
    '[class.palette-tertiary]': 'palette === "tertiary"',
    '[class.size-small]': 'size === "small"',
    '[class.size-medium]': 'size === "medium"',
    '[class.size-large]': 'size === "large"',
  },
})
export class FormFieldComponent {
  @Input() public status?: LuStatus
  @Input() public palette?: LuPalette;
  @Input() public size?: LuSize;
}

These solutions are not elegant neither extensible. What if we add a quaternary palette or a x-small size?

We could use the Renderer but we would be responsible for removing the previous status/palette/size class before adding the new one. It always feels hacky to use the Renderer anyway.

Since Angular 9 with IVY, we can use @HostBinding('class') without overriding the original CSS classes:

<!-- We want to keep `custom` class -->
<app-form-field class="custom"></app-form-field>

<!-- ❌ Before IVY: no `custom` class -->
<app-form-field class="form-field">
  I am a form field
<app-form-field>

<!-- βœ… With IVY: the `custom` class is still here -->
<app-form-field class="custom form-field">
  I am a form field
<app-form-field>

Bonus, with @HostBinding, you can return an Array<string>:

@Component({
  selector: 'app-form-field',
  template: 'I am a form field',
})
export class FormFieldComponent {
  @Input() public status?: LuStatus
  @Input() public palette?: LuPalette;
  @Input() public size?: LuSize;

  @HostBinding('class')
  get cssClass(): string[] {
    return [
      this.size && `size-${this.size}`,
      this.status && `is-${this.status}`,
      this.palette && `palette-${this.palette}`,
    ].filter(Boolean);
  }
}

And even a dictionary:

@Component({
  selector: 'app-form-field',
  template: 'I am a form field',
})
export class FormFieldComponent {
  @Input() public status?: LuStatus
  @Input() public palette?: LuPalette;
  @Input() public size?: LuSize;

  @HostBinding('class')
  get cssClass(): Record<string, boolean> {
    return {
      [`is-${this.status}`]: !!this.status,
      [`palette-${this.palette}`]: !!this.palette,
      [`size-${this.size}`]: !!this.size,
    }
  }
}

Some of you might want to have a better control on when the CSS classes are updated and avoid the use of getters. Here is one last solution!

BONUS: NgClass as a host directive

Since Angular 15, you can use directive composition to add directives on host element. One powerful directive is NgClass which allow multiple ways of providing needed classes.

@Component({
  selector: 'app-form-field',
  standalone: true,
  template: 'I am a form field',
  hostDirectives: [NgClass], // πŸ‘ˆ We add a NgClass directive on <app-form-field>
})
export class FormFieldComponent implements OnChanges {
  #ngClass = inject(NgClass); // πŸ‘ˆ We can get the NgClass directive

  @Input() public status?: LuStatus
  @Input() public palette?: LuPalette;
  @Input() public size?: LuSize;

  ngOnChanges(): void {
    // πŸ‘‡ We can set ngClass with the same syntax as in a template (string | string[] | Record<string, any>)
    this.#ngClass.ngClass = {
      [`is-${this.status}`]: this.status,
      [`palette-${this.palette}`]: this.palette,
      [`size-${this.size}`]: this.size,
    };
  };
}

No more getter which means that classes are checked only when an input changes and not on every change detection cycle!

⚠️ Using NgClass in host directives prevent the use of [ngClass] "from the outside":

<!-- 'Throws NG0309: Directive NgClass matches multiple times on > the same element. Directives can only match an element once.' -->
<app-form-field [ngClass]="{}" />

More explanations on the related Angular issue.

To overcome this limitation, you can create your own copy of NgClass:

// Copy NgClass
@Directive({
  selector: '[ngClazz]',
  standalone: true,
})
class NgClazz extends NgClass {}

@Component({
  selector: 'app-form-field',
  standalone: true,
  template: 'I am a form field',
  hostDirectives: [NgClazz], // πŸ‘ˆ Add NgClazz instead of NgClass. Now, you can use `<app-form-field [ngClass]="{}" />` without issues!
})
export class FormFieldComponent implements OnChanges {
  // ...
}

Conclusion

As often, Angular got us covered! We have seen many ways to solve our problem. I would advise to:

  • use @Component({ host: { '[class.form-field]': true } }) when you have static CSS classes that always need to be here
  • use @HostBinding('[class.is-loading]') when you want to add a CSS class conditionally
  • use @Component({ hostDirectives: [NgClass] }) when you have multiple dynamic classes

You can mix and match all these methods to find the best solution for your particular use-case. But please, do NOT do this:

@Component({
  selector: 'app-form-field',
  standalone: true,
  template: 'I am a form field',
  changeDetection: ChangeDetectionStrategy.OnPush,
  // πŸ‘‡ Some static classes here πŸ‘‡
  host: {
    '[class.form-field]': 'true',
  },
  // πŸ‘† Some static classes here πŸ‘†
  hostDirectives: [NgClass],
})
export class FormFieldComponent implements OnChanges {
  #ngClass = inject(NgClass);

  @Input() public status?: LuStatus
  @Input() public palette?: LuPalette;
  @Input() public size?: LuSize;

  // πŸ‘‡ Single dynamic class here πŸ‘‡
  @HostBinding('class')
  public get class(): string {
    return this.status ? `is-${this.status}` : '';
  }
  // πŸ‘† Single dynamic class here πŸ‘†

  // πŸ‘‡ Conditional classes here πŸ‘‡
  @HostBinding('class.size-small')
  public get sizeSmall(): boolean {
    return this.size === 'small';
  }

  @HostBinding('class.size-medium')
  public get sizeMedium(): boolean {
    return this.size === 'medium';
  }

  @HostBinding('class.size-large')
  public get sizeLarge(): boolean {
    return this.size === 'large';
  }
  // πŸ‘† Conditional classes here πŸ‘†

  ngOnChanges(): void {
    // πŸ‘‡ Dynamic classes here πŸ‘‡
    this.#ngClass.ngClass = {
      [`palette-${this.palette}`]: this.palette,
    };
    // πŸ‘† Dynamic classes here πŸ‘†
  };
}

πŸ‘‹ Thanks for reading! πŸ‘‹

About the author

Guillaume Nury

Expert Software Engineer