Scale Entrances

Menus, popovers, and dialogs usually enter with a bit of scale. It reads as the element arriving rather than switching on. But the amount matters. A scale entrance that starts at zero looks like a cartoon zoom. One that starts at 0.95 looks like a small settle.

Open the two menus below. They share the same duration and easing. Only the starting scale is different.

Wrong
From scale(0)
Right
From scale(0.95)

The menu on the left spends the first part of its animation as a tiny unreadable smudge. Text scales along with the box, so every letter is squashed until the very end. The menu on the right is readable from the first frame. The scale is a hint of movement, not the movement itself.

Somewhere between 0.9 and 0.97 is the usual range. Combine it with an opacity fade so the element does not pop into existence at its starting size.

Grow from the trigger

Scale needs an origin. By default an element scales from its center, which is fine for a dialog in the middle of the screen. A menu that opens under a button in a corner should grow from that corner, so it looks attached to the thing you clicked.

Wrong
Origin: center
Right
Origin: top right

On the left the menu inflates from its own middle and seems to drift away from the button. On the right it grows out of the button. Same scale, same timing. The only change is transform-origin.

Positioning libraries know where the anchor is. Base UI and Floating UI expose it as a CSS variable, so you can write transform-origin: var(--transform-origin) once and get the right corner for every placement.

Find your number

Drag the slider to change where the menu starts. The animation here is slowed down so the shape of it is easier to see.

Below 0.8 the entrance starts to feel like a zoom. Above 0.98 you may as well drop the scale and keep only the fade. The sweet spot is small enough that you would barely notice it if you were not looking for it.

Usage

app.tsx
<div
  class="origin-(--transform-origin) transition duration-200 ease-out
         data-closed:scale-95 data-closed:opacity-0"
>
  ...
</div>
.menu {
  transform-origin: var(--transform-origin, top center);
  transition:
    opacity 200ms cubic-bezier(0.23, 1, 0.32, 1),
    transform 200ms cubic-bezier(0.23, 1, 0.32, 1);
}

.menu[data-closed] {
  opacity: 0;
  transform: scale(0.95);
}
import { motion } from "motion/react";

<motion.div
  initial={{ opacity: 0, scale: 0.95 }}
  animate={{ opacity: 1, scale: 1 }}
  exit={{ opacity: 0, scale: 0.95 }}
  transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}
  style={{ transformOrigin: "var(--transform-origin)" }}
/>

Keep exits shorter than entrances. A menu that takes 200ms to open can close in 120ms, since the user has already decided to leave and nothing needs to be read on the way out.

Resources

Search concepts

Search for a command to run...