Keyframe Animations and Performance
Transitions react to state changes, but keyframe animations run on their own. You define a sequence of states across a timeline, attach that sequence to an element, and the browser handles the playback. This gives you full control over multi-step motion: a spinner that rotates continuously, a notification that bounces in, a skeleton loader that pulses, or a complex entrance sequence involving several properties changing at precise moments.
Keyframes are also where performance decisions become especially consequential. An animation that plays 60 times per second will expose any rendering inefficiency very quickly. The same rules from transitions apply here — and matter even more.
Defining Keyframes with @keyframes
The @keyframes rule defines the animation sequence. You give it a name, then describe the CSS properties at specific points along the timeline. Those points can be percentages from 0% to 100%, or the keywords from and to (which are aliases for 0% and 100%).
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
Any number of percentage stops can be added between from and to:
@keyframes bounce-in {
0% { transform: translateY(-100%); }
60% { transform: translateY(10px); }
80% { transform: translateY(-5px); }
100% { transform: translateY(0); }
}
Multiple stops can share the same declaration when the keyframe values are identical at those points. This is useful for pausing motion mid-sequence:
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
Keyframe names exist in a global namespace. If two @keyframes rules share the same name, the last one in source order wins. Use descriptive, specific names to avoid collisions, especially when working with component-based CSS.
Animation Properties
Once a @keyframes rule is defined, you attach it to an element using the animation properties. Like transitions, each aspect has its own property and a shorthand that combines them.
animation-name
References the name of the @keyframes rule to apply. Multiple animations can be applied to the same element as a comma-separated list.
animation-name: fade-in;
animation-name: fade-in, slide-up; /* Multiple animations */
animation-duration
How long one cycle of the animation takes. Works the same as transition-duration: seconds or milliseconds.
animation-timing-function
The same cubic-bezier and keyword options available for transitions apply here. Importantly, the timing function applies within each keyframe segment, not across the entire animation. A keyframe at 0% with ease-out will ease out as it approaches the next keyframe, not as it approaches 100%.
You can also set different timing functions on individual keyframe stops:
@keyframes complex-move {
0% {
transform: translateX(0);
animation-timing-function: ease-in;
}
50% {
transform: translateX(200px);
animation-timing-function: ease-out;
}
100% {
transform: translateX(100px);
}
}
animation-delay
Waits the specified duration before the animation starts. Negative values work the same as with transitions: the animation begins as if it had already been playing for that amount of time, which is useful for staggering multiple animations without offsetting their perceived start.
animation-iteration-count
How many times the animation plays. Accepts a number or the keyword infinite for continuous looping.
animation-iteration-count: 1; /* Play once (default) */
animation-iteration-count: 3; /* Play three times */
animation-iteration-count: infinite; /* Loop forever */
animation-direction
Controls whether alternating iterations play forward, backward, or alternating between both:
-
normal — Always plays from
0%to100%(default) -
reverse — Always plays from
100%to0% - alternate — First iteration forward, second backward, and so on. Creates smooth back-and-forth looping without a jarring jump at the end.
- alternate-reverse — First iteration backward, then alternating
animation-fill-mode
Defines what happens to the element before and after the animation plays. This is one of the most commonly misunderstood animation properties.
- none — The element returns to its original styles before and after the animation (default)
- forwards — The element retains the styles from the final keyframe after the animation ends. Essential when you want an element to stay in its animated state.
- backwards — The element applies the styles from the first keyframe during the delay period before the animation starts.
-
both — Combines
forwardsandbackwardsbehavior.
/* Element fades in and stays visible after animation completes */
.card {
animation-name: fade-in;
animation-duration: 400ms;
animation-fill-mode: forwards;
}
animation-play-state
Pauses or resumes the animation. Toggling between running and paused via JavaScript or a hover state is a clean way to add interactive control without restarting the animation from the beginning.
.spinner:hover {
animation-play-state: paused;
}
The Shorthand
The animation shorthand follows the order: name, duration, timing-function, delay, iteration-count, direction, fill-mode, play-state.
/* name | duration | easing | delay | iterations | direction | fill-mode */
animation: fade-in 400ms ease-out 0ms 1 normal forwards;
/* More common: omit defaults */
animation: fade-in 400ms ease-out forwards;
/* Multiple animations */
animation:
fade-in 400ms ease-out forwards,
slide-up 500ms ease-out forwards;
Keyframes with Transforms
The same performance principle from transitions applies directly to keyframe animations: use transform and opacity wherever possible. The browser can hand these off entirely to the GPU compositor, which means every frame of the animation bypasses the layout and paint stages entirely.
Any keyframe animation that modifies width, height, top, left, margin, padding, or similar layout properties will trigger reflow on every single frame of the animation. At 60 frames per second, that is 60 layout recalculations per second, which will cause jank on anything but the fastest hardware.
/* Expensive: recalculates layout every frame */
@keyframes move-bad {
from { left: 0; }
to { left: 300px; }
}
/* Performant: compositor only */
@keyframes move-good {
from { transform: translateX(0); }
to { transform: translateX(300px); }
}
The visual result is identical, but the rendering cost is dramatically different. Translate, rotate, scale, and skew are all safe. Everything else should be treated with suspicion and tested with DevTools performance profiling if you choose to use it.
Practical Patterns Using Transforms
Most common animation needs can be satisfied entirely with transform and opacity:
/* Entrance: slide up and fade in */
@keyframes enter {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Continuous spinner */
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Attention pulse */
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.85; }
}
/* Skeleton loader shimmer (uses background-position, acceptable tradeoff) */
@keyframes shimmer {
from { background-position: -200% center; }
to { background-position: 200% center; }
}
Animation Demo
The demo below shows three common keyframe animation patterns. Click any button to replay that animation from scratch.
GPU Acceleration and will-change
When the browser identifies that an element will be composited independently from the rest of the page — usually because it has a transform or opacity animation — it promotes that element to its own compositor layer. This means the GPU can move and blend that layer without the CPU recalculating the entire page layout. The result is hardware-accelerated animation that stays smooth even on complex pages.
This promotion happens automatically for transform and opacity animations in most modern browsers. You can also hint at it explicitly with will-change:
.animated-element {
will-change: transform, opacity;
}
The browser uses this hint to prepare the layer ahead of time, which eliminates the brief stutter that can happen on the very first frame when the promotion happens mid-animation. This matters most for animations triggered by user interaction, where the first frame is the one most likely to be missed.
When Not to Use will-change
Compositor layers consume GPU memory. Every element with will-change or an active transform animation occupies its own texture in GPU memory. Apply it to every element on a page and you will exhaust GPU memory, causing the browser to fall back to software rendering and making everything slower rather than faster.
The correct pattern is to add will-change just before an animation is expected (via JavaScript on a preceding event like mouseenter) and remove it immediately after. For CSS-only workflows, only apply it to elements that genuinely suffer from a visible first-frame stutter and where you have confirmed the problem with DevTools profiling.
/* Acceptable: a card that animates on hover */
.card {
transition: transform 300ms ease-out;
will-change: transform; /* Prep the layer on load */
}
/* Dangerous: promoting every list item regardless of whether they animate */
.list-item {
will-change: transform; /* Could promote hundreds of elements */
}
Do not add will-change as a default optimization. Use the browser's Performance panel to record and identify actual jank before deciding you need it. Premature use creates memory pressure that can make your page perform worse on the devices that need the most help.
Controlling Animations with JavaScript
CSS handles the animation itself, but JavaScript is often needed to trigger, pause, or sequence animations in response to real user actions. The most common patterns are straightforward.
Triggering via Class Toggle
The cleanest approach is to define the animation in CSS on a specific class, then add or remove that class with JavaScript. This keeps animation logic in CSS where it belongs and keeps JavaScript free of style concerns.
.notification {
opacity: 0;
transform: translateY(-10px);
}
.notification.visible {
animation: enter 300ms ease-out forwards;
}
const showNotification = (el) => {
el.classList.add('visible');
};
Restarting an Animation
A class toggle will not restart an animation if the class is already present. The standard technique to force a restart is to remove the class, force a style recalculation by reading a layout property, then re-add the class. Reading offsetWidth or getBoundingClientRect() is the conventional trigger.
const restartAnimation = (el) => {
el.classList.remove('animated');
void el.offsetWidth; /* Forces style recalculation */
el.classList.add('animated');
};
The Web Animations API
For complex sequencing, the Web Animations API gives you JavaScript-level control over animations while keeping them running on the compositor. You can programmatically play, pause, reverse, seek, and chain animations without any CSS class juggling. It is well-supported in modern browsers and a useful tool when CSS alone becomes unwieldy.
const el = document.querySelector('.card');
const animation = el.animate(
[
{ opacity: 0, transform: 'translateY(20px)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{
duration: 400,
easing: 'ease-out',
fill: 'forwards'
}
);
animation.finished.then(() => {
console.log('Animation complete');
});
Accessibility and Reduced Motion
Everything covered in CSS Transitions about prefers-reduced-motion applies here, and the stakes are higher. Keyframe animations tend to be more intense than transitions — they loop, they cover more visual distance, and they often play automatically without any user interaction triggering them. A continuously looping animation that ignores reduced motion preferences is more than a polish issue; it is a real accessibility violation.
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 800ms linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
/* Optionally show a static alternative */
opacity: 0.5;
}
}
For entrance animations specifically, consider whether the content still makes sense if it simply appears without animation. In almost every case it does, which means animation: none under reduced motion is a safe and complete solution.
Automatic looping animations (spinners, loaders, pulsing indicators) should either be stopped entirely under reduced motion or replaced with a static visual that conveys the same information. A loading state can be communicated with opacity or a static icon rather than motion.
The prefers-reduced-motion: no-preference pattern introduced in the transitions reading is especially well-suited to keyframe animations: wrap all your @keyframes rules and animation declarations inside a single @media (prefers-reduced-motion: no-preference) block so reduced-motion users never receive any animation by default.