Skip to content

Animaciones con @keyframes ​

Las transiciones animan de un estado A a un estado B cuando algo cambia. Pero ¿y si quieres un spinner que gire sin parar, un elemento que entre con rebote al cargar la página o un efecto de varios pasos? Para eso están las animaciones con @keyframes. En esta lección crearás loaders, entradas al hacer scroll y aprenderás a animar de forma accesible.

Concepto ​

Una animación tiene dos partes:

  1. @keyframes: el "guion" con los fotogramas clave (qué pasa al 0 %, al 50 %, al 100 %).
  2. animation: aplica ese guion a un elemento y define duración, repeticiones, dirección...

Analogía

Los @keyframes son como los dibujos clave de un animador de caricaturas: él dibuja las poses principales y el resto de fotogramas se "rellenan" en medio. El navegador hace ese relleno por ti.

Transición vs. animación ​

TransiciónAnimación
Necesita un cambio de estado (hover, clase)Puede empezar sola al cargar
Sólo inicio y finTantos pasos como quieras
Se ejecuta una vez por cambioPuede repetirse infinitamente
Ideal para interaccionesIdeal para loaders, entradas, efectos continuos

Sintaxis ​

css
/* 1. Define el guion */
@keyframes aparecer {
  from {                        /* = 0% */
    opacity: 0;
    transform: translateY(20px);
  }
  to {                          /* = 100% */
    opacity: 1;
    transform: translateY(0);
  }
}

/* 2. Aplícalo */
.hero__titulo {
  animation: aparecer 0.6s ease-out;
}

Con varios pasos:

css
@keyframes latido {
  0%, 100% { transform: scale(1); }
  15%      { transform: scale(1.25); }
  30%      { transform: scale(1); }
  45%      { transform: scale(1.15); }
}

.corazon {
  animation: latido 1.2s ease-in-out infinite;
}

Propiedades de animation ​

css
.elemento {
  animation-name: aparecer;              /* nombre del @keyframes */
  animation-duration: 0.8s;              /* duración de un ciclo */
  animation-timing-function: ease-out;   /* aceleración (igual que en transiciones) */
  animation-delay: 0.2s;                 /* espera antes de empezar (puede ser negativa) */
  animation-iteration-count: infinite;   /* número de repeticiones o infinite */
  animation-direction: alternate;        /* normal | reverse | alternate | alternate-reverse */
  animation-fill-mode: both;             /* qué estilos conservar antes/después */
  animation-play-state: running;         /* running | paused */
}

/* Atajo: nombre duración función retraso repeticiones dirección relleno */
.elemento {
  animation: aparecer 0.8s ease-out 0.2s 1 normal both;
}

/* Varias animaciones a la vez */
.cohete {
  animation:
    aparecer 0.6s ease-out both,
    flotar 3s ease-in-out 0.6s infinite;
}

animation-fill-mode (el que más confunde) ​

  • none (por defecto): antes y después de la animación, el elemento tiene sus estilos normales.
  • forwards: al terminar, conserva los estilos del último fotograma.
  • backwards: durante el delay, aplica ya los estilos del primer fotograma.
  • both: ambas cosas. Es el que casi siempre quieres en animaciones de entrada.

Error común

Una animación de entrada con delay y sin fill-mode: el elemento aparece visible, desaparece de golpe cuando empieza la animación y luego vuelve a aparecer. Solución: animation-fill-mode: both (o backwards).

animation-direction: alternate ​

Con alternate, los ciclos pares van al revés. Perfecto para movimientos de ida y vuelta sin escribir el regreso:

css
@keyframes balanceo {
  from { rotate: -8deg; }
  to   { rotate: 8deg; }
}

.campana {
  animation: balanceo 0.5s ease-in-out infinite alternate;
}

Loaders y efectos ​

Seis animaciones clásicas en vivo (puedes pausarlas):

Loaders con @keyframes

Spinner ​

html
<div class="spinner" role="status" aria-label="Cargando"></div>
css
@keyframes girar {
  to { rotate: 1turn; }        /* 1turn = 360deg; "from" se omite: parte del estado actual */
}

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #e2e8f0;
  border-top-color: #7c3aed;   /* sólo un lado coloreado */
  border-radius: 50%;
  animation: girar 0.8s linear infinite;
}

Puntos escalonados ​

html
<div class="puntos" role="status" aria-label="Cargando">
  <span></span><span></span><span></span>
</div>
css
@keyframes rebotar {
  0%, 80%, 100% { scale: 0; }
  40%           { scale: 1; }
}

.puntos { display: flex; gap: 6px; }

.puntos span {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #7c3aed;
  animation: rebotar 1.2s ease-in-out infinite;
}

/* El retraso escalonado crea el efecto de "ola" */
.puntos span:nth-child(2) { animation-delay: 0.15s; }
.puntos span:nth-child(3) { animation-delay: 0.3s; }

Skeleton (carga de contenido) ​

En lugar de un spinner, muestra la "silueta" del contenido mientras carga. Da sensación de mayor velocidad:

css
@keyframes brillo {
  to { background-position: -200% 0; }
}

.skeleton {
  border-radius: 6px;
  background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
  background-size: 200% 100%;
  animation: brillo 1.4s linear infinite;
}

.skeleton--texto  { height: 1em; margin-bottom: 0.5em; }
.skeleton--avatar { width: 48px; height: 48px; border-radius: 50%; }

Efecto máquina de escribir ​

css
@keyframes escribir { from { width: 0; } }
@keyframes cursor   { 50% { border-color: transparent; } }

.maquina {
  width: 22ch;                           /* número de caracteres del texto */
  white-space: nowrap;
  overflow: hidden;
  border-right: 3px solid;
  font-family: monospace;
  animation:
    escribir 2s steps(22) both,          /* steps: avanza carácter por carácter */
    cursor 0.7s step-end infinite;
}

Animaciones de entrada ​

Al cargar la página, escalonadas ​

css
@keyframes subir {
  from { opacity: 0; translate: 0 30px; }
}

.hero > * {
  animation: subir 0.7s ease-out both;
}
.hero > :nth-child(2) { animation-delay: 0.15s; }
.hero > :nth-child(3) { animation-delay: 0.3s; }

Un truco elegante es controlar el retraso con una variable desde el HTML:

html
<div class="servicio" style="--i: 0">...</div>
<div class="servicio" style="--i: 1">...</div>
<div class="servicio" style="--i: 2">...</div>
css
.servicio {
  animation: subir 0.6s ease-out both;
  animation-delay: calc(var(--i) * 120ms);
}

Al hacer scroll (con JavaScript) ​

La técnica más compatible: IntersectionObserver añade una clase cuando el elemento entra en pantalla.

html
<section class="revelar">...</section>
css
.revelar {
  opacity: 0;
  translate: 0 40px;
  transition: opacity 0.6s ease-out, translate 0.6s ease-out;
}

.revelar.visible {
  opacity: 1;
  translate: 0 0;
}
js
const observador = new IntersectionObserver((entradas) => {
  entradas.forEach((entrada) => {
    if (entrada.isIntersecting) {
      entrada.target.classList.add('visible');
      observador.unobserve(entrada.target);   // anima sólo una vez
    }
  });
}, { threshold: 0.15 });                       // cuando se ve el 15 %

document.querySelectorAll('.revelar').forEach((el) => observador.observe(el));

Contenido invisible sin JavaScript

Si el script falla, todo lo que tiene .revelar se queda en opacity: 0. Una alternativa robusta es añadir con JS una clase js al html y escribir el estado oculto como .js .revelar { opacity: 0; }.

Al hacer scroll (sólo CSS): scroll-driven animations ​

Los navegadores modernos permiten vincular una animación al scroll sin JavaScript con animation-timeline:

css
@keyframes revelar {
  from { opacity: 0; translate: 0 40px; }
  to   { opacity: 1; translate: 0 0; }
}

.revelar-css {
  animation: revelar linear both;
  animation-timeline: view();             /* progreso según su visibilidad en pantalla */
  animation-range: entry 0% entry 60%;    /* se completa al entrar el 60 % */
}

/* Barra de progreso de lectura */
@keyframes progreso { from { scale: 0 1; } }

.barra-lectura {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 4px;
  background: #7c3aed;
  transform-origin: left;
  animation: progreso linear;
  animation-timeline: scroll();           /* progreso según el scroll de la página */
}

Funciona en Chrome, Edge y Safari recientes; en navegadores sin soporte el elemento simplemente aparece sin animación. Envuélvelo en @supports (animation-timeline: view()) si necesitas un estado alternativo.

Accesibilidad: prefers-reduced-motion ​

Algunas personas sufren mareos, migrañas o crisis con el movimiento en pantalla y activan "reducir movimiento" en su sistema. Respétalo siempre:

css
/* Opción 1: desactivar globalmente */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Opción 2 (más fina): animar sólo si el usuario no pidió reducir */
@media (prefers-reduced-motion: no-preference) {
  .hero__titulo {
    animation: subir 0.7s ease-out both;
  }
}

Buena práctica

Las animaciones deben comunicar (algo cargó, algo cambió, dónde mirar), no sólo decorar. Evita animaciones infinitas cerca de texto que hay que leer, y nunca hagas parpadear algo más de 3 veces por segundo.

Pausar y controlar con JavaScript ​

css
.carrusel { animation: deslizar 20s linear infinite; }
.carrusel:hover { animation-play-state: paused; }   /* pausa al pasar el mouse */
js
const el = document.querySelector('.notificacion');

// Reiniciar una animación: quitar la clase, forzar reflow y volver a añadirla
el.classList.remove('sacudir');
void el.offsetWidth;
el.classList.add('sacudir');

// Saber cuándo terminó
el.addEventListener('animationend', () => el.remove());

Ejercicios ​

  1. Crea un @keyframes que haga aparecer un elemento desde la izquierda con opacidad, y aplícalo al título del hero.
  2. Haz un spinner de 32px con borde verde que gire indefinidamente.
  3. Haz que un botón "Comprar" pulse suavemente (escala 1 → 1.05 → 1) cada 2 segundos.
  4. Crea una animación de "sacudida" (izquierda-derecha) para un campo con error.
  5. Añade el bloque de prefers-reduced-motion a tu proyecto.
Ver solución
css
/* 1 */
@keyframes desdeIzquierda {
  from { opacity: 0; transform: translateX(-40px); }
  to   { opacity: 1; transform: translateX(0); }
}
.hero h1 { animation: desdeIzquierda 0.7s ease-out both; }

/* 2 */
@keyframes girar { to { rotate: 1turn; } }
.spinner {
  width: 32px;
  height: 32px;
  border: 3px solid #d1fae5;
  border-top-color: #10b981;
  border-radius: 50%;
  animation: girar 0.8s linear infinite;
}

/* 3 */
@keyframes pulsar {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.05); }
}
.boton-comprar { animation: pulsar 2s ease-in-out infinite; }

/* 4 */
@keyframes sacudir {
  0%, 100% { translate: 0; }
  20%, 60% { translate: -6px; }
  40%, 80% { translate: 6px; }
}
.campo--error { animation: sacudir 0.4s ease-in-out; }

/* 5 */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

Resumen ​

  • @keyframes define los pasos; animation los aplica con duración, retraso, repeticiones y dirección.
  • animation-fill-mode: both evita saltos en animaciones con retraso.
  • alternate crea idas y vueltas; steps() crea movimientos por pasos.
  • Escalona entradas con animation-delay (y variables CSS).
  • Anima al hacer scroll con IntersectionObserver o con animation-timeline: view().
  • Respeta siempre prefers-reduced-motion.

Siguiente: Sombras, degradados y filtros.

Academia Web — aprende, copia y construye.