How do you pause, reverse, or chain CSS animations?

Intermediate15 min interview
Skills tested:
Using animation-play-state to pause and resumeUsing animation-direction for reverse and alternate playbackChaining with cumulative animation-delayChaining reliably with animationendKnowing how to restart a finished animation

Advertisement

🧩 Scenario

Playback control is what turns keyframes into something interactive. A marquee that pauses on hover, a staggered list reveal, a multi-step onboarding sequence, a loading animation that reverses into a success state: each needs pausing, direction control, or sequencing. Doing it by adding and removing animation shorthand declarations is the common approach and the one that produces the snapping and restart bugs.

Architecture Walkthrough

Pausing With animation-play-state

animation-play-state takes running or paused. Setting it to paused freezes the animation at its current progress, and setting it back to running resumes from that point rather than restarting.

This is the property to use, because the intuitive alternative does not work. Removing the animation declaration, or setting animation: none, does not pause: it removes the animation entirely, so the element snaps back to its base styles and any subsequent re-application starts from zero.

Pausing on hover is therefore two lines, and it composes with prefers-reduced-motion, where pausing by default and only running on interaction is sometimes the right accommodation.

From JavaScript, el.style.animationPlayState = 'paused' works, and the Web Animations API offers el.getAnimations()[0].pause() and .play(), which are clearer when several animations are involved.

Reversing With animation-direction

animation-direction takes four values:

  • normal runs 0% to 100%
  • reverse runs 100% to 0%, and importantly also reverses the timing function, so an ease-out animation played in reverse eases in
  • alternate runs forward on odd iterations and backward on even ones
  • alternate-reverse starts backward and alternates from there

alternate is what makes a pulse or a back-and-forth motion expressible with a single keyframe block: define the outbound half and let the direction handle the return. Combined with animation-iteration-count: infinite, it is the standard breathing or bouncing pattern.

Note that reverse alone does not reverse a currently running animation mid-flight; it changes the direction of playback for the animation as applied. To reverse in response to an interaction, either swap to a reverse value and restart, or use the Web Animations API's reverse() method, which does reverse from the current position.

Chaining With Cumulative Delays

The simplest sequencing technique is to give each animation a delay equal to the total duration of everything before it. The animation shorthand accepts comma-separated lists, so one element can run several animations with different delays:

animation:
  slide-in 300ms ease-out 0ms both,
  bounce 200ms ease-in-out 300ms both;

This is concise and needs no JavaScript, and it is the right tool for staggered reveals where each child gets an incrementally larger delay, usually generated with :nth-child() or a custom property set from the index.

Its weakness is maintenance. Every delay encodes the sum of the previous durations, so changing one duration requires updating every delay after it. For a two or three step sequence that is acceptable; beyond that it drifts and becomes a source of quiet bugs.

animation-fill-mode matters here. Without forwards or both, each step reverts to the base style before the next begins, producing a visible snap between steps. both applies the first keyframe during the delay and holds the last keyframe after the run, which is almost always what a chained step wants.

Chaining With animationend

For sequences of more than a few steps, or where a step's duration is dynamic, listening for animationend is more robust:

el.addEventListener('animationend', (e) => {
  if (e.animationName === 'slide-in') el.classList.add('is-bouncing');
}, { once: true });

Checking e.animationName matters because an element may have several animations, and the event fires for each. animationiteration fires between loops of a repeating animation, and animationstart at the beginning, which together allow fairly precise sequencing.

For anything genuinely complex, the Web Animations API is the better tool. element.animate() returns an Animation with a finished promise, so a sequence becomes a chain of awaits with no event bookkeeping, and it supports playbackRate, currentTime, reverse(), and cancel() directly.

Restarting a Finished Animation

A finished animation does not re-run because its state has not changed: the animation is still applied and still complete. Re-adding a class that was never removed does nothing.

The reliable CSS approach is to remove the animation, force the browser to acknowledge the change by reading a layout property, and re-apply:

el.classList.remove('animate');
void el.offsetWidth; // forces a reflow so the removal is observed
el.classList.add('animate');

Reading offsetWidth is a deliberate synchronous layout flush, which is the one case where forcing a reflow is the point rather than a mistake. requestAnimationFrame nested twice is an alternative that avoids the forced layout.

Cleaner still is the Web Animations API: const a = el.getAnimations()[0]; a.currentTime = 0; a.play();.


Key Code Explained

/* PAUSE: freeze at current progress, resume from there */
.marquee {
  animation: scroll 12s linear infinite;
}
.marquee:hover,
.marquee:focus-within {
  animation-play-state: paused;
}

/* WRONG: this RESETS rather than pauses */
.marquee-broken:hover {
  animation: none; /* snaps back to base styles */
}

/* DIRECTION: reverse also reverses the timing function */
.slide-back {
  animation: slide-in 300ms ease-out reverse both;
  /* plays 100% -> 0%, and the ease-out becomes an ease-in */
}

/* alternate: define one half, get the return for free */
@keyframes pulse {
  from { transform: scale(1); }
  to   { transform: scale(1.08); }
}
.heart {
  animation: pulse 700ms ease-in-out infinite alternate;
}

/* CHAINING BY DELAY: comma-separated list, cumulative delays */
.hero {
  animation:
    fade-up 300ms ease-out 0ms both,
    settle 200ms ease-in-out 300ms both,
    glow 400ms linear 500ms both;
  /* Each delay is the SUM of the previous durations.
     Change the first duration and every later delay must be updated. */
}

/* fill-mode matters: without both, each step snaps back between steps */
.no-fill {
  animation: fade-up 300ms ease-out 0ms; /* reverts before the next step */
}

/* STAGGER: incremental delays via nth-child */
.list > li {
  animation: fade-up 300ms ease-out both;
}
.list > li:nth-child(1) { animation-delay: 0ms; }
.list > li:nth-child(2) { animation-delay: 80ms; }
.list > li:nth-child(3) { animation-delay: 160ms; }

/* Or drive the delay from a custom property set per item */
.list > li {
  animation: fade-up 300ms ease-out both;
  animation-delay: calc(var(--i) * 80ms);
}

/* Negative delay starts mid-sequence: offset loops without waiting */
.dot:nth-child(2) { animation-delay: -0.4s; }

@media (prefers-reduced-motion: reduce) {
  .marquee { animation-play-state: paused; }
  .hero, .list > li { animation-duration: 0.01ms; }
}
/* CHAINING BY EVENT: robust when durations change or steps are dynamic */
el.addEventListener(
  'animationend',
  (e) => {
    if (e.animationName === 'slide-in') el.classList.add('is-bouncing');
  },
  { once: true },
);

/* RESTART a finished animation: remove, force reflow, re-apply */
function restart(el) {
  el.classList.remove('animate');
  void el.offsetWidth; // deliberate synchronous layout flush
  el.classList.add('animate');
}

/* Web Animations API: clearer for pausing, reversing, and sequencing */
const [anim] = el.getAnimations();
anim.pause();
anim.playbackRate = 2;
anim.reverse();            // reverses from the CURRENT position
anim.currentTime = 0;
anim.play();

await el.animate(slideKeyframes, { duration: 300 }).finished;
await el.animate(bounceKeyframes, { duration: 200 }).finished;

The .marquee versus .marquee-broken pair is the core distinction. animation-play-state: paused freezes the scroll mid-position and resumes from exactly there; animation: none removes the animation, so the content jumps back to its starting offset and then restarts on mouse-out. They look like the same intent and behave completely differently.

The .hero chain shows both the appeal and the limitation of delay-based sequencing. It works, needs no JavaScript, and reads clearly for three steps, but every delay value encodes the sum of the durations before it, so a single timing change means editing every subsequent delay. That fragility is the reason animationend or the Web Animations API takes over for longer sequences.


Tradeoffs

TaskCSS approachJavaScript approachNotes
Pause and resumeanimation-play-stateanim.pause() / .play()Never use animation: none
Reverseanimation-direction: reverseanim.reverse()Only the API reverses mid-flight
Back and forthalternatedirection: 'alternate'One keyframe block covers both halves
Chain 2–3 stepsCumulative delaysDelays must be maintained by hand
Chain many stepsFragileanimationend or finished promiseCheck e.animationName
RestartRemove, reflow, re-applycurrentTime = 0; play()API version avoids forced layout
Speed controlNot possibleplaybackRateCSS has no equivalent

What Interviewers Actually Check

  • Whether you reach for animation-play-state rather than removing the animation
  • Whether you know reverse also reverses the timing function
  • Whether you know alternate exists and halves the keyframes you need to write
  • Whether you know delay-based chaining is fragile and can name the robust alternative
  • Whether you know why a finished animation will not re-run and how to restart it

Follow-Up Questions

  1. Why does reading offsetWidth force a reflow, and why is that the one situation where doing so deliberately is correct?
  2. What does animation-fill-mode: both do at each end, and why is it usually right for a chained step?
  3. How does animationiteration behave for an alternate animation, and how many times does it fire per full cycle?
  4. What does playbackRate allow that no CSS property can express?
  5. When would you choose the Web Animations API over CSS keyframes entirely?

Common Candidate Mistakes

  • Pausing by setting animation: none, which removes the animation and snaps the element back to its base styles rather than freezing it in place
  • Using animation-direction: reverse and being surprised the easing feels different, without knowing that reversing also reverses the timing function
  • Chaining by cumulative delays for a long sequence, so every duration change requires editing each subsequent delay and the sequence silently drifts
  • Re-adding a class to restart a finished animation without removing it and forcing a reflow first, so nothing happens because the animation state never changed
  • Omitting animation-fill-mode, so each step in a chain reverts to its base style before the next one starts and the sequence visibly snaps between steps

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you pause and resume an animation from both CSS and JavaScript?
  • Can you explain all four animation-direction values, including what reverse does to the timing function?
  • Can you chain animations with cumulative delays and say exactly when that approach breaks down?
  • Can you chain reliably with animationend, and say why checking animationName matters?
  • Can you restart a finished animation and explain why the naive approach does nothing?

Summary

Pausing is animation-play-state: paused, which freezes an animation at its current progress and resumes from that point when set back to running. Setting animation: none is not equivalent: it removes the animation, so the element snaps to its base styles and restarts from zero afterwards.

Reversal is animation-direction, with reverse playing 100% to 0% and also reversing the timing function, alternate running forward and backward on successive iterations, and alternate-reverse starting from the backward pass. alternate is what lets a pulse or bounce be written as a single keyframe block. Reversing an already-running animation from its current position requires the Web Animations API's reverse().

Chaining has two approaches. Cumulative animation-delay values in a comma-separated animation list are concise and JavaScript-free, and are the right choice for staggered reveals and two or three step sequences, provided animation-fill-mode: both is set so steps do not snap back between them. Because each delay encodes the sum of the previous durations, longer sequences are better driven by animationend, checking e.animationName, or by the Web Animations API where finished promises turn a sequence into a chain of awaits and playbackRate, currentTime, and cancel() become available. Finally, a finished animation does not re-run on class re-application because nothing changed; remove it, force a reflow by reading offsetWidth, and re-apply, or set currentTime = 0 and call play().

Frequently Asked Questions

Does pausing an animation preserve its position?

Yes. animation-play-state: paused freezes it at the current progress, and setting it back to running resumes from that point rather than restarting.

How do you restart an animation that has already finished?

Remove the animation, force a reflow or wait a frame, then re-apply it. Or use the Web Animations API and call play() after setting currentTime to 0, which is cleaner.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement