Módulo 22: Estilos: Tailwind en Angular

Objetivos del módulo

  • Entender qué le ocurre a cada hoja de estilos desde que la escribes hasta que llega al navegador
  • Saber por qué el CSS de un componente vence a una utilidad de Tailwind y cómo evitarlo con capas
  • Usar variables CSS como interfaz de estilo de un componente, y alimentarlas desde señales
  • Instalar Tailwind 4 en un proyecto Angular y explicar qué hace cada pieza
  • Evitar los errores típicos: clases concatenadas, @apply sin @reference y presupuestos superados
  • Construir componentes reutilizables con Tailwind usando input(), computed y mapas de clases completas
  • Crear un modo oscuro con señales: claro, oscuro y sistema, con memoria y sin parpadeo
  • Animar la entrada y la salida de elementos con animate.enter y animate.leave
  • Saber qué es seguro hacer con ellas en la LTS y qué cambia en Angular 22
  • Reconocer el estado real de @angular/animations, leer código que lo usa y migrarlo a CSS

Este módulo no enseña las utilidades de Tailwind una a una: para eso está el Curso de Tailwind CSS. Aquí verás cómo encaja Tailwind en Angular y qué hay que saber para que ambos se lleven bien. Si bg-indigo-600 o hover: te suenan a chino, empieza por sus módulos de fundamentos y variantes y estados.


1. Cuatro maneras de dar estilo

En una aplicación Angular conviven cuatro mecanismos. No compiten: cada uno resuelve un problema.

Mecanismo Dónde vive Alcance Para qué sirve
CSS global src/styles.css Toda la página Reset, tipografía base, tema
CSS de componente styleUrl o styles Solo ese componente Estilos ligados a una plantilla
Variables CSS Se declaran donde quieras Se heredan por el árbol Valores que cambian: tema, densidad, color de un dato
Utilidades de Tailwind Clases en la plantilla Donde escribas la clase Maquetar rápido sin inventar nombres

Antes de ver cada uno, conviene saber qué hace la compilación con tus estilos, porque casi todos los problemas de este módulo se explican con este esquema:

Tubería de estilos: styles.css pasa por PostCSS y Tailwind y acaba en styles-HASH.css; el CSS de componente pasa por PostCSS y encapsulación y acaba dentro de main-HASH.js; Tailwind lee html y ts como texto

Figura 1. Dos caminos distintos: el CSS global acaba en un fichero .css; el de componente, dentro del JavaScript.

Tres ideas salen de esta figura:

  • Tailwind no ejecuta tu código. Lee los ficheros como texto y busca cosas que parezcan clases. Esto explica el error más frecuente, que verás en el apartado 5.
  • El CSS de componente pasa también por PostCSS. Por eso @apply funciona en él, con una condición (apartado 5).
  • El CSS de componente viaja en el JavaScript. Cada kilobyte engorda el bundle inicial o el trozo perezoso de ese componente, y Angular lo vigila con un presupuesto llamado anyComponentStyle.

2. CSS en componentes

En el módulo 03 viste la encapsulación y :host. Aquí importa lo que llega al navegador, y para verlo usaremos una ficha de empleado:

// src/app/fichas/ficha-empleado.ts
import { Component, booleanAttribute, computed, input } from '@angular/core';

@Component({
  selector: 'app-ficha-empleado',
  templateUrl: './ficha-empleado.html',
  styleUrl: './ficha-empleado.css',
  host: {
    '[class.destacada]': 'destacada()',
    '[style.--ficha-acento]': 'acento()',
  },
})
export class FichaEmpleado {
  readonly nombre = input.required<string>();
  readonly oficio = input.required<string>();
  readonly salario = input.required<number>();
  readonly acento = input('#3f51b5');
  readonly destacada = input(false, { transform: booleanAttribute });

  protected readonly relleno = computed(() => `${Math.round((this.salario() / 7000) * 100)}%`);
}
<!-- src/app/fichas/ficha-empleado.html -->
<h3>{{ nombre() }}</h3>
<p class="oficio">{{ oficio() }}</p>
<div class="barra" [style.--relleno]="relleno()"></div>
<p class="salario">{{ salario() }} €</p>
/* src/app/fichas/ficha-empleado.css */
:host {
  display: block;
  padding: 16px;
  background: #fff;
  border: 1px solid #cfd8dc;
  border-top: 4px solid var(--ficha-acento, #3f51b5);
  border-radius: var(--ficha-radio, 8px);
}

:host(.destacada) {
  box-shadow: 0 4px 14px rgb(0 0 0 / 0.18);
}

h3 {
  margin: 0;
  font-size: 1.1rem;
  font-weight: 600;
  color: var(--ficha-acento, #3f51b5);
}

.oficio {
  margin: 2px 0 12px;
  font-size: 0.8rem;
  color: #546e7a;
}

.barra {
  height: 8px;
  border-radius: 4px;
  background: #eceff1;
  overflow: hidden;
}

.barra::after {
  content: '';
  display: block;
  height: 100%;
  width: var(--relleno, 0%);
  background: var(--ficha-acento, #3f51b5);
}

.salario {
  margin: 8px 0 0;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

El componente padre las coloca en una rejilla, y a propósito intenta colorear de rojo los h3:

// src/app/fichas/fichas.ts
import { Component } from '@angular/core';
import { FichaEmpleado } from './ficha-empleado';

@Component({
  selector: 'app-fichas',
  imports: [FichaEmpleado],
  template: `
    <h2>Fichas de empleados</h2>
    <div class="rejilla">
      <app-ficha-empleado nombre="Ana García" oficio="DIRECTOR GENERAL" [salario]="6500" acento="#c62828" destacada />
      <app-ficha-empleado nombre="Luis Martínez" oficio="DIRECTOR" [salario]="4800" />
      <app-ficha-empleado class="redondeada" nombre="Marta Díaz" oficio="ANALISTA" [salario]="3100" acento="#2e7d32" />
    </div>
  `,
  styles: `
    :host { display: block; padding: 20px; background: #f5f7fa; }
    h2 { margin: 0 0 14px; font-size: 1.3rem; font-weight: 700; }
    h3 { color: red; }
    .rejilla { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
    .redondeada { --ficha-radio: 24px; }
  `,
})
export class Fichas {}

Tres fichas de empleado con su barra de salario: Ana en rojo con sombra, Luis en azul y Marta en verde con esquinas muy redondeadas

Figura 2. Los títulos no salen rojos: la regla h3 del padre no llega a la ficha. La variable --ficha-radio, en cambio, sí atraviesa la encapsulación.

Qué genera la encapsulación

Este es el HTML real de la primera ficha, recortado, tal como lo muestran las DevTools:

<app-ficha-empleado _ngcontent-ng-c2152980374="" _nghost-ng-c2040283255=""
                    class="destacada" style="--ficha-acento: #c62828;">
  <h3 _ngcontent-ng-c2040283255="">Ana García</h3>

Y este es el CSS real, inyectado en una etiqueta <style>:

[_nghost-ng-c2040283255]{display:block;padding:16px; /* ... */}
.destacada[_nghost-ng-c2040283255]{box-shadow:0 4px 14px #0000002e}
h3[_ngcontent-ng-c2040283255]{margin:0;font-size:1.1rem; /* ... */}

Fíjate en el elemento app-ficha-empleado. Tiene dos atributos, y cada uno pertenece a un componente distinto:

  • _ngcontent-ng-c2152980374 lo pone el padre, porque ese elemento está en la plantilla de Fichas.
  • _nghost-ng-c2040283255 lo pone la propia ficha, porque es su anfitrión.

Por eso el h3 { color: red } del padre no funciona: Angular lo reescribe como h3[_ngcontent-<padre>], y el h3 de dentro lleva el atributo de la ficha. También por eso .redondeada sí funciona desde el padre: el anfitrión lleva los dos atributos, y es a la vez parte de la plantilla del padre y raíz de la ficha.

📘 Concepto: El anfitrión (host) es la frontera entre dos componentes. Desde fuera puedes tocar su caja: margen, posición, ancho. Lo de dentro le pertenece al componente. Si un padre necesita influir en el interior, la vía limpia es una variable CSS o un input(), no perforar la encapsulación.

💡 Consejo: :host no tiene display: block por defecto. Un elemento personalizado es inline, y sin esa línea el padding y los bordes se ven raros. Es uno de los fallos más comunes cuando un componente parece no respetar sus márgenes.

Enlazar clases y estilos

Lo viste en el módulo 04. Este resumen vale para el resto del módulo, porque con Tailwind usarás sobre todo [class]:

Sintaxis Efecto
[class.destacada]="activa()" Pone o quita una clase según un booleano
[class]="clases()" Añade las clases de una cadena, un array o un objeto, sin borrar las clases estáticas
[style.width.px]="ancho()" Una propiedad con su unidad
[style.--relleno]="relleno()" Una variable CSS

3. Variables CSS: la interfaz de estilo de un componente

Una variable CSS (custom property) se comporta distinto a un selector en un aspecto clave: se hereda por el árbol del DOM, y la encapsulación de Angular solo reescribe selectores. Por eso cruza la frontera de los componentes sin esfuerzo. Puedes pensar en ella como en un input() para CSS.

La ficha anterior ya usa los tres casos habituales:

/* 1. Un valor con alternativa: si nadie define la variable, 8px */
border-radius: var(--ficha-radio, 8px);
<!-- 2. Un valor calculado en TypeScript que llega a un pseudoelemento -->
<div class="barra" [style.--relleno]="relleno()"></div>
// 3. Un input publicado como variable en el anfitrión
host: { '[style.--ficha-acento]': 'acento()' },

El segundo caso merece atención. Desde una plantilla no hay manera de escribir un estilo en ::after, porque un pseudoelemento no es un nodo que puedas enlazar. Pero el pseudoelemento hereda las variables de su elemento, de modo que basta con publicar el valor en .barra y usarlo en el CSS.

¿Variable CSS o input()?

Situación Elige Motivo
El valor cambia la lógica o la estructura input() CSS no puede decidir qué se renderiza
El valor es solo apariencia y debe poder cambiarse desde un ancestro lejano Variable CSS Se hereda; no hay que pasarla de componente en componente
Un dato de negocio decide un color o un tamaño input() publicado como variable Tipado por fuera, flexible por dentro
Varios componentes deben cambiar a la vez (un tema) Variable CSS en :root o en una clase Un solo cambio, sin tocar ningún componente

El último caso es la base del modo oscuro del apartado 7.

⚠️ Importante: Documenta las variables públicas de tus componentes igual que sus input(). Si --ficha-radio no aparece en ninguna parte, para quien use el componente no existe. Fija siempre un valor alternativo en var(): una variable sin definir invalida la declaración y el navegador usa el valor heredado o inicial, sin ningún aviso.


4. Tailwind 4 en Angular

La instalación es parte del propio CLI. Tienes tres caminos, y los tres dejan el proyecto igual.

Proyecto nuevo:

ng new empresa --style=tailwind

Proyecto existente:

ng add tailwindcss

A mano, útil para entender qué ocurre:

npm install -D tailwindcss @tailwindcss/postcss postcss

Crea .postcssrc.json en la raíz del proyecto:

{
  "plugins": {
    "@tailwindcss/postcss": {}
  }
}

Y añade esto a src/styles.css:

@import "tailwindcss";

Eso es todo: no hay tailwind.config.js. La configuración de Tailwind 4 vive en el propio CSS, como se explica en Personalizar el tema.

Esto es lo que genera ng new --style=tailwind en la versión 22.2.5:

Fichero Contenido
.postcssrc.json El bloque de arriba
src/styles.css @import 'tailwindcss'; tras el comentario inicial
package.json tailwindcss, @tailwindcss/postcss y postcss como dependencias de desarrollo
src/app/app.css Sigue existiendo: los componentes conservan su .css

La opción --style=tailwind y ng add tailwindcss también existen en Angular 21, con los mismos rangos de versiones en sus plantillas.

Cómo se conecta con el builder

@angular/build busca un fichero postcss.config.json o .postcssrc.json, en ese orden, en el proyecto y en la raíz del espacio de trabajo. Si lo encuentra, pasa cada hoja de estilos por esos plugins.

⚠️ Importante: Solo se admiten esos dos nombres, y deben ser JSON. Un postcss.config.js copiado de un tutorial de React o Vite no se lee: el builder no lo busca, de modo que el plugin de Tailwind no llega a ejecutarse y tus utilidades no se generan.

Qué clases genera

Tailwind busca clases en todos los ficheros de texto del proyecto que no estén en .gitignore: plantillas .html, plantillas en línea dentro de .ts, y también objetos de clases escritos en TypeScript. Solo genera CSS para lo que encuentra, y por eso la aplicación de este módulo, con decenas de utilidades, produjo un styles.css de menos de 20 kB.

Durante ng serve, el proceso es automático. Comprobado con el servidor en marcha: al escribir una clase nueva en una plantilla, Angular reconstruye y la clase aparece en styles.css sin reiniciar nada.

💡 Consejo: La búsqueda es literal e incluye ficheros que no compilan. Mientras escribíamos este módulo, una clase mencionada en un script de pruebas suelto en la raíz acabó en el CSS final. No rompe nada, pero explica por qué a veces una clase «funciona» sin que la uses en ninguna plantilla, y por qué deja de funcionar cuando borras ese fichero.


5. Los tres errores que todo el mundo comete

Clases concatenadas

Este código parece razonable:

protected readonly color = 'pink';
<div [class]="'bg-' + color + '-500'">No tendrá fondo</div>

El binding funciona y el elemento recibe la clase bg-pink-500, pero esa clase no existe en el CSS. Recuerda que Tailwind solo lee texto, y en el fichero no aparece nunca bg-pink-500: aparecen tres trozos sueltos. Comprobado sobre ng serve: tras añadir el código anterior, styles.css no contiene bg-pink-500.

La solución es que la clase completa esté escrita en algún sitio:

const COLORES: Record<number, string> = {
  20: 'bg-emerald-100 text-emerald-800',
  30: 'bg-violet-100 text-violet-800',
};

Si el color es un dato libre, que llega de la API, Tailwind no es la herramienta adecuada. Usa una variable CSS, que es para lo que existen:

<span class="bg-(--color-dato)" [style.--color-dato]="colorDeLaApi()">Dato</span>

@apply sin @reference

Cada hoja de componente se procesa por separado. Tailwind no sabe qué hay en styles.css cuando procesa campo.css, así que esto falla:

/* src/app/campo/campo.css: INCORRECTO */
.campo {
  @apply rounded-md px-3;
}
Error: Cannot apply unknown utility class `rounded-md`. Are you using CSS modules
or similar and missing `@reference`?
https://tailwindcss.com/docs/functions-and-directives#reference-directive

La solución es señalar la hoja donde está tu configuración. @reference importa el tema y las variantes sin emitir ningún CSS:

/* src/app/ejercicios/campo-texto.css */
@reference "../../styles.css";

.etiqueta {
  @apply block text-sm font-medium text-texto;
}

.campo {
  @apply mt-1 block w-full rounded-md border border-borde bg-superficie px-3 py-2;
  @apply focus:border-indigo-500 focus:outline-2 focus:outline-indigo-500;
}

@import "tailwindcss" en un componente

La tentación es arreglar el error anterior así. El resultado es que todo Tailwind base se copia dentro de ese componente, y por tanto dentro del JavaScript. Con solo tres líneas de CSS propio, el presupuesto del proyecto generado por ng new lo rechaza:

X [ERROR] src/app/fallo2.css exceeded maximum budget.
Budget 8.00 kB was not met by 9.82 kB with a total of 17.82 kB.

Es de agradecer que falle: sin presupuesto, cada componente arrastraría su propia copia. El @import "tailwindcss" debe aparecer una sola vez, en styles.css.

Bonus: el CSS de componente vence a la utilidad

Este cuarto error es el más desconcertante, porque el código parece correcto:

// src/app/capas/capas.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-sin-capa',
  template: `<button class="rounded bg-blue-600 px-4 py-2 text-white">Sin capa</button>`,
  styles: `button { background-color: tomato; }`,
})
export class SinCapa {}

@Component({
  selector: 'app-con-capa',
  template: `<button class="rounded bg-blue-600 px-4 py-2 text-white">Con capa</button>`,
  styles: `@layer components { button { background-color: tomato; } }`,
})
export class ConCapa {}

Dos botones: el primero, sin capa, sale de color tomate; el segundo, con capa, sale azul

Figura 3. El mismo botón con la misma clase bg-blue-600. En el primero gana el CSS del componente; en el segundo, la utilidad.

Los valores reales que da getComputedStyle son rgb(255, 99, 71) para el primero y oklch(0.546 0.245 262.881) para el segundo.

La explicación está en las capas de cascada. Tailwind 4 mete sus utilidades en @layer utilities, y la regla de CSS es tajante: lo que no está en ninguna capa vence a cualquier capa, sin mirar la especificidad. Da igual que .bg-blue-600 sea más específico que button. El orden se decide antes.

Cuando metes tu regla en @layer components, pasa a competir dentro del sistema de capas de Tailwind, donde utilities va después, y por tanto gana. Es lo más práctico si quieres que tus componentes definan un valor por defecto y que una clase de Tailwind pueda sobrescribirlo. Puedes repasar las capas en Reutilización y componentes.

📘 Concepto: Esto vale también para styles.css. Si escribes button { ... } tras el @import, sin capa, esa regla ganará a todas las utilidades de Tailwind que afecten a la misma propiedad.


6. Componentes reutilizables con Tailwind

Tailwind llena las plantillas de clases. En React se resuelve con funciones; en Angular, la mejor pieza es un componente pequeño que reciba input() y calcule sus clases con computed.

Un botón con variantes

// src/app/tabla/boton.ts
import { Component, computed, input } from '@angular/core';

export type VarianteBoton = 'primario' | 'secundario' | 'peligro';

const BASE =
  'inline-flex items-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors ' +
  'focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50';

const VARIANTES: Record<VarianteBoton, string> = {
  primario: 'bg-indigo-600 text-white hover:bg-indigo-700 focus-visible:outline-indigo-600',
  secundario: 'border border-borde bg-superficie text-texto hover:bg-fondo focus-visible:outline-slate-500',
  peligro: 'bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600',
};

@Component({
  selector: 'button[appBoton]',
  template: `<ng-content />`,
  host: { '[class]': 'clases()', type: 'button' },
})
export class Boton {
  readonly variante = input<VarianteBoton>('primario');
  protected readonly clases = computed(() => `${BASE} ${VARIANTES[this.variante()]}`);
}

Tres decisiones de este código merecen explicación:

  • Selector de atributo. button[appBoton] hace que el elemento real siga siendo un <button>. Conservas gratis el foco, el teclado, disabled y todo lo que viste en el módulo 21. Un <app-boton> envolviendo un botón te obligaría a reenviar cada atributo.
  • Clases completas en un Record. Tailwind las encuentra todas, y TypeScript te avisa si añades una variante y olvidas su entrada.
  • [class] en el anfitrión. Quien use el botón puede añadir sus propias clases estáticas sin que se pierdan.
<button appBoton>Guardar</button>
<button appBoton variante="secundario" [disabled]="pagina() === 1">Anterior</button>

⚠️ Importante: Las clases bg-borde, text-texto y bg-superficie no son de Tailwind: son tokens propios que definiremos en el apartado 7. Si copias este botón antes de llegar allí, no tendrán efecto.

Insignias de estado

El mismo patrón sirve para traducir un dato de negocio a colores. Aquí el tipo EstadoPedido obliga a cubrir los cuatro estados de la API:

// src/app/modelos.ts
export type EstadoPedido = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO';
// src/app/tabla/insignia-estado.ts
import { Component, computed, input } from '@angular/core';
import { EstadoPedido } from '../modelos';

const COLORES: Record<EstadoPedido, string> = {
  PENDIENTE: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
  ENVIADO: 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-200',
  ENTREGADO: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
  CANCELADO: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};

@Component({
  selector: 'app-insignia-estado',
  template: `{{ estado() }}`,
  host: { '[class]': 'clases()' },
})
export class InsigniaEstado {
  readonly estado = input.required<EstadoPedido>();
  protected readonly clases = computed(
    () => `inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold ${COLORES[this.estado()]}`,
  );
}

La tabla de empleados 🔄 v22.0

🔄 Cambia en Angular 22.0. httpResource pasa de experimental a estable. En la LTS (Angular 21) es experimental. Si usas la LTS: este ejemplo compila y funciona igual, pero httpResource puede cambiar entre versiones; si no quieres depender de ello, carga los datos con HttpClient como en el módulo 12.

Juntamos las piezas en una tabla paginada contra la API EMPRESA, que cargamos con httpResource como en el módulo 13:

// src/app/api.ts
export const API = 'http://localhost:3000/api';
// src/app/modelos.ts (añade esto)
export interface Empleado {
  empId: number;
  nombre: string;
  apellidos: string;
  oficio: string;
  salario: number;
  deptId: number;
  departamento: string;
}

export interface Pagina<T> {
  contenido: T[];
  pagina: number;
  tamano: number;
  total: number;
  paginas: number;
}
// src/app/tabla/tabla-empleados.ts
import { httpResource } from '@angular/common/http';
import { CurrencyPipe } from '@angular/common';
import { Component, computed, signal } from '@angular/core';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';
import { Boton } from './boton';
import { SelectorTema } from '../tema/selector-tema';

@Component({
  selector: 'app-tabla-empleados',
  imports: [Boton, CurrencyPipe, SelectorTema],
  template: `
    <section class="min-h-screen bg-fondo p-6 text-texto">
      <header class="mb-4 flex items-center justify-between">
        <h2 class="text-xl font-bold">Empleados</h2>
        <app-selector-tema />
      </header>

      <div class="overflow-hidden rounded-lg border border-borde bg-superficie shadow-sm">
        <table class="w-full text-left text-sm">
          <thead class="bg-fondo text-xs uppercase text-texto-suave">
            <tr>
              <th scope="col" class="px-4 py-3">Nombre</th>
              <th scope="col" class="px-4 py-3">Oficio</th>
              <th scope="col" class="px-4 py-3">Departamento</th>
              <th scope="col" class="px-4 py-3 text-right">Salario</th>
            </tr>
          </thead>
          <tbody class="divide-y divide-borde">
            @for (e of empleados(); track e.empId) {
              <tr class="hover:bg-fondo">
                <td class="px-4 py-2 font-medium">{{ e.nombre }} {{ e.apellidos }}</td>
                <td class="px-4 py-2 text-texto-suave">{{ e.oficio }}</td>
                <td class="px-4 py-2">{{ e.departamento }}</td>
                <td class="px-4 py-2 text-right tabular-nums">
                  {{ e.salario | currency: 'EUR' : 'symbol' : '1.0-0' }}
                </td>
              </tr>
            }
          </tbody>
        </table>
      </div>

      <footer class="mt-4 flex items-center gap-2">
        <button appBoton variante="secundario" [disabled]="pagina() === 1" (click)="mover(-1)">Anterior</button>
        <span class="text-sm text-texto-suave">Página {{ pagina() }} de {{ paginas() }}</span>
        <button appBoton variante="secundario" [disabled]="pagina() === paginas()" (click)="mover(1)">Siguiente</button>
      </footer>
    </section>
  `,
})
export class TablaEmpleados {
  protected readonly pagina = signal(1);

  private readonly respuesta = httpResource<Pagina<Empleado>>(() => ({
    url: `${API}/empleados`,
    params: { pagina: this.pagina(), tamano: 6, orden: '-salario' },
  }));

  protected readonly empleados = computed(() =>
    this.respuesta.hasValue() ? this.respuesta.value().contenido : [],
  );
  protected readonly paginas = computed(() =>
    this.respuesta.hasValue() ? this.respuesta.value().paginas : 1,
  );

  protected mover(delta: number) {
    this.pagina.update((p) => p + delta);
  }
}

La tabla usa el locale es, registrado como viste en el módulo 07, para que el importe salga como 6.500 €.

Tabla de empleados en modo claro con nombre, oficio, departamento y salario, un selector de tema y botones de paginación

Figura 4. Tabla real con datos de la API EMPRESA, ordenada por salario.

💡 Consejo: ¿Cuándo crear un componente y cuándo usar @apply? Si el patrón tiene comportamiento o varias etiquetas, haz un componente. Reserva @apply para casos en los que no controlas el HTML, como el contenido que llega con innerHTML. El propio Curso de Tailwind lo argumenta con más detalle.


7. Modo oscuro con señales

La teoría de dark: y color-scheme está en Modo oscuro y temas. Aquí resolvemos lo que depende de Angular: quién guarda el estado y cómo llega al DOM.

Lo que queremos es que el usuario elija entre tres opciones: claro, oscuro o sistema. La tercera debe seguir al sistema operativo incluso si este cambia con la aplicación abierta, por ejemplo a la hora en que el móvil oscurece la pantalla.

Paso 1: decirle a Tailwind cómo se activa

Por defecto, dark: obedece a la preferencia del sistema. Para controlarlo con una clase, se redefine la variante en styles.css:

/* src/styles.css */
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

Paso 2: tokens semánticos

Podrías escribir bg-white dark:bg-slate-800 en cada tarjeta, pero es repetitivo y fácil de olvidar. Es mejor dar nombre al papel de cada color y hacer que ese nombre cambie de valor:

/* src/styles.css (a continuación) */
:root {
  color-scheme: light;
  --superficie: var(--color-white);
  --fondo: var(--color-slate-100);
  --texto: var(--color-slate-900);
  --texto-suave: var(--color-slate-600);
  --borde: var(--color-slate-200);
}

.dark {
  color-scheme: dark;
  --superficie: var(--color-slate-800);
  --fondo: var(--color-slate-900);
  --texto: var(--color-slate-100);
  --texto-suave: var(--color-slate-400);
  --borde: var(--color-slate-700);
}

@theme inline {
  --color-superficie: var(--superficie);
  --color-fondo: var(--fondo);
  --color-texto: var(--texto);
  --color-texto-suave: var(--texto-suave);
  --color-borde: var(--borde);
}

@theme crea utilidades: gracias a él existen bg-superficie, text-texto y border-borde. Y inline hace que la utilidad use var(--superficie) directamente, de modo que cambia con la clase .dark. Una tabla escrita con esos tokens se adapta sola, sin un solo dark:.

Paso 3: el servicio 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: en lugar de @Service(), escribe @Injectable({ providedIn: 'root' }), importado de @angular/core; el resto del apartado funciona igual.

// src/app/tema/tema.ts
import { DOCUMENT, Service, computed, effect, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { fromEvent, map } from 'rxjs';

export type Preferencia = 'claro' | 'oscuro' | 'sistema';

const CLAVE = 'tema';

@Service()
export class Tema {
  private readonly documento = inject(DOCUMENT);
  private readonly consulta = this.documento.defaultView!.matchMedia('(prefers-color-scheme: dark)');

  private readonly sistemaOscuro = toSignal(
    fromEvent<MediaQueryListEvent>(this.consulta, 'change').pipe(map((e) => e.matches)),
    { initialValue: this.consulta.matches },
  );

  readonly preferencia = signal<Preferencia>(this.leerGuardada());

  readonly oscuro = computed(() => {
    const preferencia = this.preferencia();
    return preferencia === 'sistema' ? this.sistemaOscuro() : preferencia === 'oscuro';
  });

  constructor() {
    effect(() => {
      this.documento.documentElement.classList.toggle('dark', this.oscuro());
    });
    effect(() => {
      localStorage.setItem(CLAVE, this.preferencia());
    });
  }

  private leerGuardada(): Preferencia {
    const guardada = localStorage.getItem(CLAVE);
    return guardada === 'claro' || guardada === 'oscuro' ? guardada : 'sistema';
  }
}

Flujo: las señales preferencia y sistemaOscuro alimentan el computed oscuro, que un effect convierte en la clase dark de html; otro effect guarda la preferencia

Figura 5. El estado vive en señales y el aspecto, en CSS. Los effect son el único puente entre ambos.

Repasa las piezas, porque aquí se juntan varios módulos:

  • preferencia es lo que elige el usuario. oscuro es lo que hay que pintar. Son datos distintos, y confundirlos es el error típico: con «sistema», el resultado depende de algo externo.
  • toSignal convierte el evento change en una señal, como en el módulo 15. sistemaOscuro se actualiza sola.
  • Los dos effect hacen trabajo fuera del mundo de las señales, que es justo el uso legítimo de effect del módulo 05.
  • inject(DOCUMENT) en lugar de document deja el servicio más fácil de probar. Lo importamos de @angular/core, que lo exporta tanto en 22 como en 21.

Un servicio no se crea hasta que alguien lo inyecta. Si el selector de tema solo aparece en ciertas páginas, las demás no aplicarían el tema, así que hay que inyectarlo en el componente raíz:

// src/app/app.ts
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Tema } from './tema/tema';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `<router-outlet />`,
})
export class App {
  private readonly tema = inject(Tema);
}

⚠️ Importante: Este servicio usa localStorage y matchMedia, que no existen en el servidor. Así está bien para una aplicación de cliente, pero con SSR fallaría al arrancar. En el módulo 24 verás los cuidados necesarios.

Paso 4: el selector

// src/app/tema/selector-tema.ts
import { Component, inject } from '@angular/core';
import { Preferencia, Tema } from './tema';

@Component({
  selector: 'app-selector-tema',
  template: `
    <div role="group" aria-label="Tema" class="inline-flex overflow-hidden rounded-md border border-borde">
      @for (opcion of opciones; track opcion.valor) {
        <button
          type="button"
          class="px-3 py-1 text-sm aria-pressed:bg-indigo-600 aria-pressed:text-white"
          [attr.aria-pressed]="tema.preferencia() === opcion.valor"
          (click)="tema.preferencia.set(opcion.valor)"
        >
          {{ opcion.texto }}
        </button>
      }
    </div>
  `,
})
export class SelectorTema {
  protected readonly tema = inject(Tema);
  protected readonly opciones: { valor: Preferencia; texto: string }[] = [
    { valor: 'claro', texto: 'Claro' },
    { valor: 'oscuro', texto: 'Oscuro' },
    { valor: 'sistema', texto: 'Sistema' },
  ];
}

aria-pressed no es decoración: anuncia a un lector de pantalla qué opción está activa, y la variante aria-pressed: lo aprovecha para el estilo. Un único dato gobierna el aspecto y la accesibilidad, así que no pueden desincronizarse.

La misma tabla de empleados en modo oscuro, con el botón Oscuro resaltado

Figura 6. La misma tabla con el tema oscuro. No hay ni un dark: en su plantilla: cambian los valores de los tokens.

Panel de pedido en oscuro con insignias de estado y botones primario, secundario y de peligro

Figura 7. Insignias y botones en oscuro. Aquí la preferencia es «Sistema», y la página sigue al navegador configurado en oscuro. Las insignias usan dark: porque cada estado tiene su propio color.

Probamos el comportamiento con Playwright, emulando la preferencia del sistema:

Acción Clase dark localStorage color-scheme
Primera visita, sistema claro No sistema light
Pulsar «Oscuro» Sí oscuro dark
Recargar la página Sí oscuro dark
Pulsar «Sistema» No sistema light
El sistema pasa a oscuro, sin tocar nada Sí sistema dark
El sistema vuelve a claro No sistema light

Paso 5: evitar el parpadeo

Queda un defecto. Las señales se ejecutan cuando arranca Angular, es decir, después de que el navegador haya pintado el HTML inicial. Con el tema oscuro guardado, verías un instante de pantalla blanca al recargar. Este fallo tiene nombre: FOUC, flash of unstyled content.

La solución es aplicar la clase antes de pintar, con un pequeño script en línea dentro de <head>:

<!-- src/index.html -->
<head>
  <!-- ... -->
  <script>
    (function () {
      var guardado = localStorage.getItem('tema');
      var oscuro = guardado === 'oscuro' ||
        (guardado !== 'claro' && matchMedia('(prefers-color-scheme: dark)').matches);
      document.documentElement.classList.toggle('dark', oscuro);
    })();
  </script>
</head>

Lo comprobamos bloqueando la carga del JavaScript de Angular: aun así, la etiqueta <html> recibe la clase dark.

La lógica está duplicada, y es inevitable: ese script se ejecuta antes de que exista Angular. Mantenlo pequeño, y recuerda que una política CSP estricta lo bloqueará salvo que le des un nonce; lo verás en el módulo 27.


8. Animaciones nativas: animate.enter y animate.leave

El problema

Animar la entrada de un elemento es fácil en CSS puro: al aparecer en el DOM, una animation se ejecuta sola. La salida es otra historia. Cuando @if pasa a false, Angular borra el nodo en ese instante, y no hay nada a lo que animar. Hace falta alguien que diga: «espera a que termine la animación y borra después».

Eso es lo que hacen animate.enter y animate.leave.

📘 Concepto: animate.enter y animate.leave no son directivas ni necesitan importar nada: son sintaxis que entiende directamente el compilador de plantillas. Le dices a Angular qué clase poner; la animación la hace el navegador con CSS.

Su estado es importante, porque existe bastante confusión:

  Angular 22.2 Angular 21.2 (LTS)
animate.enter / animate.leave Disponible, sin marca de preview Disponible, sin marca de preview (existe desde 20.2)
Tipos AnimationCallbackEvent, MAX_ANIMATION_TIMEOUT En @angular/core En @angular/core
Paquete @angular/animations Obsoleto desde 20.2 Obsoleto desde 20.2

Es decir: no son una novedad de Angular 22. Casi todo este apartado funciona igual en la LTS; solo los dos subapartados marcados cambian.

La forma básica

// src/app/anim/aviso-demo.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-aviso-demo',
  template: `
    <button id="alternar" type="button" (click)="visible.update((v) => !v)">Alternar</button>
    @if (visible()) {
      <p id="aviso" class="aviso" animate.enter="aparecer" animate.leave="desaparecer">
        Empleado guardado
      </p>
    }
  `,
  styles: `
    .aviso { margin: 12px; padding: 12px; border-radius: 6px; background: #e8eaf6; }
    .aparecer { animation: aparecer 300ms ease-out; }
    .desaparecer { animation: desaparecer 300ms ease-in forwards; }
    @keyframes aparecer { from { opacity: 0; transform: translateY(-10px); } }
    @keyframes desaparecer { to { opacity: 0; transform: translateY(-10px); } }
  `,
})
export class AvisoDemo {
  protected readonly visible = signal(false);
}

Medimos en Chromium lo que hace cada fase:

Fase Clases observadas Clases al final Tiempo
Entrada aviso aparecer aviso La clase se retira al terminar
Salida aviso desaparecer Nodo borrado Borrado a los 309 ms de pulsar

Línea de tiempo: se añade la clase de entrada, se retira al terminar, se añade la de salida al pasar el @if a false y el nodo se borra cuando acaba

Figura 8. Angular solo gestiona clases y tiempos. Escucha el final de la animación y actúa.

Observa forwards en la salida. Sin él, al terminar la animación el elemento volvería por un instante a su estado normal antes de ser borrado, y se notaría un parpadeo.

💡 Consejo: En @keyframes, puedes omitir el extremo que coincide con el estado normal. from { opacity: 0 } significa «empieza transparente y llega a como sea el elemento». Es más corto y no repite valores.

Transiciones y @starting-style

Las transiciones CSS necesitan un valor inicial y uno final, pero un elemento recién creado no tiene valor previo. Para eso existe @starting-style, que define desde dónde parte:

// src/app/anim/transicion.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-transicion',
  template: `
    <button id="alternar" type="button" (click)="abierto.update((a) => !a)">Alternar</button>
    @if (abierto()) {
      <div id="css" class="panel" animate.enter="abriendo" animate.leave="cerrando">CSS propio</div>
      <div
        id="tw"
        class="m-3 rounded bg-indigo-100 p-3 transition duration-300 starting:-translate-y-2 starting:opacity-0"
        animate.leave="opacity-0 -translate-y-2"
      >
        Tailwind
      </div>
    }
  `,
  styles: `
    .panel { margin: 12px; padding: 12px; background: #e8eaf6; }
    .abriendo {
      transition: opacity 300ms, transform 300ms;
      @starting-style { opacity: 0; transform: translateY(-10px); }
    }
    .cerrando {
      transition: opacity 300ms, transform 300ms;
      opacity: 0;
      transform: translateY(-10px);
    }
  `,
})
export class Transicion {
  protected readonly abierto = signal(false);
}

La segunda caja es la versión Tailwind, y resulta muy compacta:

  • transition duration-300 define la transición, siempre presente.
  • starting:opacity-0 es la variante de Tailwind para @starting-style. Como funciona sola al aparecer, ni siquiera necesita animate.enter.
  • animate.leave="opacity-0 -translate-y-2" añade varias clases separadas por espacios. El nodo se borró a los 293 ms.

Puedes repasar starting: y las transiciones en Transiciones, animaciones y 3D.

Animaciones de Tailwind

Si vas a reutilizar una animación por toda la aplicación, declárala en @theme. Tailwind te da la utilidad, y sus @keyframes quedan disponibles para toda la aplicación:

/* src/styles.css (a continuación) */
@theme {
  --animate-entrada: entrada 250ms ease-out;
  --animate-salida: salida 200ms ease-in forwards;

  @keyframes entrada {
    from { opacity: 0; transform: translateY(-8px); }
  }
  @keyframes salida {
    to { opacity: 0; transform: translateY(-8px); }
  }
}
<li animate.enter="animate-entrada" animate.leave="animate-salida">...</li>

Encaja bien con la encapsulación: los @keyframes de un componente se quedan en ese componente, mientras que los de @theme son globales.

Clases calculadas

Con corchetes, el valor es una expresión: una cadena o un array.

<p [animate.enter]="claseEntrada()" [animate.leave]="claseSalida()">Con señal</p>
<p [animate.enter]="['desde-izquierda', 'lenta']">Con array</p>

Es útil para animar según el contexto; por ejemplo, un carrusel que entra por la derecha o la izquierda según la dirección de avance. Las dos formas anteriores funcionan igual en 22 y en 21.

Enlazar la señal o la función sin llamarla 🔄 v22.2

🔄 Cambia en Angular 22.2. [animate.enter]="miSenal" y [animate.enter]="miFuncion", sin paréntesis, funcionan desde 22.2. En la LTS (Angular 21) compilan, pero no animan nada. Si usas la LTS: llama siempre a la señal o a la función en la plantilla: [animate.enter]="miSenal()".

protected readonly claseEntrada = signal('desde-izquierda');
protected readonly calcular = () => `desde-${this.direccion()}`;
<p [animate.enter]="claseEntrada">Señal sin llamar</p>
<p [animate.enter]="calcular">Función sin llamar</p>

Estas fueron las clases observadas durante la entrada en cada versión:

Enlace 22.2.4 21.2.25
[animate.enter]="claseEntrada()" Aplica la clase Aplica la clase
[animate.enter]="claseEntrada" Aplica la clase Nada, sin error
[animate.enter]="calcular" Aplica la clase Nada, sin error
[animate.leave]="claseSalida" Salida de 330 ms Borrado a los 12 ms

Además, en ambas versiones el compilador avisa con una advertencia al ver una señal sin llamar:

▲ [WARNING] NG8109: claseEntrada is a function and should be invoked: claseEntrada()

Conclusión práctica: escribe siempre los paréntesis. Funciona en las dos versiones y no genera avisos.

Control total con funciones

Si la animación no se puede expresar con una clase, usa la forma de evento, con paréntesis. Sirve para la Web Animations API o para bibliotecas externas:

// src/app/anim/funcion.ts
import { AnimationCallbackEvent, Component, signal } from '@angular/core';

@Component({
  selector: 'app-funcion',
  template: `
    <button id="alternar" type="button" (click)="visible.update((v) => !v)">Alternar</button>
    @if (visible()) {
      <p class="caja" (animate.enter)="entrar($event)" (animate.leave)="salir($event)">Web Animations</p>
    }
  `,
  styles: `.caja { margin: 8px; padding: 8px; background: #e8eaf6; }`,
})
export class Funcion {
  protected readonly visible = signal(false);

  protected entrar(evento: AnimationCallbackEvent) {
    evento.target.animate(
      [{ opacity: 0, transform: 'scale(0.9)' }, { opacity: 1, transform: 'scale(1)' }],
      { duration: 250, easing: 'ease-out' },
    );
    evento.animationComplete();
  }

  protected async salir(evento: AnimationCallbackEvent) {
    const animacion = evento.target.animate([{ opacity: 1 }, { opacity: 0 }], {
      duration: 300,
      fill: 'forwards',
    });
    await animacion.finished;
    evento.animationComplete();
  }
}

En la salida, tú tienes la responsabilidad de avisar: Angular no sabe qué estás haciendo y mantiene el nodo hasta que llamas a animationComplete().

Si lo olvidas, Angular tiene una red de seguridad: borra el nodo pasados 4 segundos. Lo medimos: una salida sin animationComplete() tardó 4015 ms en 22 y 4022 ms en 21, sin aviso alguno en consola. Si notas un elemento fantasma que tarda justo cuatro segundos en irse, ya sabes por dónde mirar.

El plazo se cambia con un token de @angular/core:

// src/app/app.config.ts
import { MAX_ANIMATION_TIMEOUT } from '@angular/core';

// en providers:
{ provide: MAX_ANIMATION_TIMEOUT, useValue: 1500 },

Animar el anfitrión

animate.enter y animate.leave también funcionan en host. Así, el componente lleva su animación consigo y el padre no tiene que recordar nada:

@Component({
  selector: 'app-hijo-host',
  template: `<p>Hijo con leave en host</p>`,
  host: { 'animate.leave': 'adios' },
  styles: `
    :host { display: block; }
    :host(.adios) { animation: adios-host 400ms forwards; }
    @keyframes adios-host { to { opacity: 0; } }
  `,
})
export class HijoHost {}

Salidas anidadas 🔄 v22.0

🔄 Cambia en Angular 22.0. Si se borra un contenedor, las salidas de sus descendientes de la misma plantilla ahora se esperan. En la LTS (Angular 21) solo se anima el elemento raíz que se borra; lo de dentro desaparece al momento. Si usas la LTS: pon animate.leave en el elemento más externo del @if, que sí se espera.

Observa este bloque. La condición quita #fondo, pero la animación está en un hijo:

@Component({
  selector: 'app-anidado',
  imports: [HijoAnimado, HijoHost],
  template: `
    <button id="alternar" type="button" (click)="visible.update((v) => !v)">Alternar</button>
    @if (visible()) {
      <div id="fondo">
        <div id="dialogo" animate.leave="sale">Diálogo</div>
      </div>
    }
    <!-- ... -->
  `,
  styles: `
    .sale { animation: sale 400ms forwards; }
    @keyframes sale { to { opacity: 0; transform: scale(0.9); } }
  `,
})
export class Anidado { /* ... */ }

Estos son los tiempos que mide #fondo hasta salir del DOM, tres casos con la misma animación de 400 ms:

Dónde está animate.leave 22.2.4 21.2.25
Descendiente en la misma plantilla 415 ms 1 ms
Anfitrión de un hijo, dentro del bloque 408 ms 1 ms
Dentro de la plantilla de un componente hijo 2 ms 2 ms

La última fila es el límite que recoge la guía oficial: la espera acaba en la frontera del componente. Angular no entra en las plantillas de los hijos para buscar salidas. Por eso, si un hijo debe animarse al irse, pon la animación en su host, como el ejemplo anterior.

📘 Concepto: El historial explica por qué conviene no dar esto por sentado. El changelog registra que esta función se añadió en 21.2.0 y se retiró en 21.2.4, y que volvió en 22.0.0 limitada a la frontera del componente, como cambio incompatible: «Leave animations are no longer limited to the element being removed». En la LTS actual no está.

Listas con @for

Las listas son el caso que más luce. Cada fila anima su entrada y su salida, y con una variable CSS logras un efecto de cascada:

// src/app/anim/lista-animada.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-lista-animada',
  template: `
    <button type="button" (click)="anadir()">Añadir</button>
    <button type="button" (click)="invertir()">Invertir</button>
    <ul>
      @for (nombre of nombres(); track nombre; let i = $index) {
        <li
          class="fila"
          [style.--indice]="i"
          animate.enter="entra"
          animate.leave="sale"
          (click)="quitar(nombre)"
        >
          {{ nombre }}
        </li>
      }
    </ul>
  `,
  styles: `
    .fila { padding: 8px; margin: 4px; background: #e8eaf6; cursor: pointer; }
    .entra {
      animation: entra 300ms ease-out backwards;
      animation-delay: calc(var(--indice) * 60ms);
    }
    .sale { animation: sale 250ms ease-in forwards; }
    @keyframes entra { from { opacity: 0; transform: translateX(-16px); } }
    @keyframes sale { to { opacity: 0; transform: translateX(16px); } }
  `,
})
export class ListaAnimada {
  protected readonly nombres = signal(['Ana', 'Luis', 'Marta', 'Sergio']);
  private contador = 0;

  protected anadir() {
    this.contador++;
    this.nombres.update((n) => [...n, `Nuevo ${this.contador}`]);
  }

  protected quitar(nombre: string) {
    this.nombres.update((n) => n.filter((x) => x !== nombre));
  }

  protected invertir() {
    this.nombres.update((n) => [...n].reverse());
  }
}

El efecto cascada es el sustituto directo de stagger(): [style.--indice] publica la posición, y animation-delay la multiplica. El valor backwards aplica el primer fotograma durante la espera; sin él, verías cada fila aparecer de golpe y luego animarse.

Cuatro filas en plena cascada de entrada, cada una más transparente y desplazada que la anterior

Figura 9. Lista congelada 200 ms después de empezar. Ana ya casi ha llegado y Sergio apenas se ve.

Lista con la fila de Luis desplazada a la derecha y semitransparente mientras las demás siguen en su sitio

Figura 10. La fila de Luis, congelada a mitad de animate.leave. El nodo sigue en el DOM hasta que termine.

⚠️ Importante: Mover un elemento no es borrarlo, y animate.leave no se dispara al reordenar. Pero animate.enter sí puede repetirse en los nodos que Angular reinserta. Al invertir una lista de cuatro filas, una de ellas repitió la clase de entrada en 22.2.4, y tres en 21.2.25. No diseñes nada que dependa de cuáles se repiten. Si la lista se reordena a menudo, usa una animación de entrada discreta, o aplica la clase tú mismo solo a los elementos nuevos.

También es imprescindible un track estable, como viste en el módulo 04. Con track $index, al borrar la segunda fila Angular reutiliza los nodos y anima el último, no el que has borrado.

Lo que no hacen

Caso Qué ocurre Comprobado
Cambiar de ruta animate.leave del componente saliente no se espera: el nodo se borró a los 9 ms en 22 y en 21 Sí
Una clase sin animación ni transición El nodo se borra enseguida: 12 ms, como sin animate.leave Sí
Usuario con movimiento reducido Nada automático: la animación se ejecutó entera, 295 ms Sí
Mezclar con @angular/animations en el mismo componente No soportado según la documentación: pueden quedar clases colgadas o nodos sin borrar Solo documentación

Para las transiciones entre páginas, la herramienta es withViewTransitions, que sigue en developer preview en 22.2 y se vio en el módulo 11.

Movimiento reducido

La tercera fila de la tabla es una responsabilidad tuya. Hay personas a las que el movimiento en pantalla les causa mareos, y los sistemas operativos permiten pedir menos animación. Angular no lo gestiona, porque la animación es CSS y el CSS es tuyo.

La solución es limpia, gracias a la fila anterior de la tabla: si la clase no anima nada, el nodo se borra enseguida. Basta con condicionar la animación:

@media (prefers-reduced-motion: no-preference) {
  .aparecer { animation: aparecer 300ms ease-out; }
  .desaparecer { animation: desaparecer 300ms ease-in forwards; }
}

Con Tailwind es aún más corto, con la variante motion-safe::

<p animate.leave="motion-safe:animate-salida">Tailwind</p>

Medido en Chromium con reducedMotion: 'reduce': los dos elementos desaparecieron a los 12 ms, sin quedarse atascados.

Cómo se prueban

En el módulo 26 verás Vitest a fondo, pero conviene saber esto ya. TestBed desactiva estas animaciones por defecto, y eso es lo que quieres: tus pruebas de lógica no deben esperar a ninguna animación.

// src/app/anim/aviso-demo.spec.ts
import { TestBed } from '@angular/core/testing';
import { AvisoDemo } from './aviso-demo';

describe('AvisoDemo', () => {
  it('sin animaciones el aviso desaparece en cuanto cambia la señal', async () => {
    const fixture = TestBed.createComponent(AvisoDemo);
    await fixture.whenStable();
    const boton: HTMLButtonElement = fixture.nativeElement.querySelector('#alternar');

    boton.click();
    await fixture.whenStable();
    expect(fixture.nativeElement.querySelector('#aviso')).not.toBeNull();

    boton.click();
    await fixture.whenStable();
    expect(fixture.nativeElement.querySelector('#aviso')).toBeNull();
  });
});

Existe la opción TestBed.configureTestingModule({ animationsEnabled: true }), pero no te servirá con jsdom, que es el entorno por defecto de ng test. Lo probamos en 22.2.4: incluso con esa opción, el elemento no recibió ninguna clase de animación y se borró al instante. jsdom no implementa animaciones (getAnimations es undefined), y la documentación oficial ya avisa de que hay entornos que no emiten estos eventos.

Para comprobar la animación de verdad, usa un navegador real. Este fragmento de Playwright comprueba que la salida espera:

const boton = page.getByRole('button', { name: 'Alternar' });
const aviso = page.getByText('Empleado guardado');

await boton.click();
await expect(aviso).toBeVisible();
await expect(aviso).not.toHaveClass(/aparecer/);

await boton.click();
await expect(aviso).toHaveClass(/desaparecer/);
await expect(aviso).toHaveCount(0);

9. @angular/animations: el sistema antiguo

Este es su estado real, leído en los tipos de las dos versiones y en npm:

  • Obsoleto desde Angular 20.2, tanto en 21 como en 22. Sigue funcionando.
  • Los comentarios de sus tipos dicen «Intent to remove in v23»: la intención es retirarlo en Angular 23. La guía de migración no da fecha, así que tómalo como un plan, no como una promesa.
  • Al instalarlo, npm muestra el aviso @angular/animations is deprecated. Use animate.enter and animate.leave instead.
  • provideAnimations, provideAnimationsAsync, provideNoopAnimations, BrowserAnimationsModule y NoopAnimationsModule están obsoletos con el mismo mensaje.
  • ng new ya no lo instala, y @angular/material 22 no lo exige entre sus dependencias.

⚠️ Importante: No escribas código nuevo con @angular/animations. Lo estudiamos porque lo encontrarás en proyectos existentes, en tutoriales y en respuestas antiguas, y porque tendrás que migrarlo. Esto vale igual para la LTS: el paquete ya está obsoleto ahí.

Cómo leerlo

Este es el aviso del apartado 8, con el sistema antiguo:

// src/main-legado.ts
import { Component, signal } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { animate, style, transition, trigger } from '@angular/animations';

@Component({
  selector: 'app-root',
  template: `
    <button id="alternar" type="button" (click)="visible.update((v) => !v)">Alternar</button>
    @if (visible()) {
      <p id="aviso" @aparecer>Empleado guardado</p>
    }
  `,
  animations: [
    trigger('aparecer', [
      transition(':enter', [
        style({ opacity: 0, transform: 'translateY(-10px)' }),
        animate('300ms ease-out'),
      ]),
      transition(':leave', [
        animate('300ms ease-in', style({ opacity: 0, transform: 'translateY(-10px)' })),
      ]),
    ]),
  ],
})
class Legado {
  protected readonly visible = signal(false);
}

bootstrapApplication(Legado, { providers: [provideAnimationsAsync()] }).catch(console.error);

Funciona en 22.2.4 y en 21.2.25: compila, y en 22 el nodo se borró a los 324 ms. Fíjate en las diferencias: la animación está en TypeScript y no en CSS, exige un provide... global, y el @aparecer de la plantilla es un trigger, no un bloque de control como @if.

Qué cuesta en el bundle

Compilamos dos aplicaciones mínimas e idénticas, una con cada sistema, en Angular 22.2.4:

Versión JS inicial JS perezoso Total Total comprimido
animate.enter / animate.leave 111,52 kB 0 111,52 kB 33,38 kB
@angular/animations 109,17 kB 64,23 kB 173,40 kB 50,34 kB

Con provideAnimationsAsync, el motor se carga en un trozo aparte, y por eso el JavaScript inicial queda parecido. Pero se descarga igualmente en cuanto hace falta: unos 62 kB más, antes de comprimir. La versión nativa solo añade un poco de CSS.

Tabla de migración

Esta es la equivalencia de la guía oficial de migración:

@angular/animations Equivalente nativo
transition(':enter', ...) animate.enter
transition(':leave', ...) animate.leave
state() Clases CSS con transition
style() y animate() transition o animation en CSS
keyframes() @keyframes
animation() reutilizable @keyframes y una clase compartida, en @theme si usas Tailwind
trigger() No hace falta: basta con añadir o quitar clases
query() Selectores CSS sobre las clases de la plantilla
stagger() animation-delay con una variable --indice
group() Varias animaciones en la propiedad animation
sequence() Retrasos, o los eventos animationend y transitionend
height: '*' Truco con grid-template-rows de 0fr a 1fr
AnimationPlayer Element.animate() y Element.getAnimations()
provideNoopAnimations prefers-reduced-motion, o no aplicar las clases
:increment y :decrement Clases aplicadas desde tu código

Los casos sin equivalente directo son los que coordinan padre e hijos a través de componentes: query() con animateChild() no tiene sustituto automático, y hay que rediseñarlo.

La guía también advierte de dos cosas que debes tener en cuenta al migrar por partes: pueden convivir ambos sistemas en la misma aplicación, pero no en el mismo componente, y no se admite proyectar contenido entre componentes que usen sistemas distintos. Migra por componentes completos.


10. Depuración y errores frecuentes

Síntoma Causa probable Solución
Tailwind no aplica nada y no hay error Falta .postcssrc.json, o el fichero tiene otro nombre Usa .postcssrc.json o postcss.config.json
Una clase concreta no funciona Se construye concatenando Escribe la clase completa
La clase está en el DOM, pero gana otro estilo CSS sin capa del componente o de styles.css Mueve la regla a @layer components
Cannot apply unknown utility class @apply sin referencia @reference a styles.css
exceeded maximum budget en un .css Tailwind importado en un componente Déjalo solo en styles.css
dark: sigue al sistema, no al botón Falta @custom-variant dark Añádelo tras el @import
Sale el tema claro un instante al recargar La clase se aplica al arrancar Angular Script en <head>
El tema solo se aplica en algunas páginas Nadie inyecta el servicio antes Inyéctalo en App
Controles nativos claros en oscuro Falta color-scheme Declara color-scheme: dark en .dark
Un elemento tarda 4 segundos en irse Falta animationComplete() Llámalo al terminar
La salida no se anima, sin error Enlace sin paréntesis en 21, o animación anidada en 21 Usa () y anima el elemento externo
La salida de un hijo se ignora Está en la plantilla de otro componente Ponla en su host
Se anima la fila equivocada track $index Haz track por identificador
Una prueba no ve la clase de animación jsdom no anima Comprueba con un navegador real

Para investigar, DevTools te da todo lo necesario:

  • Pestaña Elements: selecciona el nodo y mira en Computed qué regla gana cada propiedad. Las reglas tachadas muestran qué ha perdido. Si ves la capa utilities tachada, recuerda el apartado 5.
  • Panel Animations (se abre con More tools): registra las animaciones y permite ralentizarlas al 10 %, lo que resulta muy útil para ajustar una salida.
  • Para comprobar si una clase existe, busca con Ctrl+Shift+F dentro de styles.css.

11. Ejercicios

Ejercicio 1: Densidad con variables CSS

Crea ListaDensa, con una lista de dos empleados y un <select> con tres densidades: compacta (4 px), normal (8 px) y amplia (16 px). El relleno de las filas debe venir de una variable CSS, sin tocar ninguna clase.

Ver solución
// src/app/ejercicios/lista-densa.ts
import { Component, signal } from '@angular/core';

type Densidad = 'compacta' | 'normal' | 'amplia';

const ESPACIOS: Record<Densidad, string> = { compacta: '4px', normal: '8px', amplia: '16px' };

@Component({
  selector: 'app-lista-densa',
  template: `
    <label>
      Densidad
      <select id="densidad" (change)="cambiar($event)">
        <option value="compacta">Compacta</option>
        <option value="normal" selected>Normal</option>
        <option value="amplia">Amplia</option>
      </select>
    </label>
    <ul [style.--espacio]="espacio()">
      <li>Ana García</li>
      <li>Luis Martínez</li>
    </ul>
  `,
  styles: `
    li { padding-block: var(--espacio, 8px); border-bottom: 1px solid var(--color-borde); }
  `,
})
export class ListaDensa {
  protected readonly espacio = signal(ESPACIOS.normal);

  protected cambiar(evento: Event) {
    const valor = (evento.target as HTMLSelectElement).value as Densidad;
    this.espacio.set(ESPACIOS[valor]);
  }
}

La variable se declara en <ul> y la heredan todas las filas: un solo cambio en el DOM, aunque haya mil filas. Al elegir «Amplia», getComputedStyle devuelve 16px en el relleno. Fíjate también en que var(--color-borde) usa desde un componente una variable del tema global: la encapsulación no le afecta.

Ejercicio 2: Diagnóstico de una insignia

Un compañero escribe esto y las insignias salen sin color. Explica por qué y corrígelo. Cualquier departamento desconocido debe salir en gris.

protected readonly clases = computed(() => `bg-${this.color()}-100 text-${this.color()}-800`);
Ver solución

Tailwind lee el código fuente como texto y nunca ejecuta la plantilla de cadena. Las clases solo existen en tiempo de ejecución, así que no se genera CSS para ellas.

// src/app/ejercicios/insignia-departamento.ts
import { Component, computed, input } from '@angular/core';

const COLORES: Record<number, string> = {
  20: 'bg-emerald-100 text-emerald-800',
  30: 'bg-violet-100 text-violet-800',
};

@Component({
  selector: 'app-insignia-departamento',
  template: `Dept. {{ deptId() }}`,
  host: { '[class]': 'clases()' },
})
export class InsigniaDepartamento {
  readonly deptId = input.required<number>();
  protected readonly clases = computed(
    () => `rounded px-2 py-0.5 text-xs ${COLORES[this.deptId()] ?? 'bg-slate-100 text-slate-800'}`,
  );
}

El ?? da la alternativa gris para los identificadores que no estén en el mapa.

Ejercicio 3: Componente Alerta

Crea app-alerta con un input tipo que admita exito, aviso y error, y que proyecte su contenido. Debe verse bien en oscuro. Por accesibilidad, un error debe tener role="alert" y el resto role="status".

Ver solución
// src/app/ejercicios/alerta.ts
import { Component, computed, input } from '@angular/core';

type TipoAlerta = 'exito' | 'aviso' | 'error';

const ESTILOS: Record<TipoAlerta, string> = {
  exito: 'border-green-600 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100',
  aviso: 'border-amber-600 bg-amber-50 text-amber-900 dark:bg-amber-950 dark:text-amber-100',
  error: 'border-red-600 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100',
};

@Component({
  selector: 'app-alerta',
  template: `<ng-content />`,
  host: {
    '[class]': 'clases()',
    '[attr.role]': 'rol()',
  },
})
export class Alerta {
  readonly tipo = input<TipoAlerta>('aviso');
  protected readonly clases = computed(() => `block rounded border-l-4 p-3 text-sm ${ESTILOS[this.tipo()]}`);
  protected readonly rol = computed(() => (this.tipo() === 'error' ? 'alert' : 'status'));
}

Lleva block porque el anfitrión es inline por defecto. El role se calcula con otro computed: alert interrumpe al lector de pantalla, y por eso se reserva para lo urgente, como viste en el módulo 21.

Ejercicio 4: Campo de formulario con @apply

Crea CampoTexto, con una etiqueta y un input, usando clases propias .etiqueta y .campo en su .css construidas con @apply. Debe usar los tokens del tema.

Ver solución
// src/app/ejercicios/campo-texto.ts
import { Component, input } from '@angular/core';

@Component({
  selector: 'app-campo-texto',
  template: `
    <label class="etiqueta">
      {{ etiqueta() }}
      <input class="campo" type="text" />
    </label>
  `,
  styleUrl: './campo-texto.css',
})
export class CampoTexto {
  readonly etiqueta = input.required<string>();
}
/* src/app/ejercicios/campo-texto.css */
@reference "../../styles.css";

.etiqueta {
  @apply block text-sm font-medium text-texto;
}

.campo {
  @apply mt-1 block w-full rounded-md border border-borde bg-superficie px-3 py-2;
  @apply focus:border-indigo-500 focus:outline-2 focus:outline-indigo-500;
}

La ruta de @reference es relativa al .css. Hay que apuntar a styles.css y no a "tailwindcss" a secas, porque en caso contrario text-texto y border-borde no existirían: esos tokens están definidos en tu hoja. El resultado es CSS normal, encapsulado como cualquier otro.

Ejercicio 5: Botón que rota el tema

Crea un único botón que al pulsarlo pase de claro a oscuro, a sistema, y de nuevo a claro. El texto debe mostrar la preferencia y el resultado: Tema: sistema (claro).

Ver solución
// src/app/ejercicios/boton-tema.ts
import { Component, computed, inject } from '@angular/core';
import { Preferencia, Tema } from '../tema/tema';

const SIGUIENTE: Record<Preferencia, Preferencia> = {
  claro: 'oscuro',
  oscuro: 'sistema',
  sistema: 'claro',
};

@Component({
  selector: 'app-boton-tema',
  template: `
    <button id="boton-tema" type="button" class="rounded border border-borde px-3 py-1 text-sm" (click)="siguiente()">
      Tema: {{ tema.preferencia() }} ({{ estado() }})
    </button>
  `,
})
export class BotonTema {
  protected readonly tema = inject(Tema);
  protected readonly estado = computed(() => (this.tema.oscuro() ? 'oscuro' : 'claro'));

  protected siguiente() {
    this.tema.preferencia.update((p) => SIGUIENTE[p]);
  }
}

Con el sistema en claro, tres pulsaciones dan estos textos reales:

Tema: claro (claro)
Tema: oscuro (oscuro)
Tema: sistema (claro)

Mostrar dos valores deja clara la diferencia entre la preferencia y el resultado.

Ejercicio 6: Tarjeta sin dark:

Crea TarjetaResumen, con un título y un valor, que se vea bien en los dos temas sin escribir ninguna variante dark:.

Ver solución
// src/app/ejercicios/tarjeta-resumen.ts
import { Component, input } from '@angular/core';

@Component({
  selector: 'app-tarjeta-resumen',
  template: `
    <article class="rounded-lg border border-borde bg-superficie p-4">
      <h3 class="text-sm text-texto-suave">{{ titulo() }}</h3>
      <p class="text-2xl font-bold text-texto">{{ valor() }}</p>
    </article>
  `,
})
export class TarjetaResumen {
  readonly titulo = input.required<string>();
  readonly valor = input.required<string>();
}

Los tokens se encargan de todo. Si mañana añades un tercer tema, por ejemplo uno de alto contraste, solo necesitas otra clase que redefina --superficie y compañía. Ningún componente cambia.

Ejercicio 7: Notificaciones que se apilan

Crea Notificaciones: un botón añade un aviso arriba a la derecha, que desaparece solo al cabo de un segundo, animado con las utilidades de @theme. Debe anunciarse a los lectores de pantalla, y no dejar temporizadores vivos al destruirse.

Ver solución
// src/app/ejercicios/notificaciones.ts
import { Component, DestroyRef, inject, signal } from '@angular/core';

interface Notificacion {
  id: number;
  texto: string;
}

@Component({
  selector: 'app-notificaciones',
  template: `
    <button id="notificar" type="button" class="rounded border border-borde px-3 py-1 text-sm" (click)="notificar('Empleado guardado')">
      Notificar
    </button>
    <ul aria-live="polite" class="fixed right-4 top-4 w-64 space-y-2">
      @for (n of notificaciones(); track n.id) {
        <li
          class="notificacion rounded bg-slate-800 p-3 text-sm text-white shadow-lg"
          animate.enter="animate-entrada"
          animate.leave="animate-salida"
        >
          {{ n.texto }}
        </li>
      }
    </ul>
  `,
})
export class Notificaciones {
  private readonly destruccion = inject(DestroyRef);
  private readonly temporizadores = new Set<ReturnType<typeof setTimeout>>();
  private siguienteId = 1;

  protected readonly notificaciones = signal<Notificacion[]>([]);

  constructor() {
    this.destruccion.onDestroy(() => this.temporizadores.forEach(clearTimeout));
  }

  protected notificar(texto: string) {
    const id = this.siguienteId++;
    this.notificaciones.update((lista) => [...lista, { id, texto }]);

    const temporizador = setTimeout(() => {
      this.temporizadores.delete(temporizador);
      this.notificaciones.update((lista) => lista.filter((n) => n.id !== id));
    }, 1000);
    this.temporizadores.add(temporizador);
  }
}

Tres notificaciones apiladas en la esquina superior derecha sobre la página de ejercicios

Figura 11. Tres avisos apilados. Cada uno tiene su propio temporizador.

aria-live="polite" debe estar en el <ul>, que siempre existe: un lector de pantalla solo anuncia cambios dentro de una región ya presente. El identificador único hace que el track sea correcto aunque dos avisos tengan el mismo texto. Por eso id es un contador y no el propio texto.

Ejercicio 8: Migrar un acordeón

Esta animación antigua abre un panel de altura desconocida. Migra el acordeón para que no dependa de @angular/animations.

animations: [
  trigger('abrir', [
    state('cerrado', style({ height: 0 })),
    state('abierto', style({ height: '*' })),
    transition('cerrado <=> abierto', animate('300ms ease')),
  ]),
],
Ver solución

height: auto no se puede animar en CSS, pero fr sí, y una fila de 1fr mide lo que necesite su contenido:

// src/app/ejercicios/acordeon.ts
import { Component, input, signal } from '@angular/core';

@Component({
  selector: 'app-acordeon',
  template: `
    <button
      id="acordeon"
      type="button"
      class="font-semibold"
      [attr.aria-expanded]="abierto()"
      (click)="abierto.update((a) => !a)"
    >
      {{ titulo() }}
    </button>
    <div class="contenedor" [class.abierto]="abierto()">
      <div class="interior">
        <p>Contenido de altura desconocida.</p>
      </div>
    </div>
  `,
  styles: `
    .contenedor {
      display: grid;
      grid-template-rows: 0fr;
      transition: grid-template-rows 300ms ease;
    }
    .contenedor.abierto { grid-template-rows: 1fr; }
    .interior { overflow: hidden; }
  `,
})
export class Acordeon {
  readonly titulo = input.required<string>();
  protected readonly abierto = signal(false);
}

El interior midió 5 px a los 80 ms de pulsar, y 24 px al terminar, de modo que la transición ocurre de verdad. Aquí no se usa animate.enter porque el nodo nunca sale del DOM: basta una clase. El overflow: hidden de .interior es imprescindible, porque sin él el contenido se saldría de una fila de altura cero.

Un detalle: como el contenido sigue en el DOM cuando está cerrado, sus enlaces y botones serían alcanzables con el teclado. Si tiene elementos interactivos, añade inert a .interior cuando esté cerrado.

Ejercicio 9: Diálogo con doble salida 🔄 v22.0

🔄 Cambia en Angular 22.0. La salida del elemento interior solo se ejecuta desde Angular 22, porque es anidada. Si usas la LTS: la capa exterior se anima y se espera igual, pero la caja interior no hace su scale; o bien aceptas ese cambio, o pones una sola animación que afecte a ambas en la capa.

Crea un diálogo con una capa oscura que se desvanece, y una caja interior que se encoge al cerrar. Ambas salidas deben ejecutarse.

Ver solución
// src/app/ejercicios/dialogo.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-dialogo',
  template: `
    <button id="abrir" type="button" class="rounded border border-borde px-3 py-1 text-sm" (click)="abierto.set(true)">
      Abrir
    </button>
    @if (abierto()) {
      <div id="capa" class="capa" animate.enter="capa-entra" animate.leave="capa-sale">
        <div id="caja" role="dialog" aria-modal="true" aria-label="Confirmación" class="caja" animate.enter="caja-entra" animate.leave="caja-sale">
          <p>¿Seguro?</p>
          <button id="cerrar" type="button" (click)="abierto.set(false)">Cerrar</button>
        </div>
      </div>
    }
  `,
  styles: `
    .capa { position: fixed; inset: 0; display: grid; place-items: center; background: rgb(0 0 0 / 0.5); }
    .caja { padding: 24px; border-radius: 8px; background: var(--color-superficie); }
    .capa-entra { animation: capa-entra 200ms ease-out; }
    .capa-sale { animation: capa-sale 300ms ease-in forwards; }
    .caja-entra { animation: caja-entra 200ms ease-out; }
    .caja-sale { animation: caja-sale 300ms ease-in forwards; }
    @keyframes capa-entra { from { opacity: 0; } }
    @keyframes capa-sale { to { opacity: 0; } }
    @keyframes caja-entra { from { transform: scale(0.9); } }
    @keyframes caja-sale { to { transform: scale(0.9); } }
  `,
})
export class Dialogo {
  protected readonly abierto = signal(false);
}

Al pulsar «Cerrar», la capa tardó 328 ms en salir del DOM. Para un diálogo real, no reinventes el foco: usa el Dialog de CDK del módulo 20. Este ejercicio trata de la animación, y deja sin resolver la trampa de foco que el módulo 21 explica.

Ejercicio 10 (propuesto): Buscador con lista animada

Crea un buscador de empleados contra la API EMPRESA: un campo con debounce, y una lista con animate.enter y animate.leave en cada resultado. Usa track e.empId. Comprueba qué ocurre con las filas que siguen en la lista al cambiar la búsqueda, y añade motion-safe: para respetar el movimiento reducido.

Ejercicio 11 (propuesto): Un tercer tema

Añade un tema «sepia» al servicio Tema. Tendrás que ampliar el tipo Preferencia, sustituir el booleano oscuro por un valor de tema y crear una clase .sepia con sus tokens. Piensa qué debe ocurrir con dark: en las insignias, y actualiza el script de index.html.


Resumen

Concepto API Idea clave
Encapsulación _ngcontent, _nghost Angular reescribe los selectores; las variables CSS la cruzan
Interfaz de estilo var(--x, alternativa), [style.--x] Como un input() para CSS
Instalar Tailwind ng new --style=tailwind, ng add tailwindcss .postcssrc.json y @import "tailwindcss"
Clases dinámicas Record de clases completas Tailwind lee texto: nunca concatenes
@apply en componentes @reference "../../styles.css" Trae el tema sin emitir CSS
CSS contra utilidades @layer components Lo que no tiene capa vence a cualquier capa
Componentes computed, host: { '[class]': ... } Conserva el elemento nativo con un selector de atributo
Tokens semánticos @theme inline Cambia el valor, no la clase
Modo oscuro @custom-variant dark, señales, effect Preferencia y resultado son datos distintos
Evitar parpadeo Script en <head> Aplica la clase antes de pintar
Entrada y salida animate.enter, animate.leave Clases CSS; Angular espera y borra. Disponibles en 21 y 22
Control total (animate.leave), animationComplete() Plazo de 4 s; se cambia con MAX_ANIMATION_TIMEOUT
Salida anidada 🔄 v22.0 animate.leave en un descendiente En 21, solo el elemento raíz del bloque
Señal sin llamar 🔄 v22.2 [animate.enter]="senal" En 21 no anima: escribe () siempre
Accesibilidad prefers-reduced-motion, motion-safe: Angular no lo hace por ti
Pruebas TestBed, Playwright jsdom no anima: usa un navegador real
Sistema antiguo @angular/animations Obsoleto desde 20.2, retirada prevista en 23. No lo uses en código nuevo