Ringkasan
Panel navigasi adalah panel yang bergeser dari tepi area pandang ke atas konten halaman, sehingga meredupkan semua yang ada di belakangnya. Panel ini dibuka dari tombol pemicu dan ditutup dengan menggeser panel ke luar layar, mengetuk latar belakang yang redup, atau menekan Escape.
Panduan ini mengimplementasikan panel samping sebagai:
- Elemen
popover="manual"dipromosikan ke lapisan atas sehingga panel dan::backdrop-nya menempatkan setiap elemen lain di halaman, terlepas dari konteks tumpukan. - Penampung yang di-scroll secara horizontal dengan dua penghentian pengepasan posisi scroll CSS — satu untuk "terbuka", satu untuk "tertutup" — sehingga gestur geser ditangani secara native oleh browser. Hal ini memberikan pelacakan momentum, kecepatan, dan gangguan secara gratis, tanpa kode pointer-event JavaScript.
- Animasi berbasis scroll yang mengaitkan opasitas tampilan latar dengan posisi scroll, sehingga peredupan muncul dan menghilang dengan lancar saat pengguna menarik panel.
IntersectionObserverdi panel yang mendeteksi kapan panel tersebut telah sepenuhnya masuk atau sepenuhnya keluar dari area pandang, dan menggunakan momen tersebut untuk memperbarui fokus,aria-expanded, daninert.
Pendekatan ini lebih disukai daripada animasi transform berbasis JavaScript karena mekanisme scroll memberi pengguna kontrol langsung atas posisi panel (jari mereka yang menggerakkannya, bukan tween) dan jauh lebih cocok dengan pola interaksi yang biasa digunakan pengguna di aplikasi seluler native.
Penerapan
1. Markup
Panel samping adalah popover tunggal yang berisi scroller horizontal, yang berisi "sheet" yang terlihat. Tombol pemicu berada di konten halaman.
<!-- 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. Gaya
Mereset popover dan mengisi area pandang
Popover harus menutupi seluruh area pandang sehingga ::backdrop meredupkan seluruh halaman dan permukaan geser membentang dari tepi ke tepi. Gaya popover agen pengguna default (di tengah, berukuran otomatis, berbatas) mengganggu dan harus direset.
.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);
}
Membuat platform geser dengan pengepasan posisi scroll
Scroller adalah petak horizontal yang lebih lebar dari area pandang: kolom 1 berisi sheet (lebar --drawer-width), kolom 2 adalah spacer elemen semu kosong dengan lebar area pandang. Menjajarkan kedua kolom adalah cara membuka dan menutup panel samping.
.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;
}
Mengaitkan opasitas latar belakang ke posisi scroll
Animasi berbasis scroll memetakan --drawer-backdrop dari 1 (terbuka) ke 0 (tertutup) di seluruh rentang scroller, sehingga latar belakang memudar masuk dan keluar, yang disinkronkan dengan sempurna dengan tindakan menarik.
/* 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. Membuka dan menutup panel samping
Pembukaan terdiri dari dua langkah: mempromosikan popover ke lapisan atas, lalu men-scroll sheet ke tampilan. Penutupan dilakukan dalam satu langkah: scroll kembali ke pengatur jarak; pengamat (langkah 4) menyembunyikan popover setelah sheet sepenuhnya berada di luar layar.
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. Mendeteksi status terbuka dan tertutup
Gunakan IntersectionObserver di sheet — bukan posisi scroll — sebagai sumber kebenaran untuk status panel samping. Observer diaktifkan terlepas dari cara sheet dipindahkan (geser pengguna, scroll terprogram, penyelesaian snap), sehingga semua jalur penutupan bertemu di callback yang sama.
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. Menghubungkan pengendali pemicu dan penutupan
// 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();
});
Strategi penggantian
Popover API, animasi berbasis scroll yang memudarkan latar belakang, dan scroll-initial-target adalah progressive enhancement dengan penggantian sederhana yang dapat diterapkan dengan mudah jika diperlukan dukungan browser yang luas.
Penggantian fade backdrop (tanpa dukungan animation-timeline):
Deteksi dengan CSS.supports('animation-timeline: scroll()') dan tulis --drawer-backdrop dari pemroses peristiwa scroll jika tidak didukung. Blok @supports CSS di langkah 2 memastikan keyframe tidak pernah diterapkan di browser yang tidak didukung, sehingga nilai JavaScript adalah satu-satunya penulis.
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);
});
}
Penggantian posisi scroll awal (tanpa dukungan scroll-initial-target):
Deteksi dengan CSS.supports('scroll-initial-target', 'nearest') dan di dalam openDrawer(), lompat-scroll scroller ke posisi tertutup segera setelah showPopover(). Tanpa ini, panel samping akan langsung muncul dalam posisi terbuka tanpa animasi slide-in.
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'});
}
@property penggantian (tidak ada properti kustom yang terdaftar):
@property hanya diperlukan karena animasi berbasis scroll menginterpolasi --drawer-backdrop di antara keyframe — tanpa pendaftaran, properti akan diperlakukan sebagai string dan akan melompat antara 0 dan 1 tanpa fade. Jika penggantian animasi berbasis scroll di atas diterapkan, JavaScript tersebut akan menulis string numerik baru ke --drawer-backdrop di setiap frame scroll dan tidak pernah melakukan interpolasi, sehingga penggantian @property terpisah tidak diperlukan karena semua browser yang mendukung animasi berbasis scroll juga mendukung @property.
Penggantian Popover API (tanpa dukungan atribut popover):
Karena komponen ini menggunakan popover="manual" dan menerapkan penutupan sepenuhnya dari JavaScript, komponen ini tidak bergantung pada perilaku penentu popover API — penutupan ringan, atribut popovertarget, penanganan Escape yang dikelola lapisan atas, atau pengelolaan fokus. Satu-satunya fitur popover yang benar-benar digunakan adalah promosi lapisan atas (melalui showPopover()) dan elemen pseudo ::backdrop.
Jika diperlukan dukungan browser yang lebih luas, jangan buat cabang pada deteksi fitur — cukup jangan gunakan popover sama sekali. Hapus atribut popover="manual", ganti promosi lapisan atas dengan position: fixed dan z-index yang tinggi, ganti ::backdrop dengan elemen seinduk yang diberi gaya identik (menggunakan properti kustom --drawer-backdrop yang sama), dan alihkan visibilitas dari class, bukan showPopover()/hidePopover(). Komponen lainnya (pengepasan posisi scroll, animasi latar belakang berbasis scroll, IntersectionObserver, dan pengendali penutupan) tidak berubah.