9 hiệu ứng css modal animation gọn nhẹ
Một css modal animation là chuyển động khi hộp thoại xuất hiện và biến mất, thay vì bật thẳng từ ẩn sang hiện.
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Fade + phóng to
- 02 Bottom sheet
- 03 Drawer bên cạnh
- 04 Làm mờ nền
- 05 Dialog gốc
- 06 Popover xác nhận
- 07 Nút mở rộng toàn màn hình
- 08 Rung khi nhấn ra ngoài
- 09 Modal xếp chồng
Chín ô phía trên tự mở rồi tự đóng, lặp lại mỗi 2 giây bên trong một iframe không cuộn được và quá ngắn để chứa một tương tác thật — bạn xem chuyển động chứ không bấm gì cả, vì nút trigger trong khung xem trước này chưa được gắn sự kiện. Từ 01 đến 04 là bốn biến thể của cùng một tấm panel căn giữa hoặc dính mép, khác nhau ở vị trí và ở cách lớp nền phía sau xử lý. 05 và 06 giao việc cho trình duyệt: một cái animate <dialog> gốc, một cái dùng Popover API gắn vào nút anchor, thay vì tự dựng div overlay từ đầu. 07 đến 09 thêm một phần chuyển động thứ hai — một thumbnail phóng to thành modal, một cú rung thay cho việc đóng, và một dialog thứ hai đẩy dialog đầu lùi ra sau.
01Fade + phóng to
Modal căn giữa mờ dần hiện ra trong lúc phóng to từ 0.92 lên 1 trong khoảng 200ms, rồi chạy ngược khi đóng — không trượt, không đổi màu, chỉ opacity và scale đi cùng nhau. Đây là lựa chọn an toàn nhất cho modal xác nhận và cảnh báo cơ bản, kiểu modal mà nút thanh toán mở ra ngay trước khi trừ tiền thẻ.
.overlay {
position: fixed; inset: 0;
opacity: 0; pointer-events: none;
transition: opacity $duration $easing;
}
.modal {
position: fixed; left: 50%; top: 50%;
transform: translate(-50%, -50%) scale(.92);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .modal {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
pointer-events: auto;
}
02Bottom sheet
Một tấm sheet trượt lên từ mép dưới màn hình và dừng lại vừa khít với chiều cao nội dung thay vì phủ kín cả màn hình, kèm một thanh handle nhỏ phía trên tiêu đề để báo đây là sheet chứ không phải modal thường. Thanh handle đó quan trọng nhất trên màn hình hẹp kiểu mobile, đúng nơi kiểu sheet này được dựng ra, vì ở đó một modal đầy đủ sẽ trông quá cồng kềnh.
.sheet {
position: fixed; left: 50%; bottom: 0; top: auto;
transform: translate(-50%, 100%);
max-height: 62%;
border-radius: $r-card $r-card 0 0;
transition: transform $duration $easing;
}
.sheet__handle {
width: 36px; height: 4px;
margin: 0 auto;
border-radius: $r-pill;
background: rgba($stage-ink, .25);
}
.stage.is-open .sheet { transform: translate(-50%, 0); }
03Drawer bên cạnh
Một panel rộng khoảng 260px trượt vào từ mép phải với easing kiểu lò xo hơi vọt qua rồi mới ổn định, thay vì dừng khựng ngay khi chạm vị trí cuối. Nó gợi cảm giác giỏ hàng hoặc panel cài đặt hơn là một câu hỏi xác nhận, vì drawer ngầm ý còn nhiều thứ để xem tiếp chứ không chỉ một quyết định có-hoặc-không.
.drawer {
position: fixed; right: 0; top: 0; bottom: 0; left: auto;
transform: translateX(100%);
width: min(78vw, 260px);
transition: transform $duration $easing;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .drawer {
transform: translateX(0);
pointer-events: auto;
}
04Làm mờ nền
Trang phía sau modal mờ dần từ 0 lên 8px cùng nhịp với việc modal tự fade, nên hai hiệu ứng trông như một chuyển động duy nhất thay vì hai thứ chạy tách rời nhau. Kiểu này hợp với màn hình thanh toán và các xác nhận quan trọng khác, nơi làm mờ nền kéo sự chú ý mạnh hơn hẳn so với chỉ làm tối nền.
.overlay {
background: rgba($stage-ink, .28);
backdrop-filter: blur(0px);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing,
backdrop-filter $duration $easing;
}
.modal {
transform: translate(-50%, -50%) scale(.94);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing;
}
.stage.is-open .overlay { opacity: 1; backdrop-filter: blur(8px); }
.stage.is-open .modal {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
05Dialog gốc
<dialog> mở và đóng bằng một transition thật thay vì bật ngay lập tức, vì @starting-style định nghĩa khung hình "trước đó" cho những thuộc tính mà trình duyệt vốn chuyển trong đúng một nhịp. transition-behavior: allow-discrete giữ display và thuộc tính overlay của dialog nằm trong transition đó thay vì chuyển ngay khi showModal() chạy.
dialog.modal {
opacity: 0;
transform: scale(.92) translateY(10px);
transition: opacity $duration $easing,
transform $duration $easing,
overlay $duration allow-discrete,
display $duration allow-discrete;
&[open] { opacity: 1; transform: none; }
@starting-style {
&[open] { opacity: 0; transform: scale(.92) translateY(10px); }
}
}
06Popover xác nhận
Một ô xác nhận nhỏ hiện ngay phía trên nút delete của nó, được đặt vào top layer khi Popover API gắn nó vào, không có mũi tên chỉ ngược lại nút trigger. Nó thay thế một modal đầy đủ trong các xác nhận ít rủi ro như xóa một dòng, nơi cả một overlay sẽ là quá nhiều nghi thức cho một câu hỏi có-hoặc-không.
.confirm {
position: absolute;
left: 50%; bottom: calc(100% + 10px);
transform: translateX(-50%) translateY(6px) scale(.92);
opacity: 0;
transition: opacity $duration $easing,
transform $duration $easing;
}
.confirm:popover-open {
position: fixed;
inset: auto;
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
07Nút mở rộng toàn màn hình
Thumbnail vừa được nhấn phóng to thành modal toàn màn hình trong khoảng 420ms mà chiều rộng và chiều cao của nó chưa từng thay đổi — toàn bộ hiệu ứng chỉ là một transform: scale chạy ngược từ góc bị thu nhỏ đến kích thước đầy đủ. Caption và nút đóng chỉ mờ dần hiện ra sau khi việc phóng to đó xong, nên ô gallery vẫn giữ dáng thumbnail sạch sẽ cho đến khi nó thật sự mở.
.thumb {
position: fixed; inset: 0;
border-radius: $r-card;
transform: translate(-30%, 20%) scale(.3);
transform-origin: 20% 75%;
transition: transform $duration $easing,
border-radius $duration $easing;
}
.stage.is-open .thumb {
transform: translate(0, 0) scale(1);
border-radius: 0;
}
.thumb__caption, .thumb__close {
opacity: 0;
transition: opacity $duration $easing;
}
.stage.is-open .thumb__caption,
.stage.is-open .thumb__close { opacity: 1; }
08Rung khi nhấn ra ngoài
Nhấn ra ngoài một modal bắt buộc không đóng nó lại — viền của modal chớp đỏ và cả khối rung ngang hai lần rồi trở về màu viền bình thường. Kiểu này được dựng cho màn hình đồng ý điều khoản và các trường bắt buộc, nơi tương tác phải nói "bạn không thể bỏ qua cái này" mà không cần thêm chữ nào bật ra để nói điều đó bằng lời.
.modal.is-shaking {
animation: shake $duration cubic-bezier(.36, .07, .19, .97) both;
border-color: $error;
}
@keyframes shake {
10%, 90% { transform: translate(calc(-50% - 3px), -50%); }
20%, 80% { transform: translate(calc(-50% + 4px), -50%); border-color: $error; }
30%, 70% { transform: translate(calc(-50% - 4px), -50%); }
40%, 60% { transform: translate(calc(-50% + 3px), -50%); }
50% { transform: translate(calc(-50% - 3px), -50%); border-color: $error; }
}
09Modal xếp chồng
Mở modal thứ hai không ẩn modal thứ nhất đi — nó đẩy modal đầu lùi ra sau 16px, thu nhỏ về 0.95, và giảm opacity xuống 0.55, để màn hình đầu vẫn hiện diện như bối cảnh nằm phía sau modal thứ hai. Kiểu này được dựng cho quy trình nhiều bước như một form địa chỉ rồi tới một ô tra mã bưu điện, nơi mất hẳn màn hình đầu sẽ khiến người dùng quên mất mình đang điền gì.
.modal--1 {
transform: translate(-50%, -50%);
transition: transform $duration $easing,
opacity $duration $easing;
}
.modal--2 {
transform: translate(-50%, -50%) translateY(10px) scale(.9);
opacity: 0;
transition: transform $duration $easing,
opacity $duration $easing;
}
.stage.is-stacked .modal--1 {
transform: translate(-50%, calc(-50% - 16px)) scale(.95);
opacity: .55;
}
.stage.is-stacked .modal--2 {
transform: translate(-50%, -50%) translateY(0) scale(1);
opacity: 1;
}
Chỗ dễ hỏng ở đâu? — hai cái bẫy
Mục 05 chỉ animate được vì hai thứ phối hợp với nhau: @starting-style định nghĩa khung hình "trước đó" cho những thuộc tính mà trình duyệt vốn chuyển ngay trong một nhịp, và allow-discrete đặt trên display cùng overlay để giữ chúng nằm trong transition thay vì chuyển ngay khi showModal() chạy. Bỏ một trong hai thứ, dialog vẫn mở — nó chỉ bật thẳng vào vị trí không có animation nào cả, vì display và thuộc tính overlay ở top layer mặc định là discrete và bỏ qua mọi transition trừ khi được khai báo rõ để áp dụng cho chúng. Mức hỗ trợ trình duyệt đủ mới để đáng lưu ý ở đây: <dialog> chạy được từ Chrome 37, Safari 15.4, Firefox 98, theo trang dialog trên MDN, còn @starting-style cần Chrome 117, Safari 17.5, hoặc Firefox 129, theo trang @starting-style trên MDN — một Firefox cũ hơn vẫn mở được dialog, chỉ là bỏ qua hiệu ứng fade.
Cái bẫy thứ hai nằm trong markup của demo, không phải trong CSS: mỗi ô trong lưới chạy bằng một class .is-demo và một bộ keyframes lặp chỉ để tự động animate bản xem trước, hoàn toàn tách biệt với .is-open, [open], :popover-open đúng nghĩa mà một cú click thật sẽ kích hoạt. Copy nguyên các rule .is-demo thẳng vào bản build thật thì modal vẫn cứ lặp mỗi 2 giây bất kể người dùng làm gì, vì những keyframes đó không lắng nghe tương tác thật nào cả — mật khẩu của kho lưu trữ là udnptj2b nếu bạn muốn thấy tận mắt vòng lặp demo dừng ở đâu và hành vi thật bắt đầu ở đâu thay vì đoán chỉ từ SCSS.
Mục 06 dùng Popover API cũng có khoảng cách phiên bản riêng: Chrome hỗ trợ tính năng này từ 114, Safari từ 17, Firefox theo sau từ 125, theo hướng dẫn Popover API trên MDN, và ô xác nhận chỉ có được vị trí top layer cùng style :popover-open một khi thuộc tính popover thật sự nằm trên phần tử — trong demo này nó được gắn bằng JavaScript ngay lần click đầu tiên thay vì có sẵn trong markup từ đầu.
Khả năng tiếp cận
Không mục nào trong chín mục giảm chuyển động theo cùng một cách, vì "giảm" mang nghĩa khác nhau tùy đó là một cú rung hay một hiệu ứng mờ dần. Từ 01 đến 04 bỏ hẳn transition lúc xuất hiện và chỉ còn trạng thái đóng hoặc mở, không scale, không trượt, không mờ nào chạy nữa. Transform thumbnail-thành-toàn-màn-hình của 07 cũng rút gọn thành một cú chuyển tức thì, caption và nút đóng hiện ra ngay chứ không còn mờ dần sau một nhịp. 08 đổi hẳn hành vi thay vì chỉ đổi tốc độ: keyframes rung biến mất, viền chuyển thẳng sang đỏ, nên modal vẫn báo rõ ràng là nó từ chối đóng mà không cần chuyển động nào làm việc đó. 09 vẫn đổi scale và opacity khi modal thứ hai mở ra, chỉ là trong 1ms thay vì theo easing lò xo, nên việc xếp chồng vẫn rõ ràng dù không có animation. Hướng dẫn về prefers-reduced-motion trên MDN có số liệu hỗ trợ trình duyệt mới nhất.
Các kiểu popup và overlay khác của trang này nằm trong chuyên mục CSS, và trang giới thiệu nói rõ vì sao mỗi bài ở đây đều đi kèm một demo chạy thật thay vì chỉ một ảnh chụp màn hình.
FAQ
Dialog gốc có thay thế được cả chín kiểu này không?
Phần lớn là có, với bất cứ thứ gì vốn đã hành xử như một hộp thoại thật. <dialog> kèm showModal() cho sẵn focus trap, đóng bằng Escape, và một ::backdrop, những thứ mà 01 đến 04 phải tự tay dựng bằng một div thường cộng một trình lắng nghe keydown. Nó không giúp gì cho thanh handle của 02, transform thumbnail của 07, hay việc xếp chồng dialog thứ hai của 09, vì đó đều là lựa chọn bố cục mà <dialog> không có ý kiến gì cả.
Vì sao 05 kết hợp transition với @starting-style thay vì dùng keyframes thường?
Một animation @keyframes lặp theo vòng, như cái đang chạy trong bản xem trước lưới, không thể phản ứng với một lần gọi showModal() hay close() thật vì nó không biết khi nào những sự kiện đó xảy ra. Một transition trên [open] thì biết, nhưng display và thuộc tính overlay của trình duyệt là discrete, nên nếu thiếu @starting-style cùng allow-discrete, trình duyệt chỉ đổi chúng ở rìa transition thay vì animate xuyên suốt. Keyframes và transition ở đây giải quyết hai bài toán khác nhau — lặp theo hẹn giờ so với phản ứng với một thay đổi trạng thái thật.
Chín kiểu này có chạy được với Tailwind hay trong một dự án React không?
Có — toàn bộ chuyển động nằm trong transition, custom property, và một khối @starting-style, không thứ nào xung đột với class tiện ích của Tailwind hay tên class riêng của một component. Thư mục react/ trong zip bọc mỗi kiểu vào một component nhận đúng ba biến số đó dưới dạng props, nên đổi framework không có nghĩa là phải viết lại animation.