How animation-fill-mode Determines the Style of an Element Outside the Active Animation Duration

animation-fill-mode controls whether an element retains styles from the keyframes before the animation starts and after it ends. Learn forwards, backwards and both with runnable samples.

The moment the animation ends, the element snaps back to its original position, as if the last three seconds never happened. You set a transform, watched it glide across the screen, and then it teleports home. The culprit is a default you never chose: the animation-fill-mode CSS property defaults to none, which means the styles inside your keyframes only apply while the animation is actively running.

The One Question This Page Answers

The animation-fill-mode CSS property decides what computed value an element keeps before the animation starts, during any animation-delay, and after the animation ends, when the animation-duration is exhausted. With the default of none, the element uses its normal styles outside the active animation window: before the delay it sits at its initial state, and after the last iteration it reverts to whatever the cascade says, ignoring the keyframes entirely. That is why your transform snaps back: the 100% frame’s transform was never meant to persist. Understanding the four values, none, forwards, backwards, and both, is the difference between a button that stays where you left it and one that undoes your work the instant the animation-iteration-count runs out.

Why the Element Snaps Back: The Default of None

Write a keyframe that moves a card from left to right, apply it to an element, and watch what happens after the duration passes. With animation-fill-mode set to none, the element’s computed value for transform returns to whatever the cascade computed before the animation began, typically the initial state of no transform. The keyframes were a temporary override, active only while the animation ran. The moment it finished they were discarded. This is the single most common surprise in CSS animation, and it is not a bug: it is the defined behaviour of the initial value.

What The Cascade Does When The Animation Stops

The cascade does not stop being the cascade because an animation ran. Declarations inside an animation’s frames participate in a special origin that outranks normal author styles, but only while the animation is applied. Once the animation ends, the element returns to the cascade’s normal flow. If you set background-color on the element’s rule, that colour comes back. If you set transform in a class, that transform applies again. The animation never intended to persist its end state; without a fill mode, it has no right to.

.card {
  animation-name: slide;
  animation-duration: 2s;
  animation-timing-function: linear;
  animation-iteration-count: 1;
}

@keyframes slide {
  from { transform: translateX(0); }
  to { transform: translateX(200px); }
}

This sample runs the animation on the compositor because it animates transform, which the compositor can handle without forcing layout or paint. The prefers-reduced-motion query below disables it for users who ask for reduced motion; when animation is none, the fill mode becomes irrelevant because no frame ever applies.

The Failure Case: What You Do When The Animation Snaps Back

You are mid-debug, the element jumps, and you need it to stay at the end. The fix is to set animation-fill-mode to forwards, but there is a trap: forwards only retains the value of the last frame that was executed. If your animation-direction is alternate, that last frame might be the 0% frame, not the 100% frame. Check which frame actually ran last before you add forwards and assume it keeps the “to” state. If the animation was removed from the element via a class toggle, even forwards will not persist, because the fill mode only applies while the animation is applied to the element. Remove the animation and the fill mode goes with it.

animation-fill-mode forwards: Keeping the End State

Set animation-fill-mode to forwards and the element keeps the computed value of the last frame it executed after the animation ends. For a normal animation with a single iteration, that is the to block. The transform that moved the card now stays at translateX(200px) after the duration finishes. It stays there indefinitely until something else changes the transform property. This is the value you reach for when an animation should end in a resting state that differs from the element’s original style.

How Forward Fill Interacts With The Cascade

Forward fill does not make the frame value permanent in the cascade sense. It is a filled value that continues to override the cascade after the animation ends, but it remains part of the animation’s effect, not a normal declaration. That means if you later set animation-fill-mode to none or remove the animation, the cascade regains control and the element snaps back again. The fill only persists because the animation is still applied, just paused in a filled state. For a card that slides in and stays, this is what you want.

.card--slide-in {
  animation-name: slide-in;
  animation-duration: 1.5s;
  animation-timing-function: ease-out;
  animation-fill-mode: forwards;
}

@keyframes slide-in {
  from { transform: translateX(100%); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .card--slide-in {
    animation: none;
  }
}

This sample animates transform and opacity, both of which run on the compositor, so the browser can offload the work to the GPU and avoid main-thread jank. When prefers-reduced-motion is set, the animation is disabled entirely, and the fill mode is never consulted because there is no animation effect to fill.

animation-fill-mode backwards: Applying the Start State During the Delay

The backwards value does the mirror job: it applies the first frame’s computed value during the animation-delay period, before the animation starts. If you have a delay of two seconds and a from frame that sets opacity to 0, the element will be invisible during those two seconds, even though the animation has not started running. Without backwards, the element would show its normal styles during the delay, then jump to the from state the moment the animation begins.

The Practical Use For Backward Fill

Backward fill exists to prevent a flash of the unanimated state. To fade an element in, set the from frame to opacity: 0 and use a delay; with backwards, the element is already invisible when the page loads or when the animation is added, and it stays invisible until the delay elapses. This is useful for stagger animations on page load, where multiple pieces should appear one after another, each waiting its turn without briefly displaying its final state.

.item {
  animation-name: fade-in;
  animation-duration: 0.8s;
  animation-delay: 0.5s;
  animation-fill-mode: backwards;
}

@keyframes fade-in {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .item {
    animation: none;
  }
}

Opacity and transform are the two properties this sample animates, and both are compositor-safe, meaning the browser can update them without relayout or repaint. With the reduced-motion query, the animation is turned off, and because there is no animation, the backwards fill has no effect: the element shows its normal styles.

animation-fill-mode both: The Combination That Covers the Whole Timeline

Set animation-fill-mode to both and you get the behaviour of forwards and backwards at once. During the delay, the element takes the from frame’s computed value; after the animation ends, it keeps the to frame’s value. Use this value when you want the element to exist in its animated state at every point on the timeline, with no gap before the delay and no snap-back after the duration. If your animation has a delay and an end state that must persist, both is the right choice.

When Both Is Overkill

Both is not always necessary. If there is no animation-delay, backwards has nothing to do, because the animation starts immediately and the from frame applies at the same moment. If the end state of the animation matches the element’s normal style, forwards is redundant. Using both when you only need one side is harmless but sloppy: it fills the delay with a state you might not have intended. The real failure mode is using both and then trying to override the filled value with an inline style. The animation’s fill wins because the animation origin outranks normal author styles, so your inline transform or opacity gets ignored, and you spend an hour wondering why the element will not budge.

.hero-title {
  animation-name: rise-in;
  animation-duration: 1s;
  animation-delay: 0.2s;
  animation-fill-mode: both;
}

@keyframes rise-in {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .hero-title {
    animation: none;
  }
}

Here the title starts invisible and 20px lower during the 200ms delay, then rises into place and stays there. The transform and opacity are compositor-animated, so the main thread is free. The reduced-motion query kills the animation, at which point the both fill has no work to do and the element appears at full opacity in its normal position.

What Does animation-fill-mode Control That animation-duration Does Not

A common confusion is thinking that animation-duration controls how long the element stays in the animated state. It does not. The duration only defines how long one iteration of the frames takes to run. The fill mode is a separate axis of control: it decides whether the element’s style changes before the first iteration starts and after the last one ends. You need both. The duration says how fast the motion happens; the fill mode says what the element looks like when the motion is not happening.

The Difference In A Table

Fill modeDuring delayAfter duration
noneNormal stylesNormal styles
forwardsNormal stylesLast keyframe
backwardsFirst keyframeNormal styles
bothFirst keyframeLast keyframe

This table is the entire mental model. Read the row for both and see why it is the safest default when you have a delay: it covers the gap at the front and the snap-back at the back.

CSS Animation End State: Why Your Last Keyframe Is Not Enough

Setting the to frame to the value you want is not enough to keep it there. The frame only defines a target for the animation’s duration. The cascade governs the element outside the animation, and if the cascade does not know about your target value, it will not keep it. This is the gap that animation-fill-mode bridges. Write forwards or both, and you are telling the cascade, “this animated value should be treated as if it were the normal computed value for this element, after the animation finishes.” Without it, the cascade reverts to the rules you actually wrote.

The Real-World Cost Of Getting It Wrong

The result of ignoring this is a page where modals flash their hidden state, menus blink open then shut, and progress bars reset to zero. Each one is a user-visible glitch that erodes trust in the interface. The fix is mechanical: decide what the element should look like before the delay and after the duration, then pick the fill mode that matches. If you need both, use both. If you only need the end state, use forwards. If you have no delay and the end state is the resting state, you might not need a fill mode at all, but using both is safer than assuming.

Common Mistakes with animation-fill-mode and the JavaScript Fallback

Two mistakes send developers down a rabbit hole. First, expecting forwards to persist after the animation is removed from the element. If you add a class to trigger an animation and then remove that class when it finishes, the animation is gone, and with it the fill. The element snaps back because there is no animation effect left to fill. The old technique swapped in a JavaScript class on animationend or animationstart to set the end-state properties manually, but that is a workaround for a problem fill modes solve natively. Second, setting both and then overriding with inline styles: the animation’s fill outranks the inline declaration, so your manual value loses.

The @supports Guard And The Prefixed Alternative

Older Safari and iOS Safari releases required the -webkit- prefix for animation-fill-mode. Check caniuse for the exact version cutoff for your audience. Supporting those browsers means shipping prefixed and unprefixed declarations. A more robust fallback for the end state is to duplicate the frame’s 100% values as normal declarations on the element itself, so even if the fill mode is not supported, the computed value matches. Pair this with an @supports (animation-fill-mode: forwards) guard to only apply the fill where it works, leaving the duplicate as the base style.

Frequently Asked Questions

Does animation-fill-mode: forwards keep the element in the 100% state forever?

Yes, as long as the animation remains applied to the element and no other rule overrides the filled property, it keeps the last executed frame’s value indefinitely.

What is the difference between animation-fill-mode: both and not setting it?

Without it, the element uses normal styles before and after the animation. With both, it uses the first frame during the delay and the last frame after the duration, covering both gaps.

Can I use animation-fill-mode with animations that do not have a delay?

Yes, but backwards has no effect because there is no delay period to fill. Forwards still works to keep the end state.

Does animation-fill-mode affect performance?

No. The fill mode only determines which computed value is used; it does not change whether the animation runs on the compositor. That depends on the properties animated, not the fill mode.

Why is my element stuck at the end state and will not respond to hover styles?

The filled value from the animation overrides the cascade, including hover rules. To override it, use a more specific selector or a CSS variable set outside the animation.

The Final Word: Who This Is For and Who Should Look Away

This subject suits the working front-end developer who has watched an element snap back and wants the precise mechanism, not a platitude. It suits the technical writer who needs to state, without guessing, what a fill mode does to a computed value. It does not suit the designer looking for aesthetic inspiration, nor the beginner who has not yet learned the box model. For them, the mechanics are noise. Come back when the animation does not behave.

The single sentence this page could not have been written without is this: the default of none is the entire reason your animation snaps back, and every other value exists to tell the cascade which frame’s computed value should outlive the animation’s run.