GODRICH
#css-animation#button#loader#transition#scroll#ui#xu-huong-thiet-ke

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

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

Bạn thấy gì

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.

Fade + phóng to
Mở trong tab mới

01 · Fade + phóng to — Diện tích đo được 24.692% · khung hình chuyển động 7/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

200ms0.9→1페이드만

Dùng ở đâu

Đâ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

Chuyện gì diễn ra trên màn hình

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.

Bottom sheet
Mở trong tab mới

02 · Bottom sheet — Diện tích đo được 98.256% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

280ms반만 열기손잡이

Chỗ dù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

Chuyển động thế nào

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.

Drawer bên cạnh
Mở trong tab mới

03 · Drawer bên cạnh — Diện tích đo được 100.0% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

320px왼쪽에서전체 폭

Dùng khi nào

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

Bạn thấy gì

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.

Làm mờ nền
Mở trong tab mới

04 · Làm mờ nền — Diện tích đo được 100.0% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

8px어둡게만12px

Dùng ở đâu

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

Chuyện gì diễn ra trên màn hình

<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 gốc
Mở trong tab mới

05 · Dialog gốc — Diện tích đo được 25.228% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

@starting-styleallow-discrete::backdrop

Chỗ dùng

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

Chuyển động thế nào

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.

Popover xác nhận
Mở trong tab mới

06 · Popover xác nhận — Diện tích đo được 10.85% · khung hình chuyển động 6/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

anchor위쪽화살표 없이

Dùng khi nào

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

Bạn thấy gì

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 đủ.

Nút mở rộng toàn màn hình
Mở trong tab mới

07 · Nút mở rộng toàn màn hình — Diện tích đo được 96.809% · khung hình chuyển động 11/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

420ms둥글게 유지페이드 섞기

Dùng ở đâu

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

Chuyện gì diễn ra trên màn hình

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.

Rung khi nhấn ra ngoài
Mở trong tab mới

08 · Rung khi nhấn ra ngoài — Diện tích đo được 5.663% · khung hình chuyển động 6/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

4px두 번테두리 강조

Chỗ dù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

Chuyển động thế nào

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.

Modal xếp chồng
Mở trong tab mới

09 · Modal xếp chồng — Diện tích đo được 28.221% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

0.95어둡게3단

Dùng khi nào

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

@starting-style và khung hình trước đó

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.

Class is-demo nằm trong markup

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.

Khoảng cách phiên bản của Popover API

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

Mức giảm chuyển động theo từng mục

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.

Nhập mật khẩu file nén

Mật khẩu nằm ngay trong bài viết này. Đọc tới đó bạn sẽ thấy.