animation-fill-mode Explained

Part of CSS Transitions vs Animations in Core CSS Animation Fundamentals.

The problem

A list of cards fades in with staggered delays. On load, every card appears fully visible for a moment, disappears, then fades in — a flash. Adding forwards fixes nothing. Adding both fixes the flash, but now the cards cannot be hovered: their transform is stuck at the animation’s last keyframe and the hover rule has no effect. Removing the animation class to “reset” them makes them jump.

animation-fill-mode controls what the element looks like outside the animation’s active time. Most confusion comes from not knowing that “outside” includes both the delay before and the time after.

Root cause analysis: the active interval and its edges

An animation has an active interval: from the end of its delay to the end of its last iteration. Inside that interval, keyframe values apply. Outside it, animation-fill-mode decides.

  • none — the default. Before the delay ends and after the animation finishes, the element uses its normal styles. A delayed fade-in shows the element at full opacity during the delay, then snaps to opacity: 0 when the animation starts: the flash.
  • backwards — during the delay, apply the values of the keyframe the animation will start on. For a normal direction that is the 0% keyframe; for reverse or alternate-reverse it is 100%. The delayed card stays invisible until its turn.
  • forwards — after the animation ends, keep the values of the keyframe it ended on. For an even number of alternate iterations that is the 0% keyframe, not 100%.
  • both — backwards before, forwards after.

Filled values are animation values. They sit in the animation origin of the cascade, above normal rules including :hover. A card whose entrance fills forwards on transform has its transform controlled by the finished animation indefinitely, which is why the hover rule appears to do nothing — the mechanism behind the “dead hover” described in debugging the animation effect stack.

A 300 ms fade-in with a 200 ms delay, by fill modeOnly backwards and both avoid the flash; only forwards and both hold the end value.A 300 ms fade-in with a 200 ms delay, by fill modenonebase (visible)animatingbase650 msbackwards0% keyframeanimatingbase650 msforwardsbase (visible)animating100% held650 msboth0% keyframeanimating100% held650 msbase (visible)animatingbase0% keyframe100% held
Only backwards and both avoid the flash; only forwards and both hold the end value.

Step-by-step resolution

Picking a fill modeStart from backwards; add forwards only when the end state cannot be ordinary CSS.Picking a fill mode1Make the animation's end state match the element's base styles.No fill needed after the animation2If there is a delay, use backwards so the start keyframe applies during it.No flash before staggered entrances3Write entrance keyframes with only a from block.The to state is the base style automatically4Use forwards only for animations whose end state differs from base and must persist.Rare, deliberate5If forwards is used, plan how later state changes will override it.Avoids stuck properties6Make reduced-motion styles set the final state directly.
Start from backwards; add forwards only when the end state cannot be ordinary CSS.

Production code pattern

/* Staggered entrance: backwards fill, end state is the base style. */
.card {
  /* Base style IS the end state: visible, in place. */
  opacity: 1;
  translate: 0 0;
  animation: card-in 300ms cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
  animation-delay: calc(var(--i, 0) * 60ms);
  transition: translate 160ms ease-out;
}
@keyframes card-in {
  from { opacity: 0; translate: 0 12px; }   /* only a from: the to is the base style */
}
.card:hover { translate: 0 -4px; }           /* works: no forwards fill holding translate */

/* A genuine forwards case: a one-time "highlight" that should stay tinted
   until the user dismisses it, without adding a class in script. */
.notice.is-new {
  animation: notice-flash 800ms ease-out forwards;
}
@keyframes notice-flash {
  from { background-color: var(--color-accent-100); }
  to   { background-color: var(--color-accent-50); }
}
.notice.is-new.is-read { animation: none; }  /* dismissal removes the fill */

@media (prefers-reduced-motion: reduce) {
  .card { animation: none; }                 /* base style is already the end state */
  .notice.is-new { animation: none; background-color: var(--color-accent-50); }
}

Rendering Impact: composite for the card entrance. Fill mode does not change per-frame cost, but a forwards fill keeps the property under animation control after motion ends, which can keep a layer promoted and blocks transitions on the same property.

Writing entrance keyframes with only a from block is the habit that makes forwards unnecessary. The browser builds the missing to keyframe from the element’s current base style, so the animation ends exactly where normal CSS already puts the element, and nothing needs to be held.

Which keyframe fills, by direction and iteration countforwards holds the keyframe the last iteration ended on, which is not always 100%.Which keyframe fills, by direction and iteration countbackwards appliesforwards holdsnormal, 1iteration0%100%reverse, 1iteration100%0%alternate, 2iterations0%0%alternate, 3iterations0%100%normal, 1.5iterations0%50% keyframe values
forwards holds the keyframe the last iteration ended on, which is not always 100%.

Verification checklist

Constraints and trade-offs

  • backwards applies during the delay only; it does nothing for animations without a delay.
  • Removing an animation that fills forwards causes an instant jump to base styles.
  • Fractional iteration counts fill with interpolated mid-animation values.
  • both is a common default in snippets and is often more than needed.
  • Script animations use the fill option with the same values and the same pitfalls.

Frequently asked questions

Why does my delayed animation flash before it starts?

With the default fill mode none, the element shows its base styles during the delay. Use backwards so the first keyframe applies until the animation starts.

Why can’t I change a property after an animation with forwards finishes?

The filled value belongs to the animation, which overrides normal rules. Put the end state in base CSS and remove forwards, or remove the animation when the state changes.

What does animation-fill-mode: both do?

It applies the starting keyframe during the delay and keeps the ending keyframe after the animation finishes.

Does forwards always hold the 100% keyframe?

No. It holds whichever keyframe the final iteration ended on, which depends on direction and iteration count.