CSS te da dos herramientas para el movimiento: transiciones y animaciones @keyframes. La mayoría de los desarrolladores saben que ambas existen pero recurren a la que aprendieron primero — generalmente las transiciones — y luego se preguntan por qué el spinner de carga no hace bucle o por qué la notificación vuelve de golpe a su posición original. La decisión no es complicada una vez que entiendes para qué sirve cada una. Las transiciones son una reacción: algo cambia de estado, el navegador suaviza el cambio. Las animaciones @keyframes son scripts autocontenidos: se ejecutan solos, hacen bucle si quieres, y no necesitan un disparador de estado. Esta guía cubre ambos, las propiedades más importantes, y los detalles de rendimiento que separan las animaciones fluidas a 60fps de las que producen tartamudeo. Si escribes mucho CSS, el Formateador CSS vale la pena guardar en favoritos — limpiará cualquier propiedad abreviada desordenada rápidamente.

Transiciones CSS — El caso simple

Una transición le dice al navegador: "cuando esta propiedad cambie, anima el cambio en el tiempo en lugar de saltar". Eso es todo. El uso más común son los efectos hover — cambios de color, fundidos de opacidad, transformaciones de escala sutiles. La guía de transiciones de MDN cubre la especificación completa, pero las cuatro propiedades que importan son transition-property, transition-duration, transition-timing-function, y transition-delay.

css
/* The shorthand: property duration easing delay */
.btn-primary {
  background-color: #4f46e5;
  color: #fff;
  padding: 0.625rem 1.25rem;
  border-radius: 6px;
  border: none;
  cursor: pointer;
  transition: background-color 200ms ease, transform 150ms ease;
}

.btn-primary:hover {
  background-color: #4338ca;
  transform: translateY(-1px);
}

.btn-primary:active {
  transform: translateY(0);
}

/* Opacity fade — a card coming into view */
.notification-card {
  opacity: 0;
  transition: opacity 300ms ease-in-out;
}

.notification-card.is-visible {
  opacity: 1;
}

Puedes listar múltiples transiciones separadas por comas, cada una con su propia duración y easing — eso es lo que hace el ejemplo del botón anterior. Cada propiedad se anima de forma independiente.

No uses transition: all. Es tentador como solución universal, pero significa que cada cambio de propiedad en ese elemento se anima — incluyendo los que no tenías intención de animar. También hace que el navegador trabaje más en cada frame. Lista las propiedades específicas que quieres animar. La documentación MDN de transition-property explica qué propiedades son animables.

transition-timing-function — El easing explicado

La función de temporización controla la curva de aceleración de la animación — si empieza rápido y se desacelera, se mueve a velocidad constante, o rebota a través de una curva personalizada. Las palabras clave integradas cubren la mayoría de las situaciones.

  • ease — empieza lento, acelera, luego se desacelera cerca del final. El valor por defecto. Bueno para la mayoría del movimiento UI.
  • linear — velocidad constante en todo momento. Bueno para spinners y barras de progreso, malo para la mayoría de otras cosas — parece mecánico.
  • ease-in — empieza lento, termina rápido. Se siente como algo que coge velocidad. Bueno para elementos que salen de la pantalla.
  • ease-out — empieza rápido, termina lento. Se siente natural para elementos que entran en la pantalla (como una notificación que desliza).
  • ease-in-out — lento en ambos extremos. Se ve pulido para elementos que empiezan y se detienen en la vista.
  • cubic-bezier(x1, y1, x2, y2) — define tu propia curva. Herramientas como cubic-bezier.com te permiten construir y previsualizar curvas personalizadas visualmente.
css
/* Custom spring-like easing with cubic-bezier */
.drawer {
  transform: translateX(-100%);
  transition: transform 350ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

.drawer.is-open {
  transform: translateX(0);
}

/* The cubic-bezier values above overshoot slightly (y > 1)
   which gives a satisfying spring effect on open */

@keyframes — Cuando las transiciones no son suficientes

Las transiciones tienen un límite estricto: necesitan dos estados y un disparador. Si quieres que algo haga bucle indefinidamente, se ejecute al cargar la página sin interacción, o anime a través de más de dos pasos, necesitas @keyframes. La referencia MDN @keyframes cubre la sintaxis completa. Defines los keyframes por separado, luego los adjuntas a un elemento con la propiedad animation.

css
/* Define the animation */
@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

/* Attach it to an element */
.spinner {
  width: 24px;
  height: 24px;
  border: 3px solid #e5e7eb;
  border-top-color: #4f46e5;
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}

/* Multi-step — more than two stops */
@keyframes pulse-ring {
  0%   { transform: scale(1);    opacity: 1; }
  50%  { transform: scale(1.15); opacity: 0.7; }
  100% { transform: scale(1);    opacity: 1; }
}

.badge-live {
  animation: pulse-ring 1.5s ease-in-out infinite;
}

El shorthand animation — Las ocho propiedades

El shorthand animation empaqueta ocho propiedades en una sola declaración. No necesitas todas en cada momento, pero saber qué controla cada una te ahorra buscar en la documentación cuando algo no funciona como esperabas.

css
/*
  animation: name | duration | timing-function | delay | iteration-count | direction | fill-mode | play-state
*/
.toast {
  animation: slide-in-right 350ms ease-out 0s 1 normal forwards running;
}

/* More commonly written as just the values you need: */
.toast {
  animation: slide-in-right 350ms ease-out forwards;
}

/* Breaking it out to individual properties for clarity: */
.confetti-piece {
  animation-name: float-down;
  animation-duration: 2s;
  animation-timing-function: ease-in;
  animation-delay: calc(var(--i) * 150ms); /* staggered via CSS custom property */
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: forwards;
  animation-play-state: running;
}

/* Pausing an animation via JavaScript: toggle a class */
.spinner.is-paused {
  animation-play-state: paused;
}

animation-fill-mode — Resolviendo el problema del salto de vuelta

Esta es la propiedad que confunde a todo el mundo en algún momento. Por defecto, cuando una animación termina, el elemento vuelve de golpe a sus estilos pre-animación — como si la animación nunca hubiera ocurrido. Si animas una notificación toast deslizándose desde la derecha, volverá bruscamente fuera de la pantalla en el momento en que la animación termine. animation-fill-mode: forwards resuelve esto manteniendo el elemento en su estado del keyframe final una vez que la animación termina.

css
@keyframes slide-in-right {
  from {
    transform: translateX(110%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

/* Without forwards: toast slides in, then SNAPS back off-screen */
.toast-bad {
  animation: slide-in-right 350ms ease-out;
}

/* With forwards: toast slides in and STAYS in position */
.toast {
  animation: slide-in-right 350ms ease-out forwards;
}

/*
  fill-mode values:
  - none      (default) — no styles applied before or after
  - forwards  — hold the final keyframe after the animation ends
  - backwards — apply the first keyframe during the delay period
  - both      — forwards + backwards combined
*/

Ejemplos reales — Skeleton Loader, Spinner, Toast, Badge pulsante

Aquí tienes cuatro patrones que usarás constantemente. Cada uno demuestra una combinación específica de @keyframes y propiedades de animación.

css
/* --- 1. Skeleton loading shimmer --- */
@keyframes shimmer {
  0%   { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite linear;
  border-radius: 4px;
}

.skeleton-title  { height: 20px; width: 60%; margin-bottom: 12px; }
.skeleton-text   { height: 14px; width: 100%; margin-bottom: 8px; }
.skeleton-text:last-child { width: 80%; }
css
/* --- 2. Spinning loader --- */
@keyframes spin {
  to { transform: rotate(360deg); }
}

.loader-spinner {
  display: inline-block;
  width: 32px;
  height: 32px;
  border: 3px solid rgba(79, 70, 229, 0.2);
  border-top-color: #4f46e5;
  border-radius: 50%;
  animation: spin 600ms linear infinite;
}
css
/* --- 3. Slide-in notification toast --- */
@keyframes toast-enter {
  from {
    transform: translateX(calc(100% + 1.5rem));
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

@keyframes toast-exit {
  from {
    transform: translateX(0);
    opacity: 1;
  }
  to {
    transform: translateX(calc(100% + 1.5rem));
    opacity: 0;
  }
}

.toast {
  position: fixed;
  bottom: 1.5rem;
  right: 1.5rem;
  padding: 0.875rem 1.25rem;
  background: #1f2937;
  color: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
  animation: toast-enter 350ms ease-out forwards;
}

.toast.is-dismissing {
  animation: toast-exit 300ms ease-in forwards;
}
css
/* --- 4. Pulsing notification badge --- */
@keyframes badge-pulse {
  0%, 100% {
    box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
  }
  50% {
    box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
  }
}

.notification-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 20px;
  padding: 0 5px;
  background: #ef4444;
  color: #fff;
  font-size: 11px;
  font-weight: 700;
  border-radius: 999px;
  animation: badge-pulse 2s ease-in-out infinite;
}

Rendimiento — Propiedades compositas GPU vs las que disparan el layout

No todas las propiedades CSS son iguales en cuanto al rendimiento de animación. El pipeline de renderizado del navegador tiene tres etapas que importan aquí: layout (calcular tamaños y posiciones de elementos), paint (rellenar píxeles), y composite (combinar capas en la GPU). Animar una propiedad que dispara el layout significa que el navegador recalcula toda la geometría del documento en cada frame — es costoso, y es lo que causa el tartamudeo. La guía de rendimiento de animaciones de web.dev cubre esto en profundidad.

  • Seguro para animar (composición GPU): transform (translate, scale, rotate) y opacity. Estos se ejecutan completamente en el hilo compositor GPU — nunca disparan layout ni paint.
  • Causa paint (evitar si es posible): color, background-color, border-color, box-shadow. Estos omiten el layout pero disparan un repintado en cada frame. Las transiciones cortas (menos de 300ms) generalmente están bien.
  • Causa layout (nunca animar): width, height, top, left, margin, padding. Cada frame dispara un recálculo completo del layout — tartamudeo garantizado en páginas complejas.
La regla práctica: anima solo con transform y opacity. Si quieres mover algo, usa translate no left/top. Si quieres redimensionar algo, usa scale no width/height. CSS Triggers es una referencia que lista exactamente qué etapas de renderizado dispara cada propiedad.
css
/* Bad — animating width triggers layout on every frame */
.progress-bar-bad {
  transition: width 300ms ease;
}

/* Good — use scaleX transform instead */
.progress-bar {
  transform-origin: left center;
  transition: transform 300ms ease;
}

/* In JS, set: progressBar.style.transform = 'scaleX(0.75)' for 75% */

/* Bad — animating top/left (positions element with layout) */
.tooltip-bad {
  position: absolute;
  top: 0;
  transition: top 200ms ease;
}

/* Good — use translateY instead */
.tooltip {
  position: absolute;
  top: 0;
  transform: translateY(0);
  transition: transform 200ms ease;
}

will-change — Úsalo con moderación

will-change es una pista al navegador de que una propiedad específica está a punto de ser animada, por lo que debería promover el elemento a su propia capa composita de antemano. Esto puede eliminar el breve destello de tartamudeo que a veces ves al inicio de una animación en hardware de baja gama. Pero tiene un coste real: cada capa promovida consume memoria GPU. Si pones will-change: transform en cada elemento animado de tu aplicación, degradarás el rendimiento en la mayoría de los dispositivos — lo contrario de lo que querías.

css
/* Right way — add it just before animation starts, remove after */
.modal-overlay {
  /* Don't set will-change here by default */
}

/* Add via JavaScript only when user triggers modal open */
/* overlay.style.willChange = 'opacity'; */
/* overlay.addEventListener('transitionend', () => { */
/*   overlay.style.willChange = 'auto'; */
/* }); */

/* Acceptable in CSS for elements that animate frequently
   (e.g., a persistent floating action button on scroll) */
.fab {
  will-change: transform; /* OK — this element genuinely animates on scroll */
  transition: transform 200ms ease;
}

/* Don't do this — wastes GPU memory with zero benefit */
.card {
  will-change: transform; /* BAD — card only animates on hover, not constantly */
}

prefers-reduced-motion — La accesibilidad que no puedes saltarte

Una parte significativa de los usuarios tiene trastornos vestibulares u otras condiciones donde el movimiento desencadena incomodidad o náuseas. La media query prefers-reduced-motion te permite respetar la configuración del sistema "reducir movimiento". La directriz WCAG 2.1 2.3.3 cubre este requisito, y la referencia MDN prefers-reduced-motion muestra compatibilidad con navegadores (es universal ahora). El patrón es simple: envuelve tus animaciones en la media query estándar, y proporciona una alternativa sin movimiento dentro de la de movimiento reducido.

css
/* Define animations normally for users who are fine with motion */
@keyframes slide-in-right {
  from { transform: translateX(110%); opacity: 0; }
  to   { transform: translateX(0);    opacity: 1; }
}

.toast {
  animation: slide-in-right 350ms ease-out forwards;
}

.spinner {
  animation: spin 600ms linear infinite;
}

/* Override for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
  .toast {
    /* No sliding — just appear */
    animation: none;
    opacity: 1;
    transform: none;
  }

  .spinner {
    /* Slow it way down or stop it entirely */
    animation-duration: 4s;
  }

  /* Nuclear option — disable ALL animations site-wide */
  /* Use this only if you haven't audited each animation individually */
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

La "opción nuclear" al final de ese snippet es un patrón común para bases de código existentes que no han auditado cada animación. Es mejor que nada, pero auditar cada animación individualmente te da más control — algunas animaciones transmiten estado (una barra de progreso, un spinner de carga) y deben mantenerse, solo ralentizarse.

Transiciones vs @keyframes — La guía de decisión

Si alguna vez no sabes cuál usar, hazte dos preguntas sobre la animación que estás construyendo:

  • ¿Se dispara por un cambio de estado? (hover, focus, toggle de clase, checkbox) → Usa una transición. Es exactamente para lo que están diseñadas las transiciones.
  • ¿Hace bucle indefinidamente? → Usa @keyframes con animation-iteration-count: infinite.
  • ¿Tiene más de dos pasos? (no solo inicio → fin, sino inicio → medio → fin o más) → Usa @keyframes.
  • ¿Necesita ejecutarse al cargar la página, sin interacción del usuario? → Usa @keyframes.
  • ¿El elemento necesita quedarse en su estado final después de terminar? → Usa @keyframes con animation-fill-mode: forwards.
La combinación que confunde a la gente: quieres un efecto hover que desvanezca suavemente un color de fondo (transición), pero también quieres que un badge "nuevo" en el mismo elemento pulse continuamente (@keyframes). Puedes usar ambos en el mismo elemento — controlan propiedades diferentes y no interfieren entre sí.

Conclusión

Las transiciones manejan el movimiento impulsado por estado. @keyframes maneja todo lo demás. Siempre anima transform y opacity para el rendimiento — deja width, height, top, y left fuera. Usa animation-fill-mode: forwards siempre que necesites que el elemento mantenga su estado final. Añade el override prefers-reduced-motion antes de entregar cualquier cosa — es una adición de diez líneas que importa mucho a usuarios reales. Para más información sobre el modelo de composición y qué dispara el layout, los docs de rendimiento de renderizado de web.dev son el recurso más práctico disponible. Una vez que hayas escrito el CSS, pásalo por el Formateador CSS para mantener el shorthand legible, o por el Minificador CSS para eliminar espacios en blanco antes de enviarlo a producción.