Popover, Anchor, Select: Khi Trình Duyệt Tự Làm UI

Posted on: 10/5/2026 1:20:58 AM

Hãy mở package.json của một dự án frontend bất kỳ và đếm xem có bao nhiêu thư viện chỉ để làm những việc "nhỏ": một thư viện tính toạ độ cho tooltip, một thư viện dropdown, một thư viện modal kèm focus-trap, một hook "click ra ngoài để đóng", một component Portal để thoát khỏi overflow: hidden, và một cuộc chiến z-index: 9999 không hồi kết. Mỗi thứ chỉ vài KB, nhưng cộng lại thành hàng chục KB JavaScript chạy trên main thread, kèm theo vô số bug về bàn phím, trình đọc màn hình và hydration.

Năm 2026, trình duyệt đã lặng lẽ nhận lại phần lớn những việc này. Popover API đạt Baseline từ đầu 2025, Invoker Commands (thuộc tính command/commandfor) đạt Baseline tháng 12/2025, CSS Anchor Positioning có mặt ở cả ba engine từ khi Firefox 147 phát hành ngày 13/1/2026, và giữa tháng 9/2026, Safari 27 trở thành engine thứ hai hỗ trợ customizable select — thẻ <select> cuối cùng cũng style được bằng CSS. Bài viết này mổ xẻ từng primitive, cách chúng ghép lại thành tooltip, menu, hộp thoại, dropdown hoàn chỉnh, bảng hỗ trợ trình duyệt thực tế tháng 10/2026, và chiến lược chuyển đổi an toàn cho dự án đang chạy.

33%người trả lời State of HTML 2024 coi việc style form control (nhất là select) là nỗi đau số 1
5,1%lượt tải trang trên Chrome đã dùng Popover API
3/3engine hỗ trợ Anchor Positioning kể từ Firefox 147 (13/1/2026)
2/3engine hỗ trợ customizable select sau khi Safari 27 phát hành (9/2026)

1. Vì sao tooltip, menu, select lại tốn nhiều JavaScript đến vậy?

Một chiếc dropdown trông đơn giản, nhưng để làm đúng, nó phải giải quyết ít nhất bảy bài toán khó, và trước đây trình duyệt không giúp gì cho bất kỳ bài toán nào:

  1. Thoát khỏi stacking context: menu nằm trong một card có overflow: hidden hoặc transform sẽ bị cắt mất. Giải pháp kinh điển là "portal" — bê DOM của menu ra cuối <body> — kéo theo việc đồng bộ vị trí bằng JavaScript.
  2. Định vị và xử lý va chạm: tính toạ độ từ getBoundingClientRect(), cập nhật khi cuộn, khi resize, lật lên trên khi chạm đáy màn hình, dịch sang trái khi chạm mép phải.
  3. Light dismiss: click ra ngoài thì đóng, nhấn Esc thì đóng, nhưng click vào chính menu thì không.
  4. Quản lý focus: mở modal thì nhốt focus bên trong, đóng thì trả focus về đúng nút đã mở; phần còn lại của trang phải "trơ" (inert) với cả chuột lẫn bàn phím.
  5. Ngữ nghĩa ARIA: aria-expanded phải đổi trạng thái đúng lúc, quan hệ giữa nút và nội dung phải được khai báo để trình đọc màn hình hiểu.
  6. Quy tắc "chỉ một": mở menu B thì menu A phải đóng, trừ khi B là menu con của A.
  7. Hiệu ứng vào/ra: phần tử chuyển từ display: none sang hiển thị thì không transition được, nên phải dùng thư viện animation hoặc hẹn giờ setTimeout thủ công.

Mỗi thư viện UI giải bảy bài toán này theo cách riêng, với mức độ hoàn thiện khác nhau. Hậu quả là cùng một trang web, tooltip của thư viện A đóng bằng Esc còn dropdown của thư viện B thì không; modal của C trả focus đúng còn của D thì không.

Cái giá ẩn của overlay viết bằng JavaScript

Ngoài dung lượng bundle, mọi listener cuộn/resize và mọi lần tính lại vị trí đều chạy trên main thread — đúng nơi quyết định chỉ số INP (Interaction to Next Paint). Với ứng dụng render phía server, những component này còn phải chờ hydration mới hoạt động: người dùng bấm vào nút menu trước khi JavaScript tải xong sẽ không thấy gì xảy ra. Nếu bạn đang tối ưu INP, hãy đọc thêm bài Core Web Vitals 2026: Tối ưu LCP, INP và CLS.

2. Bản đồ: mỗi bài toán giờ có một primitive gốc

Điểm hay của thế hệ API mới là chúng không phải một "component" khổng lồ, mà là các mảnh nhỏ, ghép được với nhau. Mỗi bài toán ở trên được trình duyệt giải bằng một primitive riêng:

flowchart LR
  subgraph P["Bài toán của mọi overlay"]
    P1["Bị overflow cắt, z-index chồng chéo"]
    P2["Tính toạ độ, lật khi chạm mép"]
    P3["Click ra ngoài, Esc để đóng"]
    P4["Focus, ARIA, chỉ mở một menu"]
    P5["Nối nút với hành động"]
    P6["Hover hoặc focus mới hiện tooltip"]
    P7["Style dropdown chọn giá trị"]
    P8["Hiệu ứng mở và đóng"]
  end
  subgraph N["Primitive gốc của trình duyệt"]
    N1["Top layer: popover, dialog"]
    N2["CSS Anchor Positioning"]
    N3["Light dismiss: popover auto, closedby"]
    N4["Popover stack, focus return, aria-expanded"]
    N5["Invoker Commands: command, commandfor"]
    N6["Interest Invokers: interestfor, popover hint"]
    N7["Customizable select: base-select"]
    N8["starting-style, allow-discrete"]
  end
  P1 --> N1
  P2 --> N2
  P3 --> N3
  P4 --> N4
  P5 --> N5
  P6 --> N6
  P7 --> N7
  P8 --> N8
  style N1 fill:#e94560,stroke:#fff,color:#fff
  style N2 fill:#e94560,stroke:#fff,color:#fff
  style N3 fill:#2c3e50,stroke:#fff,color:#fff
  style N4 fill:#2c3e50,stroke:#fff,color:#fff
  style N5 fill:#2c3e50,stroke:#fff,color:#fff
  style N6 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
  style N7 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
  style N8 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Hình 1: Bảy bài toán của overlay và primitive gốc tương ứng (đỏ: nền móng, xanh đậm: Baseline, sáng: đang phủ dần)

Những mảnh ghép này không đến cùng lúc. Nhìn lại dòng thời gian sẽ thấy vì sao 2026 mới là năm "đủ bộ":

2023
Chrome 116 và Safari 17 phát hành Popover API. Chrome 115 có scroll-driven animations, Chrome 117 có @starting-style.
Tháng 5/2024
Chrome 125 phát hành CSS Anchor Positioning — lần đầu tiên định vị phần tử theo phần tử khác hoàn toàn bằng CSS.
Tháng 1/2025
Popover API đạt Baseline Newly available khi Safari trên iOS 18.3 hoàn thiện hỗ trợ.
Tháng 4/2025
Chrome 135 cùng lúc ra mắt Invoker Commands và customizable select (appearance: base-select).
Tháng 10–12/2025
Chrome 142 có Interest Invokers (interestfor). Firefox 144 rồi Safari 26.2 hỗ trợ Invoker Commands, đưa tính năng này thành Baseline ngày 12/12/2025.
Tháng 1–2/2026
Firefox 147 bật Anchor Positioning (13/1). Interop 2026 công bố 20 focus area, trong đó có "Dialogs and popovers" (closedby, :open, hint popover) và tiếp tục Anchor Positioning.
Tháng 9/2026
Safari 27 hỗ trợ customizable select, đồng thời làm anchor positioning hiểu được anchor bị transform/animate. Firefox đang thử nghiệm customizable select sau flag ở bản Nightly.

3. Top layer và Popover API: nền móng của mọi lớp phủ

Khái niệm quan trọng nhất cần nắm là top layer: một lớp hiển thị đặc biệt nằm trên toàn bộ trang, bất kể z-index, overflow hay transform của các phần tử cha. Trước đây chỉ <dialog> dạng modal và phần tử fullscreen mới vào được top layer. Popover API mở cánh cửa này cho mọi phần tử chỉ bằng một thuộc tính HTML — nghĩa là bạn không cần portal nữa: DOM của menu nằm ngay cạnh nút bấm, đúng thứ tự đọc tự nhiên, nhưng vẫn hiển thị đè lên mọi thứ.

<button popovertarget="user-menu">Tài khoản ▾</button>

<div id="user-menu" popover>
  <a href="/profile">Hồ sơ</a>
  <a href="/settings">Cài đặt</a>
  <a href="/logout">Đăng xuất</a>
</div>

Không một dòng JavaScript, nhưng trình duyệt đã tự làm những việc sau:

  • Đưa #user-menu lên top layer khi mở, ẩn khi đóng.
  • Light dismiss: click ra ngoài hoặc nhấn Esc là đóng.
  • Đặt trạng thái aria-expanded đúng cho nút trong cây accessibility.
  • Khi popover đóng mà focus đang nằm bên trong, focus được trả về phần tử trước đó — thường chính là nút đã mở.
  • Quản lý popover stack: mở một popover khác sẽ đóng popover cũ, trừ khi popover mới được mở từ một nút nằm bên trong popover cũ (menu con lồng nhau hoạt động đúng một cách tự nhiên).

Popover có ba chế độ, khác nhau ở hành vi đóng và quan hệ với các popover khác:

Giá trịLight dismissQuan hệ với popover khácDùng cho
popover / popover="auto"CóMở cái mới thì đóng các popover auto không liên quanMenu, dropdown, date picker, bảng chọn
popover="manual"KhôngĐộc lập, phải đóng bằng nút hoặc JavaScriptToast, thông báo, panel cố định
popover="hint"CóMở hint không đóng các popover auto đang mở; chỉ đóng các hint khácTooltip, hover card, gợi ý ngắn

Popover không phải là "menu" theo nghĩa ARIA

Thuộc tính popover không gán role nào cho phần tử. Đừng vội thêm role="menu": role này hứa với trình đọc màn hình rằng các mục có thể di chuyển bằng phím mũi tên, và nếu bạn không tự cài đặt điều hướng đó, trải nghiệm sẽ tệ hơn cả không có role. Với menu điều hướng đơn giản, một danh sách liên kết hoặc nút bình thường bên trong popover là lựa chọn tiếp cận tốt nhất. Điều hướng bằng phím mũi tên dạng khai báo đang được chuẩn hoá qua thuộc tính focusgroup — Chrome đã chạy origin trial cho tính năng này.

4. Invoker Commands: nối nút với hành động bằng HTML

popovertarget chỉ điều khiển được popover. Invoker Commands tổng quát hoá ý tưởng đó: bất kỳ <button> nào cũng có thể ra lệnh cho một phần tử khác qua hai thuộc tính, commandfor (id của phần tử đích) và command (hành động). Các lệnh dựng sẵn hiện gồm show-popover, hide-popover, toggle-popover, show-modal, close và request-close. Tính năng đạt Baseline ngày 12/12/2025 (Chrome 135, Firefox 144, Safari 26.2).

<button commandfor="confirm-delete" command="show-modal">Xoá dự án</button>

<dialog id="confirm-delete" closedby="any">
  <h2>Xoá dự án "Apollo"?</h2>
  <p>Thao tác này không thể hoàn tác.</p>
  <button commandfor="confirm-delete" command="request-close">Huỷ</button>
  <form method="dialog">
    <button value="confirm">Xoá vĩnh viễn</button>
  </form>
</dialog>

Đoạn mã trên tạo ra một modal xác nhận đầy đủ: nền phía sau bị làm mờ qua ::backdrop và trở nên inert, focus tự vào trong hộp thoại, Esc để đóng, click ra ngoài cũng đóng (nhờ closedby="any"), và focus quay về nút "Xoá dự án" sau khi đóng. Khác biệt giữa close và request-close: lệnh sau phát sự kiện cancel trước, cho bạn cơ hội chặn lại (ví dụ form đang có dữ liệu chưa lưu).

sequenceDiagram
  actor U as Người dùng
  participant B as Nút commandfor
  participant BR as Trình duyệt
  participant D as dialog
  U->>B: Click, Enter hoặc Space
  B->>BR: command = show-modal
  BR->>D: Phát sự kiện command, có thể preventDefault
  BR->>D: showModal, đưa lên top layer
  BR-->>BR: Phần còn lại của trang thành inert
  BR->>D: Focus vào phần tử đầu tiên
  U->>BR: Nhấn Esc hoặc click backdrop
  BR->>D: Phát cancel rồi close
  BR->>B: Trả focus về nút đã mở
Hình 2: Vòng đời một hộp thoại modal điều khiển hoàn toàn bằng Invoker Commands

Với hành vi riêng của ứng dụng, bạn có thể định nghĩa custom command, bắt buộc có tiền tố hai dấu gạch ngang. Trình duyệt vẫn lo phần nối nút với đích và khả năng truy cập bàn phím, bạn chỉ viết phần logic:

<button commandfor="gallery" command="--next">Ảnh tiếp ›</button>
<div id="gallery">...</div>

<script>
  gallery.addEventListener('command', (e) => {
    if (e.command === '--next') showNextPhoto();
    // e.source chính là button đã kích hoạt lệnh
  });
</script>

Lợi ích lớn nhất của cách viết này không nằm ở số dòng code tiết kiệm được, mà ở chỗ nút hoạt động ngay khi HTML được parse, không phải chờ JavaScript tải và hydrate. Với các lệnh dựng sẵn, nút mở hộp thoại dùng được từ khung hình đầu tiên.

closedby chưa có trên Safari

Thuộc tính closedby (any / closerequest / none) có từ Chrome 134 và Firefox 141, nhưng tới tháng 10/2026 Safari vẫn chưa hỗ trợ — trên Safari, modal chỉ đóng bằng Esc, không đóng khi click backdrop. Đây là một phần của focus area "Dialogs and popovers" trong Interop 2026, nên nhiều khả năng sẽ sớm được bổ sung. Trong lúc chờ, đừng thiết kế luồng chỉ dựa vào click backdrop để đóng: luôn có nút "Huỷ" rõ ràng.

5. Anchor Positioning: hết thời tính toạ độ bằng JavaScript

Top layer giải quyết việc "nổi lên trên", nhưng popover mặc định lại xuất hiện ở giữa màn hình. Để menu bám ngay dưới nút bấm, ta cần CSS Anchor Positioning: khai báo một phần tử là "neo" (anchor), rồi định vị phần tử khác theo hình học của neo đó. Toàn bộ việc theo dõi cuộn, resize và lật vị trí do engine layout đảm nhận, không cần một listener nào.

Bốn khái niệm cốt lõi:

  • anchor-name: --ten-neo đặt trên phần tử làm neo.
  • position-anchor: --ten-neo đặt trên phần tử được định vị, cho biết nó bám vào neo nào.
  • position-area: chia không gian quanh neo thành lưới 3×3 và đặt phần tử vào một hoặc nhiều ô, ví dụ bottom, top span-right, right. Đây là cách viết gọn nhất cho 90% trường hợp.
  • Hàm anchor() và anchor-size(): dùng trong top/left/width... khi cần kiểm soát chi tiết, ví dụ top: anchor(bottom) hoặc min-width: anchor-size(width).
.account-btn { anchor-name: --account; }

#user-menu {
  position-anchor: --account;
  position-area: bottom span-right;   /* nằm dưới nút, căn theo mép trái của nút */
  inset: auto;                        /* bỏ inset: 0 mặc định của popover */
  margin: 4px 0 0;
  min-width: anchor-size(width);      /* rộng tối thiểu bằng nút */
  position-try-fallbacks: flip-block, flip-inline;
}

Một chi tiết đáng giá: khi popover được mở bằng popovertarget hoặc commandfor, nút kích hoạt trở thành anchor ngầm định (implicit anchor) của popover. Spec đang được tinh chỉnh để chỉ cần khai báo position-area là popover tự bám vào nút đã mở nó, không cần đặt tên neo — rất tiện cho danh sách có hàng trăm nút "⋯" mỗi dòng dùng chung một menu. Tuy nhiên trong giai đoạn chuyển tiếp, khai báo tường minh anchor-name/position-anchor vẫn là cách chắc chắn nhất.

Tự lật khi chạm mép màn hình

position-try-fallbacks liệt kê các phương án dự phòng khi vị trí gốc bị tràn ra khỏi khung chứa: flip-block (lật trên/dưới), flip-inline (lật trái/phải), hoặc một phương án tự đặt tên bằng @position-try. Thuộc tính position-try-order (ví dụ most-height) cho phép ưu tiên phương án có nhiều không gian nhất — hữu ích cho menu dài.

@position-try --left-side {
  position-area: left;
  margin: 0 8px 0 0;
}

.tooltip {
  position-area: bottom;
  position-try-fallbacks: flip-block, --left-side;
  position-try-order: most-height;
}
flowchart TD
  S["Áp dụng vị trí gốc
position-area: bottom"] --> C1{"Có tràn khỏi
khung chứa?"} C1 -->|Không| OK["Hiển thị tại vị trí gốc"] C1 -->|Có| ORD["Sắp xếp danh sách fallback
theo position-try-order"] ORD --> T1["Thử lần lượt từng phương án
flip-block, --left-side..."] T1 --> FOUND{"Có phương án
không bị tràn?"} FOUND -->|Có| USE["Dùng phương án đầu tiên vừa khung
anchored container query biết đã lật"] FOUND -->|Không| BASE["Quay về vị trí gốc
hoặc ẩn bằng position-visibility"] style S fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style USE fill:#e94560,stroke:#fff,color:#fff style OK fill:#2c3e50,stroke:#fff,color:#fff style BASE fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Hình 3: Thuật toán chọn vị trí dự phòng của Anchor Positioning

Bài toán cuối cùng từng buộc phải dùng JavaScript là: khi tooltip đã lật từ dưới lên trên, mũi tên của nó vẫn chỉ sai hướng. Từ Chrome 143, anchored container queries giải quyết việc này: biến phần tử được định vị thành query container với container-type: anchored, rồi truy vấn xem phương án fallback nào đang được dùng:

.tooltip {
  position-area: bottom;
  position-try-fallbacks: flip-block;
  container-type: anchored;
}
.tooltip::before { content: "▲"; position: absolute; bottom: 100%; }

@container anchored(fallback: flip-block) {
  .tooltip::before { content: "▼"; bottom: auto; top: 100%; }
}

Baseline nhưng spec vẫn đang "sống"

Anchor Positioning đã có ở cả ba engine, nhưng vẫn là focus area tiếp nối của Interop 2026 với mục tiêu làm rõ spec và tăng độ ổn định. Riêng Safari 27 đã đổi giá trị mặc định của position-anchor từ auto sang normal, đổi tên keyword anchors-visible/anchors-valid thành anchor-visible/anchor-valid (tên cũ vẫn được hỗ trợ tạm thời), và bắt đầu tính đến transform của anchor. Hãy kiểm thử trên cả ba trình duyệt trước khi phát hành, đặc biệt với anchor nằm trong phần tử có animation.

6. Customizable Select: cuối cùng cũng style được thẻ select

Thẻ <select> là "nỗi nhục" lâu đời nhất của CSS: bạn gần như không thể đổi giao diện danh sách thả xuống, không thể đặt icon hay màu vào từng lựa chọn. Kết quả là gần như mọi design system đều tự viết lại dropdown bằng div, đánh mất bàn phím, typeahead, autofill, hành vi trên mobile và tích hợp form gốc. State of HTML 2024 ghi nhận việc style form control (đặc biệt là select) là nỗi đau số 1 với 1.029 câu trả lời, tương đương 33% người tham gia.

Customizable select cho phép giữ nguyên thẻ <select> thật — với đầy đủ ngữ nghĩa, form submission, bàn phím — nhưng mở toàn bộ phần hiển thị cho CSS. Chỉ cần "opt-in" bằng appearance: base-select trên cả select lẫn pseudo-element ::picker(select):

<label for="status">Trạng thái</label>
<select id="status" name="status">
  <button><selectedcontent></selectedcontent></button>
  <option value="todo"><span class="dot todo" aria-hidden="true"></span>Cần làm</option>
  <option value="doing"><span class="dot doing" aria-hidden="true"></span>Đang làm</option>
  <option value="done"><img src="/icons/check.svg" alt="">Hoàn thành</option>
</select>
@supports (appearance: base-select) {
  select, ::picker(select) { appearance: base-select; }

  select { border-radius: 10px; padding: 8px 12px; }
  select::picker-icon { transition: rotate .2s; }
  select:open::picker-icon { rotate: 180deg; }

  ::picker(select) {
    border-radius: 12px;
    box-shadow: 0 12px 32px rgb(0 0 0 / .12);
  }
  option { display: flex; align-items: center; gap: 8px; padding: 8px 12px; }
  option:checked { font-weight: 600; }
  option::checkmark { content: "✓"; }
}

Các mảnh ghép cần nhớ:

Thành phầnVai trò
appearance: base-selectOpt-in vào chế độ tuỳ biến; bắt buộc đặt trên cả select và ::picker(select)
<button> là con đầu tiênThay thế nút hiển thị mặc định của select
<selectedcontent>Tự sao chép nội dung của option đang chọn (kể cả icon) vào nút
::picker(select)Toàn bộ khung thả xuống — bản chất là một popover trên top layer, được neo sẵn vào nút
::picker-icon, ::checkmarkMũi tên của nút và dấu tích của lựa chọn hiện tại
:open, option:checkedStyle theo trạng thái mở và lựa chọn đang được chọn

Vì picker vốn là popover được neo vào nút, bạn có thể dùng chính position-area và position-try-fallbacks ở phần 5 để đổi hướng mở của nó. Ba primitive — top layer, anchor, select — thực chất là một hệ thống thống nhất.

"Quy tắc vàng" của WebKit: luôn có chữ trong option

Ngày 15/6/2026, nhóm WebKit đăng bài nhấn mạnh một quy tắc duy nhất: mọi option phải có nội dung chữ hoặc thuộc tính văn bản truy cập được. Đừng bao giờ dùng option chỉ có ô màu hay icon. Lý do kép: trình đọc màn hình, màn hình chữ nổi và phần mềm điều khiển bằng giọng nói cần chữ trong cây accessibility; và trên trình duyệt cũ, parser sẽ bỏ qua các thẻ lạ bên trong select (như <button>, <img>), chỉ giữ lại phần chữ. Option không có chữ sẽ thành một dòng trống. Icon trang trí dùng alt="" hoặc aria-hidden="true".

Hai giới hạn quan trọng. Thứ nhất, đây không phải combobox: chưa có ô tìm kiếm, chưa nạp dữ liệu bất đồng bộ, chưa có multi-select dạng "chip". Thứ hai, MDN cảnh báo một số framework JavaScript có thể chặn hoặc làm hỏng cấu trúc mới này, và có thể gây lỗi hydration khi render phía server — hãy kiểm thử kỹ với framework của bạn trước khi đưa vào design system.

7. Interest Invokers: tooltip và hover card đúng chuẩn

Tooltip là component khó làm đúng nhất: phải hiện khi hover và khi focus bằng bàn phím, có độ trễ để không nháy liên tục khi lướt chuột, không biến mất khi người dùng di chuột từ nút sang nội dung tooltip, và trên màn hình cảm ứng thì... không có hover. Interest Invokers mã hoá toàn bộ hành vi này vào thuộc tính interestfor: trình duyệt nhận biết "người dùng quan tâm" qua hover, focus bàn phím, phím tắt đặc biệt hoặc nhấn giữ trên màn hình cảm ứng.

<a href="/u/designer" interestfor="card-designer">@designer</a>

<div id="card-designer" popover="hint">
  <img src="/avatars/designer.webp" alt="">
  <strong>Designer</strong> · 128 bài viết
</div>
a[interestfor] { interest-delay: 400ms 150ms; } /* trễ khi hiện, trễ khi ẩn */
a:interest-source { text-decoration-thickness: 2px; }
#card-designer:interest-target { outline: 2px solid #e94560; }
#card-designer { position-area: bottom; margin-top: 6px; }

Kết hợp với popover="hint", hover card sẽ không đóng menu đang mở, và được neo vào liên kết qua anchor ngầm định. Hạn chế: tới tháng 10/2026, Interest Invokers mới chỉ có trên Chromium (từ Chrome 142); Firefox đang theo dõi qua bug tracker, Safari chưa có tín hiệu. Có sẵn polyfill interestfor trên npm để dùng trong lúc chờ.

8. Hiệu ứng mở/đóng không cần thư viện animation

Bài toán thứ bảy — transition từ display: none — được giải bởi bộ đôi @starting-style (định nghĩa trạng thái "trước khi xuất hiện") và transition-behavior: allow-discrete (cho phép transition cả thuộc tính rời rạc như display). @starting-style đã là Baseline từ tháng 8/2024.

[popover] {
  opacity: 0;
  translate: 0 -6px;
  transition: opacity .18s, translate .18s,
              overlay .18s allow-discrete,
              display .18s allow-discrete;
}
[popover]:popover-open { opacity: 1; translate: 0 0; }

@starting-style {
  [popover]:popover-open { opacity: 0; translate: 0 -6px; }
}

@media (prefers-reduced-motion: reduce) {
  [popover] { transition: none; }
}

Thuộc tính overlay giữ phần tử ở lại top layer trong lúc hiệu ứng đóng chạy; trình duyệt nào chưa hỗ trợ nó thì hiệu ứng đóng sẽ bị cắt ngang, nhưng chức năng không bị ảnh hưởng — đúng tinh thần progressive enhancement. Nếu cần hiệu ứng chuyển trạng thái phức tạp hơn giữa các phần của trang, xem thêm bài View Transitions API.

9. Bảng hỗ trợ trình duyệt (tháng 10/2026)

Tính năngChrome / EdgeFirefoxSafariTrạng thái
Popover (auto / manual)11612517Baseline từ 1/2025
popover="hint"133149 (một phần)Chưa (đã có ở Technology Preview)Hạn chế
Invoker Commands13514426.2Baseline từ 12/2025
dialog closedby134141ChưaHạn chế
Anchor Positioning12514726Có ở cả 3 engine từ 1/2026, spec vẫn đang hoàn thiện
Anchored container queries143ChưaChưaThử nghiệm sớm
Customizable select135Nightly, sau flag272/3 engine
Interest Invokers142ChưaChưaChỉ Chromium, có polyfill
@starting-style11712917.5Baseline từ 8/2024
::details-content13114318.4Baseline từ 9/2025

Đọc bảng này theo hai nhóm: nhóm Baseline (popover, invoker commands, anchor positioning, @starting-style) có thể dùng trực tiếp cho hầu hết sản phẩm hôm nay; nhóm còn lại cần chiến lược nâng cấp dần.

10. Chiến lược áp dụng: progressive enhancement ba tầng

Sai lầm phổ biến là chờ "mọi thứ Baseline rồi mới dùng", hoặc ngược lại, viết lại toàn bộ design system trong một sprint. Cách bền vững hơn là chia thành ba tầng:

  1. Tầng 0 — HTML ngữ nghĩa: nút là <button>, chọn giá trị là <select>, hộp thoại là <dialog>. Tầng này chạy ở mọi trình duyệt.
  2. Tầng 1 — Baseline: popover, commandfor, anchor positioning, @starting-style. Dùng trực tiếp, không cần fallback cho trình duyệt hiện đại.
  3. Tầng 2 — đang phủ dần: customizable select, closedby, popover="hint", interestfor, anchored container queries. Bọc trong @supports hoặc phát hiện tính năng bằng JavaScript; nạp polyfill khi cần; trải nghiệm trên trình duyệt chưa hỗ trợ vẫn phải dùng được, chỉ kém đẹp hơn.
const probe = document.createElement('div');
probe.popover = 'hint';

const supports = {
  popover:    HTMLElement.prototype.hasOwnProperty('popover'),
  hint:       probe.popover === 'hint',
  invokers:   'commandForElement' in HTMLButtonElement.prototype,
  interest:   'interestForElement' in HTMLButtonElement.prototype,
  closedBy:   'closedBy' in HTMLDialogElement.prototype,
  anchor:     CSS.supports('anchor-name: --a'),
  baseSelect: CSS.supports('appearance', 'base-select'),
};

if (!supports.interest) {
  await import('/vendor/interestfor-polyfill.js'); // chỉ tải khi cần
}

Khi cần quyết định cho từng component cụ thể trong design system, cây quyết định sau giúp tránh cả hai thái cực "thư viện cho mọi thứ" và "native bằng mọi giá":

flowchart TD
  Q["Cần dựng component gì?"] --> T["Tooltip, hover card"]
  Q --> M["Menu, dropdown hành động"]
  Q --> D["Hộp thoại xác nhận, form"]
  Q --> S["Chọn một giá trị
từ danh sách ngắn"] Q --> C["Combobox có tìm kiếm, async,
multi-select, nghìn mục"] T --> T1["popover hint + interestfor
polyfill cho Firefox, Safari"] M --> M1["popover auto + commandfor
+ anchor positioning"] D --> D1["dialog + command show-modal
closedby khi được hỗ trợ"] S --> S1["select + base-select
bọc trong supports"] C --> C1["Giữ thư viện headless
nhưng dựng trên popover + anchor"] style Q fill:#2c3e50,stroke:#fff,color:#fff style M1 fill:#e94560,stroke:#fff,color:#fff style D1 fill:#e94560,stroke:#fff,color:#fff style S1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style T1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style C1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Hình 4: Cây quyết định chọn primitive gốc hay thư viện cho từng loại component

Khi nào vẫn nên giữ thư viện?

  • Combobox / autocomplete: ô nhập có gợi ý, lọc danh sách, nạp dữ liệu từ API — chưa có primitive gốc.
  • Danh sách rất dài: chọn trong hàng nghìn mục cần virtualization; customizable select render toàn bộ option.
  • Menu ứng dụng phức tạp theo pattern role="menu" với điều hướng phím mũi tên, typeahead, menu con nhiều cấp — ít nhất cho tới khi focusgroup được phát hành rộng rãi.
  • Yêu cầu hỗ trợ trình duyệt cũ: nếu một phần đáng kể người dùng còn ở iOS cũ không được cập nhật, tầng 1 cũng cần fallback.

Ngay cả khi giữ thư viện, xu hướng 2026 là các thư viện headless đang tự chuyển sang dùng popover và anchor positioning làm nền bên dưới. Lựa chọn thư viện mới, hãy ưu tiên những cái "đứng trên vai" primitive gốc thay vì tự cài lại top layer bằng portal.

11. Đo hiệu quả sau khi chuyển đổi

Chuyển sang primitive gốc là một thay đổi kiến trúc UI, nên cần đo bằng số liệu thay vì cảm giác:

  • Dung lượng JavaScript: so sánh bundle trước/sau khi gỡ các thư viện định vị, modal, focus-trap, portal.
  • INP p75 trên dữ liệu thực (RUM): tập trung vào các tương tác mở menu, mở modal, chọn giá trị — đây là nơi ít JavaScript nhất sẽ thể hiện rõ nhất.
  • Thời điểm tương tác được: với trang render phía server, đo khoảng thời gian từ lúc HTML hiển thị tới lúc nút menu thực sự phản hồi; với Invoker Commands, khoảng này tiến về 0.
  • Kiểm thử tiếp cận: chạy axe/Lighthouse và quan trọng hơn là thử thật với bàn phím và trình đọc màn hình (VoiceOver, NVDA) — các lỗi focus và ARIA thường giảm mạnh vì trình duyệt tự xử lý.
  • Số bug UI liên quan overlay: z-index, menu bị cắt, tooltip lệch vị trí — theo dõi trong issue tracker vài sprint sau chuyển đổi.

Kết luận

Trong suốt hơn một thập kỷ, mỗi đội frontend đều phải tự phát minh lại tooltip, dropdown và modal. Năm 2026 là thời điểm mà điều đó không còn là mặc định: trình duyệt đã có top layer, light dismiss, quản lý focus, định vị theo neo, nút điều khiển khai báo và cả thẻ select tuỳ biến được. Những ý chính cần nhớ:

  • Popover + Invoker Commands + Anchor Positioning là bộ ba Baseline đủ để dựng menu, dropdown và modal chất lượng cao mà gần như không cần JavaScript.
  • Customizable select đã có trên Chrome và Safari 27; dùng ngay theo kiểu progressive enhancement với @supports, và luôn giữ chữ trong option.
  • Interest Invokers, closedby, popover hint vẫn đang phủ dần — dùng kèm polyfill hoặc fallback, không đặt cược luồng quan trọng vào chúng.
  • Ít JavaScript hơn nghĩa là UX tốt hơn: nút hoạt động từ khung hình đầu tiên, INP tốt hơn, khả năng tiếp cận nhất quán giữa các component.
  • Thư viện không biến mất, nhưng vai trò thay đổi: chỉ dành cho combobox, danh sách lớn và menu phức tạp, và nên được dựng trên chính các primitive gốc.

Một bài tập nhỏ cho sprint tới: chọn một component overlay trong design system của bạn — thường là dropdown menu — viết lại bằng popover và anchor positioning, rồi so sánh dung lượng, INP và số dòng code. Kết quả thường đủ thuyết phục để cả đội đi tiếp.