Menu popover morph theo hướng neo, không cần đoán bằng JavaScript
Popover API lo việc mở, đóng và đưa menu lên top layer, kèm Escape và click ra ngoài để đóng. Việc đặt chỗ thuộc về CSS anchor positioning: position-area chọn ô mặc định trên lưới quanh nút, còn position-try-fallbacks liệt kê các phương án khi hết chỗ. Nút có popovertarget tự tạo một neo ngầm với panel. Playground bên dưới đặt thêm anchor-name / position-anchor có tên, để menu luôn có neo kể cả khi nó không được mở bằng một cú click trực tiếp lên nút. Anchor positioning level 1 lật được vị trí, nhưng sau khi lật thì CSS không biết menu đang ở hướng nào để đổi style theo.
Anchored container queries bổ sung đúng tín hiệu đó: đặt container-type: anchored trên phần tử đã neo, rồi query bằng @container anchored(fallback: --top-left). Query chỉ áp lên con cháu của container, không áp lên chính nó. Đó là lý do menu được tách thành hai lớp.
Hai lớp: lớp ngoài giữ chỗ, lớp trong morph
- Lớp ngoài (phần tử
popover) giữ kích thước đầy đủ, làm query container, và nhậnposition-areacùng các@position-trycó tên. - Lớp trong mang nền, viền và các mục menu. Nó
scaletừ mộttransform-originkhớp với góc gần nút nhất, tức góc tương ứng với ôposition-areađang active.
Lớp ngoài không co theo animation, nên layout và vùng nhận click luôn ổn định. Lớp trong chỉ là phần hình ảnh đang lớn dần. @starting-style cho điểm xuất phát của mỗi lần mở; transition-behavior: allow-discrete cho phép chuyển cả display và overlay.
Mẫu then chốt
@position-try --top-left {
position-area: top left;
}
#menu-trigger {
anchor-name: --menu-anchor;
}
.anchored-popover {
position-anchor: --menu-anchor;
position-area: bottom right;
position-try-fallbacks: --top-left, --top-right, --bottom-left;
container-type: anchored; /* chỉ trong @supports */
display: grid; /* lớp ngoài giữ kích thước đầy đủ */
}
.anchored-popover-face {
transform-origin: top left; /* mặc định: panel ở dưới, bên phải nút */
}
@container anchored(fallback: --top-left) {
.anchored-popover-face { transform-origin: bottom right; }
}<button id="menu-trigger" popovertarget="menu-panel">Mở menu</button>
<div id="menu-panel" class="anchored-popover" popover>
<div class="anchored-popover-face">…mục menu…</div>
</div>Bọc phần scale và các @container anchored(...) trong @supports (container-type: anchored). Trình duyệt chưa hỗ trợ vẫn mở được menu, chỉ mất hiệu ứng morph theo góc.
Thử trên playground
Khung dưới đây tách phần điều khiển khỏi sân khấu. Sân khấu là một iframe lồng bên trong, nên popover và position-try chỉ thấy viewport của sân khấu và không bao giờ đè lên phần điều khiển. Chỉnh vị trí neo, thời lượng, tỉ lệ khởi đầu, easing, khoảng cách và độ rộng; bấm preset bốn góc; Replay hoặc Reset; bật giảm chuyển động (cộng thêm với prefers-reduced-motion). Thanh trạng thái cho biết chế độ đang chạy (CSS native hay JS fallback), transform-origin đang tính được và fallback suy ra từ origin đó.
Vì sao cần JS fallback, và chỉ khi nào
Khi CSS.supports('container-type: anchored') trả về đúng, origin do CSS quyết định; playground chỉ đọc getComputedStyle để hiển thị chứ không ghi đè. Khi trả về sai, script ước lượng phía nào còn chỗ rồi gán transform-origin (và position-area nếu cần). Giao diện ghi rõ chế độ đang chạy, để không ai nhầm bản native với bản polyfill.
Lưu ý về hỗ trợ và khả năng tiếp cận
- Kiểm tra hỗ trợ bằng
@supports (container-type: anchored)hoặcCSS.supportstrên đúng trình duyệt bạn nhắm tới, thay vì dựa vào số phiên bản. - Neo cơ bản (
position-area,@position-try, Popover API) và anchored container queries là hai tầng khác nhau: một trình duyệt có thể có tầng một mà chưa có tầng hai. - Query container không style được chính nó, nên phần hình ảnh luôn đặt trên phần tử con.
- Tôn trọng
prefers-reduced-motion; playground có thêm một ô để thử bằng tay. - Menu dùng Popover API nên Escape và click ra ngoài đều đóng được, và các mục menu nhận focus bằng bàn phím.
Tóm lại. Lớp ngoài giữ chỗ và báo fallback. Lớp trong morph từ đúng góc. Dùng CSS native khi có anchored container queries; chỉ dùng JS khi thiếu, và nói rõ điều đó trên giao diện.