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.
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:
@HostBinding
:@Component({
selector: 'app-form-field',
template: 'I am a form field',
})
export class FormFieldComponent {
@HostBinding('class.form-field') formFieldClass = true
}
host
property of @Component
decorator:@Component({
selector: 'app-form-field',
template: 'I am a form field',
host: {
'[class.form-field]': 'true',
},
})
export class FormFieldComponent {}
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?
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;
}
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!
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 { // ... }
As often, Angular got us covered! We have seen many ways to solve our problem. I would advise to:
@Component({ host: { '[class.form-field]': true } })
when you have static CSS classes that always need to be here@HostBinding('[class.is-loading]')
when you want to add a CSS class conditionally@Component({ hostDirectives: [NgClass] })
when you have multiple dynamic classesYou 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! 👋