Dạo gần đây mình thấy Chrome giới thiệu một loạt tính năng UI mới, trong đó có mấy món nghe khá hợp với công việc hằng ngày: @starting-style, transition-behavior: allow-discrete, sibling-index() và thuộc tính closedby của <dialog>. Ghép chúng lại, một popup có thể mở mượt, đóng mượt, click ra ngoài để tắt và cho từng dòng nội dung xuất hiện lần lượt mà JavaScript chỉ còn đúng nhiệm vụ gọi showModal().

Nghe hơi giống demo sân khấu nên mình viết một ví dụ nhỏ để kiểm tra. Kết quả khá vui: trình duyệt Chromium mình đang dùng nhận cả feature detection cho allow-discrete, sibling-index(), @supports at-rule() và selector :open. Nhưng đây chưa phải bộ tính năng có thể quăng vào production rồi quên luôn. Một vài phần vẫn chưa đạt Baseline trên mọi trình duyệt, vì vậy bài này tập trung vào cách dùng theo kiểu progressive enhancement: có thì đẹp hơn, không có vẫn dùng được.

Dialog trước đây phiền ở chỗ nào?

Bản thân thẻ <dialog> không còn mới. Nó đã xử lý giúp nhiều việc khó chịu như top layer, backdrop, khóa tương tác phía sau và hành vi đóng bằng phím Esc. Vấn đề thường xuất hiện khi mình muốn thêm chuyển động. Dialog đi từ display: none sang trạng thái hiển thị nên transition nhập cảnh không chạy như một phần tử đã nằm sẵn trong layout.

Lúc đóng cũng vậy. Nếu trình duyệt loại phần tử khỏi top layer hoặc đổi sang display: none ngay lập tức, opacity chưa kịp giảm thì popup đã biến mất. Nhiều dự án giải quyết bằng cách thêm class, chờ transitionend, quản lý timer rồi mới gọi close(). Làm được, nhưng một popup bé xíu lại có nguyên một cục state dễ lệch nhịp.

Mình từng gặp tình huống gần giống khi làm các thành phần frontend khác: phần khó không nằm ở hiệu ứng, mà nằm ở chuyện trạng thái DOM và animation không kết thúc cùng lúc. Nếu các bạn thích những thư viện UI xử lý sẵn phần này, bài mình viết về Vime HTML5 Player cũng xuất phát từ nhu cầu bớt tự ôm quá nhiều state giao diện.

Bốn mảnh ghép mới làm việc ra sao?

@starting-style tạo điểm xuất phát

Transition bình thường cần trạng thái trước và sau. Khi dialog vừa được đưa từ ẩn sang hiện, trình duyệt chưa có style hiển thị trước đó để nội suy. @starting-style cung cấp trạng thái khởi đầu cho lần render đầu tiên: opacity bằng 0, dịch xuống một chút và scale nhỏ hơn. Sau đó dialog chuyển về style của selector [open].

allow-discrete giữ phần tử đủ lâu để đóng

displayoverlay là các thuộc tính rời rạc, không chạy dần như opacity. Từ khóa allow-discrete cho phép trình duyệt sắp xếp thời điểm đổi trạng thái của chúng để phần tử còn hiện diện trong lúc animation thoát chạy. Đây là miếng ghép giúp dialog không “bốc hơi” trước khi opacity và transform kết thúc.

sibling-index() tạo stagger không cần gán biến

Hàm này trả về vị trí của phần tử trong danh sách anh em, bắt đầu từ 1. Nhờ vậy, mỗi con trực tiếp trong dialog có thể nhận delay khác nhau bằng calc(sibling-index() * 70ms). Trước đây mình thường phải viết nhiều selector :nth-child() hoặc gán custom property từ template. Giờ CSS tự biết thứ tự DOM.

closedby="any" xử lý click ra ngoài

Giá trị any cho phép light dismiss: click hoặc chạm bên ngoài dialog để đóng, ngoài phím Esc và nút đóng do lập trình viên cung cấp. Điều đáng chú ý là tính năng này vẫn được MDN đánh dấu Limited availability. Vì vậy nút đóng thật bằng <form method="dialog"> vẫn phải có, không nên đặt toàn bộ trải nghiệm vào click-outside.

Các giai đoạn dialog xuất hiện và biến mất bằng chuyển động CSS
Starting style tạo điểm xuất phát, còn allow-discrete giữ dialog đủ lâu để transition thoát hoàn tất.

Demo của mình chỉ cần một đoạn JavaScript

HTML cốt lõi khá ngắn. Hai form bên trong vừa tạo nút thao tác vừa cho trình duyệt tự đóng dialog mà không cần listener riêng:

<button id="open" type="button">Mở dialog</button>

<dialog id="demo" closedby="any">
  <div class="dialog-body">
    <h2>Thêm món vào giỏ?</h2>
    <p>Bạn có thể xác nhận hoặc để sau.</p>

    <form method="dialog">
      <button value="confirm">Thêm ngay</button>
      <button value="cancel">Để sau</button>
    </form>
  </div>
</dialog>

JavaScript chỉ còn phần mở:

const dialog = document.querySelector('#demo');

document.querySelector('#open').addEventListener('click', () => {
  dialog.showModal();
});

Không có class is-opening, is-closing, không set timeout và không chờ sự kiện transition. Đây là điểm mình thích nhất: JavaScript lo hành vi, CSS lo chuyển động, còn HTML vẫn giữ cơ chế đóng cơ bản.

Phần CSS mở và đóng mượt

Đoạn quan trọng nhất là khai báo transition cho opacity, transform, display và overlay:

dialog {
  opacity: 0;
  transform: translateY(24px) scale(.96);

  transition:
    opacity 280ms,
    transform 280ms,
    display 280ms allow-discrete,
    overlay 280ms allow-discrete;
}

dialog[open] {
  opacity: 1;
  transform: translateY(0) scale(1);
}

@starting-style {
  dialog[open] {
    opacity: 0;
    transform: translateY(24px) scale(.96);
  }
}

Backdrop cũng cần logic tương tự nếu muốn mờ dần thay vì bật tắt đột ngột. Mình transition cả màu nền, blur, display và overlay. Chuyển động 280 ms đủ để nhận ra nhưng không khiến người dùng phải chờ. Các con số này không phải chuẩn bắt buộc; quan trọng là entry và exit dùng cùng nhịp để cảm giác không bị hụt.

Một lưu ý nhỏ: đừng dùng transition cho mọi thứ bằng all khi không cần. Liệt kê đúng thuộc tính giúp tránh animation ngoài ý muốn và dễ biết phần nào đang giữ dialog trong top layer.

Stagger bằng sibling-index() gọn thật, nhưng cần fallback

Để tiêu đề, mô tả và nhóm nút đi vào lần lượt, mình dùng:

.dialog-body > * {
  opacity: 0;
  animation: enter 420ms ease forwards;
  animation-delay: calc(sibling-index() * 70ms);
}

@keyframes enter {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

Trong trình duyệt mình kiểm tra, biểu thức này được CSS.supports() chấp nhận. Dù vậy MDN vẫn đánh dấu sibling-index() là Limited availability. Nếu browser không hiểu toàn bộ giá trị delay, declaration đó bị bỏ qua; animation vẫn chạy nhưng các phần tử có thể xuất hiện cùng lúc. Đây là fallback khá lành, vì chức năng không mất.

Nếu thứ tự xuất hiện mang ý nghĩa bắt buộc, mình sẽ không dựa vào animation delay. Còn nếu stagger chỉ để giao diện có nhịp, để browser cũ hiển thị đồng thời là hoàn toàn ổn. Đừng biến hiệu ứng trang trí thành dependency nghiệp vụ, tới công chuyện là ở chỗ đó.

Dialog cốt lõi được tăng cường dần bằng animation và light dismiss
Chức năng cốt lõi đứng riêng; animation, stagger và click-outside là các lớp nâng cấp có thể thiếu.

Feature detection nên đặt ở đâu?

Chrome giới thiệu cú pháp @supports at-rule() để kiểm tra trình duyệt có nhận một at-rule cụ thể hay không. Ví dụ:

@supports at-rule(@starting-style) {
  /* Chỉ thêm entry transition khi browser hiểu at-rule này */
}

Với property và value, cách cũ vẫn rõ ràng:

@supports (transition-behavior: allow-discrete) {
  dialog {
    transition:
      opacity 280ms,
      transform 280ms,
      display 280ms allow-discrete,
      overlay 280ms allow-discrete;
  }
}

Trong lần kiểm tra của mình, bốn phép thử cho allow-discrete, sibling-index(), at-rule(@starting-style) và selector :open đều trả về hỗ trợ. Kết quả này chỉ nói về trình duyệt đang chạy lúc kiểm tra, không đại diện cho toàn bộ người dùng của website. Trước khi triển khai, các bạn vẫn nên xem analytics thực tế và bảng tương thích mới nhất.

Accessibility: animation đẹp chưa phải là xong

Dùng native dialog đã có lợi thế, nhưng vẫn còn một checklist không nên bỏ qua:

  • Luôn có nút đóng hoặc hành động rõ ràng, kể cả khi hỗ trợ closedby="any".
  • Tiêu đề phải mô tả đúng mục đích; nội dung ngắn và không đánh đố người dùng.
  • Kiểm tra focus sau khi mở và sau khi đóng, nhất là khi trigger có thể bị xóa khỏi DOM.
  • Không đặt tabindex trực tiếp lên phần tử <dialog>.
  • Tôn trọng prefers-reduced-motion và giảm transition xuống gần như tức thời.
  • Không dùng opacity hoặc chuyển động làm tín hiệu duy nhất cho trạng thái thành công, lỗi hay cảnh báo.
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    transition-duration: .01ms !important;
  }
}
Các yếu tố accessibility cần kiểm tra khi làm dialog trên web
Keyboard, focus, reduced motion và fallback đều quan trọng hơn một hiệu ứng mở đẹp.

Việc tôn trọng hệ điều hành và quyền của người dùng cũng giống câu chuyện permission trong bài Desktop Notification: API chạy được chưa có nghĩa là trải nghiệm đã đúng. Giao diện tốt cần phản hồi rõ, có đường thoát và không giành quyền kiểm soát quá mức.

Cách mình sẽ đưa vào dự án thật

Mình sẽ chia thành ba tầng. Tầng đầu là native dialog, nút mở, form đóng và nội dung có thứ tự hợp lý. Tầng hai thêm opacity/transform với @starting-styleallow-discrete. Tầng ba mới thêm stagger bằng sibling-index() cùng light dismiss bằng closedby="any".

Tầng Thành phần Khi không hỗ trợ
Cốt lõi <dialog>, showModal(), form method dialog Cần polyfill nếu dự án còn browser quá cũ
Chuyển động @starting-style, allow-discrete Dialog hiện hoặc ẩn ngay, chức năng vẫn còn
Trang trí sibling-index(), closedby="any" Không stagger; người dùng đóng bằng nút hoặc Esc

Nếu dialog chứa form quan trọng như thanh toán hoặc xóa dữ liệu, mình có thể chọn closedby="none" hoặc closerequest thay vì any. Click ra ngoài rất tiện cho bảng tùy chọn nhẹ, nhưng lại dễ gây mất dữ liệu ở quy trình dài.

Sau khi triển khai, mình vẫn kiểm tra title, heading, focus và nội dung được render trong HTML. Checklist ở bài SEO kỹ thuật cho Botble CMS có vài bước hữu ích để chắc thành phần tương tác không làm nội dung chính biến mất khỏi cấu trúc trang.

Kết luận: ít JavaScript hơn, không có nghĩa là bỏ kiểm tra

Bộ API mới giúp code dialog sạch hơn rõ rệt. @starting-style giải quyết trạng thái vào, allow-discrete giữ phần tử đủ lâu để thoát, sibling-index() bỏ bớt selector lặp và closedby đưa light dismiss về cho HTML. Mình thích hướng này vì trách nhiệm quay về đúng chỗ: HTML quản lý hành vi nền, CSS quản lý trình bày, JavaScript chỉ nối sự kiện nghiệp vụ.

Nhưng mình chưa dùng cả bốn tính năng như một khối bắt buộc. <dialog> là nền tảng, còn những phần chưa đạt Baseline nên là nâng cấp. Làm vậy thì browser mới có popup mượt và tự nhiên; browser chưa hỗ trợ vẫn mở, đọc và đóng được. Với mình, đó mới là kiểu “ít JavaScript” đáng dùng, chứ không phải đổi một đống code JS thành CSS rồi cầu mong mọi máy đều hiểu.

Nguồn tham khảo: bài What's new in web UI của Chrome for Developers, tài liệu MDN về @starting-style, transition-behavior, sibling-index()<dialog>. Mình kiểm tra thông tin và feature detection ngày 30/07/2026.