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,
@applysin@referencey presupuestos superados - Construir componentes reutilizables con Tailwind usando
input(),computedy 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.enteryanimate.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:
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
@applyfunciona 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 {}

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-c2152980374lo pone el padre, porque ese elemento está en la plantilla deFichas._nghost-ng-c2040283255lo 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:
:hostno tienedisplay: blockpor defecto. Un elemento personalizado esinline, y sin esa línea elpaddingy 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-radiono aparece en ninguna parte, para quien use el componente no existe. Fija siempre un valor alternativo envar(): 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.jscopiado 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 {}

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 escribesbutton { ... }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,disabledy 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-textoybg-superficieno 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.
httpResourcepasa de experimental a estable. En la LTS (Angular 21) es experimental. Si usas la LTS: este ejemplo compila y funciona igual, perohttpResourcepuede cambiar entre versiones; si no quieres depender de ello, carga los datos conHttpClientcomo 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 €.

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@applypara casos en los que no controlas el HTML, como el contenido que llega coninnerHTML. 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';
}
}
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:
preferenciaes lo que elige el usuario.oscuroes lo que hay que pintar. Son datos distintos, y confundirlos es el error típico: con «sistema», el resultado depende de algo externo.toSignalconvierte el eventochangeen una señal, como en el módulo 15.sistemaOscurose actualiza sola.- Los dos
effecthacen trabajo fuera del mundo de las señales, que es justo el uso legítimo deeffectdel módulo 05. inject(DOCUMENT)en lugar dedocumentdeja 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
localStorageymatchMedia, 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.

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

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.enteryanimate.leaveno 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 |
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-300define la transición, siempre presente.starting:opacity-0es la variante de Tailwind para@starting-style. Como funciona sola al aparecer, ni siquiera necesitaanimate.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.leaveen 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.

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

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.leaveno se dispara al reordenar. Peroanimate.entersí 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,BrowserAnimationsModuleyNoopAnimationsModuleestán obsoletos con el mismo mensaje.ng newya no lo instala, y@angular/material22 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
utilitiestachada, 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);
}
}

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 |