ลิ้นชักการนำทาง

ภาพรวม

ลิ้นชักการนำทางคือแผงที่เลื่อนเข้ามาจากขอบของวิวพอร์ตเหนือเนื้อหาของหน้าเว็บ โดยจะทำให้ทุกอย่างที่อยู่ด้านหลังมืดลง โดยจะเปิดจากปุ่มทริกเกอร์และปิดโดยการปัดแผงออกจากหน้าจอ แตะฉากหลังที่หรี่แสง หรือกด Escape

คู่มือนี้จะใช้ลิ้นชักเป็น:

  • องค์ประกอบ popover="manual" ได้รับการโปรโมตไปยังเลเยอร์บนสุดเพื่อให้แผงและ ::backdrop ซ้อนทับองค์ประกอบอื่นๆ ทั้งหมดในหน้าเว็บ โดยไม่คำนึงถึงบริบทการซ้อนกัน
  • คอนเทนเนอร์ที่เลื่อนในแนวนอนซึ่งมีจุดหยุดการสแนปการเลื่อน CSS 2 จุด โดยจุดหนึ่งสำหรับ "เปิด" และอีกจุดหนึ่งสำหรับ "ปิด" เพื่อให้เบราว์เซอร์จัดการท่าทางสัมผัสการปัดโดยตรง ซึ่งจะช่วยให้คุณติดตามโมเมนตัม ความเร็ว และการหยุดชะงักได้โดยไม่มีค่าใช้จ่าย และไม่ต้องใช้โค้ด pointer-event ของ JavaScript
  • ภาพเคลื่อนไหวที่ขับเคลื่อนด้วยการเลื่อนซึ่งเชื่อมโยงความทึบแสงของฉากหลังกับตำแหน่งการเลื่อน เพื่อให้ความมืดค่อยๆ ปรากฏและหายไปอย่างราบรื่นเมื่อผู้ใช้ลากแผง
  • IntersectionObserverบนแผงที่ตรวจจับเมื่อแผงเข้าสู่หรือออกจากวิวพอร์ตโดยสมบูรณ์ และใช้ช่วงเวลาเหล่านั้นเพื่ออัปเดตโฟกัส aria-expanded และ inert

วิธีนี้เป็นที่นิยมมากกว่าภาพเคลื่อนไหว transform ที่ขับเคลื่อนด้วย JavaScript เนื่องจากกลไกการเลื่อนช่วยให้ผู้ใช้ควบคุมตำแหน่งของแผงได้โดยตรง (ใช้นิ้วเลื่อน ไม่ใช่การเปลี่ยนภาพ) และตรงกับรูปแบบการโต้ตอบที่ผู้ใช้คุ้นเคยในแอปที่มากับมือถือมากกว่า

การใช้งาน

1. Markup

ลิ้นชักเป็นป๊อปโอเวอร์รายการเดียวที่มีแถบเลื่อนแนวนอน ซึ่งมี "ชีต" ที่มองเห็นได้ ปุ่มทริกเกอร์จะอยู่ในเนื้อหาของหน้าเว็บ

<!-- 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. รูปแบบ

รีเซ็ตป๊อปโอเวอร์และเติมวิวพอร์ต

ป๊อปโอเวอร์ต้องครอบคลุมทั้งวิวพอร์ตเพื่อให้ ::backdrop ทำให้ทั้งหน้ามืดลงและพื้นผิวการปัดขยายจากขอบหนึ่งไปอีกขอบหนึ่ง สไตล์ป๊อปโอเวอร์ของ User Agent เริ่มต้น (จัดกึ่งกลาง ปรับขนาดอัตโนมัติ มีเส้นขอบ) จะขัดขวางและต้องรีเซ็ต

.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);
}

สร้างพื้นผิวการปัดด้วยการสแนปการเลื่อน

แถบเลื่อนเป็นตารางกริดแนวนอนที่กว้างกว่าวิวพอร์ต โดยคอลัมน์ 1 มีชีต (ความกว้าง --drawer-width) คอลัมน์ 2 เป็นตัวเว้นวรรคองค์ประกอบเทียมที่ว่างเปล่าซึ่งมีความกว้างเท่ากับวิวพอร์ต การสลับระหว่าง 2 คอลัมน์นี้จะเปิดและปิดลิ้นชัก

.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;
}

เชื่อมโยงความทึบแสงของฉากหลังกับตำแหน่งการเลื่อน

ภาพเคลื่อนไหวที่ขับเคลื่อนด้วยการเลื่อนจะแมป --drawer-backdrop จาก 1 (เปิด) เป็น 0 (ปิด) ตลอดช่วงของแถบเลื่อน เพื่อให้ฉากหลังค่อยๆ ปรากฏและหายไปอย่างสอดคล้องกับการลากอย่างสมบูรณ์แบบ

/* 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. เปิดและปิดลิ้นชัก

การเปิดมี 2 ขั้นตอน ได้แก่ เลื่อนป๊อปโอเวอร์ไปยังเลเยอร์บนสุด แล้วเลื่อนชีตให้อยู่ในมุมมอง การปิดมีขั้นตอนเดียวคือเลื่อนกลับไปที่ตัวเว้นวรรค โดยผู้สังเกตการณ์ (ขั้นตอนที่ 4) จะซ่อนป๊อปโอเวอร์เมื่อชีตอยู่นอกหน้าจอโดยสมบูรณ์

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. ตรวจจับสถานะเปิดและปิด

ใช้ IntersectionObserver ในชีตเป็นแหล่งข้อมูลที่เชื่อถือได้สำหรับสถานะของลิ้นชัก ไม่ใช่ตำแหน่งการเลื่อน Observer จะเริ่มทำงานไม่ว่าชีตจะเคลื่อนไหวอย่างไร (การปัดของผู้ใช้, การเลื่อนแบบเป็นโปรแกรม, การสแนป) ดังนั้นเส้นทางการปิดทั้งหมดจึงรวมกันใน Callback เดียวกัน

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. เชื่อมต่อตัวแฮนเดิลทริกเกอร์และการปิด

// 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();
});

กลยุทธ์สำรอง

Popover API, ภาพเคลื่อนไหวที่ขับเคลื่อนด้วยการเลื่อนซึ่งทำให้ฉากหลังจางลง และ scroll-initial-target เป็นการเพิ่มประสิทธิภาพแบบต่อเนื่องพร้อมโฆษณาสำรองที่เรียบง่ายซึ่งนำไปใช้ได้ง่ายหากต้องการการรองรับเบราว์เซอร์ที่หลากหลาย

การเฟดฉากหลังสำรอง (ไม่รองรับ animation-timeline):

ตรวจหาด้วย CSS.supports('animation-timeline: scroll()') และเขียน --drawer-backdrop จาก Listener เหตุการณ์ scroll หากไม่รองรับ บล็อก CSS @supports ในขั้นตอนที่ 2 ช่วยให้มั่นใจว่าคีย์เฟรมจะไม่ใช้ในเบราว์เซอร์ที่ไม่รองรับ ดังนั้นค่า JavaScript จึงเป็นตัวเขียนเพียงตัวเดียว

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);
  });
}

ตำแหน่งการเลื่อนเริ่มต้นสำรอง (ไม่รองรับ scroll-initial-target):

ตรวจพบ CSS.supports('scroll-initial-target', 'nearest') และภายใน openDrawer() ให้ข้ามการเลื่อนตัวเลื่อนไปยังตำแหน่งปิดทันทีหลังจาก showPopover() หากไม่มีโค้ดนี้ ลิ้นชักจะปรากฏขึ้นทันทีในตำแหน่งเปิดโดยไม่มีภาพเคลื่อนไหวแบบเลื่อนเข้า

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 สำรอง (ไม่มีคุณสมบัติที่กำหนดเองที่ลงทะเบียนไว้):

@property จำเป็นต้องใช้เนื่องจากภาพเคลื่อนไหวที่ขับเคลื่อนด้วยการเลื่อนจะแทรก --drawer-backdrop ระหว่างคีย์เฟรม หากไม่มีการลงทะเบียน พร็อพเพอร์ตี้จะถือว่าเป็นสตริงและจะข้ามระหว่าง 0 ถึง 1 โดยไม่มีการเฟด หากใช้การสำรองข้อมูลภาพเคลื่อนไหวที่ขับเคลื่อนด้วยการเลื่อนด้านบน JavaScript จะเขียนสตริงตัวเลขใหม่ลงใน --drawer-backdrop ในทุกเฟรมการเลื่อนและไม่เคยแทรกค่า ดังนั้นจึงไม่จำเป็นต้องใช้การสำรองข้อมูล @property แยกต่างหาก เนื่องจากเบราว์เซอร์ทั้งหมดที่รองรับภาพเคลื่อนไหวที่ขับเคลื่อนด้วยการเลื่อนก็รองรับ @property ด้วย

ทางเลือกสำรองของ Popover API (ไม่รองรับแอตทริบิวต์ popover):

เนื่องจากคอมโพเนนต์นี้ใช้ popover="manual" และใช้การปิดจาก JavaScript ทั้งหมด จึงไม่ได้ขึ้นอยู่กับลักษณะการทำงานที่กำหนดของ Popover API ซึ่งได้แก่ การปิดแบบเบา, แอตทริบิวต์ popovertarget, การจัดการ Escape ที่จัดการโดยเลเยอร์บนสุด หรือการจัดการโฟกัส ฟีเจอร์ป๊อปโอเวอร์เพียงอย่างเดียวที่ใช้จริงคือการโปรโมตเลเยอร์บนสุด (ผ่าน showPopover()) และองค์ประกอบจำลอง ::backdrop

หากต้องการการรองรับเบราว์เซอร์ที่กว้างขึ้น อย่าแยกสาขาในการตรวจหาฟีเจอร์ เพียงแค่ไม่ใช้ป๊อปโอเวอร์เลย วางแอตทริบิวต์ popover="manual" แทนที่โปรโมชันชั้นบนสุดด้วย position: fixed และ z-index สูง แทนที่ ::backdrop ด้วยองค์ประกอบระดับเดียวกันที่มีสไตล์เหมือนกัน (ใช้คุณสมบัติที่กำหนดเอง --drawer-backdrop เดียวกัน) และสลับระดับการมองเห็นจากคลาสแทน showPopover()/hidePopover() ส่วนที่เหลือของคอมโพเนนต์ (การสแนปการเลื่อน, ภาพเคลื่อนไหวฉากหลังที่ขับเคลื่อนด้วยการเลื่อน, IntersectionObserver และตัวแฮนเดิลการปิด) จะไม่มีการเปลี่ยนแปลง