חלונית הזזה לניווט

סקירה כללית

חלונית הזזה לניווט היא חלונית שמופיעה מהקצה של אזור התצוגה מעל תוכן הדף, ומעמעמת את כל מה שמאחוריה. הוא נפתח באמצעות כפתור טריגר ונסגר על ידי החלקת החלונית אל מחוץ למסך, לחיצה על הרקע המעומעם או לחיצה על Escape.

במדריך הזה, התפריט הצדדי מוטמע כך:

  • רכיב popover="manual" שקודם לשכבה העליונה, כך שהחלונית וה-::backdrop שלה מכסים כל רכיב אחר בדף, בלי קשר להקשר של סידור הרכיבים.
  • קונטיינר עם גלילה אופקית ושתי נקודות עצירה (scroll-snap) ב-CSS – אחת למצב "פתוח" ואחת למצב "סגור" – כדי שתנועת ההחלקה תטופל באופן מקורי על ידי הדפדפן. התכונה הזו מאפשרת לעקוב אחרי תנופה, מהירות והפרעות בחינם, בלי קוד 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 שלו מעמעם את כל הדף ומשטח ההחלקה נמתח מקצה לקצה. סגנונות ברירת המחדל של סוכן המשתמש (ממורכז, גודל אוטומטי, עם גבולות) מפריעים וצריך לאפס אותם.

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

יצירת משטח ההחלקה עם גלילה עם נקודות עצירה (scroll snapping)

הגלילה היא רשת אופקית שרחבה יותר מאזור התצוגה: עמודה 1 מכילה את הגיליון (רוחב --drawer-width), עמודה 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. פתיחה וסגירה של חלונית ההזזה

הפתיחה מתבצעת בשני שלבים: מקדמים את החלונית הקופצת לשכבה העליונה, ואז גוללים את הגיליון לתצוגה. הסגירה מתבצעת בשלב אחד: גוללים חזרה למרווח. משקיף (שלב 4) מסתיר את ה-popover ברגע שהגיליון יוצא לגמרי מהמסך.

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 בגיליון ולא במיקום הגלילה. המשקיף מופעל בלי קשר לאופן שבו הגיליון זז (החלקה של המשתמש, גלילה פרוגרמטית, התייצבות בנקודת העצירה), כך שכל נתיבי הסגירה מתכנסים לאותו קריאה חוזרת (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();
});

אסטרטגיות חלופיות

‫API של חלון קופץ, אנימציה מבוססת-גלילה שמעמעמת את הרקע ו-scroll-initial-target הם שיפורים הדרגתיים עם חלופות פשוטות שאפשר להטמיע בקלות אם נדרשת תמיכה רחבה בדפדפן.

התאמה של דהיית הרקע (אין תמיכה ב-animation-timeline):

אם אין תמיכה, צריך לזהות באמצעות CSS.supports('animation-timeline: scroll()') ולכתוב --drawer-backdrop מרכיב event 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 בלי עמעום. אם ה-fallback של האנימציה מבוססת-הגלילה שלמעלה מוגדר, ה-JavaScript הזה כותב מחרוזת מספרית חדשה ל---drawer-backdrop בכל פריים של גלילה, ולא מבצע אינטרפולציה אף פעם. לכן, אין צורך ב-fallback נפרד של @property, כי כל הדפדפנים שתומכים באנימציות מבוססות-גלילה תומכים גם ב-@property.

חלופה ל-Popover API (אין תמיכה במאפיין popover):

הרכיב הזה משתמש ב-popover="manual" ומיישם את הסגירה לגמרי מ-JavaScript, ולכן הוא לא תלוי בהתנהגויות שמגדירות את ה-API של חלונות קופצים – סגירה קלה, המאפיין popovertarget, טיפול ב-Escape שמנוהל בשכבה העליונה או ניהול המיקוד. התכונות היחידות של חלון קופץ שנעשה בהן שימוש בפועל הן קידום השכבה העליונה (באמצעות showPopover()) והרכיב המדומה ::backdrop.

אם אתם צריכים תמיכה רחבה יותר בדפדפן, אל תסתעפו בזיהוי התכונות – פשוט אל תשתמשו ב-popover בכלל. צריך להסיר את המאפיין popover="manual", להחליף את הקידום בשכבה העליונה ב-position: fixed וב-z-index גבוה, להחליף את ::backdrop ברכיב אחים עם סגנון זהה (באמצעות אותו מאפיין מותאם אישית --drawer-backdrop), ולהחליף את החשיפה ממחלקה במקום showPopover()/hidePopover(). שאר הרכיבים (גלילה עם נקודות עצירה, אנימציית הרקע שמופעלת בגלילה, IntersectionObserver ומטפלי הסגירה) לא משתנים.