Tema
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:
@keyframes: el "guion" con los fotogramas clave (qué pasa al 0 %, al 50 %, al 100 %).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ón | Animación |
|---|---|
| Necesita un cambio de estado (hover, clase) | Puede empezar sola al cargar |
| Sólo inicio y fin | Tantos pasos como quieras |
| Se ejecuta una vez por cambio | Puede repetirse infinitamente |
| Ideal para interacciones | Ideal 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 eldelay, 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):
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
- Crea un
@keyframesque haga aparecer un elemento desde la izquierda con opacidad, y aplícalo al título del hero. - Haz un spinner de 32px con borde verde que gire indefinidamente.
- Haz que un botón "Comprar" pulse suavemente (escala 1 → 1.05 → 1) cada 2 segundos.
- Crea una animación de "sacudida" (izquierda-derecha) para un campo con error.
- Añade el bloque de
prefers-reduced-motiona 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
@keyframesdefine los pasos;animationlos aplica con duración, retraso, repeticiones y dirección.animation-fill-mode: bothevita saltos en animaciones con retraso.alternatecrea idas y vueltas;steps()crea movimientos por pasos.- Escalona entradas con
animation-delay(y variables CSS). - Anima al hacer scroll con
IntersectionObservero conanimation-timeline: view(). - Respeta siempre
prefers-reduced-motion.
Siguiente: Sombras, degradados y filtros.