Keyframe animation

Write a keyframe animation that runs on its own, and know when scroll should drive it instead.

19of 26
≈ 11Min read
9Parts

Keyframes describe a sequence

A transition needs something to change. An animation runs by itself: you describe the states, and the browser plays through them.

define, then apply
@keyframes rise {
  from {
    opacity: 0;
    translate: 0 12px;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

.card {
  animation: rise 400ms ease-out both;
}

from and to are 0% and 100%. Any percentage in between is allowed, and several can share one block.

The shorthand, unpacked

eight properties
animation: rise 400ms ease-out 100ms 1 normal both running;
/*         name duration easing delay count direction fill-mode play-state */

/* which is the same as */
animation-name: rise;
animation-duration: 400ms;
animation-timing-function: ease-out;
animation-delay: 100ms;
animation-iteration-count: 1;        /* or infinite */
animation-direction: normal;         /* reverse, alternate, alternate-reverse */
animation-fill-mode: both;
animation-play-state: running;       /* paused */

fill-mode is the one that confuses people

An entrance animation almost always wants both: the element is already at the start state during the delay (so it does not flash at full opacity first) and stays at the end state afterwards.

ValueBefore the delayAfter it finishes
noneThe element's own styleThe element's own style — it snaps back
forwardsIts own styleHolds the last keyframe
backwardsHolds the first keyframeIts own style
bothHolds the firstHolds the last

A staggered entrance

one custom property does the stagger
.item {
  animation: rise 420ms cubic-bezier(.2, .7, .1, 1) both;
  animation-delay: calc(var(--i, 0) * 60ms);
}
and in the markup
<li style="--i:0">One</li>
<li style="--i:1">Two</li>
<li style="--i:2">Three</li>
Do not stagger far

Six items at 60ms is 360ms — fine. Twenty items at 100ms is two seconds of the reader waiting for a list. Cap the delay: calc(min(var(--i), 8) * 50ms).

Looping animations

A spinner is the one place linear is right. Anything looping forever should be small, quiet and far from the text — a looping animation beside something you are reading is genuinely hard to ignore.

a spinner and a pulse
@keyframes spin {
  to { rotate: 360deg; }
}

.spinner {
  animation: spin 900ms linear infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

.dot {
  animation: pulse 1.6s ease-in-out infinite;
}

Starting when the element appears

An animation on an element below the fold has already finished by the time it is scrolled into view. Two ways to fix it:

1. IntersectionObserver adds a class
const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    e.target.classList.add("in");
    io.unobserve(e.target);
  }
}, { rootMargin: "-10% 0px" });

document.querySelectorAll("[data-reveal]").forEach((el) => io.observe(el));
2. or let the browser do it
@keyframes rise {
  from { opacity: 0; translate: 0 14px; }
}

.reveal {
  animation: rise linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 30%;
}

animation-timeline ties the animation to scroll position instead of to time — no script, and it runs off the main thread. Check support before relying on it; the observer version works everywhere.

Scroll-driven progress

a reading bar, with no JavaScript
@keyframes grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.progress {
  transform-origin: 0 50%;
  animation: grow linear both;
  animation-timeline: scroll(root block);
}

Reduced motion, again

Setting animation: none alone can leave an element stuck at opacity: 0 if that was its resting state. Always check that the reduced-motion path ends with the content visible.

give the animation somewhere to go
@media (prefers-reduced-motion: reduce) {
  .card {
    animation: none;
    opacity: 1;
    translate: none;
  }
}

Controlling it from script

el.animate() is the same engine as CSS animations, driven from JavaScript, and finished is a promise — which makes sequencing far easier than listening for animationend.

the Web Animations API
const a = el.animate(
  [{ opacity: 0, transform: "translateY(12px)" }, { opacity: 1, transform: "none" }],
  { duration: 400, easing: "cubic-bezier(.2,.7,.1,1)", fill: "both" }
);

await a.finished;
a.pause();
a.currentTime = 200;
el.getAnimations().forEach((x) => x.cancel());

Practice