Interruptibility

Open a sheet, then close it before it finishes arriving. What should happen?

The obvious answer is that it should turn around. It is somewhere in the middle, so it should head back from there. That is interruptibility: an animation that can change its mind. It sounds too simple to be worth a name, which is exactly why it is so jarring when an interface gets it wrong.

Wrong
Keyframes
Right
Transition

Tap the button twice, quickly. The left sheet will jump to fully open and then slides down, as if closing had to start from the beginning. The right one turns around wherever it happens to be.

Both look identical if you wait. The difference only shows when you do not wait, and trust me, most people do not wait.

A recording and a destination

The left sheet runs on CSS keyframes, one clip for opening and one for closing. A keyframe animation is a "recording". It has a first frame and a last frame, and when you play it, it plays from the first frame no matter where the element currently is. Closing mid-flight means jumping to the start of the closing clip, which is fully open, and sliding down from there.

The right sheet runs on a CSS transition. A transition is not a recording, it is a "destination". You tell the browser where the element should be, and it animates from wherever the element is right now. If the destination changes halfway, it simply aims at the new one and keeps going.

That is most of the idea. Describe where things should end up, not the trip to get there. Transitions, Motion's animate, and springs all work this way. Keyframes do not.

Never lock the door

Another way to get it wrong is to "lock the door". The animation is fine, but the interface stops listening while it runs. An isAnimating flag drops clicks, or pointer-events: none stays on until the motion is done.

Locked
0 taps ignored
Listening
Every tap goes through

If you click too fast, you'll notice some of your taps vanish. The interface decided its animation mattered more than your input and made you wait. It is only a few hundred milliseconds, but it is long enough to feel ignored, so people tap again, which drops another tap, which makes it worse.

The right menu stays listening the whole time. If you tap at any point, the menu will hear you. You shouldn't be obligated to wait for the full animation to finish.

Keep the momentum

A transition fixes the jump, but look closely and there is still a small lie in it. Reverse a transition halfway and the element stops, then starts again from a standstill in the other direction. It remembers where it is, but it forgets how fast it was going.

A spring remembers both. It is simulated physics: at every frame it knows its position and its speed, and when the target changes it bends that speed toward the new target instead of throwing it away. The turn becomes one movement instead of two.

Transition
Spring

So should everything be a spring? No. Sonner and Vaul, two of the best-feeling components on the web, both run on plain CSS transitions and never feel broken. A good ease-out transition is enough for most sheets, menus and sidebars. Reach for a spring when the motion comes from a gesture, like a flicked card that should keep your finger's speed, or when something gets flipped back and forth fast enough that the restart shows.

When others join in

Interrupting is not only about reversing. Sometimes the animation is still running and something new arrives: a second toast while the first is still settling, a row inserted while the list is still sliding open. Everything already in motion needs a new destination.

    Press the button a few times, fast. Each toast slides up to make room while the previous one is still landing, and none of them flinch. This is a Motion layout animation. Whenever a sibling appears or leaves, Motion measures the new positions and retargets anything still in flight.

    Usage

    Use a transition for anything a person can toggle. Keep keyframes for one-shots nothing can interrupt, like a spinner. And never gate input on an animation: no isAnimating guards, no pointer-events: none while things move.

    styles.css
    .sheet {
      transform: translateY(100%);
      transition: transform 300ms cubic-bezier(0.33, 1, 0.68, 1);
    }
    
    .sheet[data-open] {
    transform: translateY(0);
    }
    /_ Restarts from the first frame every time it is applied. _/
    @keyframes slide-up {
    from { transform: translateY(100%); }
    to { transform: translateY(0); }
    }
    
    .sheet[data-open] {
    animation: slide-up 300ms cubic-bezier(0.33, 1, 0.68, 1) both;
    }
    import { motion } from "motion/react";
    
    <motion.div
      animate={{ y: open ? 0 : "100%" }}
      transition={{ type: "spring", stiffness: 300, damping: 30 }}
    />

    Resources

    Search concepts

    Search for a command to run...