CSS Animation: Keyframes, Easing and 16 Examples

The usual reason a first CSS animation does nothing is a missing duration. When the animation shorthand leaves one out, the duration defaults to 0s, so the animationstart and animationend events still fire while nothing visible happens (MDN, 2026).

The fix is trivial, but the mechanics behind it are worth a look. You write a few keyframe states in a @keyframes rule, and the browser fills in every frame between them. Loaders and entrance effects are the usual first uses. Hover feedback and scroll-driven progress bars come up just as often.

Below are 16 CSS animation examples you can watch and copy: loaders and loops, entrances and text effects, keyframe techniques and scroll-driven animations. Every one respects the reduced-motion setting. To build your own keyframes visually, try the CSS keyframe animation generator.

By Bogdan Sandu · Updated October 26, 2026

Loaders and loops

Infinite animations for loading states and ambient motion. Every example on this page is written opt-in: the static state is the default, and the motion sits inside @media (prefers-reduced-motion: no-preference).

Orbiting dots loader

  • transform-origin
  • negative animation-delay
  • cubic-bezier()

Three dots share one rotation, and a negative delay on each puts them at different points of the same cycle, so they chase each other. transform-origin moves the pivot from the dot to the center of the ring. The wrapper has role="status" and a label, so screen readers announce the loading state.

View code
.an-orbit {
  position: relative;
  width: 72px;
  height: 72px;
  border: 2px solid rgba(255, 255, 255, .12);
  border-radius: 50%;
}
.an-orbit span {
  position: absolute;
  top: -7px;
  left: calc(50% - 6px);
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #ff6b3d;
  transform-origin: 6px 43px;
}
.an-orbit span:nth-child(2) {
  background: #ffd84d;
  transform: rotate(40deg);
}
.an-orbit span:nth-child(3) {
  background: #7ea0ff;
  transform: rotate(80deg);
}
@media (prefers-reduced-motion: no-preference) {
  .an-orbit span {
    animation: an-orbit 1.4s cubic-bezier(.55, .1, .45, .9) infinite;
  }
  .an-orbit span:nth-child(2) {
    animation-delay: -.12s;
  }
  .an-orbit span:nth-child(3) {
    animation-delay: -.24s;
  }
}
@keyframes an-orbit {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

Audio equalizer bars

  • scaleY
  • alternate
  • different durations

Five bars scale from the bottom edge with transform: scaleY(), so nothing reflows. Each bar gets its own duration and a negative delay, which keeps the pattern from ever lining up and looking mechanical. alternate plays every cycle back the other way, so there is no jump at the seam.

View code
.an-eq {
  display: flex;
  align-items: flex-end;
  gap: 8px;
  height: 90px;
}
.an-eq span {
  width: 14px;
  height: 100%;
  border-radius: 7px;
  background: #1847f0;
  transform-origin: bottom;
  transform: scaleY(.5);
}
.an-eq span:nth-child(2) { background: #ff6b3d; transform: scaleY(.8); }
.an-eq span:nth-child(3) { background: #ffd84d; transform: scaleY(.35); }
.an-eq span:nth-child(4) { background: #12a150; transform: scaleY(.65); }
.an-eq span:nth-child(5) { background: #080c14; transform: scaleY(.45); }
@media (prefers-reduced-motion: no-preference) {
  .an-eq span {
    animation: an-eq .9s ease-in-out infinite alternate;
  }
  .an-eq span:nth-child(2) { animation-duration: .7s; animation-delay: -.3s; }
  .an-eq span:nth-child(3) { animation-duration: 1.1s; animation-delay: -.5s; }
  .an-eq span:nth-child(4) { animation-duration: .8s; animation-delay: -.2s; }
  .an-eq span:nth-child(5) { animation-duration: 1s; animation-delay: -.7s; }
}
@keyframes an-eq {
  from { transform: scaleY(.2); }
  to { transform: scaleY(1); }
}

Morphing blob

  • eight-value border-radius
  • rotate
  • ease-in-out

The eight-value border-radius syntax sets each corner horizontal and vertical radius separately, and animating between four of those shapes makes a solid square read as a liquid blob. A slow rotation on top hides the repeating pattern. It works as a background accent or an avatar frame.

View code
.an-blob {
  width: 170px;
  aspect-ratio: 1;
  border-radius: 42% 58% 63% 37% / 41% 44% 56% 59%;
  background: #1847f0;
  box-shadow: inset -18px -24px 0 rgba(8, 12, 20, .18);
}
@media (prefers-reduced-motion: no-preference) {
  .an-blob {
    animation: an-blob 9s ease-in-out infinite alternate;
  }
}
@keyframes an-blob {
  0% {
    border-radius: 42% 58% 63% 37% / 41% 44% 56% 59%;
    transform: rotate(0deg);
  }
  33% {
    border-radius: 63% 37% 38% 62% / 55% 61% 39% 45%;
  }
  66% {
    border-radius: 35% 65% 52% 48% / 38% 37% 63% 62%;
  }
  100% {
    border-radius: 55% 45% 37% 63% / 62% 49% 51% 38%;
    transform: rotate(180deg);
  }
}

Seamless marquee

  • translateX(-50%)
  • duplicated track
  • :hover pause
  • Typography
  • Color
  • Layout
  • Motion
  • Grids
  • Contrast

The list is written twice inside one track, and the track slides left by exactly half its width before the loop restarts, so the seam never shows. The copy is aria-hidden to keep screen readers from reading every item twice. Hovering pauses it with animation-play-state.

View code
.an-marquee {
  width: 100%;
  overflow: hidden;
  padding: 14px 0;
  border-block: 2px solid #080c14;
  background: #ffd84d;
}
.an-marquee__track {
  display: flex;
  width: max-content;
  margin: 0;
  padding: 0;
  list-style: none;
}
.an-marquee__track li {
  display: flex;
  align-items: center;
  gap: 28px;
  padding-right: 28px;
  color: #080c14;
  font-size: 30px;
  font-weight: 800;
  letter-spacing: -.02em;
  white-space: nowrap;
}
.an-marquee__track li::after {
  content: "";
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #080c14;
}
@media (prefers-reduced-motion: no-preference) {
  .an-marquee__track {
    animation: an-marquee 22s linear infinite;
  }
  .an-marquee:hover .an-marquee__track {
    animation-play-state: paused;
  }
}
@keyframes an-marquee {
  to { transform: translateX(-50%); }
}

Ticker with a pause button

  • animation-play-state
  • aria-pressed
  • WCAG 2.2.2
Live

Markets open higher · Rain expected after 4 pm · Library hours extended through Friday · Route 9 closed for repairs · Markets open higher · Rain expected after 4 pm · Library hours extended through Friday · Route 9 closed for repairs ·

Anything that starts on its own, runs longer than five seconds and sits beside other content needs a way to pause it under WCAG 2.2.2, and a reduced-motion setting does not count. The button sets aria-pressed, and a class flips animation-play-state, so the ticker freezes exactly where it is.

View code
.an-ticker {
  display: flex;
  align-items: center;
  width: 100%;
  overflow: hidden;
  border-radius: 14px;
  background: #080c14;
  color: #f4f6fb;
}
.an-ticker__label {
  padding: 0 16px;
  color: #ff6b3d;
  font-size: 13px;
  font-weight: 800;
  letter-spacing: .1em;
  text-transform: uppercase;
}
.an-ticker__window {
  flex: 1;
  overflow: hidden;
}
.an-ticker__text {
  width: max-content;
  margin: 0;
  padding: 16px 0;
  font-size: 15px;
  white-space: nowrap;
}
.an-ticker__pause {
  min-width: 72px;
  min-height: 52px;
  border: 0;
  border-left: 1px solid #1f2637;
  background: #121829;
  color: #fff;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
}
.an-ticker__pause:focus-visible {
  outline: 2px solid #ffd84d;
  outline-offset: -4px;
}
@media (prefers-reduced-motion: no-preference) {
  .an-ticker__text {
    animation: an-ticker 26s linear infinite;
  }
  .an-ticker.is-paused .an-ticker__text {
    animation-play-state: paused;
  }
}
@keyframes an-ticker {
  to { transform: translateX(-50%); }
}

Entrances and text

Animations that play once when they scroll into view. Each has a Replay button that removes the class, forces a reflow and adds it back, the same restart trick the FAQ below describes.

Staggered card entrance

  • calc(var(--i) * 90ms)
  • fill-mode: both
  • IntersectionObserver
  • PlanScope and goals
  • DesignWireframes to UI
  • BuildCode and content
  • LaunchTest and ship

Each card carries its index in --i, and the delay multiplies it by 90ms, so the cards rise one after another. fill-mode: both keeps each card hidden during its delay and holds the final state afterward. A small observer adds the class when the row scrolls into view.

View code
.an-stagger {
  display: grid;
  justify-items: center;
  gap: 22px;
  width: 100%;
}
.an-stagger__row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: 12px;
  width: 100%;
  margin: 0;
  padding: 0;
  list-style: none;
}
.an-stagger__row li {
  display: grid;
  gap: 6px;
  padding: 20px 18px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 10px 30px rgba(8, 12, 20, .08);
}
.an-stagger__row strong {
  color: #080c14;
  font-size: 18px;
}
.an-stagger__row span {
  color: #5b6478;
  font-size: 14px;
}
.an-replay {
  min-height: 40px;
  padding: 0 18px;
  border: 1.5px solid #c9cfdc;
  border-radius: 999px;
  background: #fff;
  color: #080c14;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.an-replay:focus-visible {
  outline: 2px solid #1847f0;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .an-stagger.is-playing li {
    animation: an-rise 600ms cubic-bezier(.2, .8, .2, 1) both;
    animation-delay: calc(var(--i) * 90ms);
  }
}
@keyframes an-rise {
  from {
    opacity: 0;
    transform: translateY(28px) scale(.96);
  }
}

Typewriter with a blinking caret

  • steps(17)
  • ch units
  • two animations

Hello, keyframes.

The line is 17 characters, so the width animates from 0 to 17ch in steps(17) and each step reveals exactly one letter. A monospace font keeps every character 1ch wide. The caret is a border with its own step-end blink, the second animation in the comma-separated list.

View code
.an-type {
  display: grid;
  justify-items: center;
  gap: 26px;
}
.an-type__line {
  width: 17ch;
  margin: 0;
  overflow: hidden;
  border-right: 3px solid #ffd84d;
  color: #f4f6fb;
  font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
  font-size: clamp(20px, 5vw, 32px);
  white-space: nowrap;
}
.an-replay {
  min-height: 40px;
  padding: 0 18px;
  border: 1.5px solid #c9cfdc;
  border-radius: 999px;
  background: #fff;
  color: #080c14;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.an-replay--dark {
  border-color: #2a3247;
  background: #121829;
  color: #f4f6fb;
}
.an-replay:focus-visible {
  outline: 2px solid #ffd84d;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .an-type.is-playing .an-type__line {
    animation: an-typing 1.7s steps(17) both, an-caret .8s step-end infinite;
  }
}
@keyframes an-typing {
  from { width: 0; }
}
@keyframes an-caret {
  50% { border-right-color: transparent; }
}

Letter wave

  • per-letter --i
  • alternate
  • aria-label on the word

Each letter is its own span with an index, and the delay staggers the same bounce across the word, so a wave rolls through it. The word carries an aria-label and the spans are aria-hidden, because screen readers would otherwise spell the word out one letter at a time.

View code
.an-wave {
  display: flex;
  margin: 0;
  color: #080c14;
  font-size: clamp(56px, 12vw, 96px);
  font-weight: 800;
  letter-spacing: -.03em;
  line-height: 1;
}
.an-wave span {
  display: inline-block;
}
.an-wave span:nth-child(odd) {
  color: #1847f0;
}
@media (prefers-reduced-motion: no-preference) {
  .an-wave span {
    animation: an-wave 1.2s ease-in-out infinite;
    animation-delay: calc(var(--i) * 90ms);
  }
}
@keyframes an-wave {
  0%, 60%, 100% { transform: translateY(0); }
  30% { transform: translateY(-18px); }
}

Counting numbers with @property

  • @property <integer>
  • counter()
  • var() in keyframes
Projects shipped
128
Uptime
99%
Active users
2480

A registered integer property can animate, so the number counts up in CSS alone. counter-reset feeds the animated value into a counter, and ::after prints it. The real figure sits in visually hidden text for screen readers, and the animated copy is aria-hidden.

View code
@property --an-n {
  syntax: "<integer>";
  inherits: false;
  initial-value: 0;
}
.an-stats {
  display: grid;
  justify-items: center;
  gap: 22px;
  width: 100%;
}
.an-stats__list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 12px;
  width: 100%;
  margin: 0;
}
.an-stats__list div {
  padding: 20px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 10px 30px rgba(8, 12, 20, .08);
}
.an-stats__list dt {
  color: #5b6478;
  font-size: 14px;
}
.an-stats__list dd {
  margin: 6px 0 0;
}
.an-stats__num {
  --an-n: var(--to);
  counter-reset: an-n var(--an-n);
  color: #080c14;
  font-size: 40px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  letter-spacing: -.03em;
}
.an-stats__num::after {
  content: counter(an-n);
}
.an-stats__num--pct::after {
  content: counter(an-n) "%";
}
.an-stats__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.an-replay {
  min-height: 40px;
  padding: 0 18px;
  border: 1.5px solid #c9cfdc;
  border-radius: 999px;
  background: #fff;
  color: #080c14;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.an-replay:focus-visible {
  outline: 2px solid #1847f0;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .an-stats.is-playing .an-stats__num {
    animation: an-count 2s cubic-bezier(.2, .7, .2, 1) both;
  }
}
@keyframes an-count {
  from { --an-n: 0; }
  to { --an-n: var(--to); }
}

Keyframe techniques

Examples that each show one property doing something specific: animation-composition, linear() easing, steps() with a negative delay and the three fill modes side by side.

animation-composition: replace vs add

  • animation-composition
  • two animations, one property
  • cqw units

replace

add

Both squares run the same two animations on transform: a slide across the track and a fast wobble. With replace, the later wobble overwrites the slide, so the top square only shakes in place. With add, the wobble stacks on top of the slide, and the square travels while it wobbles.

View code
.an-comp {
  display: grid;
  gap: 6px;
  width: 100%;
  max-width: 520px;
}
.an-comp p {
  margin: 0 0 14px;
  color: #5b6478;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
}
.an-comp__track {
  container-type: inline-size;
  padding: 10px;
  border-radius: 14px;
  background: #fff;
}
.an-comp__box {
  display: block;
  width: 44px;
  height: 44px;
  border-radius: 10px;
  background: #1847f0;
}
.an-comp__box--add {
  background: #ff6b3d;
}
@media (prefers-reduced-motion: no-preference) {
  .an-comp__box {
    animation: an-slide 2.4s ease-in-out infinite alternate, an-wobble .35s ease-in-out infinite alternate;
  }
  .an-comp__box--replace {
    animation-composition: replace, replace;
  }
  .an-comp__box--add {
    animation-composition: replace, add;
  }
}
@keyframes an-slide {
  from { transform: translateX(0); }
  to { transform: translateX(calc(100cqw - 44px)); }
}
@keyframes an-wobble {
  from { transform: rotate(-14deg); }
  to { transform: rotate(14deg); }
}

Bouncing ball with linear()

  • linear() easing
  • cubic-bezier fallback
  • paired shadow

One keyframe pair, from the top to the floor, and the bounce lives entirely in the easing. linear() draws the curve as a list of points, and values that dip below 1 near the end are the rebounds. The shadow uses the same easing, so it tightens each time the ball lands.

View code
.an-bounce {
  display: grid;
  justify-items: center;
  align-content: end;
  height: 220px;
}
.an-bounce__ball {
  width: 54px;
  height: 54px;
  border-radius: 50%;
  background: #ff6b3d;
  box-shadow: inset -8px -10px 0 rgba(8, 12, 20, .15);
}
.an-bounce__shadow {
  width: 60px;
  height: 10px;
  margin-top: 6px;
  border-radius: 50%;
  background: rgba(8, 12, 20, .15);
}
@media (prefers-reduced-motion: no-preference) {
  .an-bounce__ball,
  .an-bounce__shadow {
    animation-duration: 2s;
    animation-iteration-count: infinite;
    animation-timing-function: cubic-bezier(.5, 0, 1, 1);
    animation-timing-function: linear(0, .004, .016, .035, .063, .098, .141 13.6%, .25, .391, .563, .765, 1, .891 40.9%, .848, .813, .785, .766, .754, .75, .754, .766, .785, .813, .848, .891 68.2%, 1 72.7%, .973, .953, .941, .938, .941, .953, .973, 1, .988, .984, .988, 1);
  }
  .an-bounce__ball {
    animation-name: an-drop;
  }
  .an-bounce__shadow {
    animation-name: an-shadow;
  }
}
@keyframes an-drop {
  from { transform: translateY(-150px); }
  to { transform: translateY(0); }
}
@keyframes an-shadow {
  from { transform: scale(.35); opacity: .3; }
  to { transform: scale(1); opacity: 1; }
}

Clock set by a negative delay

  • steps(60)
  • negative animation-delay
  • custom properties

The second hand ticks with steps(60), and the minute and hour hands glide on 3,600s and 43,200s cycles. Four lines of script read the current time and pass it in as a negative delay, which starts each animation partway through, so the clock shows the real time with no per-second updates.

View code
.an-clock {
  position: relative;
  width: 190px;
  aspect-ratio: 1;
  border: 6px solid #f4f6fb;
  border-radius: 50%;
  background: repeating-conic-gradient(from -1deg, #f4f6fb 0 2deg, transparent 2deg 30deg) content-box;
}
.an-clock::before {
  content: "";
  position: absolute;
  inset: 10px;
  border-radius: 50%;
  background: #0b0f1a;
}
.an-clock::after {
  content: "";
  position: absolute;
  top: calc(50% - 6px);
  left: calc(50% - 6px);
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #ff6b3d;
}
.an-clock__hand {
  position: absolute;
  bottom: 50%;
  left: calc(50% - 2px);
  z-index: 1;
  width: 4px;
  border-radius: 4px;
  background: #f4f6fb;
  transform-origin: 50% 100%;
}
.an-clock__hand--h {
  height: 26%;
  width: 6px;
  left: calc(50% - 3px);
  transform: rotate(305deg);
}
.an-clock__hand--m {
  height: 38%;
  transform: rotate(60deg);
}
.an-clock__hand--s {
  height: 42%;
  width: 2px;
  left: calc(50% - 1px);
  background: #ff6b3d;
}
@media (prefers-reduced-motion: no-preference) {
  .an-clock__hand--h {
    animation: an-turn 43200s linear infinite;
    animation-delay: var(--an-h, 0s);
  }
  .an-clock__hand--m {
    animation: an-turn 3600s linear infinite;
    animation-delay: var(--an-m, 0s);
  }
  .an-clock__hand--s {
    animation: an-turn 60s steps(60) infinite;
    animation-delay: var(--an-s, 0s);
  }
}
@keyframes an-turn {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

Fill modes side by side

  • animation-fill-mode
  • animation-delay
  • the snap-back
none
forwards
both

The same animation with a 600ms delay and three fill modes. With none, the box ignores the first keyframe during the delay and snaps back to its normal style at the end. forwards holds the last keyframe. both also applies the first keyframe during the delay, so the box turns orange before it moves.

View code
.an-fill {
  display: grid;
  gap: 12px;
  width: 100%;
  max-width: 540px;
}
.an-fill__row {
  display: grid;
  grid-template-columns: 74px 1fr;
  align-items: center;
  gap: 12px;
}
.an-fill__row code {
  color: #5b6478;
  font-size: 13px;
}
.an-fill__track {
  container-type: inline-size;
  padding: 8px;
  border-radius: 12px;
  background: #fff;
}
.an-fill__box {
  display: block;
  width: 36px;
  height: 36px;
  border-radius: 9px;
  background: #c9cfdc;
}
.an-fill .an-replay {
  justify-self: center;
  margin-top: 8px;
}
.an-replay {
  min-height: 40px;
  padding: 0 18px;
  border: 1.5px solid #c9cfdc;
  border-radius: 999px;
  background: #fff;
  color: #080c14;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.an-replay:focus-visible {
  outline: 2px solid #1847f0;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .an-fill.is-playing .an-fill__box {
    animation: an-fill 1.2s ease-in-out .6s;
  }
  .an-fill.is-playing .an-fill__box--forwards {
    animation-fill-mode: forwards;
  }
  .an-fill.is-playing .an-fill__box--both {
    animation-fill-mode: both;
  }
}
@keyframes an-fill {
  from {
    background: #ff6b3d;
    transform: translateX(0);
  }
  to {
    background: #1847f0;
    transform: translateX(calc(100cqw - 36px));
  }
}

Scroll-driven animations

Scroll inside each frame. These swap the clock for animation-timeline: scroll() or view(), sit inside @supports, and leave the content fully visible in browsers that do not support the feature.

Reading progress bar

  • animation-timeline: scroll()
  • position: sticky
  • @supports

Why motion needs a reason

Good interface motion answers a question the user already has: where did that panel come from, did my tap register, what changed after I saved.

Motion without that job is decoration, and decoration that moves competes with the content for attention.

That is why the first audit question for any animation is what it explains, not how smooth it looks.

Durations follow from the job. Feedback should start at once and finish quickly, while a panel that brings in new content can take a little longer so the eye can follow it.

Easing follows too. Things entering the screen slow down as they arrive, and things leaving speed up as they go.

Then comes the setting most sites forget, the reduced-motion preference, which turns movement into a simple fade or an instant change.

Scroll inside the frame to drive the animation.

A bar at the top of the scroller scales from 0 to full width as you read, with no scroll listener. scroll() picks the nearest scrolling ancestor, which here is the frame itself. The 1ms duration is there for Firefox, and animation-timeline comes after the shorthand so the shorthand does not reset it.

View code
.an-read {
  width: 100%;
  max-width: 560px;
  height: 340px;
  overflow-y: auto;
  border-radius: 16px;
  background: #fff;
  overscroll-behavior: contain;
}
.an-read__bar {
  position: sticky;
  top: 0;
  z-index: 1;
  height: 5px;
  background: #1847f0;
  transform: scaleX(0);
  transform-origin: left;
}
.an-read__article {
  padding: 18px 26px 28px;
  color: #3b4256;
  font-size: 15.5px;
  line-height: 1.75;
}
.an-read__article h4 {
  margin: 0 0 12px;
  color: #080c14;
  font-size: 22px;
}
.an-read__article p {
  margin: 0 0 16px;
}
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .an-read__bar {
      animation: an-progress 1ms linear both;
      animation-timeline: scroll();
    }
  }
}
@keyframes an-progress {
  to { transform: scaleX(1); }
}

Cards that reveal on scroll

  • animation-timeline: view()
  • animation-range
  • entry / cover
  • Duration200 to 300ms for feedback
  • Easingease-out for things that arrive
  • Propertiestransform and opacity first
  • DelaySmall steps for staggers
  • Fill modeboth, to avoid the snap-back
  • Motion settingOpt in with no-preference
  • LoopingPause control past five seconds
  • TestingDevTools Animations panel

Scroll inside the frame to drive the animation.

Each card fades and rises as it enters the scroller. view() ties the animation to the card position inside its scroll container, and animation-range limits it to the stretch from the start of entry to 30% cover, so a card has finished animating well before it reaches the middle.

View code
.an-view {
  width: 100%;
  max-width: 560px;
  height: 340px;
  overflow-y: auto;
  border-radius: 16px;
  background: #e8ecf5;
  overscroll-behavior: contain;
}
.an-view__list {
  display: grid;
  gap: 12px;
  margin: 0;
  padding: 18px;
  list-style: none;
}
.an-view__list li {
  display: grid;
  gap: 4px;
  padding: 20px 22px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 8px 22px rgba(8, 12, 20, .07);
}
.an-view__list strong {
  color: #080c14;
  font-size: 18px;
}
.an-view__list span {
  color: #5b6478;
  font-size: 14.5px;
}
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .an-view__list li {
      animation: an-reveal 1ms linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 30%;
    }
  }
}
@keyframes an-reveal {
  from {
    opacity: 0;
    transform: translateY(40px) scale(.94);
  }
}

Photo that settles as it scrolls in

  • view()
  • scale on an image
  • overflow: hidden frame

Above the fjord

Scroll down and the photo settles into its frame as it passes through the view.

Rocky clifftop above a long blue fjord between grey mountains

The frame never moves. Only the image inside it scales, from 1.35 down to 1, and the overflow on the figure crops the extra.

Scroll inside the frame to drive the animation.

The photo starts zoomed in and settles to its natural size as it crosses the scroller, while its frame stays put, a scroll-linked version of the Ken Burns effect. Only transform changes, so the animation stays on the compositor and causes no layout shift.

View code
.an-zoom {
  width: 100%;
  max-width: 560px;
  height: 340px;
  overflow-y: auto;
  border-radius: 16px;
  background: #fff;
  overscroll-behavior: contain;
}
.an-zoom__text {
  padding: 22px 26px;
  color: #3b4256;
  font-size: 15.5px;
  line-height: 1.7;
}
.an-zoom__text h4 {
  margin: 0 0 8px;
  color: #080c14;
  font-size: 22px;
}
.an-zoom__text p {
  margin: 0;
}
.an-zoom__text:first-child {
  min-height: 240px;
}
.an-zoom__text:last-child {
  min-height: 260px;
}
.an-zoom__figure {
  margin: 0 18px;
  overflow: hidden;
  border-radius: 14px;
  aspect-ratio: 4 / 3;
}
.an-zoom__figure img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .an-zoom__figure img {
      animation: an-settle 1ms linear both;
      animation-timeline: view();
      animation-range: entry 0% contain 40%;
    }
  }
}
@keyframes an-settle {
  from { transform: scale(1.35); }
  to { transform: scale(1); }
}

What Is CSS Animation and How Does the Browser Run It?

You describe a few states and the browser works out the frames between them. The states go in a @keyframes rule that names the animation, and one or more animation-* properties attach that rule to an element. Duration, easing, repetition, direction, and playback all get controlled the same way.

Everything lives in the stylesheet, written in CSS. The CSS Animations Module Level 1 defines the rule and the properties, and Level 2 adds animation-composition. None of it needs a script, which is the main thing that separates it from animation driven by JavaScript.

Where the animation runs

web.dev explains that CSS animations are typically handled on the compositor thread, so they keep playing while the main thread is busy with expensive tasks.

In practice that holds for transform and opacity. Animations of layout properties need the main thread.

Support isn’t a worry. The animation shorthand has been Baseline widely available, with support in every major browser since September 2015, according to MDN.

How to Write a CSS Animation

Define the states with a @keyframes rule, then attach the rule to an element with animation-name and animation-duration. Nothing plays until both are in place.

  1. Name the rule, as in @keyframes slide-in { }
  2. Add keyframe selectors inside it, either from and to, or percentages such as 0%, 50%, and 100%.
  3. Write the property values for each state inside its selector.
  4. Attach the rule with animation-name on the element, using exactly the same name.
  5. Set animation-duration, because the default is 0s.
@keyframes slide-in {
  from { transform: translateX(-40px); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}

.card {
  animation: 600ms ease-out slide-in;
}

Leave out the 0% or 100% selector and the browser fills the gap with the element’s computed value.

Percentage selectors are where keyframe rules pay off, since one rule can hold five states or fifty.

Shorthand order and its traps

The shorthand is picky about order. The first time value the parser finds becomes the duration, and the second becomes the delay.

MDN recommends putting the animation name last, because a value that parses as another animation property gets assigned there first.

A missing duration falls back to 0s. The animationstart and animationend events still fire, and nothing visible happens, which is exactly the trap from the top of this article.

Animating custom properties

An unregistered custom property cannot animate, because the browser does not know its type. Register it with @property and it interpolates like any native value.

@property has been Baseline newly available since July 2024, per MDN. The rule needs the syntax and inherits descriptors, plus initial-value unless the syntax is *, or the browser ignores the whole rule.

Animation Properties and Their Default Values

Each animation-* property controls one aspect of playback, and the animation shorthand sets most of them in a single declaration. Anything omitted from the shorthand resets to its initial value.

PropertyAccepted valuesDefaultControls
animation-durationTime such as 600ms0sLength of one cycle
animation-timing-functionKeyword, cubic-bezier(), steps(), linear()easeSpeed between keyframes
animation-delayTime, negative allowed0sWait before the first cycle
animation-iteration-countNumber or infinite1Number of repeats
animation-directionnormal, reverse, alternate, alternate-reversenormalKeyframe order per cycle
animation-fill-modenone, forwards, backwards, bothnoneStyles before and after the run
animation-play-staterunning, pausedrunningPause and resume
animation-compositionreplace, add, accumulatereplaceBlending with the base value

Looping and direction

An infinite iteration count with alternate direction plays the animation forward, then backward, with no jump at the seam. Pair infinite with a linear timing function and you get the standard setup for loaders, such as spinners built with CSS.

Fill mode and the snap-back problem

With the default fill mode of none, the element returns to its normal styles the moment the last cycle ends. That snap-back surprises almost everyone the first time.

Forwards holds the last keyframe after the run. Backwards applies the first keyframe during the delay, and both does the two together.

MDN documents a side effect of forwards. Animated properties behave as if listed in will-change, and a stacking context created during the animation stays after it finishes.

Running several animations on one element

Separate animations with commas and each gets its own timing. When two animations set the same property, the one declared later wins the cascade.

animation-composition changes that outcome, and it has been Baseline widely available, with support in every major browser since July 2023 (MDN). Take a base of blur(5px) and a keyframe of blur(10px). Replace gives blur(10px), add gives blur(5px) blur(10px), and accumulate gives blur(15px).

The shorthand treats animation-composition as reset-only, so give it its own declaration after the shorthand.

Easing and Timing Functions for CSS Animation

Easing decides how fast an animation moves between keyframes. Keywords cover most cases. cubic-bezier() handles custom curves, steps() produces jumps, and linear() approximates bounces and springs.

The default, ease, is cubic-bezier(0.25, 0.1, 0.25, 1). ease-in is cubic-bezier(0.42, 0, 1, 1) and starts slowly. ease-out, cubic-bezier(0, 0, 0.58, 1), starts fast and slows at the finish, while ease-in-out, cubic-bezier(0.42, 0, 0.58, 1), is slow at both ends.

Ease-out is my pick for micro-interactions such as button feedback, because the response begins instantly.

One detail people miss. The timing function applies between each pair of keyframes, not across the whole animation.

cubic-bezier()

Four numbers define two control points. The x values must stay between 0 and 1, while the y values can go beyond that range, which produces overshoot.

steps()

steps(4, end) splits the animation into four equal intervals and jumps between them with no blending. That suits typing effects that reveal text a character at a time, and sprite sheets that swap one frame per step.

The jump terms are jump-start, jump-end, jump-none, jump-both, start, and end. step-start and step-end are shortcuts for a single step.

linear()

linear() has worked in every major browser since December 2023 and became Baseline widely available in June 2026, according to MDN and the web-features data.

It takes two or more number stops, optional percentages position them, and values outside 0 to 1 are allowed, which is how overshoot and bounce get drawn. Approximating a curve takes many points, so generate them with a tool instead of typing them.

Staggering with animation-delay

Give each element an index through a custom property, then multiply it inside the delay: animation-delay: calc(var(–i) * 80ms).

Element 0 starts at once and element 3 starts 240ms later. Keep the step small relative to the duration, or the sequence drags.

Which Properties Are Safe to Animate?

transform and opacity are safe, because the browser can animate them on the compositor without recalculating layout or repainting. web.dev recommends restricting animations to those two properties where possible.

Pipeline stageExample propertiesEffect
Composite onlytransform, opacityNo layout shift, can run off the main thread
Paintcolor, background-color, box-shadowRepaint on every frame
Layoutwidth, height, top, left, marginReflow and repaint, can raise CLS

Cumulative Layout Shift, a Core Web Vital, scores unexpected layout shifts, and a layout shift happens when a visible element changes its start position from one rendered frame to the next, according to web.dev. Its guidance swaps height for transform: scale() and top for transform: translate().

The Chrome for Developers Lighthouse documentation defines a non-composited animation as any animation that triggers the Style, Layout, or Paint steps of the rendering pipeline. Composited animations are excluded from CLS.

Does will-change help?

Rarely, and only as a last resort. MDN warns against applying it to many elements or as premature tuning.

Set it in a stylesheet and the browser treats the element as always about to change, so it holds its preparations far longer than needed. Toggling it from script before and after the change is the safer pattern.

Also, will-change: opacity creates a stacking context up front, which can change how the element looks.

How to measure animation performance

Record while the animation plays. The Chrome DevTools Performance panel shows dropped frames, and Lighthouse lists the DOM nodes whose animations were not composited, with a failure reason for each.

Swap the property first, and add will-change only if the swap is not enough.

How to Make CSS Animation Accessible

Gate decorative motion behind prefers-reduced-motion, and give any animation that starts on its own and runs longer than five seconds a pause control. The first respects a system setting, and the second covers what that setting misses.

MDN describes prefers-reduced-motion as a media feature that detects whether the user asked the device to minimize motion, and it has been Baseline widely available, with support in every major browser since January 2020. It lives in a media query with two values, no-preference and reduce, and the bare query counts as reduce.

Opt-in or opt-out

With opt-in, you write the static state as the default and add motion inside @media (prefers-reduced-motion: no-preference). Opt-out flips that. The motion is the default, and you cancel or soften it inside @media (prefers-reduced-motion: reduce).

W3C documents both patterns in Technique C39, its CSS technique for WCAG 2.3.3. Opt-in wins for new work, because a forgotten rule leaves the page still instead of moving.

Reduce versus remove

Reduced motion does not mean zero change. MDN’s example swaps a scaling animation for a gentle dissolve under the reduce setting, which keeps the feedback and drops the movement.

Be careful with a blanket animation: none inside the reduce block. No animationend event ever fires then, so any script waiting on it stalls. Set a near-zero duration instead when script depends on the event.

What the media query does not cover

prefers-reduced-motion answers WCAG 2.3.3, which targets motion triggered by interaction and sits at Level AAA. Autoplaying and looping content falls under 2.2.2 at Level A, and a system preference is not a pause mechanism.

CriterionLevelTriggerRequirement
2.2.2 Pause, Stop, HideAStarts automatically, lasts over five seconds, plays beside other contentA way to pause, stop, or hide it
2.3.3 Animation from InteractionsAAAMotion triggered by user interactionA way to disable it
2.3.1 Three Flashes or Below ThresholdAFlashing contentFlashes stay below the seizure thresholds

Parallax scrolling is the classic case W3C names for 2.3.3, since the motion is rarely required for the content. For 2.2.2, W3C picked five seconds because it is long enough to catch attention but short enough to wait out.

What Are Scroll-Driven Animations and Where Do They Work?

Scroll position replaces the clock. Instead of running on time, the animation’s progress follows how far the page has scrolled, and the animation-timeline property picks the timeline, either scroll() or view().

With scroll(), progress follows how far a scroll container has scrolled, using the nearest ancestor on the block axis by default. With view(), it follows an element’s position inside its scroll container. The animation-range property sets the segment of the timeline where the animation plays.

Chrome for Developers notes that these animations can run off the main thread, which a JavaScript scroll listener cannot match. That makes CSS animations on scroll the better default for progress bars and reveal effects.

Writing a scroll progress bar

Keyframes scale a bar from zero to full width, and the scroll timeline replaces the duration.

@keyframes grow-progress {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

.progress-bar {
  position: fixed;
  inset: 0 0 auto 0;
  height: 4px;
  transform-origin: left;
  animation: grow-progress linear;
  animation-timeline: scroll();
}

Declare animation-timeline after the animation shorthand, or the shorthand resets it. Firefox needs a duration set before it applies the animation, and MDN’s own examples use 1ms for that.

MDN’s animation-duration reference is not consistent with itself, which is annoying. Its syntax block shows auto as the default, while its formal definition, like the animation shorthand page, gives 0s as the initial value. For time-based animations auto behaves like 0s. For scroll-driven animations the difference matters, because auto stretches the animation across the whole timeline.

Where it works today

As of October 2026, Chrome and Edge have supported it since version 115 (Chrome for Developers), and Safari supports it in version 26 (Chrome for Developers).

Firefox is the last engine to switch it on. Stable releases up to Firefox 158 keep it behind the layout.css.scroll-driven-animations.enabled flag, which has existed since Firefox 110. Firefox 159, scheduled for October 27, 2026, enables scroll-driven animations by default, according to MDN’s Firefox 159 release notes and its browser compatibility data. Once that release ships, the feature works in all three engines, and MDN’s Baseline label for animation-timeline should move from limited availability to newly available.

Older Firefox versions stay in use for a while after any release, so keep treating the animation as an enhancement. Keep the content visible by default and apply animation-timeline inside @supports (animation-timeline: view()), so visitors on older browsers see the content without the effect.

Planning for that gap is ordinary cross-browser compatibility work, not a reason to skip the feature.

Scroll-triggered animation is a separate feature

The specification separates two ideas. Scroll-driven animations take their progress from scroll position, while scroll-triggered animations start at a scroll position and then run on time.

MDN now documents an animation-trigger property and an Animation triggers module for the second kind. The feature is experimental and Chromium-only (Chrome 146 and later), with no Firefox or Safari support, so check its compatibility table before relying on it.

Scrolling counts as interaction, so gate both kinds inside prefers-reduced-motion: no-preference.

CSS Animation vs CSS Transitions, the Web Animations API and JavaScript Libraries

Use a transition for a two-state change, CSS animation for multi-step or load-time motion, the Web Animations API when script needs playback control, and a library when sequencing outgrows all three. Pick the lightest tool that covers the job.

OptionBest forControlMain limit
CSS transitionHover and class-toggle changesStart and end valuesNeeds a trigger, no middle keyframes
CSS animationMulti-step motion, loops, load-time effectsKeyframes, timing, fill, directionCSS alone cannot seek or change speed; that takes the Web Animations API
Web Animations APIScript-driven playbackPlay, pause, reverse, playbackRateNeeds JavaScript
Library such as GSAPLong sequences, scroll scrubbingTimelines and pluginsAdds a dependency

CSS animation or CSS transition

A transition moves an element from state A to state B when something changes, such as a hover or a class toggle. A CSS animation can run from A through B and C to D, and it can start on page load without any trigger.

CSS Animation Rocks (2015) adds a useful edge case. When script keeps changing a value, like the angle of a clock hand, a transition handles each change more gracefully than a keyframe animation.

The Web Animations API

The Web Animations API reaches the same underlying animation model from script. Element.animate(keyframes, options) creates and plays an animation, and MDN lists it as Baseline widely available, with support in every major browser since March 2020.

The returned Animation object offers play(), pause(), reverse(), finish(), and cancel(), plus a playbackRate where negative values reverse the animation and 0 effectively pauses it.

web.dev explains that CSS and Web Animations animations typically run on the compositor thread. When an animation triggers layout or paint, the main thread is needed whether CSS or JavaScript drives it, and that overhead dwarfs any difference between the two.

Libraries and prebuilt keyframes

GSAP is free for commercial use under its standard license since Webflow made the whole toolset free, plugins included. Its ScrollTrigger plugin is the usual choice for scroll scrubbing.

Animate.css, at version 4.1.1, ships ready-made keyframes behind the animate__ prefix and takes durations through –animate-duration. It also respects prefers-reduced-motion.

Prebuilt packs such as Animate.css belong with the CSS animation libraries that skip hand-written keyframes, though its own docs advise against endless loops.

Where CSS animation stops working

  • CSS alone cannot seek to a point or change speed. Script can, through the Web Animations API, which also reaches existing CSS animations via getAnimations() and lets you set currentTime or playbackRate.
  • Animating to height: auto needs interpolate-size: allow-keywords, which MDN lists as limited availability.
  • linear() draws a spring curve, but the curve cannot react to drag velocity.
  • Thousands of moving objects outgrow the DOM.

A requestAnimationFrame loop earns its place in that last case, as in canvas animations. For ordinary interface motion it is rarely justified.

Why Is a CSS Animation Not Working?

Most broken animations trace back to a short list of causes. Work through them in this order.

  1. Match the names. The animation-name value must equal the @keyframes name exactly, including case.
  2. Confirm a duration above zero is set. Animate.css’s docs flag the same trap when its keyframes are called directly.
  3. Check the cascade. A more specific rule or a later shorthand can override your declaration, and a shorthand written after a longhand resets that longhand.
  4. Check the property. display and unregistered custom properties do not interpolate, and MDN keeps an Animatable properties reference.
  5. Check the motion setting. A reduced-motion rule may be cancelling the animation on your own machine, so switch off Reduce motion in the system settings or emulate the media feature from the Rendering tab in Chrome DevTools.
  6. Check browser support for the feature in the failing browser.

The Styles pane in browser DevTools settles the third check quickly, because it crosses out every declaration that lost the cascade.

Inspecting animations in browser DevTools

In Chrome DevTools, open More tools, then Animations. The panel captures CSS animations, CSS transitions, web animations, and View Transitions. You can slow down and replay a group, and edit timing, delay, duration, and keyframe offsets.

Firefox’s Animations tab in the Page Inspector draws every animation on a timeline, with a draggable widget that jumps the page to any point. It labels each bar as a CSS animation, a CSS transition, or a Web Animations API animation.

Checking support before debugging code

Check the Baseline label on MDN and the per-version tables on Can I use before blaming your CSS.

Widely available means well established across browsers, while limited availability means some widely used engine lacks the feature.

CSS Animation FAQ

How do you pause, stop, or restart a CSS animation?

Set animation-play-state: paused to freeze it and running to resume. Setting animation to none stops it entirely.

To restart, remove the animation class, read an offset property to force a reflow, then add the class back.

How do you build a loading spinner?

Rotate an element from 0deg to 360deg in a keyframe rule, then apply it with an infinite iteration count. Use a linear timing function, because easing makes the spinner stutter at every turn.

What is the simplest fade-in on page load?

Animate opacity from 0 to 1, with an optional translateY for a slide. Set the fill mode to both, so the element stays hidden during any delay and holds its final state afterward.

Can you make a typing effect without JavaScript?

Yes. Animate the width of a single text line from 0 to 100% with overflow hidden and white-space nowrap, using steps() equal to the character count.

A monospace font keeps each step aligned with one letter.

Do I still need -webkit- prefixes for animations?

No. The unprefixed animation properties and @keyframes work across current browsers. Prefixed copies only serve very old browsers.

How do you detect when an animation ends?

Listen for the animationend event on the animated element. The event’s animationName property tells you which animation finished, and animationstart and animationiteration cover the beginning and each repeat.

An infinite animation never fires animationend.

Which CSS Animations to Audit First

Start with looping and autoplaying animation, because automatic motion is where a Level A WCAG failure is most likely. Performance problems come next, so look for animations on layout properties that drop frames. Scroll-driven effects go last, and they only need a fallback.

Conformance gaps rank first because they exclude users, while dropped frames only degrade the experience. Optional enhancements sit at the bottom, since a missing effect costs nothing.

Gating motion behind prefers-reduced-motion costs some polish, because visitors with the setting on see a plainer page. The plainer page is the intended result.

Rerun the accessibility audit whenever a new animation ships. Firefox 159 turns scroll-driven animations on by default, so the @supports fallback can go once older Firefox versions drop out of your analytics.