Exit Animations

The lazy exit is the entrance played backwards. Slide up and fade in, then slide down and fade out, same duration, same curve. It is symmetrical, and that is the problem. Something leaving deserves less ceremony than something arriving.

Dismiss the toasts below and watch how each one goes.

Enter, reversed
Changes savedUndo
Quick fade
Changes savedUndo

Both enter the same way, in 240ms with an ease-out. The left one exits by running that in reverse, so you sit through another 240ms while it slides away. The right one fades out in half the time, with a touch of blur and no travel at all.

The reason is attention. An entrance asks for it: something new is here, and the motion tells you where it came from. An exit is the user saying they are done. Anything that keeps them watching after that point is in the way.

A good rule of thumb: exits run at half the duration of the entrance, and they can use an ease-in, since the thing is accelerating away and nobody needs to see it settle.

Exits that keep layout honest

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

No exit
  • Reply to Sarah
  • Book flights to Lisbon
  • Review pull request
  • Send the invoice
Collapse
  • Reply to Sarah
  • Book flights to Lisbon
  • Review pull request
  • Send the invoice

On the right the row collapses its height while it fades, so the rows beneath slide up to fill the gap instead of teleporting. It is still fast, 160ms, and it never draws attention to itself. It just stops the layout from jumping.

Usage

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

<AnimatePresence initial={false}>
    {open && (
        <motion.div
            initial={{ opacity: 0, y: 12 }}
            animate={{ opacity: 1, y: 0 }}
            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;
  opacity: 1;
  translate: 0 0;
  transition:
    opacity 240ms var(--ease-out),
    translate 240ms var(--ease-out),
    display 240ms allow-discrete;
}

@starting-style {
  .toast {
    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 enter timing lives on .toast and the exit timing on .toast[hidden], and allow-discrete is what lets the fade finish before display: none takes the element out.

Resources

Search concepts

Search for a command to run...