Animaciones CSS con @keyframes
En esta lección
Con transition puedes animar el paso de un estado a otro cuando algo cambia, por ejemplo al pasar el ratón. Pero hay movimientos que no encajan ahí: un icono de carga que gira sin parar, un aviso que late para llamar la atención, una lista que aparece elemento a elemento al abrir la página. Para eso están las animaciones con fotogramas clave. En esta lección aprenderás a definirlas con @keyframes, a controlarlas con las propiedades animation-*, a respetar a quien prefiere menos movimiento y a que vayan fluidas.
Transición o animación
transition | animation | |
|---|---|---|
| Cuándo empieza | Cuando cambia una propiedad (:hover, una clase nueva…) | Sola, en cuanto se aplica |
| Estados | Dos: el de antes y el de después | Todos los fotogramas que quieras |
| Repetición | No | Sí, incluso infinita |
| Uso típico | Botones, menús, cambios de estado | Cargadores, entradas, avisos |
Si solo necesitas ir de A a B como respuesta a algo, una transición es más sencilla. Si necesitas más pasos, repetición o que empiece sola, usa una animación.
Definir los fotogramas: @keyframes
Un fotograma clave (keyframe) describe cómo debe estar el elemento en un momento concreto de la animación. El navegador calcula los estados intermedios. Se define con @keyframes y un nombre que tú eliges:
@keyframes aparecer {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
from es el inicio (equivale a 0%) y to, el final (100%). Para más pasos, usa porcentajes de la duración total:
@keyframes semaforo {
0% { background: #22c55e; }
45% { background: #22c55e; } /* verde casi la mitad del tiempo */
50% { background: #f59e0b; }
60% { background: #ef4444; }
100% { background: #ef4444; }
}
Varios porcentajes pueden compartir estilos, separados por comas:
@keyframes latido {
0%, 50%, 100% { scale: 1; }
25%, 75% { scale: 1.2; }
}
Si omites from o to, el navegador usa los valores que el elemento ya tiene. Por eso basta con escribir el estado de inicio para una entrada:
@keyframes entrar-desde-abajo {
from { opacity: 0; translate: 0 30px; } /* termina en su estado normal */
}
Aplicar la animación
Los fotogramas solo son la receta. Para usarla, el elemento necesita al menos dos propiedades: el nombre y la duración.
.aviso {
animation-name: aparecer;
animation-duration: 0.4s;
}
Sin duración, la animación dura 0 segundos y no se ve nada. Vamos con el resto de propiedades.
animation-timing-function: la curva
Controla cómo se reparte la velocidad. Los valores son los mismos que en las transiciones:
ease(por defecto): empieza suave, acelera y frena al final.linear: velocidad constante. Ideal para algo que gira sin parar.ease-out: rápido al principio y frena. Natural para elementos que entran.ease-in: lento al principio y acelera. Para elementos que salen.ease-in-out: suave en los dos extremos. Para movimientos de ida y vuelta.cubic-bezier(...): una curva a medida. Las DevTools tienen un editor visual para crearla.steps(n): sin transición, a saltos, ennpasos. Sirve para relojes, efectos de máquina de escribir o animaciones de sprites.
La curva se aplica entre cada par de fotogramas, no a la animación entera.
animation-delay: el retraso
Cuánto espera antes de empezar. Con un retraso negativo, la animación empieza ya avanzada: animation-delay: -0.5s en una animación de 2s arranca por el 25 %.
Combinado con variables, permite que los elementos de una lista aparezcan uno tras otro (un efecto llamado escalonado):
.lista li {
animation: aparecer 0.5s ease-out both;
animation-delay: calc(var(--i) * 100ms);
}
<li style="--i: 0">Lunes</li>
<li style="--i: 1">Martes</li>
<li style="--i: 2">Miércoles</li>
animation-iteration-count: repeticiones
Un número (3, incluso 2.5) o infinite para que no pare nunca. Por defecto, 1.
animation-direction: el sentido
| Valor | Qué hace |
|---|---|
normal | Siempre de 0 % a 100 % (por defecto) |
reverse | Siempre de 100 % a 0 % |
alternate | Ida y vuelta: 0→100, 100→0, 0→100… |
alternate-reverse | Ida y vuelta empezando por el final |
alternate evita el salto brusco al volver al principio. Una caja que flota arriba y abajo:
@keyframes flotar {
to { translate: 0 -10px; }
}
.globo { animation: flotar 1.5s ease-in-out infinite alternate; }
animation-fill-mode: antes y después
Por defecto, una animación solo afecta al elemento mientras se está ejecutando. Al terminar, el elemento vuelve de golpe a sus estilos normales. Y durante el retraso inicial, se ve con sus estilos normales, no con los del primer fotograma.
| Valor | Qué hace |
|---|---|
none | Sin efecto fuera de la animación (por defecto) |
forwards | Al terminar, se queda como en el último fotograma |
backwards | Durante el retraso, ya se muestra como el primer fotograma |
both | Las dos cosas |
El ejemplo típico del problema: una lista escalonada con opacity: 0 en el from. Sin backwards, cada elemento se ve un instante, desaparece al empezar su animación y vuelve a aparecer. Con both (o backwards), espera invisible hasta su turno. Por eso las entradas suelen llevar both.
animation-play-state: pausar
running (por defecto) o paused. Permite detener una animación sin reiniciarla:
.carrusel { animation: desplazar 20s linear infinite; }
.carrusel:hover { animation-play-state: paused; } /* se para al pasar el ratón */
El atajo animation
Todo en una línea:
.cargador { animation: girar 1s linear infinite; }
.aviso { animation: aparecer 0.4s ease-out 0.2s both; }
/* nombre duración curva retraso fill-mode */
El orden es bastante libre, con una regla: el primer tiempo es la duración y el segundo, el retraso. Pon nombres que no coincidan con palabras clave (infinite, ease, none…) para que el navegador no se confunda.
Puedes aplicar varias animaciones a la vez separándolas con comas:
.estrella {
animation:
girar 4s linear infinite,
latido 1s ease-in-out infinite;
}
Si las dos animan transform, la segunda pisa a la primera. Usa propiedades distintas (rotate en una y scale en otra) para que se sumen.
Tres ejemplos útiles
Cargador que gira
.cargador {
width: 32px;
aspect-ratio: 1;
border: 4px solid #dde3ee;
border-top-color: #2d6cdf; /* solo un lado de color: se ve el giro */
border-radius: 50%;
animation: girar 0.8s linear infinite;
}
@keyframes girar {
to { rotate: 1turn; }
}
Ponle role="status" y un aria-label="Cargando" en el HTML para que los lectores de pantalla anuncien qué está pasando.
Pulso de notificación
@keyframes pulso {
0% { box-shadow: 0 0 0 0 rgb(230 57 70 / 0.6); }
100% { box-shadow: 0 0 0 12px rgb(230 57 70 / 0); }
}
.punto-rojo { animation: pulso 1.5s ease-out infinite; }
Un anillo que crece y se desvanece alrededor del punto.
Esqueleto de carga
Los rectángulos grises con un brillo que avanza, mientras llegan los datos:
.esqueleto {
height: 16px;
border-radius: 4px;
background: linear-gradient(90deg, #e5e7eb 30%, #f3f4f6 50%, #e5e7eb 70%);
background-size: 300% 100%;
animation: brillo 1.4s linear infinite;
}
@keyframes brillo {
from { background-position: 100% 0; }
to { background-position: 0 0; }
}
El degradado es tres veces más ancho que la caja; al mover su posición, la franja clara cruza de lado a lado. Tienes más sobre esto en degradados.
Respetar prefers-reduced-motion
Algunas personas sufren mareos, náuseas o pérdida de concentración con el movimiento en pantalla. En su sistema operativo pueden activar la opción de reducir el movimiento, y el navegador lo comunica con la media query prefers-reduced-motion.
La forma más segura es aplicar las animaciones solo si la persona no ha pedido lo contrario:
@media (prefers-reduced-motion: no-preference) {
.tarjeta { animation: aparecer 0.5s ease-out both; }
}
O, si ya tienes animaciones por todo el CSS, añadir una red de seguridad general al final:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Se usa 0.01ms y no none porque así la animación termina al instante pero sigue existiendo: el elemento llega a su estado final y el JavaScript que espera a que acabe la animación sigue funcionando.
Reducir no significa eliminar todo: un cargador puede seguir girando más despacio, porque indica que algo está pasando. Quita los desplazamientos grandes, los rebotes y el parallax. Tienes más en CSS accesible.
Rendimiento: anima transform y opacity
Para mostrar una página, el navegador calcula la posición de cada caja, la pinta y luego compone las capas. Si animas width, height, top, left o margin, tiene que recalcular posiciones y repintar en cada fotograma (60 veces por segundo), y en un móvil modesto la animación va a tirones.
transform (y las propiedades translate, rotate, scale) y opacity se resuelven en la última fase, a menudo con la tarjeta gráfica:
/* Mal: recalcula el diseño en cada fotograma */
@keyframes deslizar-mal { from { left: -300px; } to { left: 0; } }
/* Bien: solo se recompone */
@keyframes deslizar { from { translate: -100% 0; } to { translate: 0 0; } }
Existe la propiedad will-change: transform, que avisa al navegador de que algo se va a animar. Úsala solo si notas tirones y en pocos elementos: cada uno consume memoria de la tarjeta gráfica.
Novedad: animaciones ligadas al scroll
Normalmente, una animación avanza con el tiempo. Con animation-timeline puede avanzar con el scroll: al bajar por la página, la animación progresa; al subir, retrocede. Sin una línea de JavaScript.
Una barra de progreso de lectura en lo alto de la página:
.progreso-lectura {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: #2d6cdf;
transform-origin: left;
scale: 0 1;
}
@keyframes crecer {
to { scale: 1 1; }
}
@supports (animation-timeline: scroll()) {
.progreso-lectura {
animation: crecer linear both;
animation-timeline: scroll(root); /* avanza con el scroll de la página */
}
}
Y elementos que aparecen al entrar en pantalla, con view():
@supports (animation-timeline: view()) {
.seccion {
animation: aparecer linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%; /* desde que asoma hasta que ocupa un 30 % */
}
}
scroll(): el progreso depende de cuánto has bajado en un contenedor con scroll (rootes la página).view(): el progreso depende de la posición del propio elemento mientras cruza la pantalla.animation-range: qué tramo de ese recorrido ocupa la animación.
Dos detalles: animation-timeline debe ir después del atajo animation, porque el atajo la reinicia; y la duración no se usa, la marca el scroll. Funciona en Chrome, Edge y Safari recientes; en Firefox aún no está activado por defecto. Por eso va dentro de @supports: sin soporte, el elemento se ve normal y no se pierde nada. Lo verás con más calma en la lección de scroll.
Errores frecuentes
- No poner duración.
animation: girar infiniteno se ve: la duración por defecto es 0. - El elemento vuelve de golpe al terminar. Falta
animation-fill-mode: forwards(oboth). - Parpadeo antes de una entrada con retraso. Falta
backwardsoboth: durante el retraso se ve el estado normal. - Escribir mal el nombre. Si el nombre de
animationno coincide exactamente con el de@keyframes, no pasa nada y no hay error. Revisa mayúsculas y guiones. - Animar
width,topomargin. Va a tirones. Animatransformyopacity. - Dos animaciones que pisan el mismo
transform. Solo se ve una. Repártelas entretranslate,rotateyscale. - Olvidar
prefers-reduced-motion. Una animación infinita sin alternativa puede hacer inutilizable tu web para algunas personas. - Animaciones que parpadean rápido. Más de tres destellos por segundo pueden provocar crisis epilépticas. Evítalos siempre.
Resumen
| Propiedad | Qué controla | Valores habituales |
|---|---|---|
@keyframes nombre | Los fotogramas de la animación | from, to, 0%…100% |
animation-name | Qué animación usar | El nombre de @keyframes |
animation-duration | Cuánto dura una vuelta | 0.4s, 800ms |
animation-timing-function | La curva de velocidad | ease-out, linear, steps(4) |
animation-delay | Espera antes de empezar | 0.2s, negativo para empezar avanzada |
animation-iteration-count | Repeticiones | 1, 3, infinite |
animation-direction | Sentido | normal, alternate |
animation-fill-mode | Estilos antes y después | forwards, both |
animation-play-state | Pausar | running, paused |
animation | Atajo de todo lo anterior | girar 1s linear infinite |
prefers-reduced-motion | Respetar a quien quiere menos movimiento | reduce, no-preference |
animation-timeline | Ligar la animación al scroll | scroll(), view() |
Ya sabes animar elementos. Ahora vamos a ver cómo controlar el desplazamiento de la página en sí: sigue con scroll.
Pruébalo tú
Cambia el código y pulsa Ejecutar (o Ctrl + Enter).
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.