CSS avanzado: animaciones y layouts modernos
CSS ha dejado de ser un lenguaje de "colores y cajas". Hoy puedes crear animaciones fluidas, layouts que responden al contenedor en vez de a la pantalla y temas dinámicos sin una sola línea de JavaScript. Este tutorial cubre transiciones, transformaciones, keyframes, container queries, custom properties y las funciones modernas que usan los frontends reales. Prerequisito conceptual: HTML y CSS básicos.
Transiciones
Una transición interpola suavemente el cambio de una o varias propiedades CSS entre dos estados. Se declara en el estado base (el elemento en reposo), no en el estado final:
.boton {
background: #2563eb;
transition: background-color 0.3s ease;
}
.boton:hover {
background: #1d4ed8;
}La propiedad abreviada transition agrupa cuatro subpropiedades. El orden habitual:
- transition-property: qué propiedades animar. Usar
alles cómodo pero anima de más. - transition-duration: cuánto dura, en segundos o milisegundos.
- transition-timing-function: la curva de velocidad (easing).
- transition-delay: espera antes de comenzar. Negativo inicia a mitad de camino.
.elemento {
transition-property: color, transform;
transition-duration: 0.2s, 0.4s;
transition-timing-function: ease, ease-in-out;
transition-delay: 0s, 0.1s;
}
/* Equivalente abreviado: cada grupo en orden */
.elemento {
transition: color 0.2s ease 0s, transform 0.4s ease-in-out 0.1s;
}Easing y cubic-bezier
Las curvas predefinidas son linear, ease (la de por defecto), ease-in, ease-out y ease-in-out. Para control total se define una curva de Bézier cúbica con cuatro puntos:
/* cubic-bezier(x1, y1, x2, y2) */
.transicion-suave {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Una curva "elástica" que sobrepasa ligeramente el objetivo */
.entrada-muelle {
transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}ease-out para entradas (lo que aparece), ease-in para salidas (lo que desaparece) y una curva "overshoot" (y por encima de 1) solo para efectos decorativos. Demasiada curva mata la sensación de UI nativa.Hay una quinta curva muy útil: steps(). Divide la animación en saltos discretos en lugar de interpolación continua, ideal para sprites de personajes o contadores:
/* Pasos discretos: el texto cambia como un marcador */
.contador {
transition: all 1s steps(10, end);
}prefers-reduced-motion
Muchas personas tienen sensibilidad al movimiento. La media query prefers-reduced-motion permite detectar la preferencia del sistema operativo y reducir o eliminar las animaciones:
/* Por defecto, animamos con normalidad */
.tarjeta {
transition: transform 0.3s ease;
}
/* Si el usuario pide menos movimiento, lo reducimos */
@media (prefers-reduced-motion: reduce) {
.tarjeta {
transition: none;
}
}@keyframes también deben respetarse. Un patrón común y aceptado es dejar la transición pero con duración 0.01ms, que mantiene la lógica del evento sin el movimiento visible.Transformaciones
transform modifica la geometría de un elemento sin tocar el flujo del documento: los hermanos no se recolocan. Funciones principales:
translate(x, y)— desplazar.rotate(deg)— girar (acepta grados oturn).scale(factor)— escalar.skew(x, y)— inclinar.perspective()y funciones 3D comorotateX()otranslateZ().
.tarjeta:hover {
transform: translateY(-4px) scale(1.02);
}
/* Centrado clásico sin conocer las dimensiones del hijo */
.padre {
position: relative;
}
.hijo {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}transform-origin define el punto alrededor del cual giran o escalan los elementos. Por defecto es el centro (50% 50%):
.campana {
transform-origin: top center;
}
.campana:hover {
animation: campanada 0.6s ease-in-out infinite;
}
@keyframes campanada {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(15deg); }
75% { transform: rotate(-15deg); }
}perspective en el padre (o la función perspective() dentro del transform). Sin él, las funciones 3D se ignoran y el elemento se aplana.Un detalle clave: el orden de las funciones importa. translate() y scale() no conmutan. Piensa en el orden como una cadena que se aplica de derecha a izquierda:
/* Primero escala, luego desplaza (el desplazamiento también se escala) */
.transform {
transform: translate(20px, 0) scale(2);
}
/* Primero desplaza, luego escala (desplazamiento sin escalar) */
.transform {
transform: scale(2) translate(20px, 0);
}Animaciones con keyframes
Mientras que las transiciones conectan dos estados, las animaciones recorren una secuencia de pasos definida con @keyframes y se controlan con las propiedades animation-*:
@keyframes pulsar {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
.boton-pulsante {
animation-name: pulsar;
animation-duration: 1.5s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
/* Abreviado */
.boton-pulsante {
animation: pulsar 1.5s ease-in-out infinite;
}Propiedades de animación
animation-duration— duración de un ciclo.animation-timing-function— curva del easing.animation-iteration-count— número de ciclos oinfinite.animation-delay— espera antes de empezar (acepta valores negativos para empezar a mitad del ciclo).animation-direction—normal,reverse,alternate(ida y vuelta).animation-fill-mode— qué valores se aplican antes/después del ciclo:none,forwards,backwards,both.
.linea-carga {
width: 100px;
animation: crecer 2s ease forwards;
}
@keyframes crecer {
from { width: 100px; }
to { width: 400px; }
}
/* fill-mode: forwards mantiene el estado final tras terminar,
backwards aplica el estado inicial durante el delay */
.entrada {
opacity: 0;
animation: aparecer 0.4s ease 0.3s both;
}
@keyframes aparecer {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}Un keyframe puede animar varias propiedades a la vez y puede mezclar porcentajes arbitrarios, no solo 0% y 100%:
@keyframes turno-de-color {
0% { background: #2563eb; transform: rotate(0deg); }
50% { background: #7c3aed; transform: rotate(180deg); }
100% { background: #2563eb; transform: rotate(360deg); }
}
.monigote {
animation: turno-de-color 3s linear infinite;
}display) dentro de un keyframe directamente. El truco clásico es usar visibility con un keyframe de dos pasos: se mantiene visible hasta el 99% y salta a hidden al final.Layout moderno
Container queries
Hasta hace poco, lo único "responsive" era la ventana. Con las container queries, un componente responde al tamaño de su contenedor, lo que permite piezas reutilizables que se adaptan donde sea que vivan:
.tarjeta-contenedor {
container-type: inline-size;
container-name: tarjeta;
}
/* Dentro del contenedor, @container responde a SU ancho */
@container tarjeta (min-width: 400px) {
.tarjeta__grid {
display: grid;
grid-template-columns: 1fr 1fr;
}
}container-type: inline-size indica que el elemento es un contenedor de consulta (size o inline-size). La ventaja frente a media queries: el mismo componente se reorganiza solo en una barra lateral estrecha o en un grid amplio.
@container sobre @media.El selector :has()
:has() selecciona un elemento porque contiene a otro. Es un selector relacional y resuelve el 90% de los casos que antes exigían JavaScript (como estilos "padre si hay hijo"):
/* El card que contiene una imagen con estado de error */
.tarjeta:has(.alerta) {
border-color: #dc2626;
}
/* Un formulario donde el input obligatorio está vacío */
.formulario:has(input[required]:not(:placeholder-shown)) {
outline: 2px solid #16a34a;
}
/* Menú que se abre al hover de su botón — sin JavaScript */
.menu:has(.menu__toggle:hover) .menu__desplegable {
display: block;
}:has() es caro de calcular. Evita anidarlo o aplicarlo a miles de elementos. Úsalo en contextos acotados (una tarjeta, un formulario) y no en el selector universal.Subgrid
Con grid normal, un hijo del grid no comparte sus pistas (columnas/filas) con sus propios hijos. subgrid hereda las pistas del grid padre, permitiendo alinear tarjetas de un mismo grid internamente:
.galeria {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.tarjeta {
display: grid;
grid-template-rows: subgrid; /* hereda las filas del padre */
grid-row: span 3; /* participa en 3 filas del padre */
gap: 0;
}Sin subgrid, tres tarjetas con contenido de alturas distintas tendrían sus pies de página desalineados. Con subgrid, todas comparten las mismas pistas y los botones quedan alineados en la misma fila.
Funciones modernas
clamp() fija un valor entre un mínimo y un máximo. Es la herramienta reina de la tipografía fluida, sin media queries:
/* font-size fluido: nunca menor de 1rem, nunca mayor de 2.5rem */
h1 {
font-size: clamp(1rem, 4vw + 0.5rem, 2.5rem);
}
/* Contenedor fluido con márgenes laterales */
.contenedor {
padding-inline: clamp(1rem, 5vw, 3rem);
}min() y max() eligen el menor o mayor de una lista de valores, y aspect-ratio fija la proporción sin conocer ni un ancho ni un alto:
/* El video nunca ocupa más del 60% del contenedor */
.video {
width: min(100%, 60%);
}
/* Imagen que crece hasta 400px, nunca menos de 200px */
.imagen {
width: max(200px, 40%);
}
/* Tarjetas 16:9 sin conocer el ancho */
.thumb {
aspect-ratio: 16 / 9;
object-fit: cover;
}Custom properties
Las custom properties (variables CSS) se definen con --nombre y se leen con var(). Viven en el elemento donde se declaran y se heredan hacia abajo en el árbol:
:root {
--color-primario: #2563eb;
--radio: 12px;
--espaciado: 1rem;
}
.boton {
background: var(--color-primario);
border-radius: var(--radio);
padding: var(--espaciado) calc(var(--espaciado) * 2);
}Con calc() se pueden derivar valores nuevos a partir de variables, y var() acepta un valor de respaldo si la variable no existe:
.tarjeta {
/* var(nombre, respaldo) */
padding: var(--espaciado, 1rem);
margin: calc(var(--espaciado) * 2);
}
/* Herencia: el hijo puede sobrescribir solo para sí */
.tarjeta {
--espaciado: 0.5rem;
}
.tarjeta.destacada {
--espaciado: 1.5rem;
}Temas dinámicos con data-theme
El patrón de temas más extendido: definir paletas por atributo data-theme en el <html> y que cada selector anule las mismas variables. Toda la interfaz se recolorea sin tocar un solo componente:
:root {
--fondo: #ffffff;
--texto: #1d1d1f;
--acento: #2563eb;
}
html[data-theme="oscuro"] {
--fondo: #0a0a0a;
--texto: #f5f5f7;
--acento: #60a5fa;
}
body {
background: var(--fondo);
color: var(--texto);
}
.boton-primario {
background: var(--acento);
}// Cambiar tema desde JavaScript: un solo atributo
function cambiarTema(nombre: string) {
document.documentElement.setAttribute("data-theme", nombre);
localStorage.setItem("tema", nombre);
}
// En el arranque, antes del primer paint
const temaGuardado = localStorage.getItem("tema") ?? "claro";
document.documentElement.setAttribute("data-theme", temaGuardado);prefers-color-scheme para el tema por defecto y data-theme para la elección explícita del usuario. Ambos conviven: la media query pone el valor inicial y el atributo HTML tiene la última palabra por especificidad extra.Rendimiento de animaciones
No todas las propiedades se animan igual de barato. El navegador ejecuta las animaciones en capas distintas y solo transform y opacity se procesan en la GPU sin tocar el layout. Animar width, top o box-shadow fuerza recálculos de layout y paint en cada frame:
/* Caro: recalcula layout en cada frame */
.animacion-cara {
transition: width 0.3s ease, height 0.3s ease;
}
/* Barato: la GPU solo recompone la capa */
.animacion-buena {
transition: transform 0.3s ease;
}
/* Equivalencia: animar position con transform */
.elemento {
transform: translateY(0);
transition: transform 0.3s ease;
}
.elemento.movido {
transform: translateY(-40px);
}will-change avisa al navegador qué propiedad va a cambiar, para que prepare una capa propia:
.elemento {
will-change: transform;
}
/* En hover ya no hace falta: la capa está preparada */
.elemento:hover {
transform: scale(1.05);
}will-change en cientos de elementos ni de forma permanente: cada capa reserva memoria. El patrón correcto es activarlo un momento antes (por ejemplo, con mouseenter) y retirarlo al terminar.contain aísla un subárbol para que el navegador no propague sus cambios de layout al resto de la página. Es útil en componentes autocontenidos:
.widget {
/* layout: el layout interno no afecta a lo de fuera */
contain: layout paint;
}
/* Contenido estricto: tamaño fijo, sin leaks */
.ventana-modal {
contain: strict;
}transform y opacity. Si necesitas un movimiento "visual" de layout (altura, ancho), simúlalo con transformaciones o usa grid-template-rows 0fr → 1fr, que el navegador optimiza nativamente.Sass y PostCSS en breve
Los preprocesadores siguen vivos en muchos proyectos. Su valor no es sustituir CSS sino darle superpoderes de construcción que el CSS plano no tiene (aunque cada vez se solapan más):
- Anidamiento: escribir selectores dentro de otros. Hoy CSS nativo también lo soporta, con reglas propias.
- Mixins: bloques de estilos reutilizables con argumentos (Sass).
- Variables:
$variableen Sass, estáticas en compilación; las custom properties las superan en dinamismo.
// Sass: mixin con argumentos
@mixin boton($color) {
background: $color;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
&:hover { filter: brightness(0.9); }
}
.boton-primario { @include boton(#2563eb); }
.boton-peligro { @include boton(#dc2626); }
// Sass: anidamiento
.tarjeta {
padding: 1rem;
.titulo { font-weight: bold; }
&.destacada { border: 2px solid #2563eb; }
}@container) ha absorbido las features más demandadas. Sass sigue siendo excelente para codebases grandes que ya lo usan.PostCSS, por su parte, no es un lenguaje: es un pipeline de plugins que transforman CSS (autoprefixing, minificación, nesting, soporte de @apply en Tailwind). Casi todo CSS moderno de producción pasa por uno u otro al compilar.
Ejercicios
CSS moderno es un lenguaje de verdad: animaciones con control de tiempo, layouts que responden a su contenedor y sistemas de temas dinámicos, todo sin JavaScript. La clave está en combinar bien las piezas — transiciones para estados, keyframes para secuencias, custom properties para datos — y en animar solo lo que la GPU puede hacer rápido. Con esto ya tienes la base para construir interfaces que se sienten vivas y que rinden.