Modal & Sheet Animation
Modals and bottom sheets are both overlays that appear above the main content — but they have different motion behavior because they come from different directions and are interacted with in different ways.
Click to open a modal or sheet — click the backdrop or press ESC to close
Modal (center overlay)
A modal appears in the center of the viewport, usually triggered by a button click (not a gesture). The right animation: scale + fade, not slide.
@keyframes modal-enter {
from {
opacity: 0;
transform: scale(0.96) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.modal {
animation: modal-enter 0.2s cubic-bezier(0.2, 0, 0, 1);
transform-origin: center 40%; /* appears from slightly above center */
}
scale(0.96) — only 4% smaller. Enough to create the feeling of “appearing” without causing a jump.
translateY(-4px) — slight, providing a directional cue without making the modal “fall down”.
Backdrop: always include a backdrop to signal focus. Fade it separately with a shorter duration than the modal.
.modal-backdrop {
animation: fade-in 0.15s ease-out;
background: rgba(0, 0, 0, 0.5);
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
Exit: fade-out + a slight scale down, faster than enter (150ms). Exit must be fast so the user returns to their main task right away.
.modal.closing {
animation: modal-exit 0.15s ease-in forwards;
}
@keyframes modal-exit {
to { opacity: 0; transform: scale(0.96); }
}
Bottom Sheet
A bottom sheet slides up from the bottom — it’s the closest to physical motion, so a spring fits well.
// Framer Motion
const sheet = useSpring({
y: isOpen ? 0 : '100%',
config: { stiffness: 200, damping: 25, mass: 1.2 }
});
/* CSS fallback — bezier mimicking a spring */
.bottom-sheet {
transform: translateY(100%);
transition: transform 0.35s cubic-bezier(0.2, 0, 0, 1);
}
.bottom-sheet.open {
transform: translateY(0);
}
Drag-to-close: allow swiping down to close the sheet.
const handler = useDrag(
({ last, velocity: [, vy], offset: [, oy], cancel }) => {
if (oy < 0) cancel(); // don't allow dragging above the closed position
if (last) {
// close if dragged far enough or swiped fast
if (oy > 80 || vy > 0.5) close();
else api.start({ y: 0 }); // snap back
} else {
api.start({ y: oy, immediate: true }); // follow finger
}
},
{ from: () => [0, y.get()], filterTaps: true, bounds: { top: 0 } }
);
Handle indicator: a small bar at the top of the sheet that signals draggability.
.sheet-handle {
width: 36px;
height: 4px;
background: var(--overlay-20);
border-radius: 2px;
margin: 8px auto;
}
Drawer (side panel)
A drawer slides in from the left or right. Similar to a bottom sheet but horizontal.
.drawer {
transform: translateX(-100%); /* left drawer */
transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1);
}
.drawer.open {
transform: translateX(0);
}
A drawer usually doesn’t need a spring because there’s no gesture velocity on desktop. A mobile drawer can use a spring if it supports swipe-to-open.
Timing reference
| Component | Enter | Exit | Easing |
|---|---|---|---|
| Modal | 200ms | 150ms | cubic-bezier(0.2, 0, 0, 1) |
| Modal backdrop | 150ms | 150ms | ease-out / ease-in |
| Bottom sheet | 350ms | 250ms | spring(200, 25) |
| Drawer | 300ms | 250ms | cubic-bezier(0.2, 0, 0, 1) |
| Tooltip | 120ms | 80ms | ease-out |
Exit is always faster than enter. The user is done with the modal and wants to return right away.
Stack behavior
When opening a modal from within a modal, the new modal shouldn’t fully cover the old one — create a visual stack so the user understands the depth.
.modal-layer-1 { transform: scale(0.95) translateY(-8px); }
.modal-layer-2 { transform: scale(1) translateY(0); }
See also: Spring Parameters, Gesture Feedback Motion, Ease vs Spring.