Saltar al contenido principal
55 min de lectura3 de agosto de 2026
CSSAnimacionesKeyframesCustom PropertiesFrontend

Requisitos previos

  • html-css

Lo que aprenderás

  • Dominar transiciones, transformaciones y keyframes
  • Construir layouts modernos con container queries y :has()
  • Crear temas escalables con custom properties
  • Optimizar animaciones animando solo transform y opacity
  • Escribir CSS mantenible con funciones modernas
Tutorial55 min

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 all es 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);
}
Regla práctica: 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;
  }
}
No elimines solo la transición: las animaciones con @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 o turn).
  • scale(factor) — escalar.
  • skew(x, y) — inclinar.
  • perspective() y funciones 3D como rotateX() o translateZ().
.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); }
}
Las transformaciones 3D requieren un contexto: 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 o infinite.
  • 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;
}
No puedes animar propiedades no animables (como 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.

En la práctica, los componentes de bibliotecas modernas ya usan container queries internamente. Si tu componente no necesita estilos basados en el viewport, prefiere @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);
}
La gran ventaja frente a un preprocesador: las custom properties son dinámicas. Cambiar el valor de una variable (desde JS o desde una media query) re-renderiza todos los usos en caliente, sin recompilar. Eso las convierte en el mecanismo ideal para temas.

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);
Combina custom properties con 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);
}
No pongas 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;
}
Regla de oro del rendimiento de UI: anima siempre 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: $variable en 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; }
}
Si empiezas un proyecto nuevo hoy, pregúntate si lo necesitas. Tailwind, CSS Modules, Lightning CSS o PostCSS cubren la mayoría de casos y el CSS nativo (custom properties, nesting, @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.

Siguiente lección

JavaScript: cero a avanzado

Tutorial completo de JavaScript: fundamentos, funciones, arrays, DOM, asincronía con Promises y async/await, ES6+ y 5 ejercicios prácticos.