What Are CSS Animations and Keyframes?
CSS animations allow you to animate transitions of HTML elements from one style configuration to another without relying on JavaScript or Flash. At their core, they consist of two building blocks: the @keyframes at-rule that defines the sequence of style changes, and the animation shorthand (or its constituent longhand properties) that binds the keyframes to a specific element and controls timing, repetition, and behavior.
Keyframes define the "stops" along the animation timeline — points where explicit style declarations are applied. The browser interpolates smoothly between these stops. You can define keyframes using from/to (equivalent to 0% and 100%) or percentage-based offsets for finer-grained control.
Here is the simplest possible animation setup:
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.element {
animation: fadeIn 1s ease-out;
}
In this snippet, @keyframes fadeIn declares two keyframe stops. The .element rule applies the animation using the shorthand animation property — specifying the keyframe name, duration, and easing function. When the page loads (or the element appears), it fades from invisible to fully opaque over one second using an ease-out curve.
Why CSS Animations Matter
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →CSS animations are not just decorative — they serve critical UX and performance roles:
- Hardware-accelerated: Most CSS animations run on the compositor thread, off the main JavaScript thread. This means smooth 60fps motion even under heavy JS workloads, provided you animate only
transformandopacityproperties. - Declarative and lightweight: You describe what should happen, and the browser engine optimizes the rest. No need to schedule
requestAnimationFrameloops or manage interpolation math manually. - Natural feedback loops: Hover states, loading indicators, page transitions, and micro-interactions benefit enormously from motion cues that guide the user's attention and convey state changes intuitively.
- Reduced JavaScript dependence: Simple UI animations (spinners, skeleton screens, accordion reveals) can be built entirely in CSS, keeping your scripts lean and focused on business logic.
- Accessibility-aware: The
prefers-reduced-motionmedia query lets you disable or tone down animations for users who experience motion sickness or vestibular disorders.
Mastering CSS animations gives you a powerful, performant, and accessible tool for crafting polished user interfaces.
Understanding @keyframes in Depth
The @keyframes rule defines a reusable animation timeline. Each keyframe block contains a percentage (or the from/to aliases) followed by CSS property declarations that should hold at that exact moment. The browser interpolates values between adjacent keyframes based on the timing function in use.
Percentage-Based Keyframes
Using percentages gives you precise control over intermediate states:
@keyframes complexPulse {
0% { transform: scale(1); opacity: 1; }
25% { transform: scale(1.1); opacity: 0.8; }
50% { transform: scale(0.95); opacity: 0.9; }
75% { transform: scale(1.05); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
.pulse-element {
animation: complexPulse 2s ease-in-out infinite;
}
Here, five distinct stops create a nuanced pulsing effect. The element scales up, dips slightly below the original size, then settles back — all while opacity subtly shifts. The browser handles the mathematical interpolation between each adjacent pair of percentages.
Multiple Keyframe Sets
You can define as many @keyframes rules as you need in a stylesheet. Each is identified by its name and can be reused across many elements. Names follow the same conventions as CSS identifiers — they are case-sensitive and must not start with a digit.
@keyframes slideInLeft {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideInRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideInTop {
from { transform: translateY(-100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
This modular approach lets you compose animation libraries that can be mixed and matched across components.
Animation Properties Deep Dive
The animation shorthand accepts up to eight constituent properties in a specific order. Understanding each individually is essential for debugging and fine-tuning.
The Eight Animation Properties
animation-name: The identifier matching a@keyframesrule. Usenoneto remove an animation.animation-duration: How long one cycle takes, in seconds (s) or milliseconds (ms). Default is0s, meaning no animation.animation-timing-function: Easing curve for the entire animation. Acceptsease,linear,ease-in,ease-out,ease-in-out,cubic-bezier(),steps(), orstep-*functions.animation-delay: Time to wait before starting. Negative values cause the animation to begin partway through its cycle.animation-iteration-count: Number of repetitions — an integer orinfinite. Default is1.animation-direction: Controls whether the animation plays forward (normal), backward (reverse), or alternates (alternate,alternate-reverse).animation-fill-mode: Determines which styles persist before the animation starts and after it ends. Options:none,forwards,backwards,both.animation-play-state: Lets you pause (paused) and resume (running) an animation via CSS or JavaScript toggling.
The Shorthand Order
The formal shorthand syntax is:
animation: [name] [duration] [timing-function] [delay] [iteration-count] [direction] [fill-mode] [play-state];
A practical example combining several properties:
.card-enter {
animation: slideInLeft 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s both;
}
This reads: apply the slideInLeft keyframes, over 0.6 seconds, with a custom cubic-bezier easing, starting after a 0.2-second delay, and use animation-fill-mode: both to maintain the from styles before start and the to styles after completion.
Per-Keyframe Timing Functions
You can override the global timing function on individual keyframe stops by adding a animation-timing-function declaration inside the keyframe block. This changes the interpolation curve from that keyframe to the next.
@keyframes bounceWithEasing {
0% { transform: translateY(0); animation-timing-function: ease-out; }
50% { transform: translateY(-60px); animation-timing-function: ease-in; }
100% { transform: translateY(0); }
}
.bounce {
animation: bounceWithEasing 1s infinite;
}
Here the ascent uses ease-out (fast start, slow finish), while the descent back to the ground uses ease-in (slow start, fast finish), mimicking real gravity.
Practical Code Examples
Example 1: Spinner / Loading Indicator
A classic use case — an infinite rotating circle. This relies on transform only, ensuring GPU acceleration.
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #333;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
The linear timing function ensures constant angular velocity. The infinite iteration count keeps it spinning indefinitely.
Example 2: Skeleton Screen Shimmer
Skeleton placeholders use a traveling gradient to indicate loading activity:
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
width: 100%;
height: 20px;
border-radius: 4px;
background: linear-gradient(
90deg,
#e0e0e0 25%,
#f5f5f5 50%,
#e0e0e0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
The gradient is twice the width of the element (background-size: 200%) and the animation shifts background-position from -200% to +200%, creating a sweeping highlight.
Example 3: Staggered List Entrance
Animating multiple children sequentially creates a polished reveal effect. Use animation-delay with incremental values:
@keyframes listItemEnter {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.list li {
opacity: 0; /* hidden until animation fires */
animation: listItemEnter 0.4s ease-out forwards;
}
.list li:nth-child(1) { animation-delay: 0.1s; }
.list li:nth-child(2) { animation-delay: 0.2s; }
.list li:nth-child(3) { animation-delay: 0.3s; }
.list li:nth-child(4) { animation-delay: 0.4s; }
.list li:nth-child(5) { animation-delay: 0.5s; }
Each list item waits an additional 0.1s before animating. The forwards fill mode keeps them visible after completion. The initial opacity: 0 prevents a flash of visible content before the delay elapses.
Example 4: Hover-triggered Card Lift
Animations can be triggered via pseudo-classes like :hover. Pairing transition for the out-state and animation for the in-state can yield rich interactions:
@keyframes cardLift {
0% { transform: translateY(0) scale(1); box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
100% { transform: translateY(-8px) scale(1.02); box-shadow: 0 12px 24px rgba(0,0,0,0.2); }
}
.card {
transform: translateY(0) scale(1);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
animation: cardLift 0.3s ease-out forwards;
}
The transition handles the smooth return to the resting state when the mouse leaves, while the animation (with forwards) snaps to the elevated state on hover and holds it.
Example 5: Using steps() for Frame-by-Frame Animation
The steps() timing function jumps between discrete states, ideal for sprite-sheet animations or typewriter effects:
@keyframes typewriter {
from { width: 0; }
to { width: 100%; }
}
.typewriter-text {
display: inline-block;
overflow: hidden;
white-space: nowrap;
border-right: 2px solid black; /* cursor */
animation: typewriter 4s steps(40) 1s forwards;
}
Here steps(40) breaks the animation into 40 equal-duration "ticks," each one revealing roughly one character if the text has ~40 characters. The result is a convincing typewriter effect.
Advanced Techniques
Chaining Multiple Animations
An element can run multiple animations simultaneously by comma-separating them in the animation shorthand or longhand properties:
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleUp {
from { transform: scale(0.8); }
to { transform: scale(1); }
}
.hero-element {
animation:
fadeIn 0.8s ease-out both,
scaleUp 0.6s ease-out 0.2s both;
}
This fades in while scaling up, with the scale starting slightly later (0.2s delay) for a layered feel. Each animation runs independently; properties that don't overlap are merged, while overlapping ones are resolved by the cascade (later animation wins for the same property at the same time).
Animation Composition with animation-composition
The animation-composition property (modern browsers) controls how an animation's effect combines with the element's underlying style. The replace value (default) causes the animation to override the base style completely during playback. The add value lets the animation's transform accumulate on top of the existing transform:
.sliding-element {
transform: translateX(100px); /* base offset */
animation: wobble 2s infinite;
animation-composition: add; /* wobble adds onto the base translation */
}
@keyframes wobble {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-10px); }
75% { transform: translateX(10px); }
}
With add, the element remains at its base position of 100px from the left and wobbles around that point. Without it (or with replace), the animation would override the base transform entirely, potentially causing unwanted jumps.
Orchestrating with CSS Variables and animation-delay
You can use custom properties to dynamically compute delays, creating staggered effects without repetitive CSS rules:
.grid-item {
animation: gridEnter 0.5s ease-out both;
animation-delay: calc(var(--index, 0) * 0.08s);
}
/* In your HTML:
Item 1
Item 2
Item 3
*/
This pattern scales elegantly — you set --index via inline styles (or a preprocessor loop) and the delay is automatically computed.
Reduced Motion Media Query
Always respect user preferences for reduced motion. Use the prefers-reduced-motion media query to disable or simplify animations:
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
}
.list li {
animation: none;
opacity: 1;
transform: none;
}
/* Or tone down: */
.card:hover {
animation: cardLiftSubtle 0.3s ease-out forwards;
}
}
@keyframes cardLiftSubtle {
from { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
to { box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
}
For users who have opted into reduced motion, you can either remove animations entirely or replace them with simpler, non-motion-heavy alternatives like opacity or subtle shadow changes.
Best Practices
1. Animate Only transform and opacity Whenever Possible
These are the only two properties that trigger purely compositor-based animations. Animating properties like width, height, left, top, margin, or padding forces the browser to recalculate layout (reflow) and repaint, which can cause jank, especially on low-end devices. If you need to animate size, use transform: scale() instead of changing width/height. If you need to move something, use transform: translate() instead of left/top/margin.
/* Bad — triggers layout recalculation */
@keyframes badMove {
from { left: 0; }
to { left: 200px; }
}
/* Good — compositor-only */
@keyframes goodMove {
from { transform: translateX(0); }
to { transform: translateX(200px); }
}
2. Keep Keyframe Selectors Minimal
Only include the properties that actually change within a given keyframe rule. Redundant declarations bloat the stylesheet and may cause unexpected inheritance issues.
3. Use will-change Sparingly and Temporarily
The will-change property hints to the browser that an element will be animated, allowing it to pre-allocate GPU layers. However, overusing it can consume excessive GPU memory. Apply it shortly before animation starts and remove it afterward, or scope it to elements that genuinely need it.
.animated-element {
will-change: transform, opacity;
}
4. Prefer animation Over transition for Complex Sequences
Transitions are great for simple A-to-B state changes (like hover effects). For multi-stop sequences, looping, or complex choreography, animation with @keyframes is the right tool. Use transitions for reactive UI feedback and animations for declarative, pre-planned motion.
5. Set Initial States to Prevent Flash of Unstyled Content
Elements with animation delays or those triggered by JavaScript should have their initial styles explicitly set to match the animation's starting keyframe. Use animation-fill-mode: backwards or manual property declarations.
.delayed-fade {
opacity: 0; /* match keyframe start */
animation: fadeIn 0.5s ease-out 0.5s backwards;
}
6. Test on Real Devices
Desktop browsers often handle animations smoothly, but mobile devices with weaker GPUs may struggle. Test your animations on a range of devices. Use browser DevTools to profile rendering performance and look for paint flashing or frame rate drops.
7. Document Your Animation Library
When building a design system, document keyframe names, durations, easing curves, and intended usage. Consistent naming conventions (like fadeIn, slideOutRight, scaleUp) help teams reuse animations predictably.
Common Pitfalls to Avoid
- Forgetting the
@keyframesname must matchanimation-nameexactly: Names are case-sensitive.@keyframes fadeInwon't matchanimation-name: fadein. - Omitting
animation-duration: The default is0s, so the animation won't run at all if you forget to specify a duration. - Animating non-animatable properties: Some CSS values cannot be interpolated (e.g.,
display,positionvalues likestaticvsabsolute, orz-indexchanges). Useopacityandpointer-eventsto simulate hiding, andtransformfor movement. - Overlapping animations competing for the same property: When two animations running on the same element both animate
transform, the last one declared wins. Useanimation-composition: addor nest elements to isolate transforms. - Excessive keyframe stops causing stutter: Densely packed keyframes (e.g., every 1%) can create choppy motion if the browser struggles to interpolate between tiny deltas. Keep keyframe count reasonable.
- Ignoring the
prefers-reduced-motionquery: Failing to accommodate users with motion sensitivity is an accessibility failure and can make your UI unusable for some people.
Conclusion
CSS animations and keyframes give you a declarative, performant, and expressive way to bring interfaces to life. By understanding the full @keyframes syntax, mastering the eight animation properties, and sticking to compositor-friendly transform and opacity changes, you can craft fluid 60fps motion that works across devices. Combine these fundamentals with advanced techniques like multiple animation chaining, animation-composition, CSS variable-driven delays, and the prefers-reduced-motion media query to build animations that are not only beautiful but also resilient, accessible, and maintainable. Start small, profile often, and let the browser engine do the heavy lifting — that's the true power of mastering CSS animations.