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 (center overlay)
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
| 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 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.