Tổng quan
Ngăn điều hướng là một bảng điều khiển trượt vào từ cạnh của khung nhìn trên nội dung trang, làm mờ mọi thứ phía sau. Bảng điều khiển này được mở bằng nút kích hoạt và đóng bằng cách vuốt bảng điều khiển ra khỏi màn hình, nhấn vào nền mờ hoặc nhấn phím Escape.
Hướng dẫn này triển khai ngăn điều hướng dưới dạng:
- Một phần tử
popover="manual"được đưa lên lớp trên cùng để bảng điều khiển và::backdropcủa bảng điều khiển phủ lên mọi phần tử khác trên trang, bất kể bối cảnh xếp chồng. - Một vùng chứa cuộn theo chiều ngang có hai điểm dừng cố định vị trí cuộn CSS (một cho trạng thái "mở", một cho trạng thái "đóng") để trình duyệt xử lý cử chỉ vuốt theo cách gốc. Tính năng này cung cấp miễn phí tính năng theo dõi động lượng, vận tốc và gián đoạn mà không cần mã sự kiện con trỏ JavaScript.
- Ảnh động dựa trên thao tác cuộn liên kết độ mờ của phông nền với vị trí cuộn, nhờ đó, hiệu ứng mờ dần sẽ xuất hiện và biến mất một cách mượt mà khi người dùng kéo bảng điều khiển.
- Một
IntersectionObservertrên bảng điều khiển phát hiện thời điểm bảng điều khiển đã hoàn toàn vào hoặc hoàn toàn rời khỏi khung nhìn, đồng thời sử dụng những thời điểm đó để cập nhật tiêu điểm,aria-expandedvàinert.
Phương pháp này được ưu tiên hơn so với ảnh động transform dựa trên JavaScript vì cơ chế cuộn giúp người dùng trực tiếp kiểm soát vị trí của bảng điều khiển (ngón tay của họ điều khiển bảng điều khiển, chứ không phải hiệu ứng chuyển động giữa các khung hình) và phù hợp hơn nhiều với các mẫu tương tác mà người dùng quen thuộc trong ứng dụng gốc dành cho thiết bị di động.
Triển khai
1. Markup (note: đây là tên ứng dụng)
Ngăn kéo là một cửa sổ bật lên duy nhất chứa một thanh cuộn ngang, trong đó có "trang tính" hiển thị. Nút kích hoạt nằm trong nội dung trang.
<!-- popover="manual" is REQUIRED. Do not use popover="auto" or "hint". -->
<div class="Drawer" id="drawer" popover="manual">
<div class="Drawer-scroller">
<nav class="Drawer-sheet" tabindex="-1">
<!-- tabindex="-1" makes the sheet programmatically focusable so we
can move focus into it when the drawer opens, without adding it
to the natural tab order. -->
<ul>
<li><a href="/page-1">Page 1</a></li>
<li><a href="/page-2">Page 2</a></li>
<!-- ... -->
</ul>
</nav>
</div>
</div>
<main>
<header>
<!-- aria-controls links the trigger to the drawer; aria-expanded
reflects the current state for assistive tech. -->
<button id="drawer-open"
aria-label="Menu"
aria-expanded="false"
aria-controls="drawer">
<!-- MANDATORY: Inline decorative SVGs MUST define aria-hidden="true" -->
<svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
</button>
</header>
<!-- Page content. -->
</main>
2. Kiểu
Đặt lại cửa sổ bật lên và lấp đầy khung nhìn
Cửa sổ bật lên phải bao phủ toàn bộ khung nhìn để ::backdrop của cửa sổ đó làm mờ toàn bộ trang và bề mặt vuốt mở rộng từ cạnh này sang cạnh kia. Kiểu cửa sổ bật lên mặc định của tác nhân người dùng (ở giữa, tự động định cỡ, có đường viền) sẽ gây cản trở và phải được đặt lại.
.Drawer {
/* min() caps the sheet width on large screens but on a phone leaves
a 20% peek of page content visible, which is the affordance that
tells the user they can tap outside to dismiss. */
--drawer-width: min(20em, 80dvw);
/* Custom property driven by the scroll-driven animation below.
0 = drawer fully closed (transparent backdrop).
1 = drawer fully open (visible backdrop). */
--drawer-backdrop: 0;
/* Reset UA popover style that would constrain the element. */
width: auto;
height: auto;
background: transparent;
border: 0;
overflow: visible;
}
/* Style the popover's ::backdrop to achieve the overlay effect and
provide visual affordances indicating that the rest of the page is inert */
.Drawer::backdrop {
background: #000;
/* Use calc() to limit the opacity range so the content beneath is visible */
opacity: calc(var(--drawer-backdrop) / 2);
}
Tạo bề mặt vuốt bằng tính năng cố định vị trí cuộn
Trình cuộn là một lưới ngang rộng hơn khung nhìn: cột 1 chứa trang tính (chiều rộng --drawer-width), cột 2 là một dải phân cách phần tử giả trống có chiều rộng bằng khung nhìn. Thao tác kéo thả giữa hai cột là thao tác mở và đóng ngăn kéo.
.Drawer-scroller {
position: relative;
display: grid;
/* Sheet on the left, full-viewport spacer on the right. The user
scrolls between the two snap stops to open and close. */
grid-template-columns: var(--drawer-width) 100%;
overflow-x: scroll;
/* Stop the swipe from chaining into the page's vertical scroll
when the user reaches either snap edge. */
overscroll-behavior: none;
scrollbar-width: none;
/* `mandatory` guarantees the drawer always settles fully open or
fully closed — never half-open after a partial swipe. */
scroll-snap-type: x mandatory;
}
/* Enable smooth scrolling natively, but only if the user has not
requested reduced motion. */
@media (prefers-reduced-motion: no-preference) {
.Drawer-scroller {
scroll-behavior: smooth;
}
}
/* The empty spacer that creates the "closed" snap stop. */
.Drawer-scroller::after {
content: '';
scroll-snap-align: end;
/* Open the popover already scrolled to this stop (drawer off-screen),
so the JS only needs to scroll to the open position to
animate it in. */
scroll-initial-target: nearest;
}
.Drawer-sheet {
display: grid;
grid-template-rows: auto 1fr;
/* Use `svh` (small viewport height) — not `vh` or `dvh` — so the
sheet height does not jump when the iOS Safari address bar
resizes mid-swipe. */
height: 100svh;
background: #333;
color: #fff;
overflow-y: auto;
scroll-snap-align: start;
scrollbar-width: none;
}
Liên kết độ mờ của phông nền với vị trí cuộn
Ảnh động dựa trên thao tác cuộn sẽ liên kết --drawer-backdrop từ 1 (mở) đến 0 (đóng) trên phạm vi của thanh cuộn, nhờ đó, phông nền sẽ mờ dần và hiện rõ dần, đồng bộ hoá hoàn hảo với thao tác kéo.
/* MANDATORY: Wrap this entire block in @supports. Browsers that don't
support animation-timeline still parse the @keyframes and would
apply the animation's `0%` value (--drawer-backdrop: 1) at all
times, leaving the backdrop permanently opaque. The @supports gate
ensures the animation is only registered where it actually works. */
@supports (animation-timeline: scroll()) {
.Drawer {
/* timeline-scope lets .Drawer reference a scroll-timeline that
is defined on its descendant (the scroller). Without this, the
timeline name is not visible to the .Drawer element. */
timeline-scope: --drawer-fade;
animation: fade-drawer-backdrop linear both;
animation-timeline: --drawer-fade;
}
.Drawer-scroller {
/* The horizontal scroll position of this element drives the
timeline named `--drawer-fade`. */
scroll-timeline: --drawer-fade x;
}
/* @property is REQUIRED. Without registering --drawer-backdrop with
a `<number>` syntax, the browser treats it as a string and cannot
interpolate it — the backdrop would jump from 0 to 1 with no
fade. */
@property --drawer-backdrop {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
@keyframes fade-drawer-backdrop {
/* Scroll position 0 = drawer fully open = backdrop visible. */
0% { --drawer-backdrop: 1 }
/* Scroll position 100% = drawer fully closed = backdrop hidden. */
100% { --drawer-backdrop: 0 }
}
}
3. Mở và đóng ngăn kéo
Quá trình mở gồm hai bước: đưa cửa sổ bật lên lên lớp trên cùng, sau đó cuộn trang tính vào khung hiển thị. Việc đóng là một bước: cuộn trở lại dải phân cách; một trình quan sát (bước 4) sẽ ẩn cửa sổ bật lên sau khi trang tính hoàn toàn nằm ngoài màn hình.
const drawer = document.getElementById('drawer');
const openBtn = document.getElementById('drawer-open');
const scroller = drawer.querySelector('.Drawer-scroller');
const sheet = drawer.querySelector('.Drawer-sheet');
function openDrawer() {
// Show the popover first so the element is in the top layer before
// we trigger any scrolling. `scroll-initial-target` (set on the
// ::after spacer) places the initial scroll position at the closed
// stop, so the drawer enters the top layer already off-screen.
drawer.showPopover();
// Scroll the sheet into view. The `behavior: 'auto'` option defers
// to the CSS `scroll-behavior` property, which will be smooth unless
// the user prefers reduced motion. Snap takes over at the end and
// locks the drawer fully open.
scroller.scrollTo({left: 0, behavior: 'auto'});
}
function closeDrawer() {
// Scroll back to the spacer. Do NOT call hidePopover() here —
// doing so would remove the element from the top layer mid-animation
// and the close animation would not be visible. The
// IntersectionObserver in step 4 hides the popover once the sheet
// has actually left the viewport.
scroller.scrollTo({left: scroller.offsetWidth, behavior: 'auto'});
}
4. Phát hiện trạng thái mở và đóng
Sử dụng IntersectionObserver trên trang tính (không phải vị trí cuộn) làm nguồn đáng tin cậy cho trạng thái của ngăn kéo. Trình quan sát sẽ kích hoạt bất kể cách trang tính di chuyển (thao tác vuốt của người dùng, thao tác cuộn có lập trình, thao tác cố định vị trí), vì vậy, tất cả các đường dẫn đóng đều hội tụ trong cùng một lệnh gọi lại.
function onDrawerOpened() {
// Mark the rest of the page inert so keyboard and screen-reader
// users cannot tab into content hidden behind the drawer.
document.querySelector('main').inert = true;
openBtn.setAttribute('aria-expanded', 'true');
// Move focus into the drawer for keyboard users.
sheet.focus();
}
function onDrawerClosed() {
// Hide the popover only after the close animation completes,
// so the slide-out is visible to the user.
drawer.hidePopover();
document.querySelector('main').inert = false;
openBtn.setAttribute('aria-expanded', 'false');
}
// Treat "any pixel of the sheet visible inside the popover root" as
// "open enough to count as not closed". This threshold is intentionally
// tiny so the closed callback only fires once the sheet is truly gone.
const visibleThreshold = 1 / window.innerWidth;
const observer = new IntersectionObserver(
(entries) => {
// During programmatic scrolling the observer can deliver multiple
// entries in one batch. Only the most recent describes the
// current state; earlier entries are intermediate positions.
const entry = entries.at(-1);
if (entry.intersectionRatio < visibleThreshold) onDrawerClosed();
if (entry.intersectionRatio === 1) onDrawerOpened();
},
// root: drawer makes the popover element the intersection root,
// so the ratio reflects the sheet's visibility within the popover
// (i.e. how much of it has been swiped on-screen).
{root: drawer, threshold: [visibleThreshold, 1]},
);
observer.observe(sheet);
5. Kết nối trình xử lý kích hoạt và đóng
// Open trigger.
openBtn.addEventListener('click', openDrawer);
// Light-dismiss: a tap on the dimmed area (anywhere inside the
// popover but outside the sheet) closes the drawer. We implement
// this manually because popover="manual" disables the browser's
// built-in light-dismiss (which would also fire mid-swipe — see step 1).
drawer.addEventListener('click', (event) => {
if (!sheet.contains(event.target)) closeDrawer();
});
// Escape key. Listen on document because focus may be inside the
// drawer when the user presses Escape.
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeDrawer();
});
Chiến lược dự phòng
API cửa sổ bật lên, ảnh động dựa trên thao tác cuộn làm mờ phông nền và scroll-initial-target là những cải tiến tăng dần với các phương án dự phòng đơn giản, có thể dễ dàng triển khai nếu cần hỗ trợ nhiều trình duyệt.
Dự phòng làm mờ phông nền (không hỗ trợ animation-timeline):
Phát hiện bằng CSS.supports('animation-timeline: scroll()') và ghi --drawer-backdrop từ trình nghe sự kiện scroll nếu không được hỗ trợ. Khối @supports CSS ở bước 2 đảm bảo khung hình chính không bao giờ áp dụng trong các trình duyệt không được hỗ trợ, vì vậy, giá trị JavaScript là trình ghi duy nhất.
if (!CSS.supports('animation-timeline: scroll()')) {
scroller.addEventListener('scroll', () => {
// Same mapping as the @keyframes: 0 scroll = 1 (open),
// sheet-width scroll = 0 (closed).
const ratio = 1 - scroller.scrollLeft / sheet.offsetWidth;
drawer.style.setProperty('--drawer-backdrop', ratio);
});
}
Dự phòng vị trí cuộn ban đầu (không hỗ trợ scroll-initial-target):
Phát hiện bằng CSS.supports('scroll-initial-target', 'nearest') và bên trong openDrawer(), chuyển nhanh thanh cuộn đến vị trí đóng ngay sau showPopover(). Nếu không có thuộc tính này, ngăn kéo sẽ xuất hiện ngay lập tức ở vị trí mở mà không có ảnh động trượt vào.
async function openDrawer() {
drawer.showPopover();
if (!CSS.supports('scroll-initial-target', 'nearest')) {
// Jump-scroll to the closed stop so the scroll below
// animates the drawer in from off-screen.
scroller.scrollTo({left: scroller.offsetWidth, behavior: 'instant'});
// Wait two animation frames for the jump-scroll to commit.
// A single rAF is not enough — the second `scrollTo` would
// cancel the first before the browser has a chance to apply it.
await new Promise((r) =>
requestAnimationFrame(() => requestAnimationFrame(r))
);
}
scroller.scrollTo({left: 0, behavior: 'auto'});
}
Dự phòng @property (không có thuộc tính tuỳ chỉnh đã đăng ký):
@property chỉ cần thiết vì ảnh động dựa trên thao tác cuộn nội suy --drawer-backdrop giữa các khung hình chính — nếu không đăng ký, thuộc tính này sẽ được coi là một chuỗi và sẽ chuyển đổi giữa 0 và 1 mà không có hiệu ứng mờ dần. Nếu ảnh động dựa trên thao tác cuộn dự phòng ở trên được áp dụng, thì JavaScript đó sẽ ghi một chuỗi số mới vào --drawer-backdrop trên mỗi khung cuộn và không bao giờ nội suy, vì vậy, không cần có ảnh động dự phòng @property riêng biệt vì tất cả trình duyệt hỗ trợ ảnh động dựa trên thao tác cuộn cũng hỗ trợ @property.
Dự phòng API Popover (không hỗ trợ thuộc tính popover):
Vì thành phần này sử dụng popover="manual" và triển khai tính năng đóng hoàn toàn từ JavaScript, nên thành phần này không phụ thuộc vào các hành vi xác định của API cửa sổ bật lên — đóng nhẹ, thuộc tính popovertarget, xử lý phím Escape do lớp trên cùng quản lý hoặc quản lý tiêu điểm. Các tính năng duy nhất của cửa sổ bật lên mà nó thực sự sử dụng là quảng bá lớp trên cùng (thông qua showPopover()) và phần tử giả ::backdrop.
Nếu cần hỗ trợ trình duyệt rộng hơn, đừng phân nhánh khi phát hiện tính năng – chỉ cần không sử dụng cửa sổ bật lên. Bỏ thuộc tính popover="manual", thay thế quảng cáo lớp trên cùng bằng position: fixed và z-index cao, thay thế ::backdrop bằng một phần tử đồng cấp có kiểu giống hệt (sử dụng cùng một thuộc tính tuỳ chỉnh --drawer-backdrop) và bật/tắt chế độ hiển thị từ một lớp thay vì showPopover()/hidePopover(). Phần còn lại của thành phần (cố định vị trí cuộn, ảnh động phông nền dựa trên thao tác cuộn, IntersectionObserver và trình xử lý đóng) không thay đổi.