Skip to content

Exit Animations

The lazy exit is the entrance played backwards: same distance, same duration, same curve. It looks tidy, but it gives a goodbye as much time as a hello. Something leaving deserves less ceremony than something arriving.

Dismiss the toasts and watch them go.

Wrong
Changes saved
240ms, reversed
Right
Changes saved
120ms fade

Both enter the same way, rising 12px over 240ms. The left one leaves by doing that again in reverse. The right one fades out in half the time, 120ms, with 2px of blur, no travel and an ease-in.

An entrance has a job. Something new is here, and the motion shows you where it came from. An exit has none. The user has already moved on, and anything that makes them watch is in the way. So exits get about half the duration and less movement. They are also the one place ease-in fits, since the element is speeding away and nobody needs to watch it settle.

In React, an element removed from the tree is gone before it can animate. Motion's AnimatePresence keeps it mounted until its exit animation finishes.

Exits that keep layout steady

The exit people forget most is a row leaving a list. Without one, the row vanishes and everything below it jumps up in a single frame. Delete a few tasks on each side.

Wrong
  • Call Sarah
  • Book flights
  • Review PR
  • Pay rent
Removed instantly
Right
  • Call Sarah
  • Book flights
  • Review PR
  • Pay rent
160ms collapse

On the right the row collapses its height while it fades, so the rows beneath slide up instead of teleporting. It takes 160ms, short enough that you barely notice it, and long enough that your eye can follow the list.

Usage

app.tsx
import { AnimatePresence, motion } from "motion/react";

<AnimatePresence initial={false}>
  {open && (
    <motion.div
      initial={{ opacity: 0, y: 12, filter: "blur(0px)" }}
      animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
      exit={{
        opacity: 0,
        filter: "blur(2px)",
        transition: { duration: 0.12, ease: "easeIn" },
      }}
      transition={{ duration: 0.24, ease: [0.23, 1, 0.32, 1] }}
    >
      Changes saved
    </motion.div>
  )}
</AnimatePresence>
.toast {
  display: block;
  transition:
    opacity 240ms cubic-bezier(0.23, 1, 0.32, 1),
    translate 240ms cubic-bezier(0.23, 1, 0.32, 1),
    display 240ms allow-discrete;

  @starting-style {
    opacity: 0;
    translate: 0 12px;
  }
}

.toast[hidden] {
  display: none;
  opacity: 0;
  filter: blur(2px);
  transition:
    opacity 120ms ease-in,
    filter 120ms ease-in,
    display 120ms allow-discrete;
}

In CSS, the transition that runs is the one declared on the state you are moving to. So the entrance timing lives on .toast, the exit timing lives on .toast[hidden], and allow-discrete holds off display: none until the fade has finished.

Resources

Search concepts

Search for a command to run...