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.
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.
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
<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
7 Practical Animation Tips - Emil Kowalski's list, including why popovers should never start from zero.
CSS Transforms - A clear walk through scale, translate, and transform-origin, with visuals.
Base UI Popover - Exposes the resolved transform origin as a CSS variable so the menu grows from its anchor.
transform-origin - The MDN reference for the property that decides where a scale grows from.