← Back to DevBytes

Mastering CSS Animations and Keyframes: Tips and Best Practices

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:

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

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles