Modal & Sheet Animation

Modal và bottom sheet đều là overlay hiện lên trên content chính — nhưng chúng có hành vi chuyển động khác nhau vì chúng đến từ những hướng khác nhau và được tương tác theo cách khác nhau.

Click để mở modal hoặc sheet — click backdrop hoặc ESC để đóng

Modal xuất hiện ở giữa viewport, thường triggered bởi button click (không phải gesture). Animation phù hợp: scale + fade, không 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%; /* xuất hiện từ hơi trên center */
}

scale(0.96) — chỉ 4% nhỏ hơn. Đủ để tạo cảm giác “xuất hiện” mà không gây giật.

translateY(-4px) — nhẹ, tạo direction cue nhưng không làm modal “rơi xuống”.

Backdrop: luôn có backdrop để signal focus. Fade riêng với duration ngắn hơn 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 + scale xuống nhẹ, nhanh hơn enter (150ms). Exit phải nhanh để người dùng quay lại task chính ngay.

.modal.closing {
  animation: modal-exit 0.15s ease-in forwards;
}

@keyframes modal-exit {
  to { opacity: 0; transform: scale(0.96); }
}

Bottom Sheet

Bottom sheet slide lên từ dưới — gần với vật lý nhất nên phù hợp dùng spring.

// Framer Motion
const sheet = useSpring({
  y: isOpen ? 0 : '100%',
  config: { stiffness: 200, damping: 25, mass: 1.2 }
});
/* CSS fallback — bezier giả 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: cho phép swipe xuống để đóng sheet.

const handler = useDrag(
  ({ last, velocity: [, vy], offset: [, oy], cancel }) => {
    if (oy < 0) cancel(); // không cho kéo lên trên closed position
    if (last) {
      // đóng nếu kéo đủ xa hoặc swipe nhanh
      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: dải nhỏ ở top của sheet signal draggability.

.sheet-handle {
  width: 36px;
  height: 4px;
  background: var(--white-20);
  border-radius: 2px;
  margin: 8px auto;
}

Drawer (side panel)

Drawer slide vào từ trái hoặc phải. Tương tự bottom sheet nhưng horizontal.

.drawer {
  transform: translateX(-100%); /* left drawer */
  transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1);
}

.drawer.open {
  transform: translateX(0);
}

Drawer thường không cần spring vì không có gesture velocity trên desktop. Mobile drawer có thể dùng spring nếu 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 luôn nhanh hơn enter. Người dùng đã xử lý xong với modal và muốn quay lại ngay.

Stack behavior

Khi mở modal từ trong modal, modal mới không nên phủ kín modal cũ — tạo visual stack để người dùng hiểu độ sâu.

.modal-layer-1 { transform: scale(0.95) translateY(-8px); }
.modal-layer-2 { transform: scale(1) translateY(0); }

Tham khảo: Spring Parameters, Gesture Feedback Motion, Ease vs Spring.