Como adicionar e remover classes CSS de forma dinâmica no Angular com ngClass

Descubra como a diretiva ngClass simplifica a manipulação de classes CSS de forma intuitiva e eficiente. Neste post, você aprenderá a adicionar e remover classes CSS condicionalmente. Além disso, mostraremos como a ngClass contribui para um código mais limpo e manutenível.

Como adicionar e remover classes CSS de forma dinâmica no Angular com ngClass
Angular: atribuindo classes CSS dinamicamente com ngClass

Uma tarefa comum em projetos frontend é a atribuição dinâmica de classes CSS, ou seja, definir a classe CSS de um elemento com base em uma regra de negócio. Através de algumas revisões de código, tenho observado essa tarefa sendo executada frequentemente com o uso de interpolação ({{}}), por exemplo:

<div
  class="produto
    {{ esgotado ? 'produto-esgotado' : '' }}
    {{ (!esgotado && porcentagemDeDesconto > 0)
      ? 'produto-com-desconto'
      : ''
    }}">
...
</div>

Apesar do exemplo acima não estar errado, podemos alcançar o mesmo objetivo usando recursos nativos do Angular, como a diretiva ngClass. Neste texto falaremos mais sobre o uso do ngClass na atribuição dinâmica de classes CSS.

Construção do cenário de estudo

Para iniciar, usei o Angular 18.1 para criar um projeto e preparar os exemplos. A seguir deixei o conteúdo de cada arquivo modificado.

No arquivo app.config.ts, configurei a globalização e a sigla da moeda, conforme já apresentado por aqui:

import { registerLocaleData } from '@angular/common';
import localePT from '@angular/common/locales/pt';
import {
  ApplicationConfig,
  DEFAULT_CURRENCY_CODE,
  LOCALE_ID,
  provideZoneChangeDetection,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

registerLocaleData(localePT);

export const appConfig: ApplicationConfig = {
  providers: [
    { provide: LOCALE_ID, useValue: 'pt-br' },
    { provide: DEFAULT_CURRENCY_CODE, useValue: 'BRL' },
    provideZoneChangeDetection({
      eventCoalescing: true,
    }),
    provideRouter(routes),
  ],
};

app.config.ts

app.component.ts:

import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { EMPTY, Observable, of } from 'rxjs';

export type Produto = {
  nome: string;
  categoria: string;
  preco: number;
  porcentagemDeDesconto: number;
  esgotado: boolean;
};

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, CommonModule],
  templateUrl: './app.component.html',
  styleUrl: './app.component.css',
})
export class AppComponent implements OnInit {
  produtos$: Observable<Produto[]> = EMPTY;

  ngOnInit(): void {
    this.produtos$ = this.carregarProdutos();
  }

  carregarProdutos() {
    return of<Produto[]>([
      {
        nome: 'Camisa',
        categoria: 'Vestuário',
        preco: 59.9,
        porcentagemDeDesconto: 20,
        esgotado: true,
      },
      {
        nome: 'Calça',
        categoria: 'Vestuário',
        preco: 129.9,
        porcentagemDeDesconto: 0,
        esgotado: false,
      },
      {
        nome: 'Tênis',
        categoria: 'Vestuário',
        preco: 189.9,
        porcentagemDeDesconto: 40,
        esgotado: false,
      },
      {
        nome: 'Jaqueta',
        categoria: 'Vestuário',
        preco: 249.9,
        porcentagemDeDesconto: 0,
        esgotado: false,
      },
      {
        nome: 'Boné',
        categoria: 'Vestuário',
        preco: 39.9,
        porcentagemDeDesconto: 60,
        esgotado: false,
      },
    ]);
  }

  obterClassesCSSPorProduto(produto: Produto): string[] {
    const classesCSS = ['produto'];

    if (produto.esgotado) {
      classesCSS.push('produto-esgotado');
    } else {
      if (produto.porcentagemDeDesconto > 0) {
        classesCSS.push('produto-com-desconto');
      }
    }

    return classesCSS;
  }
}

app.component.ts

Arquivo app.component.html utilizando a interpolação para atribuir algumas classes CSS:

@if (produtos$ | async; as produtos) {
  <div class="container-produtos">
    @for (produto of produtos; track produto.nome) {
      @let categoria = produto.categoria;
      @let esgotado = produto.esgotado;
      @let nome = produto.nome;
      @let preco = produto.preco;

      @let porcentagemDeDesconto =
        produto.porcentagemDeDesconto;

      @let precoFinal =
        preco * (100 - porcentagemDeDesconto) / 100;

    <div
      class="produto
        {{ esgotado ? 'produto-esgotado' : '' }}
        {{ (!esgotado && porcentagemDeDesconto > 0)
            ? 'produto-com-desconto'
            : ''
        }}">
      <img
        src="https://placehold.jp/150x150.png"
        alt="Imagem do produto">
      <p class="produto-categoria">
        {{ categoria }}
        @if (esgotado) {
          <span> - ESGOTADO!</span>
        }
      </p>
      <h2 class="produto-nome">
        {{ nome }}
      </h2>
      <p class="produto-preco">
        {{ precoFinal | currency }}
      </p>
      @if (porcentagemDeDesconto) {
        <del>{{ preco | currency }}</del>
        <div>
          {{ porcentagemDeDesconto / 100 | percent }} OFF
        </div>
      }
    </div>
    }
  </div>
}

app.component.html

app.component.css:

.container-produtos {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 8px;
  max-width: 720px;
}

.produto {
  border: 4px solid #ccc;
  text-align: center;
}

.produto * {
  margin: 4px auto;
}

.produto-categoria {
  font-size: 1em;
  font-weight: bold;
  color: #888;
  font-family: Georgia, 'Times New Roman', Times, serif;
}

.produto-nome {
  font-size: 1.6em;
  font-weight: bold;
  font-family: 'Segoe UI', Tahoma, Verdana, sans-serif;
  margin: 0;
}

.produto-preco {
  font-size: 2em;
  font-weight: bold;
}

.produto-esgotado {
  color: #ccc;
  background: #eee;
  border-style: dotted;
}

.produto-com-desconto {
  border-color: #c35e79;
}

app.component.css

A seguir há uma imagem mostrando o projeto em execução:

Navegador mostrando o conteúdo do endereço localhost:4200. Na página, há 5 quadrados, onde cada um representa um projeto.
Projeto em execução

Atribuindo classes CSS dinamicamente com ngClass

O Angular tem um recurso chamado ngClass, que é uma diretiva extremamente útil que oferece diversas vantagens na manipulação de classes CSS de forma dinâmica. Ela permite que você aplique, remova ou alterne classes CSS com base em condições.

Adds and removes CSS classes on an HTML element.
The CSS classes are updated as follows, depending on the type of the expression evaluation:

string - the CSS classes listed in the string (space delimited) are added,
Array - the CSS classes declared as Array elements are added,
Object - keys are CSS classes that get added when the expression given in the value evaluates to a truthy value, otherwise they are removed.

fonte: https://angular.dev/api/common/NgClass?tab=description

Na documentação oficial encontramos os seguintes exemplos do ngClass:

<some-element
  [ngClass]="'first second'">
...
</some-element>

<some-element
  [ngClass]="['first', 'second']">
  ...
</some-element>

<some-element
  [ngClass]="{'first': true, 'second': true, 'third': false}">
  ...
</some-element>

<some-element
  [ngClass]="stringExp|arrayExp|objExp">
  ...
</some-element>

<some-element
  [ngClass]="{'class1 class2 class3' : true}">
  ...
</some-element>

A seguir construí alguns exemplos de como utilizar o ngClass. Em todos os casos, o resultado visual será exatamente o mesmo.

Exemplo 1: adicionando e removendo (toggle) uma classe CSS de forma dinâmica

Para adicionar ou remover uma classe CSS com base em uma condição booleana, podemos usar a sintaxe: [class.minha-classe-css]="variavel". Com essa sintaxe, a classe CSS só é adicionada se a condição for verdadeira. Por exemplo, a classe produto-esgotado será adicionada se a variável esgotado for true; caso contrário, a classe será removida.

<div
  class="produto"
  [class.produto-esgotado]="esgotado"
  [class.produto-com-desconto]="!esgotado
                                && porcentagemDeDesconto > 0">
  ...
</div>

trecho do app.component.html

Exemplo 2: adicionando e removendo (toggle) um conjunto de classes CSS de forma dinâmica

Uma opção similar à anterior é passar um objeto "chave-valor", onde a chave é o nome da classe CSS (ou múltiplos nomes separados por espaço) e o valor é um booleano que indica se a classe deve ser adicionada ou não. Veja o exemplo abaixo:

<div
  class="produto
  [ngClass]="{
    'produto-esgotado': esgotado,
    'produto-com-desconto': !esgotado
                            && porcentagemDeDesconto > 0
  }">
  ...
</div>

trecho do app.component.html

Exemplo 3: adicionando classes CSS através de um array

Outra opção é passar um array com o nome das classes CSS para o ngClass. No exemplo a seguir, criei uma função para retornar um objeto do tipo string[] com cada classe CSS:

<div
  [ngClass]="obterClassesCSSPorProduto(produto)">
   ...
</div>

trecho do app.component.html

obterClassesCSSPorProduto(produto: Produto): string[] {
  const classesCSS = ['produto'];

  if (produto.esgotado) {
    classesCSS.push('produto-esgotado');
  } else {
    if (produto.porcentagemDeDesconto > 0) {
      classesCSS.push('produto-com-desconto');
    }
  }

  return classesCSS;
}

trecho do app.component.ts

Testes unitários

Para concluir, criei um teste unitário que demonstra como validar a atribuição dinâmica de classes CSS com o ngClass:

import { TestBed } from '@angular/core/testing';
import { AppComponent, Produto } from './app.component';
import { of } from 'rxjs';

describe('AppComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [AppComponent],
    }).compileComponents();
  });

  it('should create the app', () => {
    const fixture =
      TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });

  it(`deve tratar as classes CSS de acordo com as
      propriedades do produto`, () => {
    const produtosMock: Produto[] = [
      {
        nome: 'Camisa',
        categoria: 'Vestuário',
        preco: 59.9,
        porcentagemDeDesconto: 20,
        esgotado: true,
      },
      {
        nome: 'Calça',
        categoria: 'Vestuário',
        preco: 129.9,
        porcentagemDeDesconto: 0,
        esgotado: false,
      },
      {
        nome: 'Calça',
        categoria: 'Vestuário',
        preco: 129.9,
        porcentagemDeDesconto: 10,
        esgotado: false,
      },
    ];

    const fixture =
      TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    spyOn(app, 'carregarProdutos').and.returnValue(
      of(produtosMock)
    );

    fixture.detectChanges();
    const compiled =
      fixture.nativeElement as HTMLElement;

    // ************************************************
    // É esperado que seja renderizado um <div
    // class="produto"> para cada item da variável
    // `produtosMock`:
    // ************************************************
    expect(
      compiled.querySelectorAll('div.produto')
    ).toHaveSize(produtosMock.length);

    // ************************************************
    // É esperado que sejam encontramos elementos
    // <div> com a classe CSS `produto-esgotado`
    // quando produto.esgotado === true
    // ************************************************
    const qtdProdutosEsgotados = produtosMock.filter(
      (a) => a.esgotado
    ).length;

    expect(
      compiled.querySelectorAll(
        'div.produto.produto-esgotado'
      )
    ).toHaveSize(qtdProdutosEsgotados);

    // ************************************************
    // É esperado que sejam encontramos elementos
    // <div> com a classe CSS `produto-com-desconto`
    // quando produto.esgotado === true &&
    // produto.porcentagemDeDesconto > 0
    // ************************************************
    const qtdProdutosEmEstoqueComDesconto =
      produtosMock.filter((a) => a.esgotado).length;

    expect(
      compiled.querySelectorAll(
        'div.produto.produto-com-desconto'
      )
    ).toHaveSize(qtdProdutosEmEstoqueComDesconto);
  });
});

app.component.spec.ts

Considerações

Existem diversas formas de utilizar o ngClass no Angular, cada uma com suas vantagens e desvantagens. A escolha da melhor abordagem depende do contexto específico e dos requisitos do seu projeto. Ao decidir como aplicar classes CSS, considere fatores como a legibilidade do código, a flexibilidade e o desempenho.

Links interessantes: