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.
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.
- Explicar por qué el CSS se degrada más rápido que el TypeScript y qué propiedades del lenguaje lo provocan, para poder neutralizarlas con arquitectura en lugar de con disciplina.
- Elegir con criterio entre CSS plano con BEM, preprocesadores, utilidades tipo Tailwind y estilos de componente de Angular, sabiendo qué aporta y qué cuesta cada opción en un proyecto con encapsulación emulada.
- Usar flexbox, grid, variables personalizadas,
clamp(), contenedores de consulta,:has(), capas en cascada y propiedades lógicas para resolver con CSS nativo lo que hace cinco años exigía una librería entera. - Diseñar un sistema de tokens de diseño y construir sobre él un modo claro y un modo oscuro que respeten la preferencia del sistema, se puedan cambiar sin recargar, persistan entre sesiones y no parpadeen al arrancar.
- Decidir si Angular Material encaja en tu proyecto, instalarlo, personalizar su tema por los mecanismos oficiales y no con
::ng-deep, e integrarlo con formularios reactivos tipados. - Construir componentes propios sobre el CDK —overlay, portal, arrastrar y soltar, scroll virtual, puntos de ruptura, gestión del foco, anuncios a lectores de pantalla, portapapeles y campos de texto— en lugar de importar una librería completa para resolver un problema pequeño.
- Integrar Tailwind en Angular entendiendo por qué funciona pese a la encapsulación de estilos, y aplicar el patrón de extraer componentes antes de que las plantillas se vuelvan ilegibles.
- Maquetar en móvil primero con puntos de ruptura justificados, imágenes adaptativas y tipografía fluida.
- Animar sin degradar el rendimiento: saber qué propiedades se pueden animar sin provocar recálculo de diseño, cuándo usar CSS y cuándo la API de animaciones de Angular, y respetar la preferencia de movimiento reducido.
- Diseñar la API pública de un componente de interfaz reutilizable: entradas, salidas, contenido proyectado, tokens de personalización y estados de carga, vacío, error y deshabilitado.
- Implementar esqueletos de carga que mejoren la velocidad percibida sin provocar saltos de diseño, y estados vacíos que sirvan para algo.
- Valorar con honestidad si Storybook merece la pena en tu equipo, y medir el coste real de la capa visual en el paquete final.
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.
- Todo es global por defecto. Una regla
.card { padding: 16px }escrita en cualquier archivo afecta a todos los elementos con esa clase en toda la aplicación, ahora y en el futuro. En TypeScript, para que un módulo afecte a otro hay que importarlo explícitamente; en CSS basta con que el navegador cargue la hoja. Esto convierte cada nombre de clase en una variable global. - No hay errores. Si escribes
colr: redo apuntas a un selector que ya no existe, el navegador ignora la declaración en silencio. No hay compilador, no hay excepción, no hay prueba que falle. El código muerto se acumula sin dejar rastro, y nadie borra una regla que no sabe si sigue en uso. - La especificidad es una guerra de escalada. Cuando una regla no se aplica, la solución rápida es añadir un selector más, luego un
#id, luego!important. Cada escalada obliga a la siguiente a escalar más. Es una carrera armamentística dentro del propio proyecto. - El resultado depende del contexto. El mismo componente se ve distinto según el ancho del contenedor padre, la fuente heredada, el
overflowde un antepasado o unz-indexque crea un contexto de apilamiento tres niveles más arriba. La composición no es local, y por tanto no se puede razonar localmente.
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í:
| Fase | Qué ocurre | Coste real |
|---|---|---|
| 1. Duplicación silenciosa | La 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 visual | Una 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 bruta | Un 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. Miedo | Nadie 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. Reescritura | Alguien 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:
- Los cambios transversales pasan de días a minutos. «Sube un punto el contraste de los textos secundarios» es una línea si existe un token
--tf-text-muted, y una semana de arqueología si hay ciento treinta declaraciones de color repartidas por cuarenta archivos. - El modo oscuro deja de ser un proyecto. Si los componentes no contienen colores literales sino referencias a tokens, añadir un tema es redefinir un conjunto de variables. Si los contienen, hay que revisar cada componente uno a uno. La sección 24.5 lo desarrolla.
- Las revisiones de código dejan de discutir píxeles. «¿Por qué 14px y no 16px?» es una discusión sin criterio; «esto debería usar el token
--tf-space-3» es una regla verificable, y hasta automatizable con un linter. - La accesibilidad se garantiza una vez. Si el contraste se valida en la paleta de tokens, ningún componente puede violarlo por accidente. Si cada componente elige su color, la validación hay que repetirla en cada pantalla y en cada revisión.
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.
/* 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; }
/* 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
| Estrategia | Cómo aísla | Ventajas | Inconvenientes | Encaje 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:
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 {}| Modo | Qué hace el compilador | Cuándo usarlo |
|---|---|---|
Emulatedpor 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. |
None | No 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. |
ShadowDom | Usa 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. |
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.
<!-- 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.
: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:
- ¿Cuánta gente va a tocar estos estilos y con qué rotación? Cuanta más rotación, más conviene un sistema que imponga las restricciones por herramienta (utilidades, tokens con linter) y no por convención.
- ¿Hay diseñadores con un sistema propio, o el diseño se decide sobre la marcha? Con un sistema de diseño externo bien definido, Angular Material puede estorbar más de lo que ayuda, porque tendrás que redefinir casi todo. Sin diseñadores, Material te regala centenares de decisiones razonables y accesibles.
- ¿La aplicación es de producto o de herramienta interna? Una herramienta interna con doscientas pantallas de formularios y tablas es el caso de uso ideal de Material. Una aplicación de producto con identidad visual fuerte suele salir mejor con CSS propio más el CDK.
- ¿Cuánto tiempo va a vivir? A cinco años vista, el CSS nativo y el CDK envejecen mucho mejor que cualquier librería de componentes, porque la superficie de API que consumes es más pequeña y más estable.
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 TaskFlow | Herramienta | Por qué |
|---|---|---|
| Barra de una tarea: icono, título que crece, fecha y menú | Flexbox | Una sola fila, número variable de elementos, uno de ellos absorbe el espacio sobrante. |
| Tablero de proyecto con columnas «Pendiente / En curso / Hecho» | Grid | Estructura conocida y estable; se quiere que las columnas midan lo mismo aunque tengan distinto contenido. |
| Lista de etiquetas que se desborda a varias líneas | Flexbox con flex-wrap | Anchura dictada por el contenido de cada etiqueta. |
| Cuadrícula de tarjetas de proyecto adaptativa | Grid con auto-fit y minmax | El número de columnas debe depender del ancho disponible sin escribir un solo punto de ruptura. |
| Formulario de tarea con etiquetas y campos alineados | Grid con áreas nombradas | Alineación entre filas independientes, imposible de garantizar 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 */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.
/* 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:
- Se resuelven en tiempo de ejecución. Una variable de Sass desaparece en la compilación; una variable CSS existe en el navegador y se puede leer y modificar desde JavaScript con
getComputedStyleystyle.setProperty. Esto es lo que hace posible el cambio de tema sin recargar. - Se heredan por el árbol del DOM. No tienen ámbito de archivo, sino ámbito de elemento: el valor que ve un componente es el que haya declarado su antepasado más cercano. Esta es la propiedad que convierte una variable en un punto de extensión de un componente.
- Atraviesan la encapsulación de Angular. La encapsulación emulada reescribe selectores, no valores heredados. Una variable declarada en el padre llega al hijo, y por eso es la forma correcta de personalizar un componente desde fuera sin
::ng-deep. - Aceptan valores de reserva.
var(--tf-radius-md, 8px)permite publicar un componente que funciona aunque el consumidor no defina ningún token.
: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);
}@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.
@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.
/* 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; } }
/* 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%); }
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.
/* 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); }
}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.
/* 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.
/* 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 */
}@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ísico | Lógico | Nota |
|---|---|---|
margin-left / margin-right | margin-inline-start / margin-inline-end | Con dir="rtl" se intercambian solas. |
padding-top + padding-bottom | padding-block | Una sola declaración para los dos lados del eje de bloque. |
width / height | inline-size / block-size | Coherente con container-type: inline-size. |
border-left | border-inline-start | Típico en el indicador lateral de una tarjeta urgente. |
top: 0; left: 0 | inset-block-start: 0; inset-inline-start: 0 | inset: 0 sigue siendo válido y cómodo. |
text-align: left | text-align: start | Aplicable ya mismo, sin coste alguno. |
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.
@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;
}
}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.
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;
}
}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' },
];
}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.
<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>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.
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 filtros | Sí, claramente | Es 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ñador | Sí | Material 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 propio | Probablemente no | Acabará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ítico | Con cuidado | Es 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álogo | No: usa el CDK | El 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 Design | Sí | Coincidencia total entre lo que espera el diseñador y lo que la librería hace por defecto: el escenario ideal. |
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
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>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
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.
@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);
}@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); }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:
- Tokens del tema. Si el color primario no es el que quieres, cámbialo en el tema, no en el componente.
- 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.
- 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. - Tokens de opciones por defecto.
MAT_FORM_FIELD_DEFAULT_OPTIONS,MAT_DIALOG_DEFAULT_OPTIONS,MAT_SNACK_BAR_DEFAULT_OPTIONSy sus equivalentes cambian el comportamiento por configuración. - 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.
/* 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;
}
// 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.
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:
| Componente | Uso en TaskFlow | Lo que hay que saber |
|---|---|---|
MatFormField + matInput | Todos los campos de tarea y proyecto | Es 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 / MatAutocomplete | Elegir proyecto, responsable o etiquetas | Se 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 + MatPaginator | Listado de tareas en vista de tabla | Potente 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. |
MatDialog | Crear o editar tarea, confirmaciones | Trae trampa de foco, cierre con Escape, roles ARIA y restauración del foco al cerrar. Reimplementarlo bien cuesta más de lo que parece. |
MatSnackBar | Avisos de guardado y deshacer | Solo 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. |
MatMenu | Acciones de cada tarea | Navegación completa con teclado incluida. Si solo necesitas esto, valora el overlay del CDK (24.7.1). |
MatChips | Etiquetas de una tarea | El modo de entrada permite crear etiquetas escribiendo y confirmando con Intro o coma. Cuidado con la accesibilidad al implementar el borrado. |
MatIcon | Toda la interfaz | Con fuente de iconos hay parpadeo y peso; con iconos SVG registrados en el registro de iconos tienes control total. Ver 24.14. |
MatProgressBar / MatProgressSpinner | Cargas | Ú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.
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());
}
}<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>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:
- Navegación por teclado completa: flechas dentro de menús y selectores,
HomeyEnd, búsqueda por escritura,Escapepara cerrar. - Gestión del foco: trampa de foco en diálogos, restauración del foco al elemento que abrió el overlay, y foco inicial configurable.
- Roles y estados ARIA correctos y sincronizados con el estado real del componente, incluido
aria-expanded,aria-selectedyaria-activedescendantdonde procede. - Asociación de etiquetas, pistas y errores:
mat-form-fieldconectamat-label,mat-hintymat-errorcon el campo mediantearia-describedby. Hacerlo a mano y mantenerlo sincronizado es tedioso y se olvida. - Modo de alto contraste del sistema operativo contemplado en los estilos.
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.
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;
}
}<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>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.
| Pieza | Qué es | Cuándo se usa |
|---|---|---|
ComponentPortal | Envuelve 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). |
TemplatePortal | Envuelve 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. |
CdkPortalOutlet | Directiva que marca dónde se pinta un portal. | El destino, dentro de una plantilla de Angular. |
DomPortalOutlet | Destino que es un elemento del DOM cualquiera. | Integración con código no-Angular o con un contenedor creado a mano. |
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.
<!-- 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>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');
},
});
}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 tareas | Renderizado completo | Con cdk-virtual-scroll-viewport |
|---|---|---|
| Nodos del DOM | ~60.000 | ~250 (los visibles más un colchón) |
| Tiempo hasta la primera pintura de la lista | Segundos, con la pestaña bloqueada | Decenas de milisegundos |
| Coste de un ciclo de detección de cambios | Proporcional a 5.000 filas | Proporcional a ~20 filas |
| Memoria del documento | Cientos de MB en casos reales | Prácticamente constante |
| Coste de aplicar un filtro | Rehacer decenas de miles de nodos | Rehacer 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.
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;
}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.
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.
| Herramienta | Qué resuelve | Uso típico en TaskFlow |
|---|---|---|
cdkTrapFocus | Impide 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. |
FocusMonitor | Informa 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 / ActiveDescendantKeyManager | Navegació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. |
LiveAnnouncer | Anuncia un mensaje a los lectores de pantalla mediante una región activa gestionada por el CDK. | «Tarea movida a En curso», «12 tareas encontradas». |
cdkAriaLive | Convierte un elemento en región activa de forma declarativa. | El contador de resultados de la lista de tareas. |
InteractivityChecker | Determina si un elemento es visible, enfocable o alcanzable con tabulador. | Componentes propios que necesitan calcular el siguiente elemento enfocable. |
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');
});
}
}<!-- 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>
<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
<!-- 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>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.
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');
}
}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.
# 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;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.
<!-- 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>
// 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" />
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.
/* 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
| Necesidad | Herramienta | Motivo |
|---|---|---|
| Cambiar tamaños, columnas, visibilidad o espaciado | CSS | El navegador lo resuelve sin pasar por JavaScript ni por la detección de cambios. Es gratis. |
| Renderizar un componente distinto según el ancho | BreakpointObserver | Es 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») | BreakpointObserver | Es una entrada de un componente, no una regla de estilo. |
| Desactivar el arrastre en pantallas táctiles pequeñas | BreakpointObserver | Comportamiento, no apariencia. |
| Ocultar una columna secundaria de la tabla | CSS | Con display: none basta. Ojo: el nodo sigue en el DOM; si el coste de renderizarlo importa, entonces sí es un caso de TypeScript. |
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.
<!-- 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">- Siempre
widthyheight(ofillcon un contenedor posicionado): sin ellos el navegador no reserva espacio y el contenido salta al cargar la imagen, que es la principal causa de mala puntuación en desplazamiento acumulado del diseño. prioritysolo en la imagen más grande visible al cargar, típicamente una por página. Marcarlo todo como prioritario equivale a no priorizar nada.sizesimporta tanto comosrcset: sin él el navegador supone que la imagen ocupa todo el ancho y descarga la variante más grande aunque tu tarjeta mida 320 píxeles.
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.
- Duraciones: entre 120 y 200 milisegundos para elementos pequeños (un menú, una casilla), entre 200 y 320 para paneles y diálogos. Por encima de 400 se percibe como lentitud, no como elegancia.
- Curvas: salidas rápidas y entradas suaves. Un elemento que aparece debe frenar al llegar; uno que desaparece debe irse deprisa, porque el usuario ya ha decidido.
- Nunca bloquees: si el usuario pulsa dos veces seguidas, la segunda acción no puede esperar a que termine la primera animación.
24.10.2 CSS frente a la API de animaciones de Angular
| Criterio | Transiciones y animaciones de CSS | API de animaciones de Angular |
|---|---|---|
| Coste | Cero 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 salen | Difícil: al eliminarse del DOM desaparecen de golpe. | Es su punto fuerte: :enter y :leave retrasan la eliminación real. |
| Escalonado de listas | Posible con retardos calculados, incómodo. | Directo, con la utilidad de escalonado. |
| Coordinación con el estado del componente | Mediante clases; suficiente casi siempre. | Estados nombrados y transiciones explícitas entre ellos. |
| Recomendación | Por 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. |
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.
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 animada | Etapas que dispara | Coste |
|---|---|---|
width, height, top, left, margin, padding | Diseño + pintado + composición | Alto. Recalcula la geometría de los hermanos y a menudo del documento entero. |
background-color, box-shadow, border-radius, color | Pintado + composición | Medio. Sin recálculo de diseño, pero hay que repintar píxeles. |
transform, opacity, filter | Solo composición | Bajo. 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.
/* 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; }
/* 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.
@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; }
}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.
| Mecanismo | Para 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 proyectado | Composición: el consumidor aporta marcado arbitrario. | Acciones de una tarjeta, cuerpo de un panel |
| Variable CSS | Personalizació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 |
@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>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.
@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)));
}- El esqueleto debe tener las dimensiones del contenido real. Si la tarjeta mide 72 píxeles y el esqueleto 40, al llegar los datos todo salta: has cambiado un problema de espera por uno de desplazamiento acumulado del diseño, que es peor porque provoca clics erróneos.
- Umbral de aparición. Si la respuesta llega en 150 milisegundos, mostrar un esqueleto produce un parpadeo molesto. Muéstralo solo a partir de unos 200-300 milisegundos, y una vez mostrado mantenlo un mínimo para que no desaparezca de golpe.
- Marca el estado para la tecnología asistiva con
role="status"y oculta las barras decorativas conaria-hidden; un lector de pantalla no debe leer doce rectángulos. - Un estado vacío es una oportunidad, no un error. «No hay tareas» es información inútil. «Todavía no hay tareas en este proyecto» más un botón de «Crear la primera tarea» y una frase sobre qué se puede hacer aquí convierte un callejón sin salida en el mejor momento para enseñar el producto. Distingue siempre vacío por primera vez de vacío por un filtro: en el segundo caso lo útil es ofrecer quitar el filtro.
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.
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. |
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.
| Frente | Síntoma | Qué hacer |
|---|---|---|
| Tamaño de los estilos | Hoja 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. |
| Fuentes | Texto 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. |
| Iconos | Los 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ágenes | La 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 largas | La 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. |
| Animaciones | Tirones al desplazarse o al abrir paneles. | Solo transform y opacity (24.10.3); will-change con moderación. |
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íntoma | Causa | Solució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 aplique | Guerra 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ágina | Imá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óvil | Se 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 cargar | El 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 oscuro | Falta 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 esperas | Se 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 descuadra | El 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 texto | Fuente 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ña | Decenas 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 recortarse | El 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ón | El 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ágina | No 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
:hosty: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
transformyopacity, y respeta siempreprefers-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
widthyheighten 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
@layerpara 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-deepsin: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,topoleft, y añadirwill-changea 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?
::ng-deep con otro disfraz.¿Cómo cambio el aspecto interno de un componente de Material sin ::ng-deep?
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?
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?
Mi menú del CDK aparece sin estilos y en una posición absurda. ¿Qué falta?
panelClass y define esos estilos en la hoja global.¿Puedo usar el nuevo bloque @for con el scroll virtual del CDK?
*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?
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?
¿Cuándo uso la API de animaciones de Angular en lugar de CSS?
¿Cómo se prueba la capa visual? ¿Tiene sentido probar CSS?
Heredo un proyecto con cuatro mil líneas de CSS global y ::ng-deep por todas partes. ¿Por dónde empiezo?
@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?
[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?
24.18 Ejercicios
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.
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.
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.
/* 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.
: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.
/* --- 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-deepsin:hostes una regla global disfrazada. - El CSS moderno —
gridconauto-fit,clamp(), variables, contenedores de consulta,:has(),@layery 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-schemey 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
transformyopacity, y respetaprefers-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
- angular.dev — documentación oficial de Angular: estilos de componente, encapsulación de vistas, animaciones y la directiva de imagen optimizada. Es la referencia para saber qué ofrece tu versión.
- material.angular.io — Angular Material: guía de temas, catálogo de componentes con ejemplos vivos y notas de migración entre versiones mayores (el sitio redirige a la documentación actual del proyecto).
- Angular CDK — overlay, portal, arrastrar y soltar, scroll virtual, layout, accesibilidad, portapapeles y campos de texto, cada uno con su API y sus ejemplos.
- MDN · CSS — la referencia del lenguaje, con tablas de compatibilidad. Imprescindible para
@container,:has(),@layer,@propertyylight-dark(). - MDN · CSS Grid Layout — guías completas de rejilla, incluidas las áreas nombradas y las funciones de repetición automática.
- Tailwind CSS — documentación oficial, con las guías de instalación y de configuración, que cambian de forma notable entre versiones mayores.
- Storybook — documentación oficial, incluida la guía específica para Angular y los complementos de accesibilidad y pruebas.
- ARIA Authoring Practices Guide — los patrones de teclado y ARIA que implementan Material y el CDK, y la referencia obligada si construyes un componente propio.
- web.dev · Core Web Vitals — definición y medición de las métricas de rendimiento percibido citadas en la sección 24.14.