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

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

ComponentEnterExitEasing
Modal200ms150mscubic-bezier(0.2, 0, 0, 1)
Modal backdrop150ms150msease-out / ease-in
Bottom sheet350ms250msspring(200, 25)
Drawer300ms250mscubic-bezier(0.2, 0, 0, 1)
Tooltip120ms80msease-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.