Animating entrances and exits

Sep 18, 2026 · Motion · 3 min read

Every element that appears on screen has to answer two questions: where did it come from, and where did it go? When the motion answers them clearly, the interface feels like one connected space. When it does not, things just pop in and out of a void.

Grow out of the trigger

A popover that scales up from its own center looks like it came from nowhere. One that grows out of the button that opened it looks caused. The difference is a single property.

.popover {
  /* Opens below-left of the trigger, so it grows from its top-left. */
  transform-origin: top left;
  transition:
    scale 180ms ease-out,
    opacity 180ms ease-out;
}
.popover[data-starting-style] {
  scale: 0.96;
  opacity: 0;
}

Positioning libraries already know the right corner, because they placed the popover. Radix exposes it as --radix-popover-content-transform-origin and Base UI as --transform-origin. Use the variable instead of guessing, so the origin flips when the popover flips to the other side of the trigger.

Exits are quieter than entrances

An entrance has a job: draw attention to something new. An exit has no job except getting out of the way. So make it smaller in every sense: shorter, less distance, less movement. A slight blur softens the end so the element dissolves instead of sliding off.

Not recommended
Recommended
<motion.div
  initial={{ opacity: 0, y: 8 }}
  animate={{ opacity: 1, y: 0, transition: { duration: 0.24 } }}
  exit={{ opacity: 0, y: -4, filter: "blur(4px)", transition: { duration: 0.15 } }}
/>

Swapping one icon for another

Copy turning into a check, play into pause, a menu into a close icon. Swapping them instantly looks like a glitch; sliding them looks busy. A cross-fade with a little scale and blur reads as one icon transforming into the other.

<AnimatePresence mode="popLayout" initial={false}>
  <motion.span
    key={copied ? "check" : "copy"}
    initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
    animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
    exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
    transition={{ type: "spring", duration: 0.3, bounce: 0 }}
  >
    {copied ? <CheckIcon /> : <CopyIcon />}
  </motion.span>
</AnimatePresence>

Stagger in small groups

When a whole section arrives at once, animating it as one block feels heavy, like a wall sliding in. Animating every single element feels fussy. The middle ground is a few logical groups (a heading, then the text, then the actions) with a short delay between them.

Ship interfaces that feel finished
Notes on the small decisions that make a product feel considered.

Keep the step around 40 to 80ms and cap the total. If the last group starts more than about 300ms after the first, people are waiting for your animation instead of reading.

Checklist

  • Popovers and menus scale from the trigger's corner, using the library's origin variable.
  • Modals scale from the center.
  • Exits are shorter and travel less than entrances.
  • Icon swaps cross-fade with scale and blur.
  • Staggers use a few groups, 40 to 80ms apart, finishing within about 300ms.

This piece expands on the Motion rules in my cheat sheet, where every rule has a live example.

Keep readingCSS transitions vs keyframesChoosing between transitions and keyframes, animating only what you mean to, fixing jitter and switching themes cleanly.