Parte VIII · Ampliaciones

24. Interfaz de usuario: Material, CDK, estilos y animaciones

La capa visual es la parte de una aplicación que todo el mundo ve y casi nadie diseña. Se empieza con tres reglas de CSS, se añade una librería de componentes porque hay prisa, se corrige un margen con ::ng-deep porque no había tiempo, y dos años después nadie se atreve a tocar una hoja de estilos por miedo a romper una pantalla que ni siquiera sabe que existe. Este capítulo trata la interfaz como lo que es —una capa de arquitectura con sus propias reglas, sus propios contratos y su propia deuda técnica— y enseña a construirla con CSS moderno, con el CDK de Angular y, cuando conviene, con Angular Material, sin acabar peleando contra ninguno de los tres.

CORE ANGULAR AMPLIACIÓN Tiempo de lectura: ~145 min Prerrequisitos: conviene haber leído los capítulos 3 (componentes, entradas, salidas y proyección de contenido) y 5 (plantillas, control flow y formularios)

24.1 Qué vas a poder hacer al terminar

Este capítulo no va de «poner bonita» la aplicación. Va de que la capa visual siga siendo modificable dentro de dos años, con otro equipo y con el triple de pantallas.

24.2 El problema: por qué la capa visual acaba siendo la más difícil de mantener

Hay una asimetría curiosa en casi todos los equipos que conozco. Nadie aceptaría una función de TypeScript de setecientas líneas con variables globales y sin pruebas; sin embargo, casi todos conviven con una hoja de estilos de setecientas líneas, con selectores globales, sin pruebas y sin dueño. La razón no es que los programadores de front-end sean menos rigurosos: es que el CSS tiene propiedades de lenguaje que empujan activamente hacia el desorden, y hay que compensarlas con arquitectura.

24.2.1 Cuatro propiedades del CSS que generan deuda

Conviene nombrarlas con precisión, porque cada una tiene un antídoto distinto.

Analogía El CSS es como una oficina en la que todo el mundo comparte un mismo tablón de anuncios. Cualquiera puede pegar una nota encima de otra, nadie firma, no hay registro de quién puso qué y la única forma de que tu nota se vea es pegarla más arriba o hacerla más grande. Funciona con tres personas. Con treinta, hace falta un sistema: zonas asignadas, formato común y una norma que prohíba tapar notas ajenas. Ese sistema es lo que llamamos arquitectura de estilos.

24.2.2 Los síntomas, en orden de aparición

La degradación de la capa visual sigue un guion tan repetido que se puede predecir. En TaskFlow, nuestra aplicación de tareas y proyectos, sucedería así:

FaseQué ocurreCoste real
1. Duplicación silenciosaLa pantalla de tareas necesita una tarjeta. Existe una en la pantalla de proyectos, pero copiarla es más rápido que extraerla. Nacen task-card y project-card casi idénticas.Bajo al principio. Cada arreglo posterior hay que hacerlo dos veces, y una de las dos se olvida.
2. Divergencia visualUna tarjeta tiene border-radius: 8px y la otra 6px; una usa #5b8cff y la otra #5a8bfe. Nadie lo decidió: es sedimento.La aplicación parece hecha por tres equipos. La confianza del usuario baja aunque no sepa explicar por qué.
3. Corrección por fuerza brutaUn margen de un componente de terceros no se puede cambiar desde fuera. Alguien escribe ::ng-deep .mat-mdc-form-field { margin: 0 } y funciona.Esa regla acaba de convertirse en global. Seis meses después, un formulario de otra pantalla pierde su espaciado y nadie relaciona ambos hechos.
4. MiedoNadie borra CSS. Se añade. El archivo styles.scss crece monótonamente y contiene reglas que no se aplican a nada desde hace un año.El coste de cada cambio visual se multiplica; las estimaciones de tareas «de maquetación» dejan de ser fiables.
5. ReescrituraAlguien propone rehacer la capa visual desde cero, normalmente adoptando la librería de moda.Se repite el ciclo, con otra librería y dos años de retraso.

24.2.3 Un sistema de diseño es una decisión de ingeniería

Aquí está la tesis del capítulo. Cuando alguien propone «montar un sistema de diseño», la reacción habitual de un equipo bajo presión es tratarlo como una petición estética: colores bonitos, una guía de estilo, algo que hace feliz al departamento de diseño. Es un error de categoría.

Un sistema de diseño es, en términos de ingeniería, un mecanismo de reducción del espacio de estados. Sin él, el color de un texto secundario puede ser cualquiera de los 16,7 millones que admite el formato de 24 bits; el espaciado entre dos elementos puede ser cualquier número de píxeles; el radio de una esquina, cualquier valor. Con él, el color secundario es uno, el espaciado es uno de siete valores de una escala y el radio es uno de cuatro. Has pasado de un espacio infinito a un espacio enumerable, y todo lo que sabemos sobre diseño de software dice que eso es exactamente lo que hace un sistema mantenible.

Las consecuencias prácticas son medibles y no tienen nada de estético:

La regla que resume todo el capítulo

Un componente de interfaz debe declarar intención, no apariencia. La diferencia entre color: #8892b0 y color: var(--tf-text-muted) no es de sintaxis: el primero es un dato incrustado y el segundo es un punto de extensión. Todo lo que viene después —temas, modo oscuro, personalización de Material, componentes reutilizables— depende de haber interiorizado esa distinción.

task-card.component.cssINCORRECTO
/* Apariencia incrustada: cada valor es una decisión
   irrepetible y no localizable desde ningún otro sitio */
.task-card {
  background: #161b2e;
  border: 1px solid #262c44;
  border-radius: 9px;          /* en project-card son 8px */
  padding: 14px 16px;          /* nadie recuerda por qué 14 */
  color: #e6e9f2;
  box-shadow: 0 4px 14px rgba(0,0,0,.35);
}
.task-card__meta { color: #8892b0; font-size: 12.5px; }
.task-card--urgent { border-color: #ff5c7a; }
task-card.component.cssCORRECTO
/* Intención declarada: los valores viven en el sistema
   de tokens y el componente solo los consume */
.task-card {
  background: var(--tf-surface-2);
  border: 1px solid var(--tf-border);
  border-radius: var(--tf-radius-md);
  padding: var(--tf-space-3) var(--tf-space-4);
  color: var(--tf-text);
  box-shadow: var(--tf-shadow-1);
}
.task-card__meta { color: var(--tf-text-muted); font-size: var(--tf-fs-sm); }
.task-card--urgent { border-color: var(--tf-danger); }

Fíjate en que el bloque correcto no tiene menos líneas ni es más listo. Simplemente ha movido las decisiones a un único lugar donde se pueden cambiar todas a la vez, revisar en conjunto y validar automáticamente. Eso es arquitectura: no escribir menos código, sino colocar cada decisión donde solo haya que tomarla una vez.

24.3 Estrategias de estilado comparadas

No existe la estrategia correcta en abstracto; existe la que encaja con tu equipo, tu framework y el tiempo de vida esperado del proyecto. Lo que sí existe es la obligación de elegir conscientemente en lugar de acumular las cinco a la vez, que es lo que ocurre en la mayoría de las bases de código que audito.

24.3.1 Tabla honesta

EstrategiaCómo aíslaVentajasInconvenientesEncaje con Angular
CSS plano con BEM
bloque__elemento--modificador
Por convención de nombres. Nada lo impone. Cero herramientas. Especificidad plana y predecible (una clase). Se entiende sin formación previa. Sobrevive a cualquier cambio de framework. Nombres largos. La disciplina depende de las personas: basta un despistado para romperla. No hay forma de detectar código muerto. Innecesario dentro de un componente, porque Angular ya aísla. Sigue siendo útil en la hoja global y en librerías compartidas.
Preprocesadores
Sass/SCSS, Less
No aíslan. Solo generan CSS. Anidamiento, mixins, funciones, bucles y parciales. Imprescindible para consumir el sistema de temas de Angular Material, que se configura con mixins de Sass. El anidamiento es una trampa: tres niveles generan selectores de alta especificidad sin que te des cuenta. Añade un paso de compilación. Muchas de sus funciones ya están en CSS nativo. Soportado de serie (ng new --style=scss). Obligatorio si personalizas los temas de Material.
CSS Modules Real, en tiempo de compilación: reescribe los nombres de clase a identificadores únicos. Aislamiento garantizado por la herramienta, no por la disciplina. Detecta clases no usadas. Pensado para el ecosistema de React y los bundlers con importación de CSS en JavaScript. Encaje forzado en Angular. Redundante. Angular ya resuelve el mismo problema con la encapsulación emulada. Añadirlo es duplicar mecanismo.
Utilidades (Tailwind) No hay nombres propios que colisionen: solo utilidades atómicas globales. Velocidad de desarrollo alta. Los valores salen de una escala, así que la consistencia es el camino por defecto. El CSS generado deja de crecer con el número de pantallas. Plantillas cargadas de clases. La revisión de código se vuelve más difícil. Requiere convenios estrictos para no repetir cadenas de veinte clases. Bueno, con un matiz clave: las utilidades son globales, así que atraviesan la encapsulación sin problema. Ver sección 24.8.
CSS-in-JS
Emotion, styled-components
Real, generando nombres en tiempo de ejecución o de compilación. Estilos dinámicos con toda la potencia de JavaScript. Colocación junto al componente. Coste en tiempo de ejecución si no es de compilación. Complica el renderizado en servidor. Ecosistema alineado con React. Desaconsejado. Angular ya coloca los estilos junto al componente y los aísla, sin coste en tiempo de ejecución. Aporta problemas y no capacidades.
Estilos de componente de Angular
la opción por defecto
Real, con encapsulación emulada: atributos únicos añadidos por el compilador. Sin configuración. Los estilos viajan con el componente y se eliminan con él. Compatible con :host, variables CSS y cualquier preprocesador. Solo aísla hacia dentro: los estilos globales siguen entrando. No ayuda a la consistencia por sí solo; para eso hacen falta tokens. Es la base. Todo lo demás se construye encima.

24.3.2 Cómo aísla Angular realmente

Merece la pena entender el mecanismo, porque explica casi todos los problemas de estilado que se ven en un proyecto Angular. El compilador tiene tres modos, controlados por encapsulation:

task-card.component.ts
import { Component, ViewEncapsulation } from '@angular/core';

@Component({
  selector: 'tf-task-card',
  templateUrl: './task-card.component.html',
  styleUrl: './task-card.component.css',   // singular desde Angular 17
  encapsulation: ViewEncapsulation.Emulated, // valor por defecto
})
export class TaskCardComponent {}
ModoQué hace el compiladorCuándo usarlo
Emulated
por defecto
Añade un atributo único a los elementos de la plantilla (_ngcontent-abc-1) y otro al selector de cada regla. La regla .title se convierte en .title[_ngcontent-abc-1].Siempre, salvo motivo muy concreto. Es un buen equilibrio entre aislamiento y practicidad.
NoneNo transforma nada: las reglas se inyectan tal cual en el documento y son globales.Solo en un componente cuya misión explícita sea publicar estilos globales, y aun así es preferible una hoja global de verdad.
ShadowDomUsa el shadow DOM nativo del navegador. Aislamiento real en ambas direcciones: los estilos globales tampoco entran.Componentes que se van a incrustar en páginas ajenas (elementos personalizados, widgets embebidos). Ojo: rompe las hojas globales y complica Material y Tailwind.
El detalle que lo explica casi todo

La encapsulación emulada añade el atributo únicamente a los elementos que aparecen en la plantilla de ese componente. Los elementos que genera un componente hijo llevan su atributo, no el tuyo. Por eso una regla escrita en el padre no puede alcanzar el interior de un hijo, y por eso la gente recurre a ::ng-deep. La solución correcta no es perforar el aislamiento, sino que el hijo exponga puntos de extensión: variables CSS, entradas o clases documentadas. La sección 24.6 lo desarrolla con Angular Material y la 24.11 con componentes propios.

Qué genera el compilador (simplificado)
<!-- Plantilla escrita -->
<div class="task-card">
  <h3 class="title">{{ task.title }}</h3>
  <tf-tag-list [tags]="task.tags" />
</div>

<!-- DOM resultante: el hijo lleva OTRO atributo de contenido -->
<div class="task-card" _ngcontent-tf-c12>
  <h3 class="title" _ngcontent-tf-c12>Revisar el informe</h3>
  <tf-tag-list _ngcontent-tf-c12 _nghost-tf-c34>
    <span class="tag" _ngcontent-tf-c34>urgente</span>  <!-- inalcanzable desde el padre -->
  </tf-tag-list>
</div>

Los dos selectores especiales que Angular sí ofrece son :host, que apunta al elemento anfitrión del propio componente, y :host-context(), que permite reaccionar a una clase presente en cualquier antepasado. El segundo es la forma canónica de que un componente cambie de aspecto según el tema activo sin que nadie le pase una entrada.

task-card.component.css
:host {
  display: block;                 /* los componentes son inline por defecto */
  container-type: inline-size;    /* ver contenedores de consulta, 24.4.4 */
}

:host([data-urgent='true']) .task-card { border-color: var(--tf-danger); }

/* Reacciona a un antepasado con .theme-dark sin recibir ninguna entrada */
:host-context(.theme-dark) .task-card { box-shadow: var(--tf-shadow-2); }

/* Estado deshabilitado del propio anfitrión */
:host(.is-disabled) { opacity: .55; pointer-events: none; }
::ng-deep está obsoleto y además es peligroso

Angular lo marca como deprecated desde hace años y su comportamiento es peor de lo que la mayoría cree: una regla con ::ng-deep que no esté anclada con :host se convierte en global. Es decir, ::ng-deep .mat-mdc-card { padding: 0 } escrito en un componente afecta a todas las tarjetas de Material de la aplicación entera, incluidas las de pantallas que nadie ha tocado. Si no queda más remedio que usarlo, escríbelo siempre como :host ::ng-deep .selector y deja un comentario explicando qué se intentó antes. La sección 24.6.4 recoge las alternativas reales.

24.3.3 Criterios de elección

Si tuviera que reducir la decisión a cuatro preguntas, serían estas:

Recomendación por defecto para un proyecto Angular nuevo

1) Estilos de componente de Angular como base, sin excepciones. 2) Una hoja global mínima con la reinicialización y, sobre todo, con los tokens de diseño. 3) Sass solo si vas a usar Material o si necesitas mixins de verdad. 4) Angular Material si el proyecto es intensivo en formularios, tablas y diálogos; el CDK a secas si solo necesitas comportamiento. 5) Tailwind si el equipo ya lo domina y aceptáis el convenio de extraer componentes. Lo que no debe ocurrir es tener las cinco estrategias a la vez sin que nadie sepa cuál manda.

24.4 Fundamentos de CSS moderno que sustituyen a librerías enteras

Una parte muy grande de las dependencias que arrastra un proyecto de front-end resuelve problemas que el CSS ya resuelve de forma nativa. Rejillas de doce columnas, sistemas de espaciado, utilidades de posicionamiento, plugins para detectar el ancho del contenedor, envoltorios para tipografía adaptativa: todo eso existía porque el lenguaje no llegaba. Hoy llega. Esta sección repasa las siete capacidades que más peso de dependencias eliminan.

24.4.1 Flexbox y grid: no compiten, se reparten el trabajo

La confusión habitual se disuelve con una frase: flexbox distribuye elementos a lo largo de un eje y grid coloca elementos en un plano de dos dimensiones definido de antemano. Flexbox parte del contenido (los hijos negocian el espacio); grid parte del contenedor (el contenedor declara la retícula y los hijos se colocan en ella).

Situación en TaskFlowHerramientaPor qué
Barra de una tarea: icono, título que crece, fecha y menúFlexboxUna sola fila, número variable de elementos, uno de ellos absorbe el espacio sobrante.
Tablero de proyecto con columnas «Pendiente / En curso / Hecho»GridEstructura conocida y estable; se quiere que las columnas midan lo mismo aunque tengan distinto contenido.
Lista de etiquetas que se desborda a varias líneasFlexbox con flex-wrapAnchura dictada por el contenido de cada etiqueta.
Cuadrícula de tarjetas de proyecto adaptativaGrid con auto-fit y minmaxEl número de columnas debe depender del ancho disponible sin escribir un solo punto de ruptura.
Formulario de tarea con etiquetas y campos alineadosGrid con áreas nombradasAlineación entre filas independientes, imposible de garantizar con flexbox.
task-row.component.css · una fila de tarea con flexbox
.task-row {
  display: flex;
  align-items: center;
  gap: var(--tf-space-3);        /* sustituye a los márgenes con :last-child */
  padding-block: var(--tf-space-2);
}

.task-row__title {
  flex: 1 1 auto;                /* crece, se encoge, base automática */
  min-width: 0;                  /* CLAVE: permite que text-overflow funcione */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.task-row__due,
.task-row__assignee { flex: 0 0 auto; }   /* nunca se encogen */

.task-row__menu { margin-inline-start: auto; }  /* empuja al final del eje */
El error de flexbox que más tiempo consume

Un elemento flexible tiene min-width: auto por defecto, lo que significa que no puede encogerse por debajo del tamaño de su contenido. Por eso un título largo desborda la fila en lugar de recortarse con puntos suspensivos, y por eso una tabla dentro de un contenedor flexible rompe la maquetación. La solución casi siempre es min-width: 0 en el elemento que debe poder encogerse (o min-height: 0 si el eje es vertical). Es la línea que más veces he tenido que añadir en revisiones de código ajeno.

project-board.component.css · tablero y cuadrícula con grid
/* 1. Tablero de columnas fijas, cada una con scroll propio */
.board {
  display: grid;
  grid-template-columns: repeat(3, minmax(280px, 1fr));
  gap: var(--tf-space-4);
  align-items: start;            /* que las columnas no se estiren entre sí */
  overflow-x: auto;              /* en móvil se desplaza horizontalmente */
}

/* 2. Cuadrícula adaptativa SIN media queries: el número de columnas
      lo decide el espacio disponible, no un punto de ruptura inventado */
.project-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
  gap: var(--tf-space-4);
}

/* 3. Formulario con áreas nombradas: la estructura se lee en el CSS */
.task-form {
  display: grid;
  grid-template-columns: 160px 1fr;
  grid-template-areas:
    'label-title   title'
    'label-project project'
    'label-due     due'
    '.             actions';
  gap: var(--tf-space-3) var(--tf-space-4);
  align-items: center;
}
.task-form__actions { grid-area: actions; justify-self: end; }

La regla minmax(min(260px, 100%), 1fr) merece un comentario, porque es el idioma que evita el error clásico: con minmax(260px, 1fr) a secas, en una pantalla de 240 píxeles la columna sigue midiendo 260 y aparece el temido desplazamiento horizontal. El min(260px, 100%) dice «260 píxeles, salvo que no quepan, en cuyo caso todo el ancho disponible». Con esa sola línea desaparecen dos o tres puntos de ruptura.

auto-fit frente a auto-fill

Ambos repiten columnas hasta llenar el espacio. La diferencia aparece cuando sobran huecos: auto-fill mantiene las pistas vacías (las tarjetas conservan su ancho y queda espacio libre a la derecha) y auto-fit las colapsa (las tarjetas existentes se reparten todo el ancho). Para una cuadrícula de proyectos con pocos elementos, auto-fit suele verse mejor; para una galería en la que quieres que el tamaño de la tarjeta sea constante, auto-fill.

24.4.2 Variables personalizadas: el mecanismo, no la sintaxis

Las propiedades personalizadas de CSS no son «las variables de Sass pero en el navegador». Son algo distinto y más potente, y las diferencias importan:

Ámbito, herencia y reserva
:root {                          /* alcance global: toda la aplicación */
  --tf-accent: #5b8cff;
  --tf-space-3: 12px;
}

/* Redefinición local: solo dentro de este subárbol */
.project-panel--danger { --tf-accent: #ff5c7a; }

/* El componente consume la variable sin saber quién la define */
.btn { background: var(--tf-accent, #4361ee); }   /* con valor de reserva */

/* Truco útil: componer variables a partir de otras */
:host {
  --tf-card-padding: var(--tf-space-3);
  padding: var(--tf-card-padding);
}
Registro con @property

Una variable CSS normal es una cadena sin tipo, y por eso no se puede interpolar en una animación: al pasar de 0 a 1 salta de golpe. Registrarla con @property le da tipo, valor inicial y semántica de herencia, y a partir de ahí el navegador sí puede animarla. Es una capacidad relativamente reciente, con soporte amplio en navegadores actuales pero no en versiones antiguas; compruébalo si tu público incluye navegadores con varios años.

styles.css · variable animable para la barra de progreso de un proyecto
@property --tf-progress {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 0%;
}

.project-progress {
  background: conic-gradient(var(--tf-accent) var(--tf-progress), var(--tf-surface-3) 0);
  transition: --tf-progress .6s ease;   /* ahora sí interpola */
}

24.4.3 clamp() y tipografía fluida

clamp(mínimo, preferido, máximo) expresa en una línea lo que antes exigía tres consultas de medios. El valor preferido suele mezclar una parte fija y una proporcional al ancho de la ventana, de modo que la tipografía crece de forma continua entre dos límites en lugar de dar saltos.

typography.cssINCORRECTO
/* Saltos bruscos y tres reglas que mantener sincronizadas */
h1 { font-size: 22px; }
@media (min-width: 640px)  { h1 { font-size: 28px; } }
@media (min-width: 1024px) { h1 { font-size: 34px; } }

.board { padding: 12px; }
@media (min-width: 640px)  { .board { padding: 20px; } }
@media (min-width: 1024px) { .board { padding: 32px; } }
typography.cssCORRECTO
/* Continuo, con mínimo y máximo garantizados */
h1     { font-size: clamp(1.375rem, 1rem + 1.6vw, 2.125rem); }
.board { padding:  clamp(0.75rem, 0.4rem + 1.4vw, 2rem); }

/* Medida de línea legible sin cálculos: 45-75 caracteres */
.prose { max-width: min(68ch, 100%); }
Accesibilidad: nunca uses solo vw

Un font-size: 4vw a secas ignora el zoom del navegador y el tamaño de fuente configurado por el usuario, lo que incumple el criterio de redimensionado de texto de las WCAG. La fórmula correcta siempre incluye un término en rem: clamp(1rem, 0.9rem + 0.6vw, 1.25rem). Así, si el usuario aumenta el tamaño base, el texto crece con él.

24.4.4 Contenedores de consulta: el fin del componente que no sabe dónde está

Una consulta de medios pregunta por el ancho de la ventana. Pero un componente rara vez quiere saber eso: quiere saber cuánto espacio tiene él. La tarjeta de tarea de TaskFlow aparece en tres sitios —la lista principal a todo lo ancho, una columna estrecha del tablero y el panel lateral de detalle— y con consultas de medios es imposible que se adapte, porque en los tres casos la ventana mide lo mismo.

task-card.component.css · adaptación al contenedor, no a la ventana
/* 1. El anfitrión se declara contenedor consultable */
:host {
  display: block;
  container-type: inline-size;   /* solo se consulta el eje en línea */
  container-name: task-card;     /* opcional pero recomendable */
}

/* 2. Diseño compacto por defecto (móvil primero también aquí) */
.task-card { display: grid; gap: var(--tf-space-2); }
.task-card__assignee,
.task-card__project { display: none; }

/* 3. A partir de 380px DE CONTENEDOR, no de ventana */
@container task-card (min-width: 380px) {
  .task-card { grid-template-columns: 1fr auto; align-items: center; }
  .task-card__assignee { display: flex; }
}

@container task-card (min-width: 560px) {
  .task-card__project { display: inline-flex; }
  .task-card__title { font-size: var(--tf-fs-lg); }
}
Dos trampas de container-type

1. Declarar container-type: inline-size crea contención de tamaño en el eje en línea: el contenedor deja de dimensionarse según su contenido en ese eje. Si el elemento medía «lo que ocupa su texto», dejará de hacerlo. 2. Un elemento no puede consultarse a sí mismo: las reglas dentro de @container deben apuntar a descendientes del contenedor. Por eso se declara el contenedor en :host y se estilan los hijos, nunca :host mismo dentro de la consulta.

24.4.5 :has(), el selector de padre que llevábamos veinte años pidiendo

:has() selecciona un elemento en función de lo que contiene. Su impacto práctico es que muchas clases de estado que hoy calcula el componente y aplica con [class.x] dejan de ser necesarias: el CSS puede deducirlas del propio DOM.

task-form.component.css · estados deducidos del DOM
/* El campo se marca en rojo porque contiene un input inválido ya tocado */
.field:has(input.ng-invalid.ng-touched) { --tf-field-border: var(--tf-danger); }

/* La fila cambia de fondo si su casilla está marcada */
.task-row:has(input[type='checkbox']:checked) {
  background: var(--tf-surface-3);
  text-decoration: line-through;
}

/* El panel se compacta cuando NO contiene descripción */
.task-panel:not(:has(.task-panel__description)) { padding-block-end: var(--tf-space-2); }

/* La tarjeta se realza si alguno de sus descendientes tiene el foco visible */
.task-card:has(:focus-visible) { outline: 2px solid var(--tf-accent); outline-offset: 2px; }

Ese último ejemplo sustituye a un patrón completo: antes había que escuchar focusin y focusout en el componente, mantener una propiedad booleana y enlazarla a una clase. Cuatro líneas de TypeScript, un estado más que probar y una posible fuga de listeners, resueltas con un selector.

24.4.6 Capas en cascada: el final de las guerras de especificidad

@layer introduce un nivel de decisión por encima de la especificidad. Cuando dos reglas de capas distintas compiten, gana la de la capa declarada más tarde sin mirar la especificidad. Un selector de un solo nombre de clase en una capa posterior vence a un #id .a .b .c de una capa anterior. Esto cambia por completo la forma de integrar CSS de terceros.

styles.css · orden de capas declarado una sola vez
/* El orden se fija aquí; lo que venga después se coloca en su capa
   independientemente del orden de los @import o del bundler */
@layer reset, tokens, vendor, base, componentes, utilidades;

@layer reset {
  *, *::before, *::after { box-sizing: border-box; }
  body { margin: 0; }
}

@layer vendor {
  /* Todo el CSS de una librería de terceros entra aquí y queda
     por debajo de nuestros componentes pase lo que pase */
  @import url('vendor/tabla-legacy.css');
}

@layer componentes {
  /* Una sola clase gana a cualquier selector de la capa vendor */
  .tf-table th { background: var(--tf-surface-2); }
}

@layer utilidades {
  .u-hidden { display: none; }   /* gana siempre, sin !important */
}
Tres reglas de @layer que hay que saber

1. El CSS sin capa tiene más prioridad que cualquier CSS en capas. Es contraintuitivo y es la causa número uno de sorpresas: si tu regla no gana, comprueba si la del rival está fuera de capas. 2. !important invierte el orden de las capas, así que la primera capa gana en las declaraciones importantes. 3. En Angular, los estilos de componente se inyectan como hojas independientes; si quieres integrarlos en el sistema de capas, envuelve su contenido con @layer componentes { ... } dentro del propio archivo del componente.

24.4.7 Propiedades lógicas

Las propiedades físicas (left, right, margin-left) describen la pantalla; las lógicas (inline-start, inline-end, margin-inline-start) describen el flujo del texto. La diferencia solo se nota cuando llega la primera traducción a árabe o hebreo, y entonces se nota mucho: con propiedades físicas hay que revisar la aplicación entera; con lógicas, basta con dir="rtl".

FísicoLógicoNota
margin-left / margin-rightmargin-inline-start / margin-inline-endCon dir="rtl" se intercambian solas.
padding-top + padding-bottompadding-blockUna sola declaración para los dos lados del eje de bloque.
width / heightinline-size / block-sizeCoherente con container-type: inline-size.
border-leftborder-inline-startTípico en el indicador lateral de una tarjeta urgente.
top: 0; left: 0inset-block-start: 0; inset-inline-start: 0inset: 0 sigue siendo válido y cómodo.
text-align: lefttext-align: startAplicable ya mismo, sin coste alguno.
Ganancia inmediata sin internacionalización

Aunque nunca vayas a traducir a un idioma de derecha a izquierda, padding-block, margin-inline y gap ahorran declaraciones y hacen el CSS más legible. margin-inline: auto centra un bloque con menos ruido que margin-left: auto; margin-right: auto. Es de las pocas modernizaciones que no tienen contrapartida.

24.5 Temas y modo oscuro

El modo oscuro es la prueba del algodón de una arquitectura de estilos. Si los tokens están bien, es un archivo de treinta líneas y una tarde. Si no, es un proyecto de dos semanas que además deja la aplicación medio rota. Vamos a construirlo entero para TaskFlow.

24.5.1 Anatomía de un sistema de tokens

El error más común al empezar es tener una sola capa de variables: --azul-claro, --gris-oscuro. Falla en cuanto llega el segundo tema, porque el nombre describe el valor y en modo oscuro el «gris oscuro» tiene que ser claro. La solución es separar tres niveles, cada uno con una responsabilidad distinta:

  ┌──────────────────────────────────────────────────────────────────────────┐
  │  NIVEL 1 · TOKENS PRIMITIVOS         (la paleta cruda; nunca se usan     │
  │                                       directamente en un componente)     │
  │    --tf-blue-500: #5b8cff        --tf-gray-900: #0d1020                  │
  │    --tf-blue-600: #4571e8        --tf-gray-700: #262c44                  │
  │    --tf-red-500:  #ff5c7a        --tf-gray-100: #e6e9f2                  │
  └───────────────────────────────┬──────────────────────────────────────────┘
                                  │  se referencian desde
                                  ▼
  ┌──────────────────────────────────────────────────────────────────────────┐
  │  NIVEL 2 · TOKENS SEMÁNTICOS         (describen FUNCIÓN, no color;       │
  │                                       AQUÍ es donde cambia el tema)      │
  │    --tf-surface-1   --tf-text        --tf-accent      --tf-danger        │
  │    --tf-surface-2   --tf-text-muted  --tf-border      --tf-shadow-1      │
  │                                                                          │
  │    tema claro  →  --tf-surface-1: var(--tf-gray-050)                     │
  │    tema oscuro →  --tf-surface-1: var(--tf-gray-900)                     │
  └───────────────────────────────┬──────────────────────────────────────────┘
                                  │  se referencian desde
                                  ▼
  ┌──────────────────────────────────────────────────────────────────────────┐
  │  NIVEL 3 · TOKENS DE COMPONENTE      (puntos de extensión publicados     │
  │                                       por cada componente)               │
  │    --tf-card-bg: var(--tf-surface-2)     --tf-card-radius: …            │
  │    --tf-btn-bg:  var(--tf-accent)        --tf-btn-height: …             │
  └───────────────────────────────┬──────────────────────────────────────────┘
                                  │  los consume
                                  ▼
              .task-card { background: var(--tf-card-bg); }

  REGLA DE ORO: un componente SOLO consume niveles 2 y 3. Si un componente
  menciona --tf-blue-500, el sistema ya está roto: ese componente no podrá
  cambiar de tema y habrá que editarlo a mano.

Con esta separación, cambiar de tema consiste en redefinir el nivel 2. Ni un solo componente se toca. Y como el nivel 1 es una paleta cerrada, el diseñador puede ajustar la marca entera cambiando seis valores.

24.5.2 Los tokens de TaskFlow

Los tokens no son solo colores. Un sistema completo cubre al menos cinco familias: color, espaciado, tipografía, radios y sombras. Las escalas deben ser cortas —si tienes doce valores de espaciado, no tienes escala, tienes libertad disfrazada— y no lineales, porque la percepción tampoco lo es.

src/styles/tokens.css
@layer tokens {
  :root {
    /* ---------- NIVEL 1: primitivos ---------- */
    --tf-blue-400: #7ba4ff;  --tf-blue-500: #5b8cff;  --tf-blue-600: #4571e8;
    --tf-red-500:  #ff5c7a;  --tf-green-500: #2ecc8f; --tf-amber-500: #ffb020;
    --tf-gray-000: #ffffff;  --tf-gray-050: #f6f7fb;  --tf-gray-100: #e6e9f2;
    --tf-gray-400: #8892b0;  --tf-gray-700: #262c44;  --tf-gray-800: #161b2e;
    --tf-gray-900: #0d1020;

    /* ---------- Escalas independientes del tema ---------- */
    --tf-space-1: 4px;  --tf-space-2: 8px;  --tf-space-3: 12px;
    --tf-space-4: 16px; --tf-space-5: 24px; --tf-space-6: 32px; --tf-space-7: 48px;

    --tf-fs-xs: .75rem;   --tf-fs-sm: .8125rem; --tf-fs-md: .9375rem;
    --tf-fs-lg: 1.125rem; --tf-fs-xl: clamp(1.375rem, 1rem + 1.6vw, 2.125rem);

    --tf-radius-sm: 6px; --tf-radius-md: 10px; --tf-radius-lg: 16px; --tf-radius-full: 999px;

    --tf-dur-fast: 120ms; --tf-dur-base: 200ms; --tf-dur-slow: 320ms;
    --tf-ease: cubic-bezier(.2, 0, 0, 1);

    /* ---------- NIVEL 2: semánticos (tema claro por defecto) ---------- */
    --tf-surface-1: var(--tf-gray-050);   /* fondo de página */
    --tf-surface-2: var(--tf-gray-000);   /* tarjetas y paneles */
    --tf-surface-3: var(--tf-gray-100);   /* estados hover y zonas hundidas */
    --tf-text:       #12172b;
    --tf-text-muted: #5b6480;
    --tf-border:     #dfe3ee;
    --tf-accent:     var(--tf-blue-600);
    --tf-on-accent:  var(--tf-gray-000);
    --tf-danger:     #d93a5c;
    --tf-success:    #15915f;
    --tf-shadow-1: 0 1px 2px rgba(16, 24, 48, .08), 0 4px 12px rgba(16, 24, 48, .06);
    --tf-shadow-2: 0 8px 30px rgba(16, 24, 48, .14);

    color-scheme: light;   /* barras de scroll y controles nativos claros */
  }

  /* ---------- Tema oscuro: SOLO se redefine el nivel 2 ---------- */
  :root[data-theme='dark'] {
    --tf-surface-1: var(--tf-gray-900);
    --tf-surface-2: var(--tf-gray-800);
    --tf-surface-3: var(--tf-gray-700);
    --tf-text:       var(--tf-gray-100);
    --tf-text-muted: var(--tf-gray-400);
    --tf-border:     #2b3252;
    --tf-accent:     var(--tf-blue-500);
    --tf-on-accent:  #071024;
    --tf-danger:     var(--tf-red-500);
    --tf-success:    var(--tf-green-500);
    --tf-shadow-1: 0 1px 2px rgba(0, 0, 0, .5), 0 6px 18px rgba(0, 0, 0, .35);
    --tf-shadow-2: 0 12px 40px rgba(0, 0, 0, .55);

    color-scheme: dark;
  }
}
El modo oscuro no es invertir los colores

Tres detalles que separan un tema oscuro profesional de uno amateur: 1) el negro puro (#000) sobre blanco puro produce halo y fatiga; usa grises muy oscuros y textos ligeramente apagados. 2) Las sombras no funcionan en oscuro —una sombra negra sobre fondo negro no se ve—, así que la jerarquía se transmite con elevación por luminosidad: cuanto más «alto» está un panel, más claro es su fondo. Por eso --tf-surface-2 es más claro que --tf-surface-1 en el tema oscuro y más oscuro en el claro. 3) Los colores saturados vibran sobre fondo oscuro; el acento del tema oscuro debe ser algo más claro y menos saturado que el del claro.

color-scheme no es decorativo

La declaración color-scheme: dark le dice al navegador que el fondo es oscuro, y este adapta lo que tú no controlas: barras de desplazamiento, el aspecto por defecto de input, select y textarea, los controles de fecha y el fondo del formulario de autocompletado. Sin ella, tendrás una aplicación oscura preciosa con barras de scroll blancas. Es una línea y resuelve un problema que la gente intenta arreglar con decenas de reglas.

24.5.3 Cambiar de tema sin recargar, con señales

El servicio de tema tiene tres responsabilidades: recordar la preferencia del usuario (que puede ser «claro», «oscuro» o «lo que diga el sistema»), calcular el tema efectivo a partir de ella y del sistema operativo, y aplicarlo al documento. Con señales queda muy limpio, porque el tema efectivo es literalmente un valor derivado.

src/app/core/theme.service.ts
import { DOCUMENT } from '@angular/common';   // en Angular 19+ también se exporta desde @angular/core
import { Injectable, computed, effect, inject, signal } from '@angular/core';

export type ThemePreference = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'tf-theme';

@Injectable({ providedIn: 'root' })
export class ThemeService {
  private readonly doc = inject(DOCUMENT);
  private readonly win = this.doc.defaultView;

  /** Lo que el usuario ha elegido explícitamente. */
  readonly preference = signal<ThemePreference>(this.readStoredPreference());

  /** Lo que dice el sistema operativo en este instante. */
  private readonly systemDark = signal(this.matchSystemDark());

  /** Tema realmente aplicado: valor derivado, nunca duplicado. */
  readonly resolved = computed<'light' | 'dark'>(() => {
    const pref = this.preference();
    if (pref !== 'system') return pref;
    return this.systemDark() ? 'dark' : 'light';
  });

  constructor() {
    // Escucha los cambios del sistema para que 'system' sea realmente reactivo
    const mq = this.win?.matchMedia('(prefers-color-scheme: dark)');
    mq?.addEventListener('change', (e) => this.systemDark.set(e.matches));

    // Un único efecto que sincroniza el DOM con el estado derivado
    effect(() => {
      this.doc.documentElement.dataset['theme'] = this.resolved();
    });
  }

  setPreference(pref: ThemePreference): void {
    this.preference.set(pref);
    try {
      if (pref === 'system') this.win?.localStorage.removeItem(STORAGE_KEY);
      else this.win?.localStorage.setItem(STORAGE_KEY, pref);
    } catch {
      /* modo privado o almacenamiento lleno: el tema sigue funcionando en memoria */
    }
  }

  private readStoredPreference(): ThemePreference {
    try {
      const raw = this.win?.localStorage.getItem(STORAGE_KEY);
      return raw === 'light' || raw === 'dark' ? raw : 'system';
    } catch {
      return 'system';
    }
  }

  private matchSystemDark(): boolean {
    return this.win?.matchMedia('(prefers-color-scheme: dark)').matches ?? false;
  }
}
src/app/shared/theme-switch.component.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ThemePreference, ThemeService } from '../core/theme.service';

@Component({
  selector: 'tf-theme-switch',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <fieldset class="switch">
      <legend class="switch__legend">Apariencia</legend>
      @for (opt of options; track opt.value) {
        <button
          type="button"
          class="switch__btn"
          [class.is-active]="theme.preference() === opt.value"
          [attr.aria-pressed]="theme.preference() === opt.value"
          (click)="theme.setPreference(opt.value)">
          {{ opt.label }}
        </button>
      }
    </fieldset>
  `,
  styles: `
    .switch { display: inline-flex; gap: var(--tf-space-1); border: 1px solid var(--tf-border);
              border-radius: var(--tf-radius-full); padding: var(--tf-space-1); }
    .switch__btn { border: 0; background: transparent; color: var(--tf-text-muted);
                   border-radius: var(--tf-radius-full); padding: 4px 12px; cursor: pointer; }
    .switch__btn.is-active { background: var(--tf-accent); color: var(--tf-on-accent); }
  `,
})
export class ThemeSwitchComponent {
  protected readonly theme = inject(ThemeService);
  protected readonly options: { value: ThemePreference; label: string }[] = [
    { value: 'light',  label: 'Claro'    },
    { value: 'dark',   label: 'Oscuro'   },
    { value: 'system', label: 'Sistema'  },
  ];
}
El parpadeo al cargar y cómo se evita de verdad

Si el tema se aplica desde un servicio de Angular, entre que el navegador pinta el HTML y que arranca la aplicación pasan unos cientos de milisegundos con el tema claro por defecto. El usuario que eligió oscuro ve un fogonazo blanco en cada carga. La única solución fiable es aplicar el atributo antes de la primera pintura, con un fragmento de JavaScript síncrono en el <head> de index.html. Es la excepción justificada a «nada de scripts sueltos»: no hay alternativa, porque el problema ocurre antes de que exista el framework.

src/index.html · dentro de <head>, antes de cualquier hoja de estilos
<script>
  // Se ejecuta de forma síncrona antes de la primera pintura: sin parpadeo.
  (function () {
    try {
      var stored = localStorage.getItem('tf-theme');
      var dark = stored ? stored === 'dark'
                        : matchMedia('(prefers-color-scheme: dark)').matches;
      document.documentElement.dataset.theme = dark ? 'dark' : 'light';
    } catch (e) { /* sin almacenamiento: se queda el tema claro por defecto */ }
  })();
</script>
Si usas renderizado en servidor

Con SSR el servidor no conoce la preferencia del sistema del usuario, así que el HTML se genera con un tema y el cliente puede corregirlo después. Hay dos caminos: guardar la preferencia en una cookie (el servidor sí la lee y puede pintar el atributo correcto de entrada) o mantener el fragmento anterior, que se ejecuta antes de la hidratación. La cookie es la solución completa; el fragmento, la barata. Lo que no funciona es leer localStorage en el servidor: allí no existe.

La función light-dark()

El CSS moderno incluye light-dark(valorClaro, valorOscuro), que devuelve uno u otro según el color-scheme efectivo y permite escribir un tema doble sin duplicar el bloque de tokens. Es cómoda, pero tiene dos límites: solo sirve para colores y solo distingue dos esquemas, así que no vale si algún día quieres un tercer tema (alto contraste, marca blanca). Su soporte es reciente; compruébalo contra los navegadores que tengas que admitir antes de apoyar en ella toda la arquitectura de temas.

24.6 Angular Material: cuándo conviene y cuándo no

Angular Material es la implementación oficial de Material Design mantenida por el equipo de Angular. Es, con diferencia, la librería de componentes más usada del ecosistema, y también la que más frustración genera, casi siempre por el mismo motivo: se adopta esperando «unos componentes bonitos que luego personalizo» y resulta que es un sistema de diseño completo con opiniones fuertes. Personalizarla es posible y está previsto; lo que no funciona es pelearse con ella.

24.6.1 La decisión, sin marketing

Situación¿Material?Razonamiento
Herramienta interna con muchos formularios, tablas, diálogos y filtrosSí, claramenteEs el caso para el que fue diseñado. Te ahorra meses: selector con autocompletado, selector de fechas, tabla con ordenación y paginación, y todo accesible desde el primer día.
Panel de administración con plazos ajustados y sin diseñadorMaterial aporta centenares de decisiones razonables que nadie de tu equipo va a tomar mejor en el tiempo disponible.
Producto con identidad visual muy marcada y un sistema de diseño propioProbablemente noAcabarás redefiniendo tanto que la librería solo aporta peso, indirección y una capa de tokens ajena que hay que traducir a la tuya.
Aplicación pública donde el tamaño del paquete es críticoCon cuidadoEs modular y sólo pagas por lo que importas, pero componentes como la tabla, el selector de fechas o el árbol pesan. Mide antes de decidir (sección 24.14).
Necesitas solo un menú desplegable y un diálogoNo: usa el CDKEl CDK te da el comportamiento (posicionamiento, foco, accesibilidad) sin imponer ni un píxel de estilo. Es la sección 24.7 y es la respuesta correcta más veces de lo que la gente cree.
El diseño de referencia ya está hecho en Material DesignCoincidencia total entre lo que espera el diseñador y lo que la librería hace por defecto: el escenario ideal.
La regla de las tres capas

Angular publica tres cosas distintas y conviene no confundirlas. 1) Angular CDK: comportamiento puro sin estilos (overlay, portal, foco, scroll virtual, arrastrar y soltar). 2) Angular Material: componentes construidos sobre el CDK con los estilos de Material Design. 3) Material Design: la especificación de diseño de Google, ajena a Angular. Puedes usar el CDK sin Material, pero no al revés. Si lo que necesitas es comportamiento, baja una capa: es más trabajo inicial y muchísimo menos trabajo a largo plazo.

24.6.2 Instalación y qué toca realmente

terminal
ng add @angular/material

# El esquema oficial hace, aproximadamente, esto:
#  · instala @angular/material y @angular/cdk con la versión que corresponde a tu Angular
#  · añade un tema (prefabricado o generado) a la configuración de estilos de angular.json
#  · registra el proveedor de animaciones en la configuración de la aplicación
#  · añade la tipografía Roboto y la fuente de iconos a index.html (puedes quitarlas)
#  · aplica la clase de tipografía global al <body>
src/app/app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideNativeDateAdapter } from '@angular/material/core';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';

export const appConfig: ApplicationConfig = {
  providers: [
    // Carga el paquete de animaciones de forma diferida (Angular 17+).
    // Alternativas: provideAnimations() para cargarlo de inmediato
    //               provideNoopAnimations() para desactivarlas (útil en tests).
    provideAnimationsAsync(),

    // Necesario si usas el selector de fechas (Angular Material 17+).
    provideNativeDateAdapter(),

    // Fija la apariencia de TODOS los campos sin repetirla en cada plantilla.
    { provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
      useValue: { appearance: 'outline', subscriptSizing: 'dynamic' } },

    provideZonelessChangeDetection(),
  ],
};

Ese último proveedor ya es una lección de arquitectura: casi todos los módulos de Material exponen un token de opciones por defecto. Antes de repetir appearance="outline" en ciento veinte campos, o de escribir CSS para corregir un espaciado, busca si existe el token correspondiente. Es configuración de verdad, no un parche.

24.6.3 El sistema de temas

Aviso de versión, imprescindible antes de copiar nada

El sistema de temas de Angular Material ha cambiado varias veces en pocos años, y por eso hay tantos tutoriales que ya no funcionan. A grandes rasgos: en la versión 15 los componentes se reescribieron sobre los Material Components for Web, lo que cambió por completo los nombres de las clases internas. Hasta la versión 17 el tema se construía con los mixins de Material Design 2 (mat.define-light-theme, mat.define-dark-theme, mat.all-component-themes, mat.core). En la 18 llegó Material Design 3 con una API de definición distinta. Desde la 19 existe un mixin único que declara el tema y publica variables CSS de sistema con el prefijo --mat-sys-. Comprueba siempre la documentación de tu versión en material.angular.io antes de escribir el archivo de tema: los nombres de los mixins no son intercambiables entre versiones y un ejemplo de una versión anterior fallará en la compilación de Sass.

El planteamiento conceptual, en cambio, es estable y es lo que conviene entender: tú declaras un tema en Sass y Material genera a partir de él un conjunto de tokens, que los componentes consumen. Personalizar bien significa intervenir en esa generación de tokens, no en el CSS resultante.

src/styles.scss · enfoque de Angular Material 19 y posteriores
@use '@angular/material' as mat;

html {
  // Un único mixin declara color, tipografía y densidad.
  // El resultado son variables CSS de sistema (--mat-sys-*) que puedes leer.
  @include mat.theme((
    color: (
      primary: mat.$azure-palette,     // paletas prefabricadas de M3
      tertiary: mat.$violet-palette,
    ),
    typography: Roboto,
    density: 0,                        // 0 = cómodo; valores negativos = compacto
  ));
}

// El modo oscuro se declara en el mismo sitio que el resto de nuestros tokens:
// Material lee color-scheme, así que basta con mantenerlo coherente.
:root[data-theme='dark'] { color-scheme: dark; }

// PUENTE entre el sistema de tokens de Material y el nuestro (sección 24.5):
// nuestros componentes siguen usando --tf-*, y aquí decidimos qué significa cada uno.
:root {
  --tf-accent: var(--mat-sys-primary);
  --tf-on-accent: var(--mat-sys-on-primary);
  --tf-surface-2: var(--mat-sys-surface-container);
  --tf-text: var(--mat-sys-on-surface);
}
src/styles.scss · enfoque de Angular Material 17 y anteriores (Material Design 2)
@use '@angular/material' as mat;

@include mat.core();   // estilos base comunes, una sola vez en todo el proyecto

$tf-primary: mat.define-palette(mat.$indigo-palette, 500);
$tf-accent:  mat.define-palette(mat.$pink-palette, A200, A100, A400);
$tf-warn:    mat.define-palette(mat.$red-palette);

$tf-light: mat.define-light-theme((
  color: (primary: $tf-primary, accent: $tf-accent, warn: $tf-warn),
  density: 0,
));

$tf-dark: mat.define-dark-theme((
  color: (primary: $tf-primary, accent: $tf-accent, warn: $tf-warn),
));

@include mat.all-component-themes($tf-light);

// Solo los COLORES se repiten para el tema oscuro: repetir el tema entero
// duplicaría tipografía y densidad y engordaría el CSS sin motivo.
:root[data-theme='dark'] { @include mat.all-component-colors($tf-dark); }
El error que multiplica por tres el CSS

Incluir mat.all-component-themes dos veces (una por tema) genera de nuevo tipografía y densidad para el segundo tema, cuando lo único que cambia es el color. En proyectos reales he visto hojas de 900 KB reducirse a 300 KB solo por usar el mixin de colores en el tema secundario. Y un consejo relacionado: si sabes qué componentes usas, existen mixins por componente para incluir únicamente sus temas en lugar de los de la librería entera.

24.6.4 Personalizar de verdad, sin ::ng-deep

Esta es la subsección que más disgustos ahorra. Cuando algo de Material no se ve como quieres, hay cinco mecanismos legítimos, y conviene probarlos en este orden antes de pensar siquiera en perforar la encapsulación:

  1. Tokens del tema. Si el color primario no es el que quieres, cámbialo en el tema, no en el componente.
  2. Mixins de sobrescritura de tokens. Las versiones recientes de Material publican mixins que permiten redefinir tokens concretos de un componente (por ejemplo el radio de un botón o el fondo de una tarjeta) de forma global o dentro de un selector. Comprueba en la documentación de tu versión cómo se llaman exactamente los que necesitas y qué tokens admiten.
  3. Variables CSS de sistema. En las versiones con Material Design 3 puedes redefinir --mat-sys-* dentro de un selector para afectar solo a una zona de la aplicación.
  4. Tokens de opciones por defecto. MAT_FORM_FIELD_DEFAULT_OPTIONS, MAT_DIALOG_DEFAULT_OPTIONS, MAT_SNACK_BAR_DEFAULT_OPTIONS y sus equivalentes cambian el comportamiento por configuración.
  5. Clases de panel. Los componentes que se pintan fuera del árbol del componente (diálogos, menús, selectores, avisos) aceptan una clase propia (panelClass) que puedes estilar desde la hoja global, donde no hay encapsulación que perforar.
task-dialog.component.cssINCORRECTO
/* 1. Sin :host, esta regla es GLOBAL: afecta a todos los
      diálogos de la aplicación, incluidos los de otros equipos */
::ng-deep .mat-mdc-dialog-surface {
  border-radius: 20px;
  background: #161b2e;
}

/* 2. Depende de un nombre de clase INTERNO de la librería,
      que cambió en la versión 15 y puede volver a cambiar */
::ng-deep .mat-mdc-form-field-subscript-wrapper { display: none; }

/* 3. Guerra de especificidad: la siguiente parada es !important */
::ng-deep .mat-mdc-raised-button.mat-primary .mdc-button__label {
  color: #fff;
}
Configuración y clase de panelCORRECTO
// task-list.component.ts — se pide la personalización al abrir
this.dialog.open(TaskDialogComponent, {
  panelClass: 'tf-dialog',      // clase propia, nuestra, documentada
  width: 'min(560px, 92vw)',
  autoFocus: 'first-tabbable',
  restoreFocus: true,
  data: { taskId },
});

/* styles.css — hoja GLOBAL: es donde deben vivir los estilos de
   elementos que se renderizan fuera del árbol del componente */
.tf-dialog .mat-mdc-dialog-surface {
  border-radius: var(--tf-radius-lg);
  background: var(--tf-surface-2);
}

/* Y el subscript se oculta por configuración, no por CSS: */
// { provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
//   useValue: { subscriptSizing: 'dynamic' } }

La diferencia de fondo entre las dos columnas no es de estilo, es de acoplamiento. La izquierda depende de nombres de clase internos de una librería que no controlas y que cambian entre versiones mayores; la derecha depende de una API pública. Cuando actualices Angular Material, la izquierda se romperá en silencio —recuerda: el CSS no da errores— y la derecha seguirá funcionando o fallará ruidosamente en la compilación.

Si no queda más remedio

Habrá casos en los que ningún token cubra lo que necesitas. Entonces: 1) escribe siempre :host ::ng-deep, nunca ::ng-deep a secas, para que el ámbito quede acotado al componente; 2) deja un comentario con la versión de Material, el motivo y el enlace a la incidencia si existe; 3) agrupa todos esos parches en un único archivo material-overrides.css para que la deuda sea visible y se pueda revisar en cada actualización. Un parche documentado y localizado es deuda gestionada; treinta parches repartidos son deuda oculta.

24.6.5 Los componentes que de verdad se usan

La librería tiene decenas de componentes, pero en una aplicación de gestión como TaskFlow el noventa por ciento del uso se concentra en unos pocos. Estos son, con la advertencia que importa de cada uno:

ComponenteUso en TaskFlowLo que hay que saber
MatFormField + matInputTodos los campos de tarea y proyectoEs el envoltorio: la etiqueta, el error, la pista y los prefijos y sufijos van dentro. El subscript reserva espacio para el mensaje de error; con subscriptSizing: 'dynamic' deja de hacerlo, lo que evita huecos pero puede provocar saltos de diseño al aparecer el error.
MatSelect / MatAutocompleteElegir proyecto, responsable o etiquetasSe pintan en un overlay: sus estilos no están dentro de tu componente. Para listas largas, el autocompletado con filtrado es mejor experiencia que un selector con doscientas opciones.
MatTable + MatSort + MatPaginatorListado de tareas en vista de tablaPotente y pesado. MatTableDataSource es cómodo pero filtra y ordena en memoria: con datos del servidor paginados, implementa tu propia fuente de datos o gestiona el estado tú mismo.
MatDialogCrear o editar tarea, confirmacionesTrae trampa de foco, cierre con Escape, roles ARIA y restauración del foco al cerrar. Reimplementarlo bien cuesta más de lo que parece.
MatSnackBarAvisos de guardado y deshacerSolo debe mostrar mensajes no críticos. No es un sistema de errores: un fallo que exige acción del usuario necesita un diálogo o un mensaje en el formulario.
MatMenuAcciones de cada tareaNavegación completa con teclado incluida. Si solo necesitas esto, valora el overlay del CDK (24.7.1).
MatChipsEtiquetas de una tareaEl modo de entrada permite crear etiquetas escribiendo y confirmando con Intro o coma. Cuidado con la accesibilidad al implementar el borrado.
MatIconToda la interfazCon fuente de iconos hay parpadeo y peso; con iconos SVG registrados en el registro de iconos tienes control total. Ver 24.14.
MatProgressBar / MatProgressSpinnerCargasÚsalos poco: en listas, un esqueleto funciona mejor (sección 24.12).

24.6.6 Formularios con Material y tipado estricto

Este ejemplo conecta con el capítulo 5: formulario reactivo tipado, validación, errores del servidor y accesibilidad, con los componentes de Material puestos donde toca.

src/app/tasks/task-form.component.ts
import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { Project } from '../projects/project.model';

@Component({
  selector: 'tf-task-form',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    ReactiveFormsModule, MatFormFieldModule, MatInputModule,
    MatSelectModule, MatDatepickerModule, MatButtonModule,
  ],
  templateUrl: './task-form.component.html',
})
export class TaskFormComponent {
  readonly projects = input.required<Project[]>();
  readonly saved = output<TaskFormValue>();

  private readonly fb = inject(FormBuilder);

  protected readonly form = this.fb.nonNullable.group({
    title: ['', [Validators.required, Validators.maxLength(120)]],
    projectId: ['', Validators.required],
    priority: ['medium' as Priority],
    dueDate: this.fb.control<Date | null>(null),
    description: [''],
  });

  protected submit(): void {
    if (this.form.invalid) {
      this.form.markAllAsTouched();   // dispara los mat-error de todos los campos
      return;
    }
    this.saved.emit(this.form.getRawValue());
  }
}
src/app/tasks/task-form.component.html
<form [formGroup]="form" (ngSubmit)="submit()" class="task-form">

  <mat-form-field>
    <mat-label>Título de la tarea</mat-label>
    <input matInput formControlName="title" maxlength="120" required>
    <mat-hint align="end">{{ form.controls.title.value.length }}/120</mat-hint>

    <!-- Un mat-error por caso: Material ya gestiona cuándo mostrarlo
         (control inválido y tocado o formulario enviado) -->
    @if (form.controls.title.hasError('required')) {
      <mat-error>El título es obligatorio.</mat-error>
    }
    @if (form.controls.title.hasError('maxlength')) {
      <mat-error>Máximo 120 caracteres.</mat-error>
    }
  </mat-form-field>

  <mat-form-field>
    <mat-label>Proyecto</mat-label>
    <mat-select formControlName="projectId">
      @for (p of projects(); track p.id) {
        <mat-option [value]="p.id">{{ p.name }}</mat-option>
      }
    </mat-select>
    <mat-error>Selecciona un proyecto.</mat-error>
  </mat-form-field>

  <mat-form-field>
    <mat-label>Fecha límite</mat-label>
    <input matInput [matDatepicker]="picker" formControlName="dueDate">
    <mat-datepicker-toggle matIconSuffix [for]="picker" />
    <mat-datepicker #picker />
  </mat-form-field>

  <div class="task-form__actions">
    <button mat-button type="button">Cancelar</button>
    <button mat-flat-button color="primary" type="submit">Guardar tarea</button>
  </div>
</form>
Nota de versión sobre los botones

Los atributos históricos de los botones (mat-button, mat-raised-button, mat-flat-button, mat-stroked-button, mat-icon-button) llevan años funcionando y son los que verás en la mayoría del código existente. En versiones recientes de Angular Material la forma de declarar la apariencia de un botón se ha unificado y el atributo color ha ido cediendo terreno a los tokens del tema. Consulta la sección de botones en material.angular.io para la sintaxis exacta de tu versión antes de hacer una migración masiva.

24.6.7 La accesibilidad que traen de serie (y la que no)

Es probablemente el mejor argumento a favor de Material, y el más ignorado. Sus componentes implementan los patrones del estándar de autoría ARIA con un nivel de detalle que ningún equipo replica en un sprint:

Lo que Material NO puede hacer por ti

Un botón de icono sin texto es invisible para un lector de pantalla por muy bien hecho que esté el componente: <button mat-icon-button aria-label="Eliminar tarea"> es responsabilidad tuya. Tampoco puede saber el orden lógico de tu página, ni si el contraste de tu paleta cumple, ni si el mensaje de error explica cómo corregir el problema. Material te da un suelo alto; el techo sigue dependiendo de ti. El capítulo 26 desarrolla la accesibilidad a fondo.

24.7 El CDK a fondo: la parte infravalorada

Si tuviera que quedarme con una sola idea de este capítulo, sería esta: el Component Dev Kit es más valioso que Angular Material, y casi nadie lo usa directamente. El CDK resuelve los problemas que de verdad son difíciles —posicionar un elemento flotante que no se salga de la pantalla, atrapar el foco dentro de un diálogo, renderizar diez mil filas sin morir, anunciar un cambio a un lector de pantalla— y no impone ni un solo píxel de estilo. Es exactamente lo que necesita un equipo que tiene su propio sistema de diseño y no quiere pelearse con el de Google.

24.7.1 Overlay: la base de todo lo flotante

Un menú, un desplegable, un diálogo, un aviso emergente y un panel de filtros son el mismo problema con distinta apariencia: pintar contenido fuera del flujo del documento, encima de todo lo demás, en una posición relacionada con otro elemento, sin que se salga de la ventana y sin romper el foco. Ese problema es sorprendentemente difícil, y el overlay del CDK lo tiene resuelto.

  ANATOMÍA DE UN OVERLAY DEL CDK

  <body>
   ├── <div class="layout"> … tu aplicación entera …
   │      └── <button #trigger>Filtros</button>   ← ORIGEN (solo se usa como referencia)
   │
   └── <div class="cdk-overlay-container">          ← creado UNA vez, al final del body
          │                                            (por eso ningún overflow lo recorta)
          ├── <div class="cdk-overlay-backdrop">    ← opcional: hasBackdrop
          │      captura los clics de fuera y emite backdropClick()
          │
          └── <div class="cdk-overlay-connected-position-bounding-box">
                 │   lo coloca la PositionStrategy; define el área donde
                 │   el panel puede crecer o encogerse
                 └── <div class="cdk-overlay-pane">  ← aquí se ANCLA el Portal
                        └── tu componente o tu ng-template

  PIEZAS QUE SE COMBINAN
  ┌────────────────────┬────────────────────────────────────────────────────────┐
  │ PositionStrategy   │ global()  → centrado o pegado a la ventana (diálogos)   │
  │                    │ flexibleConnectedTo(origen) → pegado a un elemento,     │
  │                    │   con lista ORDENADA de posiciones alternativas         │
  ├────────────────────┼────────────────────────────────────────────────────────┤
  │ ScrollStrategy     │ noop()       → no hace nada                             │
  │                    │ reposition() → recalcula al hacer scroll (menús)        │
  │                    │ block()      → bloquea el scroll (diálogos modales)     │
  │                    │ close()      → cierra al hacer scroll (autocompletado)  │
  ├────────────────────┼────────────────────────────────────────────────────────┤
  │ Portal             │ ComponentPortal  → instancia un componente              │
  │                    │ TemplatePortal   → usa un <ng-template> ya existente    │
  ├────────────────────┼────────────────────────────────────────────────────────┤
  │ OverlayRef         │ attach() detach() dispose() updatePosition()            │
  │                    │ backdropClick()  keydownEvents()  detachments()         │
  └────────────────────┴────────────────────────────────────────────────────────┘

  CICLO DE VIDA
    create() ──► attach(portal) ──► [visible] ──► detach() ──► dispose()
       │                                            │            │
   reserva el pane                        vacía el pane   destruye el pane
   (aún no hay DOM del contenido)         (se puede       (ya NO se puede
                                           volver a         reutilizar)
                                           attach)

La distinción entre detach() y dispose() es la fuente número uno de fugas de memoria con el overlay: si abres un menú cien veces y creas cien OverlayRef sin destruirlos, tienes cien paneles vivos escuchando eventos. La regla es simple: crea el overlay una vez, adjunta y desadjunta tantas veces como haga falta, y destruye en ngOnDestroy.

src/app/shared/popover.directive.ts · un popover propio sobre el CDK
import { DestroyRef, Directive, ElementRef, TemplateRef, ViewContainerRef,
         inject, input } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { filter } from 'rxjs';

@Directive({ selector: '[tfPopover]', host: { '(click)': 'toggle()' } })
export class PopoverDirective {
  /** Plantilla que se mostrará dentro del panel flotante. */
  readonly content = input.required<TemplateRef<unknown>>({ alias: 'tfPopover' });

  private readonly overlay = inject(Overlay);
  private readonly host = inject(ElementRef<HTMLElement>);
  private readonly vcr = inject(ViewContainerRef);
  private overlayRef?: OverlayRef;

  constructor() {
    inject(DestroyRef).onDestroy(() => this.overlayRef?.dispose());
  }

  toggle(): void {
    if (this.overlayRef?.hasAttached()) { this.close(); return; }
    this.open();
  }

  private open(): void {
    this.overlayRef ??= this.createOverlay();
    this.overlayRef.attach(new TemplatePortal(this.content(), this.vcr));
  }

  private close(): void { this.overlayRef?.detach(); }

  private createOverlay(): OverlayRef {
    const position = this.overlay.position()
      .flexibleConnectedTo(this.host)
      // Lista ORDENADA: el CDK prueba de arriba abajo y usa la primera que cabe.
      .withPositions([
        { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top',    offsetY: 6 },
        { originX: 'start', originY: 'top',    overlayX: 'start', overlayY: 'bottom', offsetY: -6 },
        { originX: 'end',   originY: 'bottom', overlayX: 'end',   overlayY: 'top',    offsetY: 6 },
      ])
      .withPush(true);          // si ninguna cabe, lo empuja para que se vea entero

    const ref = this.overlay.create({
      positionStrategy: position,
      scrollStrategy: this.overlay.scrollStrategies.reposition(),
      hasBackdrop: true,
      backdropClass: 'cdk-overlay-transparent-backdrop',  // invisible pero captura clics
      panelClass: 'tf-popover',
      disposeOnNavigation: true,
    });

    ref.backdropClick()
      .pipe(takeUntilDestroyed())
      .subscribe(() => this.close());

    ref.keydownEvents()
      .pipe(filter((e) => e.key === 'Escape'), takeUntilDestroyed())
      .subscribe(() => { this.close(); this.host.nativeElement.focus(); });

    return ref;
  }
}
src/app/tasks/task-filters.component.html · uso
<button type="button" [tfPopover]="filtros" aria-haspopup="dialog">
  Filtros ({{ activos() }})
</button>

<ng-template #filtros>
  <div class="tf-popover__panel" role="dialog" aria-label="Filtros de tareas" cdkTrapFocus>
    <h3 class="tf-popover__title">Filtrar tareas</h3>
    <!-- El contenido se declara aquí, pero se RENDERIZA en el overlay container.
         El contexto de inyección y el enlace de datos siguen siendo los de este componente. -->
    <tf-tag-picker [(selected)]="etiquetas" />
  </div>
</ng-template>
Estilos del overlay: dónde ponerlos

El contenido del overlay se renderiza en cdk-overlay-container, al final del <body>, fuera del árbol de tu componente. Con encapsulación emulada eso significa que los estilos del componente no se le aplican: hay que ponerlos en la hoja global, apuntando a la clase que hayas pasado en panelClass. Además, no olvides importar los estilos base del CDK (@angular/cdk/overlay-prebuilt.css) si no usas Angular Material; sin ellos el contenedor no tiene posicionamiento y los paneles aparecen en sitios inesperados.

24.7.2 Portal: renderizar aquí lo que se declara allí

El portal es la abstracción que hay debajo del overlay, y se puede usar por separado. Resuelve un problema muy concreto: separar el lugar donde se declara un contenido del lugar donde se pinta. Es lo que permite que la barra superior de TaskFlow muestre acciones contextuales definidas por la pantalla activa sin que la barra sepa nada de ellas.

PiezaQué esCuándo se usa
ComponentPortalEnvuelve una clase de componente para instanciarla en otro sitio.Contenido dinámico decidido en tiempo de ejecución (un diálogo cuyo cuerpo depende del tipo de entidad).
TemplatePortalEnvuelve un ng-template ya declarado, conservando su contexto.Lo más común: el contenido se escribe en la plantilla del componente y se pinta en otro lugar.
CdkPortalOutletDirectiva que marca dónde se pinta un portal.El destino, dentro de una plantilla de Angular.
DomPortalOutletDestino que es un elemento del DOM cualquiera.Integración con código no-Angular o con un contenedor creado a mano.
src/app/shell/toolbar-slot.service.ts · acciones contextuales en la barra superior
import { Injectable, signal } from '@angular/core';
import { Portal } from '@angular/cdk/portal';

@Injectable({ providedIn: 'root' })
export class ToolbarSlotService {
  /** El portal que la barra superior debe pintar en este momento, si hay alguno. */
  readonly portal = signal<Portal<unknown> | null>(null);

  set(portal: Portal<unknown>): void { this.portal.set(portal); }
  clear(): void { this.portal.set(null); }
}

// toolbar.component.html (la barra superior, que no conoce ninguna pantalla)
// <ng-template [cdkPortalOutlet]="slot.portal()" />

// task-list.component.ts (la pantalla, que aporta sus acciones)
// constructor() {
//   afterNextRender(() => this.slot.set(new TemplatePortal(this.acciones(), this.vcr)));
//   inject(DestroyRef).onDestroy(() => this.slot.clear());
// }

24.7.3 Arrastrar y soltar

El tablero de proyecto de TaskFlow es el ejemplo canónico: tres columnas y tarjetas que se mueven entre ellas. El CDK lo resuelve con dos directivas y dos funciones auxiliares, y —esto es lo importante— no toca tus datos: te dice qué ha pasado y tú decides.

src/app/projects/board.component.html
<!-- cdkDropListGroup conecta automáticamente todas las listas descendientes -->
<div class="board" cdkDropListGroup>
  @for (column of columns(); track column.status) {
    <section class="board__column">
      <h3>{{ column.label }} ({{ column.tasks.length }})</h3>

      <div class="board__list"
           cdkDropList
           [cdkDropListData]="column"
           (cdkDropListDropped)="onDrop($event)">
        @for (task of column.tasks; track task.id) {
          <article class="task-card" cdkDrag [cdkDragData]="task">
            <span class="task-card__grip" cdkDragHandle aria-hidden="true">⋮⋮</span>
            {{ task.title }}

            <!-- Hueco que queda en el sitio original mientras se arrastra -->
            <div class="task-card__placeholder" *cdkDragPlaceholder></div>
          </article>
        }
        @empty {
          <p class="board__empty">Arrastra tareas aquí</p>
        }
      </div>
    </section>
  }
</div>
src/app/projects/board.component.ts
import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';

protected onDrop(event: CdkDragDrop<BoardColumn>): void {
  const task = event.item.data as Task;

  if (event.previousContainer === event.container) {
    // Reordenar dentro de la misma columna: solo cambia la posición
    moveItemInArray(event.container.data.tasks, event.previousIndex, event.currentIndex);
    this.api.reorder(task.id, event.currentIndex).subscribe();
    return;
  }

  // Mover entre columnas: cambia el estado de la tarea.
  // Actualizamos la interfaz PRIMERO (respuesta inmediata) y revertimos si falla.
  transferArrayItem(
    event.previousContainer.data.tasks,
    event.container.data.tasks,
    event.previousIndex,
    event.currentIndex,
  );

  const nuevoEstado = event.container.data.status;
  this.api.updateStatus(task.id, nuevoEstado).subscribe({
    error: () => {
      // Deshacer el movimiento optimista y avisar
      transferArrayItem(
        event.container.data.tasks,
        event.previousContainer.data.tasks,
        event.currentIndex,
        event.previousIndex,
      );
      this.announcer.announce('No se ha podido mover la tarea', 'assertive');
    },
  });
}
Arrastrar y soltar no es accesible por sí solo

Un usuario que navega con teclado no puede arrastrar nada, y un lector de pantalla no anuncia el movimiento. Si el arrastre es la única forma de cambiar el estado de una tarea, tu aplicación excluye a esos usuarios. La solución no es compleja: ofrece siempre una alternativa —un menú «Mover a…» en cada tarjeta— y anuncia el resultado con el live announcer (24.7.6). Esto no es un extra: es el criterio de accesibilidad de teclado de las WCAG.

24.7.4 Scroll virtual: el problema de las listas grandes, medido

Renderizar una lista de cinco mil tareas no es «un poco más lento»: es un cambio de categoría. Cada tarjeta de TaskFlow genera del orden de una docena de nodos del DOM entre el contenedor, el título, la fecha, el responsable y las etiquetas. Cinco mil tarjetas son unos sesenta mil nodos, y el navegador tiene que construirlos, calcular su estilo, disponerlos y mantenerlos en memoria. Angular, además, tiene que comprobarlos en cada ciclo de detección de cambios.

Lista de 5.000 tareasRenderizado completoCon cdk-virtual-scroll-viewport
Nodos del DOM~60.000~250 (los visibles más un colchón)
Tiempo hasta la primera pintura de la listaSegundos, con la pestaña bloqueadaDecenas de milisegundos
Coste de un ciclo de detección de cambiosProporcional a 5.000 filasProporcional a ~20 filas
Memoria del documentoCientos de MB en casos realesPrácticamente constante
Coste de aplicar un filtroRehacer decenas de miles de nodosRehacer una veintena

Las cifras son órdenes de magnitud medidos en un portátil de gama media con una tarjeta de complejidad realista; lo que importa no es el número exacto, sino que la diferencia es de dos órdenes de magnitud y que crece con el tamaño de la lista.

src/app/tasks/task-list.component.ts
import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  selector: 'tf-task-list',
  imports: [ScrollingModule, TaskCardComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <cdk-virtual-scroll-viewport
      [itemSize]="72"
      [minBufferPx]="288"
      [maxBufferPx]="576"
      class="task-list">

      <tf-task-card
        *cdkVirtualFor="let task of tasks(); trackBy: trackById; templateCacheSize: 20"
        [task]="task" />
    </cdk-virtual-scroll-viewport>
  `,
  styles: `
    .task-list { block-size: 70vh; }   /* ALTURA EXPLÍCITA: obligatoria */
    tf-task-card { display: block; block-size: 72px; }  /* debe coincidir con itemSize */
  `,
})
export class TaskListComponent {
  readonly tasks = input.required<Task[]>();
  protected trackById = (_: number, t: Task) => t.id;
}
Las tres causas del «no me funciona el scroll virtual»

1) El viewport no tiene altura. Necesita una altura explícita (o participar en una rejilla que se la dé); sin ella mide cero y no renderiza nada. 2) itemSize no coincide con la altura real de la fila. La estrategia por defecto es de tamaño fijo y calcula posiciones con ese número; si mientes, el scroll se descuadra y las filas parpadean. Si tus filas tienen alturas distintas, necesitas otra estrategia: existe una experimental de tamaño automático, y la alternativa estable es forzar una altura uniforme. 3) Se usa *ngFor en lugar de *cdkVirtualFor. Son directivas distintas; solo la segunda habla con el viewport. Ten en cuenta además que *cdkVirtualFor es una directiva estructural clásica y no tiene equivalente en la sintaxis de bloques @for.

24.7.5 Layout: puntos de ruptura desde TypeScript

BreakpointObserver permite reaccionar a un punto de ruptura desde la clase, no desde el CSS. La distinción importante —y la desarrolla la sección 24.9— es que si solo cambia la apariencia, debe hacerlo el CSS; el observador se justifica cuando cambia la estructura o el comportamiento: renderizar un componente distinto, cambiar el modo de un panel lateral o desactivar una interacción.

src/app/shell/shell.component.ts
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';

export class ShellComponent {
  private readonly breakpoints = inject(BreakpointObserver);

  /** Señal booleana lista para usar en la plantilla. */
  protected readonly isHandset = toSignal(
    this.breakpoints.observe([Breakpoints.Handset, Breakpoints.TabletPortrait])
      .pipe(map((state) => state.matches)),
    { initialValue: false },
  );

  /** Aquí sí está justificado: cambia el COMPORTAMIENTO del panel lateral. */
  protected readonly sidenavMode = computed(() => this.isHandset() ? 'over' : 'side');

  // Consulta puntual, sin suscripción
  protected get prefiereMenosMovimiento(): boolean {
    return this.breakpoints.isMatched('(prefers-reduced-motion: reduce)');
  }
}

Breakpoints incluye constantes como Handset, Tablet, Web y sus variantes de orientación, además de la escala XSmall a XLarge. Son los puntos de ruptura de Material Design; si tu sistema de diseño usa otros, pasa directamente una cadena de consulta de medios, que es igual de válida y evita mezclar dos escalas distintas en el mismo proyecto.

24.7.6 Accesibilidad: foco, trampas de foco y anuncios

El paquete de accesibilidad del CDK es el que más código propio ahorra, porque implementa cosas que la gente cree fáciles y no lo son.

HerramientaQué resuelveUso típico en TaskFlow
cdkTrapFocusImpide que el tabulador salga de una región. Con cdkTrapFocusAutoCapture mueve el foco dentro al abrirse y lo devuelve al cerrarse.Diálogo de edición de tarea, panel de filtros modal.
FocusMonitorInforma de cómo se obtuvo el foco: ratón, teclado, táctil o programación.Mostrar el anillo de foco solo con teclado; registrar por qué se abrió un menú.
FocusKeyManager / ActiveDescendantKeyManagerNavegación con flechas, inicio y fin, y búsqueda por escritura dentro de una lista de opciones.Selector de etiquetas propio, lista de resultados del buscador.
LiveAnnouncerAnuncia un mensaje a los lectores de pantalla mediante una región activa gestionada por el CDK.«Tarea movida a En curso», «12 tareas encontradas».
cdkAriaLiveConvierte un elemento en región activa de forma declarativa.El contador de resultados de la lista de tareas.
InteractivityCheckerDetermina si un elemento es visible, enfocable o alcanzable con tabulador.Componentes propios que necesitan calcular el siguiente elemento enfocable.
src/app/tasks/task-list.component.ts · anuncios que sí sirven
import { LiveAnnouncer } from '@angular/cdk/a11y';

export class TaskListComponent {
  private readonly announcer = inject(LiveAnnouncer);

  protected aplicarFiltro(texto: string): void {
    const resultados = this.filtrar(texto);
    this.tasks.set(resultados);

    // 'polite' espera a que el lector termine lo que esté diciendo.
    // 'assertive' interrumpe: resérvalo para errores y acciones destructivas.
    this.announcer.announce(
      resultados.length === 0
        ? 'Ningún resultado para el filtro aplicado'
        : `${resultados.length} tareas encontradas`,
      'polite',
    );
  }

  protected eliminar(task: Task): void {
    this.api.delete(task.id).subscribe(() => {
      this.announcer.announce(`Tarea ${task.title} eliminada`, 'assertive');
    });
  }
}
dialog.component.htmlINCORRECTO
<!-- Diálogo casero: el tabulador sale por detrás, el lector
     de pantalla sigue leyendo la página de debajo y al cerrar
     el foco se pierde al principio del documento -->
<div class="mi-dialogo">
  <h2>Eliminar tarea</h2>
  <button (click)="cerrar()">Cancelar</button>
  <button (click)="borrar()">Eliminar</button>
</div>
dialog.component.htmlCORRECTO
<div class="mi-dialogo"
     role="dialog"
     aria-modal="true"
     aria-labelledby="dlg-titulo"
     cdkTrapFocus
     cdkTrapFocusAutoCapture>
  <h2 id="dlg-titulo">Eliminar tarea</h2>
  <button (click)="cerrar()">Cancelar</button>
  <button cdkFocusInitial (click)="borrar()">Eliminar</button>
</div>

24.7.7 Clipboard y text field: dos utilidades pequeñas y muy rentables

src/app/tasks/task-detail.component.html
<!-- Copiar el enlace de la tarea: directiva declarativa -->
<button type="button"
        [cdkCopyToClipboard]="enlaceTarea()"
        (cdkCopyToClipboardCopied)="onCopiado($event)">
  Copiar enlace
</button>

<!-- Textarea que crece con el contenido, entre 3 y 10 filas.
     Sin saltos de diseño y sin medir alturas a mano en TypeScript. -->
<textarea matInput
          cdkTextareaAutosize
          cdkAutosizeMinRows="3"
          cdkAutosizeMaxRows="10"
          formControlName="description"
          placeholder="Descripción de la tarea"></textarea>
El servicio, para casos con lógica

Además de la directiva existe el servicio Clipboard, que devuelve un booleano indicando si la copia tuvo éxito y permite reintentos para textos muy largos. Úsalo cuando necesites confirmar al usuario el resultado o registrar el evento. Y recuerda que copiar al portapapeles requiere un gesto del usuario: no funciona desde un temporizador ni desde una respuesta HTTP.

24.7.8 Construir en lugar de importar: un selector de etiquetas

Éste es el ejemplo que resume la sección. TaskFlow necesita un selector de etiquetas con búsqueda, navegación por teclado y creación al vuelo. La tentación es instalar una librería de selectores múltiples de 80 KB. La alternativa es componer tres piezas del CDK que ya tienes instaladas y quedarte con el control total del marcado y de los estilos.

src/app/shared/tag-picker.component.ts
import { ActiveDescendantKeyManager, LiveAnnouncer } from '@angular/cdk/a11y';
import { OverlayModule } from '@angular/cdk/overlay';
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef,
         computed, inject, model, signal, viewChild, viewChildren } from '@angular/core';
import { TagOptionComponent } from './tag-option.component';

@Component({
  selector: 'tf-tag-picker',
  imports: [OverlayModule, TagOptionComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input
      #input
      class="tag-picker__input"
      role="combobox"
      aria-autocomplete="list"
      aria-controls="tag-listbox"
      [attr.aria-expanded]="abierto()"
      [attr.aria-activedescendant]="activeId()"
      [value]="consulta()"
      (input)="consulta.set($any($event.target).value)"
      (focus)="abierto.set(true)"
      (keydown)="onKeydown($event)"
      cdkOverlayOrigin
      #origen="cdkOverlayOrigin">

    <ng-template
      cdkConnectedOverlay
      [cdkConnectedOverlayOrigin]="origen"
      [cdkConnectedOverlayOpen]="abierto()"
      [cdkConnectedOverlayWidth]="anchoOrigen()"
      (overlayOutsideClick)="abierto.set(false)">

      <ul class="tag-picker__list" id="tag-listbox" role="listbox">
        @for (tag of filtradas(); track tag.id) {
          <tf-tag-option [tag]="tag" (selected)="alternar(tag)" />
        } @empty {
          <li class="tag-picker__empty">Sin coincidencias. Pulsa Intro para crearla.</li>
        }
      </ul>
    </ng-template>
  `,
})
export class TagPickerComponent implements AfterViewInit {
  readonly selected = model<Tag[]>([]);
  readonly disponibles = input.required<Tag[]>();

  protected readonly consulta = signal('');
  protected readonly abierto = signal(false);
  protected readonly filtradas = computed(() => {
    const q = this.consulta().trim().toLowerCase();
    return this.disponibles().filter((t) => t.name.toLowerCase().includes(q));
  });

  private readonly opciones = viewChildren(TagOptionComponent);
  private readonly announcer = inject(LiveAnnouncer);
  private keyManager?: ActiveDescendantKeyManager<TagOptionComponent>;

  ngAfterViewInit(): void {
    // El CDK aporta flechas, Home/End, búsqueda por escritura y ciclado.
    this.keyManager = new ActiveDescendantKeyManager(this.opciones())
      .withWrap()
      .withTypeAhead(200);
  }

  protected onKeydown(event: KeyboardEvent): void {
    if (event.key === 'Escape') { this.abierto.set(false); return; }
    if (event.key === 'Enter' && this.keyManager?.activeItem) {
      this.alternar(this.keyManager.activeItem.tag());
      event.preventDefault();
      return;
    }
    this.keyManager?.onKeydown(event);
  }

  protected alternar(tag: Tag): void {
    const actuales = this.selected();
    const yaEsta = actuales.some((t) => t.id === tag.id);
    this.selected.set(yaEsta ? actuales.filter((t) => t.id !== tag.id) : [...actuales, tag]);
    this.announcer.announce(`${tag.name} ${yaEsta ? 'quitada' : 'añadida'}`, 'polite');
  }
}
Qué has ganado con esto

Unas ochenta líneas de TypeScript y el marcado exactamente que quieres, frente a una dependencia externa con su propio sistema de estilos, su ciclo de publicación, sus incidencias abiertas y su riesgo de quedar sin mantenimiento. Y lo más valioso: la navegación por teclado, el posicionamiento y los anuncios los pone el CDK, que mantiene el equipo de Angular y que ya está en tu package.json si usas Material. La regla práctica: antes de instalar una librería de interfaz, comprueba si el CDK cubre la parte difícil. En mi experiencia, lo hace en tres de cada cuatro casos.

24.8 Tailwind con Angular

Tailwind propone algo que a mucha gente le parece un retroceso: volver a poner las clases de presentación en el marcado. La justificación es sólida y conviene entenderla antes de opinar. El problema que ataca no es escribir CSS, sino nombrarlo: si cada componente inventa nombres de clase, la hoja de estilos crece con el número de pantallas y nadie se atreve a borrar nada. Con utilidades atómicas, el conjunto de clases es cerrado y el CSS generado deja de crecer a partir de cierto punto, por muchas pantallas que añadas.

24.8.1 Integración y por qué funciona pese a la encapsulación

La pregunta que todo el mundo hace la primera vez es cómo puede funcionar Tailwind si Angular aísla los estilos. La respuesta desmonta un malentendido frecuente: la encapsulación emulada reescribe los selectores de las hojas de tus componentes, no los atributos class de tu plantilla. Las utilidades de Tailwind viven en la hoja global, y una regla global se aplica a cualquier elemento que tenga la clase, esté donde esté. No hay conflicto porque no hay competencia: son dos mecanismos ortogonales.

Puesta en marcha
# Instalación (la configuración exacta depende de la versión mayor de Tailwind:
# la 3 usa tailwind.config.js con la clave content; la 4 se configura desde CSS
# con @import y @theme. Sigue la guía oficial de TU versión).
npm install -D tailwindcss

# Tailwind 3 · src/styles.css        Tailwind 4 · src/styles.css
#   @tailwind base;                    @import "tailwindcss";
#   @tailwind components;              @theme { --color-tf-accent: #4571e8; }
#   @tailwind utilities;
Dos avisos de integración

1) @apply dentro de estilos de componente. El archivo CSS de un componente se procesa por separado: para poder usar @apply ahí, la configuración de Tailwind debe estar disponible en ese archivo. En Tailwind 4 esto requiere una directiva de referencia al principio del archivo; en la 3, que el pipeline procese también los estilos de componente. Consulta la documentación de tu versión: es el fallo de integración más habitual. 2) Las clases se detectan por texto. Si construyes un nombre de clase concatenando cadenas en TypeScript, Tailwind no lo verá y la utilidad no existirá en producción. Escribe siempre las clases completas y elige entre ellas.

24.8.2 Cuándo acelera y cuándo produce plantillas ilegibles

Tailwind acelera de verdad en la fase de exploración: maquetar una pantalla nueva sin cambiar de archivo es notablemente más rápido. Se degrada cuando la misma cadena de veinte clases aparece en once sitios, porque entonces has reinventado la duplicación que el CSS con nombres al menos permitía centralizar. El patrón correcto es el mismo de siempre: extraer un componente en cuanto una combinación se repite. No una clase con @apply —eso reintroduce el problema del nombrado sin ganar nada—, sino un componente de Angular con su API.

task-list.component.htmlINCORRECTO
<!-- La misma cadena repetida en cada pantalla. Cambiar el radio
     de las tarjetas exige buscar y reemplazar en 11 archivos -->
<article class="flex items-center gap-3 rounded-lg border border-slate-200
                bg-white p-4 shadow-sm hover:shadow-md transition-shadow
                dark:border-slate-700 dark:bg-slate-800">
  <h3 class="flex-1 min-w-0 truncate text-sm font-medium
             text-slate-900 dark:text-slate-100">{{ task.title }}</h3>
  <span class="shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-xs
               font-semibold text-amber-800">{{ task.priority }}</span>
</article>
task-card.component.ts + usoCORRECTO
// Las utilidades viven UNA vez, dentro del componente que las justifica
@Component({
  selector: 'tf-task-card',
  host: { class: 'flex items-center gap-3 rounded-lg border p-4' },
  template: `
    <h3 class="flex-1 min-w-0 truncate text-sm font-medium">{{ task().title }}</h3>
    <tf-badge [tone]="tone()">{{ task().priority }}</tf-badge>
  `,
})
export class TaskCardComponent {
  readonly task = input.required<Task>();
  protected readonly tone = computed(() =>
    this.task().priority === 'high' ? 'danger' : 'neutral');
}

// Y en las 11 pantallas:  <tf-task-card [task]="task" />
Convivencia con el sistema de tokens

Tailwind y los tokens de la sección 24.5 no compiten si se conectan: define la escala de Tailwind a partir de tus variables CSS en lugar de mantener dos paletas paralelas. Así bg-surface-2 y var(--tf-surface-2) son el mismo valor, el cambio de tema sigue funcionando con una sola redefinición y no hay ningún momento en el que un color exista en un sitio y no en el otro. Mantener dos escalas es el camino directo a la incoherencia visual que describía la sección 24.2.

24.9 Diseño responsive

24.9.1 Móvil primero no es una preferencia estética

Escribir primero los estilos del caso estrecho y ampliar con min-width tiene tres consecuencias técnicas concretas: el CSS base es el más simple (menos declaraciones que sobrescribir), los dispositivos menos potentes ejecutan menos reglas, y —la más importante— obliga a decidir la jerarquía de la información. En 360 píxeles no cabe todo, así que hay que responder a «¿qué es lo imprescindible de una tarea?» antes de maquetar. Esa respuesta mejora también la versión de escritorio.

task-list.component.css · móvil primero
/* Base: el caso más restrictivo, sin ninguna consulta de medios */
.task-list { display: grid; gap: var(--tf-space-2); }
.task-list__filters { display: none; }        /* en móvil van en un panel aparte */

/* Ampliación progresiva: solo se AÑADE, nunca se deshace */
@media (min-width: 48rem) {                   /* en rem, para respetar el zoom */
  .task-list { grid-template-columns: 1fr 280px; gap: var(--tf-space-4); }
  .task-list__filters { display: block; }
}

Los puntos de ruptura se eligen por el contenido, no por dispositivos. Perseguir los tamaños de los modelos de teléfono de moda es una carrera perdida: hay cientos y cambian cada año. El criterio correcto es ensanchar la ventana hasta que la maquetación se vea mal y poner ahí el punto de ruptura. Con las técnicas de la sección 24.4 —auto-fit, clamp() y contenedores de consulta— una aplicación como TaskFlow necesita dos o tres puntos de ruptura globales, no siete.

24.9.2 CDK de layout frente a consultas de medios

NecesidadHerramientaMotivo
Cambiar tamaños, columnas, visibilidad o espaciadoCSSEl navegador lo resuelve sin pasar por JavaScript ni por la detección de cambios. Es gratis.
Renderizar un componente distinto según el anchoBreakpointObserverEs una decisión de estructura del árbol; el CSS no puede crear ni destruir componentes.
Cambiar el modo de un panel lateral («over» frente a «side»)BreakpointObserverEs una entrada de un componente, no una regla de estilo.
Desactivar el arrastre en pantallas táctiles pequeñasBreakpointObserverComportamiento, no apariencia.
Ocultar una columna secundaria de la tablaCSSCon display: none basta. Ojo: el nodo sigue en el DOM; si el coste de renderizarlo importa, entonces sí es un caso de TypeScript.
El error de duplicar puntos de ruptura

Si el CSS considera «móvil» por debajo de 768 píxeles y el BreakpointObserver usa la constante Handset de Material, tienes dos definiciones distintas de la misma idea y llegará el ancho en el que discrepen: el menú se comporta como móvil y la rejilla como escritorio. Define los puntos de ruptura una sola vez, expórtalos como constantes de TypeScript y pásalos como cadena a observe() en lugar de mezclar dos escalas.

24.9.3 Imágenes adaptativas

Servir una imagen de 2000 píxeles de ancho a un teléfono es de los desperdicios más caros que existen: consume datos del usuario, retrasa la pintura y ocupa memoria de decodificación. Angular incluye una directiva que aplica las buenas prácticas por defecto y avisa en consola cuando algo está mal.

src/app/projects/project-card.component.html
<!-- NgOptimizedImage: genera el srcset, exige dimensiones (evita saltos de
     diseño) y aplica carga diferida salvo que marques la imagen como prioritaria -->
<img [ngSrc]="project.coverUrl"
     width="640" height="360"
     sizes="(min-width: 64rem) 320px, 100vw"
     [priority]="esPrimeraVisible"
     alt="Portada del proyecto {{ project.name }}">

<!-- Avatar del responsable: decorativo junto a un nombre visible → alt vacío -->
<img [ngSrc]="user.avatarUrl" width="32" height="32" alt="" class="avatar">

24.10 Animaciones

24.10.1 Cuándo aportan y cuándo estorban

Una animación es útil cuando explica un cambio: de dónde viene ese panel, adónde ha ido la tarea que acabo de archivar, qué elemento es el que ha cambiado. Estorba cuando decora sin informar, cuando se interpone entre la intención del usuario y el resultado, o cuando se repite cientos de veces al día. Ese último punto es el que más se olvida: una animación de 400 milisegundos es deliciosa la primera vez y una tortura la número doscientas.

24.10.2 CSS frente a la API de animaciones de Angular

CriterioTransiciones y animaciones de CSSAPI de animaciones de Angular
CosteCero JavaScript. El navegador puede ejecutarlas en el hilo del compositor.Requiere el paquete de animaciones y trabajo en el hilo principal.
Elementos que entran y salenDifícil: al eliminarse del DOM desaparecen de golpe.Es su punto fuerte: :enter y :leave retrasan la eliminación real.
Escalonado de listasPosible con retardos calculados, incómodo.Directo, con la utilidad de escalonado.
Coordinación con el estado del componenteMediante clases; suficiente casi siempre.Estados nombrados y transiciones explícitas entre ellos.
RecomendaciónPor defecto. El 90 % de las animaciones de una aplicación de gestión son transiciones de CSS.Cuando de verdad necesitas controlar la salida de un elemento o coordinar una secuencia.
Nota de versión importante

Angular ha ido incorporando APIs de plantilla para animar entradas y salidas basadas en CSS, con el objetivo de que la mayoría de los casos no necesiten el paquete de animaciones ni su coste. Antes de construir una arquitectura de animaciones completa con la API imperativa, comprueba en angular.dev qué ofrece tu versión concreta y en qué estado de estabilidad se encuentra: es un área en evolución activa y la recomendación oficial puede haber cambiado desde la versión que usas.

src/app/tasks/task-list.component.ts · entrada y salida de una lista
import { animate, animateChild, query, stagger, style,
         transition, trigger } from '@angular/animations';

@Component({
  selector: 'tf-task-list',
  animations: [
    trigger('listaTareas', [
      transition('* => *', [
        // El escalonado da sensación de orden; 40 ms por elemento es suficiente.
        query(':enter', [
          style({ opacity: 0, transform: 'translateY(8px)' }),
          stagger(40, animate('180ms cubic-bezier(.2,0,0,1)',
                              style({ opacity: 1, transform: 'none' }))),
        ], { optional: true }),

        query(':leave', [
          animate('120ms ease-in', style({ opacity: 0, transform: 'translateY(-4px)' })),
        ], { optional: true }),
      ]),
    ]),
  ],
  template: `
    <ul [@listaTareas]="tasks().length">
      @for (task of tasks(); track task.id) {
        <li><tf-task-card [task]="task" /></li>
      }
    </ul>
  `,
})
export class TaskListComponent {}

24.10.3 Rendimiento: qué se puede animar y qué no

Para entender por qué unas propiedades van fluidas y otras a tirones hay que saber qué hace el navegador en cada fotograma. El proceso tiene tres etapas encadenadas: diseño (calcular la geometría de cada elemento), pintado (rellenar los píxeles de cada capa) y composición (combinar las capas en la imagen final). Animar una propiedad obliga a repetir su etapa y todas las posteriores, sesenta veces por segundo.

Propiedad animadaEtapas que disparaCoste
width, height, top, left, margin, paddingDiseño + pintado + composiciónAlto. Recalcula la geometría de los hermanos y a menudo del documento entero.
background-color, box-shadow, border-radius, colorPintado + composiciónMedio. Sin recálculo de diseño, pero hay que repintar píxeles.
transform, opacity, filterSolo composiciónBajo. El navegador puede resolverlas en el hilo del compositor, sin tocar el hilo principal.

De ahí la regla que conviene grabarse: anima transform y opacity; todo lo demás, replantéalo. Casi cualquier animación se puede reescribir en esos términos: mover es translate, crecer es scale, aparecer es opacity.

sidebar.component.cssINCORRECTO
/* Recálculo de diseño en CADA fotograma: el panel va
   a tirones y arrastra a toda la página con él */
.sidebar {
  left: -320px;
  transition: left 250ms ease, width 250ms ease;
}
.sidebar.is-open { left: 0; }

/* Y esto es aún peor: anima 'height' desde auto,
   que además ni siquiera es interpolable */
.task-details { height: 0; transition: height 200ms; }
.task-details.is-open { height: auto; }
sidebar.component.cssCORRECTO
/* Solo composición: fluido incluso en un móvil modesto */
.sidebar {
  transform: translateX(-100%);
  transition: transform var(--tf-dur-base) var(--tf-ease);
  will-change: transform;      /* solo si de verdad se anima a menudo */
}
.sidebar.is-open { transform: none; }

/* Para desplegar contenido, usa una rejilla: la fracción SÍ interpola
   y no necesitas conocer la altura de antemano */
.task-details { display: grid; grid-template-rows: 0fr;
                transition: grid-template-rows var(--tf-dur-base) var(--tf-ease); }
.task-details.is-open { grid-template-rows: 1fr; }
.task-details > * { overflow: hidden; }
will-change no es un acelerador

Promociona el elemento a una capa propia, lo que consume memoria de la GPU. Aplicarlo a decenas de elementos «por si acaso» empeora el rendimiento en lugar de mejorarlo, sobre todo en móviles. Úsalo solo en el puñado de elementos que se animan de forma continua, y retíralo cuando la animación acabe si es puntual.

24.10.4 Movimiento reducido: es un requisito, no un detalle

Hay personas para las que el movimiento en pantalla provoca mareo, náuseas o crisis vestibulares reales. Por eso todos los sistemas operativos tienen un ajuste de «reducir movimiento», y por eso las WCAG lo recogen explícitamente. Respetarlo cuesta cinco líneas de CSS y no respetarlo puede hacer tu aplicación literalmente inutilizable para alguien.

src/styles.css · al final, para que gane a lo anterior
@media (prefers-reduced-motion: reduce) {
  /* Ojo: NO se elimina la transición, se acorta a un valor imperceptible.
     Poner 0s puede romper componentes que esperan el evento transitionend. */
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Mejor aún: que las duraciones vengan de tokens y cambiar el token */
@media (prefers-reduced-motion: reduce) {
  :root { --tf-dur-fast: .01ms; --tf-dur-base: .01ms; --tf-dur-slow: .01ms; }
}
Reducir no siempre es eliminar

La preferencia se llama «movimiento reducido», no «sin animación». Una transición de opacidad no provoca mareo; un desplazamiento amplio o un zoom, sí. La lectura correcta es sustituir el movimiento por un cambio de opacidad en lugar de suprimirlo todo, porque una interfaz sin ninguna transición puede resultar más confusa: los cambios instantáneos son difíciles de seguir. Y recuerda comprobarlo también en las animaciones definidas en TypeScript, donde el CSS anterior no llega: ahí toca consultar la preferencia y ajustar la duración desde el código.

24.11 Componentes de interfaz propios bien diseñados

Antes de escribir un componente conviene decidir si hay que escribirlo. Este es el árbol de decisión que uso en revisiones de arquitectura:

  ¿NECESITO UN COMPONENTE DE INTERFAZ?  →  ¿DE DÓNDE LO SACO?

            ¿existe ya en NUESTRA librería interna?
                     │ sí → úsalo. Si no encaja, MEJÓRALO ahí (no lo copies)
                     │ no
                     ▼
            ¿es un patrón COMPLEJO y estándar?
            (diálogo, selector de fecha, tabla con orden y paginación,
             autocompletado, árbol, deslizador)
                     │ no ──────────────────────────────┐
                     │ sí                               │
                     ▼                                  ▼
        ¿usamos Angular Material               ¿necesita COMPORTAMIENTO difícil?
         y su estética nos vale?               (posicionar flotantes, atrapar el
                     │                          foco, virtualizar, arrastrar,
          ┌──────────┴──────────┐               navegar con flechas)
          │ sí                  │ no                    │
          ▼                     ▼              ┌────────┴────────┐
    ┌───────────┐      ┌──────────────┐        │ sí              │ no
    │  MATERIAL │      │ CDK + estilos│        ▼                 ▼
    │  + tokens │      │    propios   │  ┌──────────┐     ┌─────────────┐
    └───────────┘      └──────────────┘  │   CDK    │     │ CSS + HTML  │
                                          │ + estilos│     │   propios   │
                                          │  propios │     │ (lo más     │
                                          └──────────┘     │  barato)    │
                                                            └─────────────┘

  COSTE TOTAL DE PROPIEDAD (mantenimiento a 3 años, de menor a mayor):
    CSS propio  <  CDK + estilos propios  <  Material con tokens
       <  Material muy personalizado  <<  librería externa sin mantenimiento

  SEÑAL DE ALARMA: si has escrito ::ng-deep tres veces sobre el mismo
  componente de Material, estabas en la rama equivocada del árbol.

24.11.1 La API pública: entradas, salidas y contenido

Un componente de interfaz es una API, y las reglas del buen diseño de API se le aplican enteras. La más importante: una entrada por decisión, no una entrada por detalle visual. En cuanto veas un componente con [paddingTop], [borderColor] y [titleFontSize], sabes que alguien está reimplementando el CSS a través de TypeScript, con el coste añadido de que cada una de esas entradas pasa por la detección de cambios.

MecanismoPara quéEjemplo en TaskFlow
Entrada (input)Datos y variantes semánticas de un conjunto cerrado.[task], [variant]="'compact'", [disabled]
Salida (output)Intenciones del usuario, no eventos del DOM en bruto.(archived), (assigneeChanged), nunca (clicked)
Contenido proyectadoComposición: el consumidor aporta marcado arbitrario.Acciones de una tarjeta, cuerpo de un panel
Variable CSSPersonalización visual sin tocar TypeScript ni detección de cambios.--tf-card-radius, --tf-card-bg
Modelo bidireccional (model)Estado que el componente y su consumidor comparten.[(selected)] del selector de etiquetas
src/app/shared/panel.component.ts · componente compuesto con tokens
@Component({
  selector: 'tf-panel',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <section class="panel" [class]="'panel--' + tone()">
      <header class="panel__head">
        <h3 class="panel__title">{{ heading() }}</h3>
        <!-- Ranura con selector: el consumidor decide las acciones -->
        <div class="panel__actions"><ng-content select="[panelActions]" /></div>
      </header>

      @switch (state()) {
        @case ('loading') { <tf-skeleton rows="3" /> }
        @case ('error')   { <ng-content select="[panelError]" /> }
        @case ('empty')   { <ng-content select="[panelEmpty]" /> }
        @default          { <div class="panel__body"><ng-content /></div> }
      }
    </section>
  `,
  styles: `
    /* Tokens del componente: puntos de extensión PUBLICADOS y documentados.
       El consumidor los redefine desde fuera sin ::ng-deep y sin entradas. */
    :host {
      display: block;
      --tf-panel-bg: var(--tf-surface-2);
      --tf-panel-radius: var(--tf-radius-md);
      --tf-panel-pad: var(--tf-space-4);
    }
    .panel { background: var(--tf-panel-bg); border-radius: var(--tf-panel-radius);
             padding: var(--tf-panel-pad); border: 1px solid var(--tf-border); }
    .panel--danger { --tf-panel-bg: color-mix(in srgb, var(--tf-danger) 8%, var(--tf-surface-2)); }
  `,
})
export class PanelComponent {
  readonly heading = input.required<string>();
  readonly tone = input<'neutral' | 'danger' | 'success'>('neutral');
  readonly state = input<'idle' | 'loading' | 'empty' | 'error'>('idle');
}

// Uso: la personalización visual va por CSS, no por entradas
// <tf-panel heading="Tareas urgentes" [state]="state()" style="--tf-panel-radius: 20px">
//   <button panelActions (click)="crear()">Nueva</button>
//   <p panelEmpty>No hay tareas urgentes. Buenas noticias.</p>
//   <tf-task-list [tasks]="tasks()" />
// </tf-panel>
Los cuatro estados que casi nadie diseña

Un componente que muestra datos tiene siempre cuatro estados, no uno: cargando, vacío, error y con datos. Diseñar solo el último es la causa de la mitad de los fallos que llegan a producción con aspecto de «página rota». Modelarlos como una entrada explícita, como en el ejemplo anterior, obliga a decidirlos en tiempo de diseño y hace que sean triviales de revisar en Storybook y de probar. Añade el estado deshabilitado cuando el componente sea interactivo, y recuerda que deshabilitado no es solo opacity: .5: hay que impedir la interacción y comunicarlo con aria-disabled o el atributo nativo.

24.12 Estados vacíos, esqueletos y percepción de velocidad

La velocidad percibida y la velocidad medida no son lo mismo, y la percibida es la que decide si tu aplicación «va bien». Un indicador de progreso giratorio comunica «espera, no sé cuánto»; un esqueleto comunica «esto es lo que va a llegar y llega ya». Con el mismo tiempo real, el esqueleto se percibe más rápido por dos motivos: mantiene al usuario orientado —la estructura de la página ya está ahí— y le da algo que procesar mientras espera, lo que acorta la percepción del tiempo muerto.

src/app/shared/skeleton.component.ts
@Component({
  selector: 'tf-skeleton',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (row of rows(); track $index) {
      <div class="sk" [style.inline-size.%]="row" aria-hidden="true"></div>
    }
    <span class="sr-only" role="status">Cargando tareas</span>
  `,
  styles: `
    :host { display: grid; gap: var(--tf-space-2); }
    .sk {
      block-size: 1rem; border-radius: var(--tf-radius-sm);
      background: linear-gradient(90deg, var(--tf-surface-3) 25%,
                  var(--tf-surface-2) 37%, var(--tf-surface-3) 63%);
      background-size: 400% 100%;
      animation: sk 1.2s ease-in-out infinite;   /* transform/opacity no valen aquí:
                                                    es background-position, coste medio */
    }
    @keyframes sk { from { background-position: 100% 50% } to { background-position: 0 50% } }
    @media (prefers-reduced-motion: reduce) { .sk { animation: none } }
  `,
})
export class SkeletonComponent {
  readonly count = input(3, { transform: numberAttribute });
  protected readonly rows = computed(() =>
    Array.from({ length: this.count() }, (_, i) => (i % 3 === 2 ? 60 : 100)));
}

24.13 Storybook: qué aporta y qué cuesta

Storybook es un entorno que renderiza tus componentes aislados de la aplicación, con controles para cambiar sus entradas en vivo. Su valor no es la galería bonita, sino cuatro cosas concretas: desarrollar un componente sin navegar por la aplicación hasta el estado que quieres reproducir; forzarte a declarar todos los estados (incluidos los feos, que es donde están los fallos); dar a diseño y a producto un sitio donde ver lo que existe sin pedir un despliegue; y servir de base para pruebas de regresión visual e inspección de accesibilidad automatizada.

src/app/tasks/task-card.stories.ts
import type { Meta, StoryObj } from '@storybook/angular';
import { TaskCardComponent } from './task-card.component';

const meta: Meta<TaskCardComponent> = {
  title: 'Tareas/TaskCard',
  component: TaskCardComponent,
  tags: ['autodocs'],
  argTypes: { variant: { control: 'inline-radio', options: ['default', 'compact'] } },
};
export default meta;
type Story = StoryObj<TaskCardComponent>;

export const PorDefecto: Story = { args: { task: unaTarea() } };

/* Los estados incómodos: son los que rompen la maquetación en producción */
export const TituloMuyLargo: Story = {
  args: { task: unaTarea({ title: 'Revisar y consolidar el informe trimestral de incidencias '.repeat(3) }) },
};
export const SinResponsableNiFecha: Story = {
  args: { task: unaTarea({ assignee: null, dueDate: null }) },
};
export const ConCatorceEtiquetas: Story = { args: { task: unaTarea({ tags: muchasEtiquetas(14) }) } };
export const Cargando: Story = { args: { state: 'loading' } };
Merece la pena cuando…No merece la pena cuando…
Mantienes una librería de componentes compartida entre varias aplicaciones o equipos.El proyecto tiene diez componentes propios y todos se usan en una sola pantalla.
Hay diseñadores que necesitan revisar estados sin tocar el código.El equipo es una o dos personas que además hacen el diseño.
Quieres pruebas de regresión visual automatizadas sobre estados concretos.No hay presupuesto ni intención de integrarlo en la integración continua.
Los componentes tienen muchos estados combinables difíciles de alcanzar en la aplicación.Los componentes son finos y todo el estado viene del servidor.
El coste real, sin adornos

Storybook es una segunda aplicación: su propia configuración, sus propias dependencias, su propio arranque y su propia actualización cada vez que cambia la versión mayor de Angular o de Storybook. Y tiene un modo de fallo silencioso muy dañino: los stories que nadie actualiza y muestran un componente que ya no existe así, con lo que la documentación empieza a mentir. La regla que aplicaría: adóptalo si vas a integrarlo en la integración continua —de modo que un story roto rompa la construcción— y no lo adoptes si va a ser un proyecto paralelo que se mantiene «cuando haya tiempo».

24.14 Rendimiento de la capa visual

El JavaScript se lleva toda la atención, pero en muchas aplicaciones el camino crítico está en los estilos, las fuentes y las imágenes. Un detalle decisivo: el CSS bloquea la pintura. Mientras el navegador descarga y analiza una hoja de estilos referenciada en el <head>, no pinta nada. Un archivo de estilos de 900 KB no es «un poco de peso extra»: es medio segundo de pantalla en blanco en una conexión mediocre.

FrenteSíntomaQué hacer
Tamaño de los estilosHoja global de cientos de KB, casi toda sin usar.Fija presupuestos en angular.json (global y por componente) para que la construcción falle al superarlos. Si usas Material, incluye solo los temas de los componentes que utilizas. El CSS de los componentes solo se carga si el componente se carga, así que la carga diferida de rutas también adelgaza los estilos.
FuentesTexto invisible durante un segundo, o salto tipográfico al cargar.Autohospeda las fuentes en lugar de pedirlas a un tercero (ahorra una conexión y una resolución de DNS), usa font-display: swap, precarga solo la fuente del texto principal y limita los pesos: cada peso y cada cursiva es un archivo más.
IconosLos iconos tardan y aparecen como cuadrados o como texto.Una fuente de iconos descarga cientos de glifos para usar veinte y provoca ese parpadeo. Prefiere SVG en línea o registrados en el registro de iconos, y usa un sprite si son muchos.
ImágenesLa página salta al cargar; se descargan megas innecesarios.Dimensiones siempre declaradas, formatos modernos, srcset con sizes y carga diferida salvo la imagen principal (sección 24.9.3).
Listas largasLa pestaña se congela al abrir un proyecto grande.Scroll virtual (24.7.4). Como paliativo, content-visibility: auto con contain-intrinsic-size permite al navegador saltarse el renderizado de lo que está fuera de pantalla, pero no reduce el número de nodos ni el coste de la detección de cambios.
AnimacionesTirones al desplazarse o al abrir paneles.Solo transform y opacity (24.10.3); will-change con moderación.
Mide antes de optimizar, y mide lo que ve el usuario

Las tres métricas que importan aquí son la pintura del mayor contenido (la afectan la fuente, la imagen principal y el CSS bloqueante), el desplazamiento acumulado del diseño (lo provocan las imágenes sin dimensiones, las fuentes que cambian de métrica y los esqueletos de tamaño distinto al contenido) y la capacidad de respuesta a la interacción (la degradan las listas enormes y los ciclos de detección de cambios caros). El panel de rendimiento del navegador y una auditoría automatizada te dan las tres en dos minutos; hacerlo antes de tocar nada evita optimizar lo que no se nota.

24.15 Errores comunes y cómo solucionarlos

SíntomaCausaSolución
Un cambio de estilo en una pantalla rompe otra que nadie ha tocado::ng-deep sin :host: la regla se ha convertido en global aunque esté escrita dentro de un componente.Ánclalo siempre como :host ::ng-deep. Mejor aún: sustitúyelo por tokens del tema, opciones por defecto o una panelClass estilada desde la hoja global (24.6.4).
Hay que añadir !important para que una regla se apliqueGuerra de especificidad: el rival tiene más selectores o viene de una librería.Organiza el CSS en capas con @layer (24.4.6): una capa posterior gana sin importar la especificidad. Si el rival está fuera de capas, mételo dentro.
El contenido salta mientras carga la páginaImágenes sin width y height, fuentes que cambian las métricas al cargar, o esqueletos con distinta altura que el contenido real.Dimensiones siempre declaradas (o aspect-ratio), font-display: swap con una fuente de respaldo de métricas parecidas, y esqueletos con la altura exacta de la fila (24.12).
Las animaciones van a tirones, sobre todo en móvilSe animan propiedades que disparan recálculo de diseño (width, height, top, left, margin).Reescríbelas con transform y opacity. Para desplegar contenido de altura desconocida, anima grid-template-rows de 0fr a 1fr (24.10.3).
El tema oscuro parpadea en blanco al cargarEl tema se aplica desde un servicio de Angular, es decir, después de la primera pintura.Aplica el atributo con un fragmento síncrono en el <head> de index.html, o mediante una cookie si usas renderizado en servidor (24.5.3).
Las barras de desplazamiento y los selectores de fecha siguen blancos en modo oscuroFalta color-scheme: el navegador no sabe que el fondo es oscuro.Declara color-scheme: dark junto a los tokens del tema oscuro (24.5.2).
Angular Material no se estiliza como esperasSe está apuntando a nombres de clase internos que cambiaron en la reescritura de la versión 15, o el tema se define con mixins de otra versión mayor.Comprueba la versión y su documentación (24.6.3). Personaliza por tokens del tema y opciones por defecto; los nombres internos no son API pública.
Los estilos del componente no llegan a un diálogo o a un menúSe renderizan en el contenedor de overlay, al final del <body>, fuera del árbol del componente.Pasa una panelClass y estila desde la hoja global. Comprueba también que están importados los estilos base del overlay del CDK (24.7.1).
El scroll virtual no muestra nada o se descuadraEl viewport no tiene altura, itemSize no coincide con la altura real, o se usa *ngFor en vez de *cdkVirtualFor.Altura explícita en el viewport, altura uniforme y real en la fila, y la directiva correcta (24.7.4).
Los iconos tardan y aparecen cuadrados o como textoFuente de iconos externa: cientos de glifos descargados para usar veinte, sin control del font-display.SVG en línea o registrados en el registro de iconos; autohospeda si mantienes la fuente (24.14).
La lista de miles de elementos congela la pestañaDecenas de miles de nodos del DOM, comprobados además en cada ciclo de detección de cambios.Scroll virtual, OnPush y track estable. Paginar en el servidor si el caso lo admite.
El título largo de una tarea desborda la fila en lugar de recortarseEl elemento flexible tiene min-width: auto, así que no se encoge por debajo de su contenido.min-width: 0 en el hijo que debe encogerse, junto a overflow: hidden y text-overflow: ellipsis (24.4.1).
La clase de Tailwind funciona en desarrollo pero no en producciónEl nombre de clase se construye concatenando cadenas, así que el analizador no lo detecta.Escribe las clases completas y elige entre ellas con una expresión, o mejor, extrae un componente con variantes (24.8.2).
Al cerrar un menú el foco se pierde al principio de la páginaNo se restaura el foco al elemento que abrió el overlay.Devuelve el foco al disparador al cerrar y usa cdkTrapFocus mientras esté abierto (24.7.6).

24.16 Buenas y malas prácticas

Buenas prácticas

  • Define tokens semánticos y prohíbe los valores literales de color, espaciado y radio dentro de los componentes.
  • Deja los estilos dentro del componente por defecto y reserva la hoja global para reinicialización, tokens y elementos de overlay.
  • Publica variables CSS como puntos de extensión de tus componentes en lugar de multiplicar entradas de presentación.
  • Usa :host y :host-context() antes de plantearte perforar la encapsulación.
  • Piensa en móvil primero y coloca los puntos de ruptura donde el contenido lo pida, no donde estén los teléfonos de moda.
  • Anima solo transform y opacity, y respeta siempre prefers-reduced-motion.
  • Diseña los cuatro estados —cargando, vacío, error y con datos— desde el primer día.
  • Baja al CDK antes de instalar una librería de interfaz: casi siempre cubre la parte difícil.
  • Declara siempre width y height en las imágenes y usa la directiva de imagen optimizada.
  • Fija presupuestos de tamaño en la configuración de compilación para que la deuda visual falle en la construcción y no en producción.
  • Usa @layer para integrar CSS de terceros por debajo del tuyo sin escalar especificidad.
  • Agrupa los parches inevitables sobre librerías en un único archivo documentado y revísalo en cada actualización.

Malas prácticas

  • Escribir ::ng-deep sin :host: acabas de crear una regla global sin saberlo.
  • Resolver conflictos con !important: la siguiente persona tendrá que escalar más y no habrá salida.
  • Poner ViewEncapsulation.None «para que los estilos lleguen»: renuncias al único aislamiento que tenías.
  • Mantener dos escalas paralelas (los tokens de la marca y los de la librería) sin puente entre ellas.
  • Copiar un componente en lugar de extraerlo porque hoy hay prisa.
  • Anidar cuatro niveles en Sass: generas selectores de altísima especificidad sin darte cuenta.
  • Animar width, height, top o left, y añadir will-change a todo para compensar.
  • Usar un indicador giratorio para cualquier espera, incluida la carga de una lista que podría llevar esqueleto.
  • Confiar el arrastrar y soltar como única forma de mover una tarea, dejando fuera al teclado.
  • Duplicar los puntos de ruptura entre el CSS y el BreakpointObserver.
  • Mantener un Storybook que nadie actualiza: documentación que miente es peor que ninguna.
  • Depender de nombres de clase internos de una librería: no son API pública y cambian sin aviso.

24.17 Preguntas frecuentes

¿Angular Material o Tailwind? ¿Puedo usar los dos?
No compiten en el mismo plano: Material aporta componentes con comportamiento y accesibilidad, Tailwind aporta utilidades de estilo. Convivir es posible y bastante común: se usan los componentes de Material para diálogos, campos y tablas, y las utilidades para maquetar el resto. La condición para que salga bien es que exista un solo sistema de tokens: la escala de Tailwind debe derivarse de las mismas variables que alimentan el tema de Material. Si mantienes dos paletas independientes, tendrás dos azules parecidos y ninguna forma de saber cuál es el correcto. Lo que sí desaconsejo es usar utilidades de Tailwind para reescribir el interior de los componentes de Material: eso es ::ng-deep con otro disfraz.
¿Cómo cambio el aspecto interno de un componente de Material sin ::ng-deep?
En este orden: 1) cambia el token en el tema, si es un color, una tipografía o una densidad; 2) usa los mixins de sobrescritura de tokens por componente que publica tu versión de Material, que permiten redefinir aspectos concretos de forma global o dentro de un selector; 3) redefine las variables CSS de sistema en un ámbito acotado, si tu versión usa Material Design 3; 4) comprueba si existe un token de opciones por defecto que resuelva lo que quieres por configuración; 5) si el elemento se pinta en un overlay, pásale una panelClass y estílalo desde la hoja global. Solo si nada de eso funciona, escribe :host ::ng-deep con un comentario que indique versión y motivo, y agrúpalo con los demás parches.
¿No es más fácil poner ViewEncapsulation.None y acabar antes?
Es más fácil hoy y mucho más caro después. Con None las reglas de ese componente pasan a ser globales: dejan de estar acotadas y afectan a cualquier elemento de la aplicación que coincida con el selector, incluidos los de componentes que aún no existen. Además, los estilos se quedan inyectados aunque el componente se destruya. El síntoma clásico llega meses después: alguien añade una clase .title en otra pantalla y hereda estilos que nadie relaciona con su origen. Si de verdad necesitas publicar estilos globales, escríbelos en la hoja global, donde son visibles y buscables, y no escondidos en un componente.
Con CSS moderno, ¿sigue teniendo sentido Sass?
Menos que hace cinco años, pero sí en dos casos. El primero es obligatorio: el sistema de temas de Angular Material se configura con mixins de Sass, así que si lo usas, Sass entra con él. El segundo es la generación de código repetitivo —bucles para crear una escala de utilidades, mixins con lógica— que el CSS nativo no cubre. Lo que ya no justifica adoptarlo es tener variables (CSS las tiene, y además en tiempo de ejecución), anidar (el CSS nativo también anida) ni dividir en archivos. Si empiezas un proyecto sin Material, plantéate CSS a secas: menos herramientas, compilación más rápida y ninguna tentación de anidar cuatro niveles.
Mi menú del CDK aparece sin estilos y en una posición absurda. ¿Qué falta?
Casi siempre dos cosas. La primera: no has importado los estilos base del overlay del CDK. Angular Material los trae consigo, pero si usas el CDK a secas hay que añadir explícitamente su hoja preconstruida; sin ella el contenedor de overlay no tiene posicionamiento y todo aparece en el flujo normal, al final de la página. La segunda: los estilos que has escrito en el componente no se aplican al panel, porque el contenido se renderiza en el contenedor de overlay, fuera del árbol del componente y, por tanto, fuera del alcance de la encapsulación emulada. Pasa una panelClass y define esos estilos en la hoja global.
¿Puedo usar el nuevo bloque @for con el scroll virtual del CDK?
No: *cdkVirtualFor es una directiva estructural que se comunica con el viewport para saber qué rango de elementos debe renderizar, y la sintaxis de bloques no tiene equivalente que haga esa comunicación. Dentro de un cdk-virtual-scroll-viewport hay que seguir usando *cdkVirtualFor con su trackBy. Es perfectamente válido: el resto de la plantilla puede usar @if, @switch y @for con normalidad, y solo esa repetición concreta mantiene la sintaxis con asterisco.
¿Cuántos puntos de ruptura necesita una aplicación de gestión?
Dos o tres globales bastan casi siempre, y conviene que sean pocos porque cada uno multiplica los estados que hay que diseñar, revisar y probar. La razón por la que muchos proyectos acaban con siete es que usan consultas de medios para problemas que no son de ventana sino de contenedor. Si adoptas grid con auto-fit y minmax para las cuadrículas, clamp() para tipografía y espaciado, y contenedores de consulta para los componentes que aparecen en sitios de distinto ancho, descubrirás que la mayoría de tus puntos de ruptura sobran. Y defínelos en rem, no en píxeles, para que respeten el tamaño de fuente del usuario.
¿El modo oscuro merece el esfuerzo o es una moda?
Depende por completo de si partes de un sistema de tokens. Si lo tienes, el coste es redefinir un bloque de variables y revisar las pantallas: una tarde. Si no lo tienes, es un proyecto de semanas y, lo que es peor, te obliga a tocar todos los componentes, con el riesgo de regresión que eso supone. Mi recomendación práctica: diseña desde el principio como si fueras a tener dos temas, aunque solo publiques uno. La disciplina de no escribir colores literales es valiosa por sí sola —permite ajustes de marca, temas de alto contraste y personalización por cliente— y hace que el modo oscuro sea casi un efecto secundario gratuito.
¿Cuándo uso la API de animaciones de Angular en lugar de CSS?
Por defecto, CSS. La API de Angular se justifica cuando necesitas controlar la salida de un elemento del DOM —animar algo que se está eliminando es difícil en CSS puro, porque desaparece de golpe— o cuando quieres coordinar una secuencia, por ejemplo escalonar la entrada de una lista o encadenar la animación de un elemento con la de sus hijos. Ten en cuenta el coste: importa un paquete adicional y ejecuta trabajo en el hilo principal. Y comprueba la documentación de tu versión de Angular: el framework ha ido incorporando APIs de plantilla basadas en CSS para los casos de entrada y salida, precisamente para evitar ese coste.
¿Cómo se prueba la capa visual? ¿Tiene sentido probar CSS?
Probar reglas de CSS una por una no aporta nada. Lo que sí tiene sentido son tres cosas. 1) Pruebas de comportamiento: que el panel se abra, que el foco quede atrapado, que Escape lo cierre, que el estado vacío aparezca cuando la lista está vacía. Eso es una prueba de componente normal, como las del capítulo 8. 2) Pruebas de accesibilidad automatizadas, que detectan contraste insuficiente, etiquetas ausentes y roles incorrectos. 3) Regresión visual: capturas de estados concretos que se comparan entre versiones, normalmente sobre Storybook. La tercera es la más costosa de mantener por los falsos positivos, así que limítala a los componentes compartidos que más se reutilizan.
Heredo un proyecto con cuatro mil líneas de CSS global y ::ng-deep por todas partes. ¿Por dónde empiezo?
No por reescribir. El orden que funciona es este: 1) introduce el archivo de tokens sin cambiar nada más, con los valores que ya usa el proyecto; 2) envuelve el CSS heredado en una capa @layer legacy declarada antes que la de tus componentes, de modo que todo lo nuevo gane sin necesidad de !important; 3) inventaría los ::ng-deep con una búsqueda y clasifícalos por número de apariciones; 4) ve migrando componente a componente, sustituyendo literales por tokens, y prohíbe por revisión que entre CSS nuevo fuera del sistema. La clave es que la capa @layer te permite convivir con lo viejo sin pelearte con su especificidad mientras dure la migración.
¿Cómo evito que un componente reutilizable acabe con veinte entradas de presentación?
Aplicando una regla sencilla: las entradas son para decisiones semánticas, las variables CSS para valores visuales y el contenido proyectado para lo que no puedas prever. [tone]="'danger'" es semántico y merece ser una entrada porque el componente hace algo con esa información. [borderRadius]="12" no lo es: debe ser un token CSS que el consumidor redefina desde fuera, sin pasar por la detección de cambios y sin ampliar la API pública. Y cuando alguien pide una entrada para cambiar el marcado interno, la respuesta correcta suele ser una ranura de contenido proyectado. Un componente con tres entradas y tres ranuras es infinitamente más flexible que uno con veinte entradas.
¿Es obligatorio Storybook para tener un sistema de diseño?
No. Un sistema de diseño es un conjunto de decisiones compartidas —tokens, componentes, reglas de uso— y puede existir sin ninguna herramienta de catálogo. Storybook aporta visibilidad y aislamiento durante el desarrollo, pero es una segunda aplicación que hay que mantener y actualizar. Antes de adoptarlo, valora la alternativa barata: una ruta interna en la propia aplicación, protegida o solo disponible en desarrollo, que renderice los componentes con sus estados. Cubre buena parte del valor con una fracción del coste y nunca se queda desactualizada respecto a la versión de Angular, porque es la misma aplicación.

24.18 Ejercicios

Nivel 1 · básico

1. Extraer tokens. Toma un componente con colores, espaciados y radios literales y sustitúyelos por tokens semánticos. Comprueba después que puedes cambiar el color de marca de toda la aplicación modificando una sola línea.

2. Tipografía y espaciado fluidos. Sustituye tres consultas de medios que solo cambian tamaños por expresiones clamp(). Verifica con el zoom del navegador al 200 % que el texto sigue creciendo.

3. Cuadrícula sin puntos de ruptura. Maqueta la lista de proyectos de TaskFlow con grid y auto-fit de forma que pase de una a cuatro columnas sin escribir ninguna consulta de medios, y sin desbordar a 320 píxeles de ancho.

4. Movimiento reducido. Añade el bloque de prefers-reduced-motion a TaskFlow y compruébalo activando el ajuste en tu sistema operativo. Razona por qué se usa una duración mínima en lugar de cero.

Nivel 2 · intermedio

5. Temas completos. Implementa el modo claro, el oscuro y la opción «seguir al sistema» con el servicio de la sección 24.5.3, persistencia y el fragmento anti-parpadeo. Comprueba que cambiar el ajuste del sistema operativo con la aplicación abierta actualiza el tema en vivo.

6. Componente consciente de su contenedor. Convierte la tarjeta de tarea para que se adapte con contenedores de consulta y colócala simultáneamente en una columna de 300 píxeles y en un panel de 700. Debe verse correcta en ambos sin ninguna entrada nueva.

7. Scroll virtual medido. Renderiza cinco mil tareas primero con un bucle normal y después con cdk-virtual-scroll-viewport. Registra en ambos casos el número de nodos del DOM y el tiempo hasta la primera pintura, y escribe dos líneas de conclusión.

8. Animación barata. Reescribe la apertura del panel lateral para que solo anime transform, y el desplegable de detalles de una tarea para que anime grid-template-rows. Compara ambas versiones en el panel de rendimiento del navegador.

Nivel 3 · avanzado

9. Popover propio sobre el CDK. Construye el panel de filtros de tareas con overlay, posiciones alternativas, cierre con Escape y clic fuera, trampa de foco y devolución del foco al disparador. Pruébalo entero con el teclado, sin usar el ratón ni una sola vez.

10. Selector de etiquetas accesible. Completa el componente de la sección 24.7.8: navegación con flechas, búsqueda por escritura, creación de etiquetas nuevas, anuncios con el live announcer y atributos ARIA de combinación correctos. Verifícalo con un lector de pantalla real.

11. Auditoría y plan de migración. Busca todos los ::ng-deep de un proyecto, clasifícalos por causa (falta de token, nombre interno de librería, prisa) y propón para cada grupo la alternativa concreta de la sección 24.6.4. Estima el esfuerzo.

12. Componente compuesto completo. Diseña un tf-data-panel con los cuatro estados, ranuras de contenido para acciones, estado vacío y error, tokens CSS documentados y ninguna entrada de presentación. Escribe sus stories o su ruta de catálogo con al menos ocho estados, incluidos los incómodos.

Solución comentada · Ejercicio 2 (tipografía y espaciado fluidos)

La clave está en que el término medio de clamp() debe combinar una parte en rem y otra en vw. Sin el término en rem, el texto ignora el tamaño de fuente configurado por el usuario y el zoom, lo que incumple las WCAG.

typography.css
/* ANTES: tres reglas acopladas que hay que mantener sincronizadas */
/* h1 { font-size: 22px }
   @media (min-width: 640px)  { h1 { font-size: 28px } }
   @media (min-width: 1024px) { h1 { font-size: 34px } } */

/* DESPUÉS. Cómo se obtienen los coeficientes:
   queremos 22px (1.375rem) a 360px de ancho y 34px (2.125rem) a 1280px.
   pendiente = (34 - 22) / (1280 - 360) = 0.01304  →  1.304vw
   base      = 22 - 0.01304 * 360 = 17.3px = 1.081rem                       */
:root {
  --tf-fs-h1: clamp(1.375rem, 1.081rem + 1.3vw, 2.125rem);
  --tf-fs-h2: clamp(1.125rem, 0.98rem + 0.65vw, 1.5rem);
  --tf-pad-seccion: clamp(0.75rem, 0.4rem + 1.4vw, 2rem);
}

h1 { font-size: var(--tf-fs-h1); }
h2 { font-size: var(--tf-fs-h2); }
.section { padding-block: var(--tf-pad-seccion); }

/* Verificación: con zoom al 200 % el navegador duplica el valor de rem,
   así que el mínimo pasa de 22px a 44px. Si hubiéramos escrito
   clamp(22px, 4vw, 34px) el texto NO habría crecido: fallo de accesibilidad. */
Solución comentada · Ejercicio 6 (tarjeta consciente de su contenedor)

El componente no recibe ninguna entrada nueva: toda la adaptación ocurre en CSS y depende del ancho que le dé quien lo coloque. Fíjate en que el contenedor se declara en :host y las reglas de @container apuntan siempre a descendientes, nunca al propio anfitrión.

task-card.component.css
:host {
  display: block;
  container-type: inline-size;
  container-name: task-card;
}

/* Base: el caso más estrecho. Todo apilado, solo lo imprescindible. */
.task-card { display: grid; gap: var(--tf-space-2); padding: var(--tf-space-3);
             background: var(--tf-surface-2); border-radius: var(--tf-radius-md); }
.task-card__title { font-size: var(--tf-fs-md); min-width: 0;
                    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-card__assignee,
.task-card__project,
.task-card__description { display: none; }

/* ≥ 380px de CONTENEDOR: cabe una segunda columna con el responsable */
@container task-card (min-width: 380px) {
  .task-card { grid-template-columns: 1fr auto; align-items: center;
               column-gap: var(--tf-space-4); }
  .task-card__assignee { display: flex; }
}

/* ≥ 620px: hay sitio para proyecto y descripción resumida */
@container task-card (min-width: 620px) {
  .task-card { grid-template-columns: 1fr auto auto; padding: var(--tf-space-4); }
  .task-card__project { display: inline-flex; }
  .task-card__description { display: block; grid-column: 1 / -1;
                            color: var(--tf-text-muted); }
}

/* Comprobación: colócala en .board__column (300px) y en .task-detail (700px).
   El MISMO componente, sin entradas y sin que el padre sepa nada de él,
   se muestra compacto en una y completo en la otra. Con consultas de medios
   esto era imposible: la ventana mide lo mismo en los dos casos. */
Solución comentada · Ejercicio 8 (animación barata)

Las dos reescrituras aplican la misma idea: mover el trabajo de la etapa de diseño a la de composición, o —cuando eso no es posible, como al desplegar contenido de altura desconocida— animar una propiedad que sí interpola sin necesidad de conocer la altura final.

sidebar.component.css + task-details.component.css
/* --- Panel lateral: de 'left' a 'transform' --- */
.sidebar {
  position: fixed; inset-block: 0; inset-inline-start: 0; inline-size: 320px;
  transform: translateX(-100%);          /* fuera de pantalla, sin animar 'left' */
  transition: transform var(--tf-dur-base) var(--tf-ease);
  will-change: transform;                /* solo aquí: se anima muy a menudo */
}
.sidebar.is-open { transform: translateX(0); }

/* El fondo oscuro entra con opacidad, que también es de composición.
   visibility se transiciona con 'step-end' para que no capture clics al cerrarse. */
.backdrop { opacity: 0; visibility: hidden;
            transition: opacity var(--tf-dur-base) var(--tf-ease),
                        visibility 0s linear var(--tf-dur-base); }
.backdrop.is-open { opacity: 1; visibility: visible; transition-delay: 0s; }

/* --- Detalles de la tarea: altura desconocida sin animar 'height' --- */
.task-details {
  display: grid;
  grid-template-rows: 0fr;               /* 0fr y 1fr SÍ interpolan */
  transition: grid-template-rows var(--tf-dur-base) var(--tf-ease);
}
.task-details.is-open { grid-template-rows: 1fr; }
.task-details > .task-details__inner { overflow: hidden; }  /* imprescindible */

@media (prefers-reduced-motion: reduce) {
  .sidebar, .backdrop, .task-details { transition-duration: .01ms; }
}

/* Medición esperada en el panel de rendimiento del navegador:
   la versión con 'left' muestra barras moradas de recálculo de diseño en
   cada fotograma; la de 'transform' solo muestra composición, y en un móvil
   modesto la diferencia entre 60 y ~25 fotogramas por segundo es evidente
   a simple vista. La de grid-template-rows sí recalcula el diseño, pero solo
   dentro del panel desplegado y no en todo el documento. */

24.19 Resumen del capítulo

  • El CSS se degrada más rápido que el TypeScript porque es global por defecto, no da errores, premia la escalada de especificidad y depende del contexto. La respuesta no es disciplina, es arquitectura.
  • Un sistema de diseño es una decisión de ingeniería: reduce un espacio de estados infinito a uno enumerable. De ahí salen los cambios transversales baratos, el modo oscuro gratuito y la accesibilidad garantizada en un solo sitio.
  • La base en Angular son los estilos de componente con encapsulación emulada. Todo lo demás —Sass, utilidades, Material— se construye encima, y ::ng-deep sin :host es una regla global disfrazada.
  • El CSS moderno —grid con auto-fit, clamp(), variables, contenedores de consulta, :has(), @layer y propiedades lógicas— elimina dependencias enteras y la mayoría de los puntos de ruptura.
  • Los tokens en tres niveles —primitivos, semánticos y de componente— son lo que convierte el modo oscuro en un archivo de treinta líneas. Recuerda color-scheme y el fragmento síncrono que evita el parpadeo.
  • Angular Material brilla en aplicaciones intensivas en formularios, tablas y diálogos, y estorba cuando hay un sistema de diseño propio fuerte. Personalízalo por tokens y opciones por defecto, nunca por sus nombres de clase internos, y comprueba siempre la versión: su sistema de temas ha cambiado varias veces.
  • El CDK es la pieza infravalorada: overlay, portal, arrastrar y soltar, scroll virtual, puntos de ruptura, gestión del foco y anuncios, todo sin un solo píxel de estilo impuesto. Antes de instalar una librería de interfaz, comprueba si el CDK cubre la parte difícil.
  • Tailwind funciona en Angular porque las utilidades son globales y la encapsulación reescribe selectores, no atributos class. Su punto de rotura es la repetición: extrae un componente en cuanto una cadena se repita.
  • Anima solo transform y opacity, y respeta prefers-reduced-motion: no es un detalle, es accesibilidad.
  • Diseña siempre los cuatro estados. Un esqueleto con las dimensiones reales del contenido se percibe más rápido que un indicador giratorio y no provoca saltos de diseño.
  • El rendimiento visual se juega en el CSS bloqueante, las fuentes, los iconos, las imágenes y las listas largas. Mide primero; optimiza lo que el usuario nota.

24.20 Recursos adicionales