CSS animations let you transition an element between multiple states over time, without JavaScript. They are smoother than JS-driven animations for visual effects, better for performance when done correctly, and supported in every modern browser.

This guide covers how CSS animations work from the ground up: the @keyframes rule, every animation property, timing functions, common ready-to-use patterns, and the performance rules you must follow.

How CSS animation works

A CSS animation has two parts that work together:

  • @keyframes: defines what changes happen and when during the animation
  • animation properties: applied to the element to tell it which keyframes to use, how long to run, and how to behave
/* Step 1: Define the keyframes */
@keyframes fadeIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* Step 2: Apply to an element */
.modal {
  animation: fadeIn 0.3s ease forwards;
}

@keyframes syntax

Inside @keyframes, you use percentage values to define the state of the element at each point in the animation. from is an alias for 0% and to is an alias for 100%.

@keyframes slideUp {
  0%   { transform: translateY(20px); opacity: 0; }
  100% { transform: translateY(0);    opacity: 1; }
}

/* Multiple stops */
@keyframes bounce {
  0%   { transform: translateY(0); }
  30%  { transform: translateY(-20px); }
  60%  { transform: translateY(-10px); }
  80%  { transform: translateY(-4px); }
  100% { transform: translateY(0); }
}

/* Same styles at multiple stops */
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.4; }
}

All animation properties

PropertyWhat it doesExample value
animation-nameWhich @keyframes to usefadeIn
animation-durationHow long one cycle takes0.3s
animation-timing-functionSpeed curve of the animationease, linear
animation-delayWait before starting0.2s
animation-iteration-countHow many times to repeat1, infinite
animation-directionForward, reverse, or alternatenormal, alternate
animation-fill-modeState before/after animation runsforwards, both
animation-play-statePause or run the animationrunning, paused

The shorthand

All properties can be written in one line. The order that matters: duration must come before delay.

/* name | duration | easing | delay | iterations | direction | fill-mode */
animation: slideUp 0.4s ease-out 0.1s 1 normal forwards;

/* Minimal: name and duration are required */
animation: fadeIn 0.3s;

/* Multiple animations separated by commas */
animation: fadeIn 0.3s ease, slideUp 0.4s ease-out;

Timing functions explained

The timing function controls how the animation accelerates and decelerates through its duration.

ValueBehaviourBest for
easeSlow start, fast middle, slow endMost UI animations (default)
linearConstant speed throughoutSpinning loaders, progress bars
ease-inSlow start, fast endElements leaving the screen
ease-outFast start, slow endElements entering the screen
ease-in-outSlow start and endRepositioning elements
cubic-bezier()Custom curve with 4 control pointsBrand-specific motion feel
steps(n)Jumps in discrete stepsSprite animations, typewriter effect
/* Custom cubic-bezier: use cubic-bezier.com to generate */
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); /* springy overshoot */

/* Steps: typewriter effect */
@keyframes typing {
  from { width: 0; }
  to   { width: 100%; }
}
.typewriter {
  overflow: hidden;
  white-space: nowrap;
  animation: typing 2s steps(30) forwards;
}

fill-mode: the most misunderstood property

By default, an element snaps back to its original style when an animation ends. animation-fill-mode controls what happens before and after.

/* none (default): element returns to original state after animation */
animation-fill-mode: none;

/* forwards: element keeps the final keyframe state */
animation-fill-mode: forwards;

/* backwards: applies the first keyframe during the delay period */
animation-fill-mode: backwards;

/* both: applies backwards before and forwards after */
animation-fill-mode: both;

Most of the time you want forwards or both. Using none causes a visible snap-back at the end of the animation, which looks broken on entrance effects like fade-in or slide-up.

Common ready-to-use patterns

Fade in on load

@keyframes fadeIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}
.page-content {
  animation: fadeIn 0.4s ease both;
}

Slide up entrance

@keyframes slideUp {
  from { transform: translateY(16px); opacity: 0; }
  to   { transform: translateY(0);    opacity: 1; }
}
.card {
  animation: slideUp 0.35s ease-out both;
}

Infinite spinning loader

@keyframes spin {
  to { transform: rotate(360deg); }
}
.spinner {
  width: 24px;
  height: 24px;
  border: 3px solid #2d3148;
  border-top-color: #7c6af7;
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}

Pulsing skeleton loader

@keyframes shimmer {
  0%   { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}
.skeleton {
  background: linear-gradient(90deg, #1a1d27 25%, #2d3148 50%, #1a1d27 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}

Staggered list entrance

@keyframes fadeSlide {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: translateY(0); }
}
.list-item {
  animation: fadeSlide 0.3s ease both;
}
/* Delay each item progressively */
.list-item:nth-child(1) { animation-delay: 0s; }
.list-item:nth-child(2) { animation-delay: 0.05s; }
.list-item:nth-child(3) { animation-delay: 0.1s; }
.list-item:nth-child(4) { animation-delay: 0.15s; }

Pulse / notification dot

A soft, repeating scale-and-fade pulse is the standard way to draw the eye to a notification badge, a live indicator, or a "new" tag without being as jarring as a shake.

@keyframes pulseDot {
  0%   { box-shadow: 0 0 0 0 rgba(124, 106, 247, 0.5); }
  70%  { box-shadow: 0 0 0 8px rgba(124, 106, 247, 0); }
  100% { box-shadow: 0 0 0 0 rgba(124, 106, 247, 0); }
}
.notification-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #7c6af7;
  animation: pulseDot 1.6s ease-out infinite;
}

Animating box-shadow is cheaper than it looks here because the shadow ring never changes size relative to layout, only its color and spread. For a pulsing scale effect instead of a ring, animate transform: scale() and opacity together, which stays on the fast GPU path described in the performance section below.

Image hover zoom

A contained zoom on hover is one of the most common effects requested for product cards, galleries, and thumbnails. The trick is clipping the zoom with overflow: hidden on a wrapper so the image never spills past its container.

.image-card {
  overflow: hidden;
  border-radius: 10px;
}
.image-card img {
  display: block;
  width: 100%;
  transition: transform 0.4s ease;
}
.image-card:hover img {
  transform: scale(1.08);
}

This particular effect is a transition, not a keyframe animation, since it only moves between two states on hover. If you want the image to animate automatically on page load instead (a reveal effect), pair it with the fadeSlide keyframes from the staggered entrance pattern above and swap :hover for a class applied by JavaScript or an @starting-style block.

Animated hamburger menu icon

Three stacked bars morphing into an X on click is one of the most searched-for UI animations on the web. It is driven entirely by transform, so it stays smooth even on low-end mobile devices.

.hamburger {
  width: 28px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  cursor: pointer;
}
.hamburger span {
  height: 2px;
  background: #e2e8f0;
  transform-origin: center;
  transition: transform 0.3s ease, opacity 0.2s ease;
}
/* Toggled with a .open class via JS */
.hamburger.open span:nth-child(1) { transform: translateY(8px) rotate(45deg); }
.hamburger.open span:nth-child(2) { opacity: 0; }
.hamburger.open span:nth-child(3) { transform: translateY(-8px) rotate(-45deg); }

Like the image zoom above, this is a transition rather than a @keyframes animation because it only needs two states: closed and open. The span:nth-child(2) middle bar fades out with opacity while the top and bottom bars rotate into an X using translateY() plus rotate() on the same transform.

Card flip (3D)

A flip animation reveals a hidden back face by rotating a card around its Y-axis. It relies on transform-style: preserve-3d and backface-visibility: hidden, two properties that are easy to forget and are the most common cause of a flip that looks flat or shows both faces at once.

.flip-card {
  perspective: 1000px;
  width: 220px;
  height: 280px;
}
.flip-card-inner {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  transition: transform 0.6s ease;
}
.flip-card:hover .flip-card-inner {
  transform: rotateY(180deg);
}
.flip-card-front,
.flip-card-back {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  border-radius: 10px;
}
.flip-card-back {
  transform: rotateY(180deg);
}

Accordion / collapse expand

height cannot be animated to auto directly, which is why most accordion demos either hardcode a max-height or jump instantly. The reliable fix is animating a grid-template-rows track instead of height: the row itself can transition from 0fr to 1fr, and the content's real height is measured automatically.

.accordion-panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.3s ease;
}
.accordion-panel.open {
  grid-template-rows: 1fr;
}
.accordion-panel > div {
  overflow: hidden;
}

The inner <div> with overflow: hidden is required because a grid row still reports its content's full height for layout purposes; the wrapper is what actually clips it during the animation.

Underline sweep on hover

A sweeping underline that grows from one side is almost always built as a transition on a pseudo-element, not a @keyframes animation, since it only needs a start state and an end state.

.underline-link {
  position: relative;
  text-decoration: none;
}
.underline-link::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: -2px;
  width: 100%;
  height: 2px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.25s ease;
}
.underline-link:hover::after {
  transform: scaleX(1);
}

See the CSS transition guide for more hover-triggered patterns like this one. If you specifically need the underline to animate automatically (looping or on page load rather than on hover), swap the transition for a two-step @keyframes animation on transform: scaleX() instead.

Animating elements as they scroll into view

Triggering a @keyframes animation as an element enters the viewport used to require JavaScript, either a scroll event listener or an IntersectionObserver. CSS now has a native way to do this with animation-timeline, tying an animation's progress directly to scroll position instead of time.

@keyframes fadeInUp {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: translateY(0); }
}
.reveal-on-scroll {
  animation: fadeInUp linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 60%;
}

animation-timeline: view() links the animation to how far the element has crossed into its scroll container. animation-range: entry 0% entry 60% means the animation plays only while the element is entering the viewport, finishing once it is 60% of the way in, rather than running for the whole time it is on screen.

BrowserSupportNotes
Chrome / Edge / Opera✅ FullSince Chrome 115
Safari✅ FullSince Safari 18
Firefox🟡 PartialBehind the layout.css.scroll-driven-animations.enabled flag

Because unsupported browsers simply ignore animation-timeline, the safest approach is progressive enhancement: give the element its final, visible state by default, then only apply the scroll-linked animation inside an @supports block.

.reveal-on-scroll {
  opacity: 1; /* visible by default */
}

@supports (animation-timeline: view()) {
  .reveal-on-scroll {
    opacity: 0;
    animation: fadeInUp linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 60%;
  }
}

For a JavaScript fallback that covers Firefox and older browsers today, toggle a class with IntersectionObserver and animate that class with a normal time-based @keyframes animation instead:

// JS fallback: adds .in-view when the element enters the viewport
const observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    if (entry.isIntersecting) entry.target.classList.add('in-view');
  });
}, { threshold: 0.2 });

document.querySelectorAll('.reveal-on-scroll').forEach(el => observer.observe(el));

Stick to transform and opacity in scroll-driven animations too. Because they run on the compositor thread rather than the main thread, scroll-linked transform/opacity animations stay smooth even while the page is actively scrolling, which is exactly when jank is most noticeable.

Performance: what you can and cannot animate

Not all CSS properties are equal when it comes to animation performance. The browser has to do very different amounts of work depending on what you change.

PropertyPerformanceWhy
transform✅ ExcellentHandled by GPU, no layout recalculation
opacity✅ ExcellentGPU compositing only
filter🟡 GoodGPU but heavier than transform/opacity
color / background🟡 AcceptableTriggers repaint but not layout
width / height🔴 AvoidTriggers full layout recalculation
top / left / margin🔴 AvoidTriggers layout. Use transform instead

Rule: Animate only transform and opacity whenever possible. To move an element, use transform: translate(). Never animate top, left, or margin.

will-change

For complex animations, you can hint to the browser that an element will be animated so it can prepare ahead of time:

.animated-element {
  will-change: transform, opacity;
}

/* Remove it after animation completes to free GPU memory */
.animated-element.done {
  will-change: auto;
}

Do not overuse will-change. Applying it to many elements at once consumes extra GPU memory and can hurt performance rather than help it. Only use it on elements that are about to animate.

Accessibility: respecting user preferences

Some users have vestibular disorders or motion sensitivity and set their operating system to reduce motion. Always respect this preference using the prefers-reduced-motion media query.

@keyframes slideUp {
  from { transform: translateY(16px); opacity: 0; }
  to   { transform: translateY(0);    opacity: 1; }
}

.card { animation: slideUp 0.35s ease-out both; }

/* Disable or simplify animations for users who prefer it */
@media (prefers-reduced-motion: reduce) {
  .card {
    animation: fadeIn 0.1s ease both; /* simple fade instead of motion */
  }
}

FAQ

What is the difference between CSS animation and CSS transition?

A CSS transition reacts to a state change such as a hover and moves between two values: a start and an end. A CSS animation uses @keyframes, can run automatically without a trigger, loop indefinitely, go through multiple steps, and play in reverse. Use transitions for hover effects. Use animations for anything that needs to run on its own or repeat. See the CSS transition guide for a full breakdown.

How do I make a CSS animation loop forever?

Set animation-iteration-count to infinite. For example: animation: spin 1s linear infinite. This makes the animation repeat without stopping.

Why does my CSS animation snap back to its original state when it ends?

By default, animation-fill-mode is set to none, which means the element returns to its original styles after the animation ends. Set animation-fill-mode: forwards to keep the final keyframe state, or use both to also apply the first keyframe during any delay period.

How do I add a delay before a CSS animation starts?

Use animation-delay. For example: animation: fadeIn 0.3s ease 0.5s both, where the 0.5s is the delay. To prevent a flash before the animation starts, also set animation-fill-mode to backwards or both.

Can I pause and resume a CSS animation?

Yes. Set animation-play-state: paused to freeze an animation at its current position and animation-play-state: running to resume it. This is commonly toggled with JavaScript by adding or removing a class on the element.

How do I trigger a CSS animation on hover?

Apply the animation property inside a :hover rule. For example: .card:hover { animation: shake 0.3s ease; }. The animation restarts each time the hover state is entered. For a smoother experience on repeated hovers, use a CSS transition instead if you only need a two-state change.

How do I animate an element when it scrolls into view?

Use animation-timeline: view() together with animation-range to link a @keyframes animation to scroll position instead of time. This is supported in Chrome, Edge, Opera, and Safari, but only partially in Firefox, so wrap it in an @supports (animation-timeline: view()) block and give the element a visible default state. For full cross-browser coverage today, use an IntersectionObserver in JavaScript to add a class when the element enters the viewport, then animate that class with a normal time-based @keyframes animation. See the scroll section above for both approaches.

How do I make a CSS animation play only once instead of looping?

Set animation-iteration-count to 1, or simply omit it, since 1 is the default value. Combine it with animation-fill-mode: forwards so the element holds its final keyframe state instead of snapping back once the single cycle finishes.

What is the difference between animation-direction: alternate and reverse?

reverse plays every cycle of the animation backward, from the last keyframe to the first. alternate plays the first cycle forward and then flips direction on each following cycle, creating a back-and-forth ping-pong motion. alternate is the one to reach for on infinite pulsing or breathing effects; reverse is better when you just want to run an existing animation backward once.

Should I use a CSS animation library like Animate.css, or write my own?

A library like Animate.css is fine for prototyping or a quick one-off effect, since it saves you writing @keyframes from scratch. For production sites, custom animations are usually the better choice: they add no extra CSS weight beyond what you actually use, they are easy to tune for your own timing and easing, and you are not stuck overriding a library's specificity when you need to change one detail.

Generate CSS animations visually

Our CSS Animation Generator lets you build keyframe animations in real time and copy the exact code. No manual writing needed.

Open Animation Generator →