Gezinme Çekmecesi

Genel Bakış

Gezinme çekmecesi, görüntü alanının kenarından sayfa içeriğinin üzerine kayarak arkasındaki her şeyi karartan bir paneldir. Tetikleyici düğmesinden açılır ve panel ekran dışına kaydırılarak, karartılmış arka plana dokunarak veya Escape tuşuna basılarak kapatılır.

Bu kılavuzda, çekmece şu şekilde uygulanır:

  • Üst katmana yükseltilen bir popover="manual" öğesi, böylece panel ve ::backdrop, yığınlama bağlamından bağımsız olarak sayfadaki diğer tüm öğelerin üzerine yerleştirilir.
  • İki CSS kaydırarak tutturma noktası (biri "açık", diğeri "kapalı" için) içeren, yatay olarak kaydırılan bir kapsayıcı. Bu sayede kaydırma hareketi, tarayıcı tarafından yerel olarak işlenir. Bu sayede, JavaScript işaretçi etkinliği kodu olmadan ücretsiz olarak momentum, hız ve kesinti izleme olanağı sunulur.
  • Arka planın opaklığını kaydırma konumuna bağlayan, kaydırmaya dayalı bir animasyon. Bu sayede, kullanıcı paneli sürükledikçe karartma sorunsuz bir şekilde belirir ve kaybolur.
  • Panelde, görüntü alanına tamamen girdiğini veya görüntü alanından tamamen çıktığını algılayan ve bu anları odağı, aria-expanded ve inert değerlerini güncellemek için kullanan bir IntersectionObserver.

Bu yaklaşım, JavaScript tabanlı transform animasyonlara tercih edilir. Çünkü kaydırma mekanizması, kullanıcının panelin konumunu doğrudan kontrol etmesini sağlar (panel, bir ara değer değil, kullanıcının parmağıyla hareket ettirilir) ve kullanıcıların yerel mobil uygulamalarda alışık olduğu etkileşim kalıplarına çok daha yakındır.

Uygulama

1. Brüt kar

Çekmece, görünür "sayfayı" içeren yatay bir kaydırıcıya sahip tek bir popover'dır. Tetikleyici düğme, sayfa içeriğinde yer alır.

<!-- 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. Stiller

Pop-over'ı sıfırlayıp görüntü alanını doldurma

Pop-over, tüm görüntü alanını kaplamalıdır. Böylece ::backdrop, sayfanın tamamını karartır ve kaydırma yüzeyi uçtan uca uzanır. Varsayılan kullanıcı aracısı popover stilleri (ortalanmış, otomatik boyutlandırılmış, kenarlıklı) engel oluşturur ve sıfırlanmalıdır.

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

Kaydırarak tutturma ile kaydırma yüzeyi oluşturma

Kaydırıcı, görüntü alanından daha geniş bir yatay ızgaradır: 1. sütunda sayfa (genişlik --drawer-width), 2. sütunda ise görüntü alanı genişliğinde boş bir sözde öğe ayırıcı bulunur. İki sütun arasında geçiş yapmak, çekmeceyi açıp kapatır.

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

Arka plan opaklığını kaydırma konumuna bağlama

Kaydırmaya dayalı bir animasyon, kaydırıcının aralığı boyunca --drawer-backdrop değerini 1 (açık) ile 0 (kapalı) arasında eşler. Böylece arka plan, sürükleme işlemiyle mükemmel bir şekilde senkronize olarak belirir ve kaybolur.

/* 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. Çekmeceyi açma ve kapatma

Açma işlemi iki adımdan oluşur: Popover'ı üst katmana yükseltin, ardından sayfayı kaydırarak görünür hale getirin. Kapatma işlemi tek bir adımdan oluşur: Boşluk bırakıcıya geri gidin. Sayfa tamamen ekran dışına çıktığında bir gözlemci (4. adım) popover'ı gizler.

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. Açık ve kapalı durumunu algılama

Sayfanın durumunu belirlemek için kaydırma konumunu değil, sayfadaki bir IntersectionObserver kullanın. Gözlemci, sayfanın nasıl taşındığına (kullanıcı kaydırma, programatik kaydırma, tutturma) bakılmaksızın tetiklenir. Bu nedenle, tüm kapatma yolları aynı geri çağırmada birleşir.

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. Tetikleyici ve kapatma işleyicilerini bağlama

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

Yedek stratejiler

Popover API, arka planı solduran kaydırmaya dayalı animasyon ve scroll-initial-target, geniş tarayıcı desteği gerektiğinde kolayca uygulanabilen basit yedeklere sahip progresif geliştirmelerdir.

Arka plan karartma yedeği (animation-timeline desteği yok):

CSS.supports('animation-timeline: scroll()') ile algılayın ve desteklenmiyorsa scroll etkinlik işleyiciden --drawer-backdrop yazın. 2. adımdaki CSS @supports bloğu, ana karelerin desteklenmeyen tarayıcılarda hiçbir zaman uygulanmamasını sağlar. Bu nedenle, JavaScript değeri tek yazıcıdır.

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

İlk kaydırma konumu yedeği (scroll-initial-target desteği yok):

CSS.supports('scroll-initial-target', 'nearest') ile ve openDrawer() içinde algılayın, showPopover()'den hemen sonra kaydırıcıyı kapalı konuma kaydırın. Bu olmadan, çekmece kayma animasyonu olmadan anında açık konumda görünür.

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 yedek (kayıtlı özel özellik yok):

@property, yalnızca kaydırmaya dayalı animasyon, kareler arasında --drawer-backdrop değerini enterpolasyonla hesapladığı için gereklidir. Kayıt yapılmazsa özellik dize olarak değerlendirilir ve kararma olmadan 0 ile 1 arasında geçiş yapar. Yukarıdaki kaydırmaya dayalı animasyon yedeği kullanılıyorsa bu JavaScript, her kaydırma karesinde --drawer-backdrop öğesine yeni bir sayısal dize yazar ve hiçbir zaman interpolasyon yapmaz. Bu nedenle, kaydırmaya dayalı animasyonları destekleyen tüm tarayıcılar @property özelliğini de desteklediğinden ayrı bir @property yedeği gerekmez.

Popover API yedek (popover özelliği desteği yok):

Bu bileşen popover="manual" kullandığından ve kapatma işlemini tamamen JavaScript'ten uyguladığından, popover API'nin tanımlayıcı davranışlarına (hafif kapatma, popovertarget özelliği, üst katman tarafından yönetilen Escape tuşu kullanımı veya odak yönetimi) bağlı değildir. Aslında kullandığı tek popover özellikleri, üst katman tanıtımı (showPopover() aracılığıyla) ve ::backdrop sözde öğesidir.

Daha geniş tarayıcı desteği gerekiyorsa özellik algılamaya göre dallandırma yapmayın, popover'ı hiç kullanmayın. popover="manual" özelliğini bırakın, üst katman promosyonunu position: fixed ve yüksek bir z-index ile değiştirin, ::backdrop öğesini aynı şekilde stillendirilmiş bir eşdüzey öğeyle (aynı --drawer-backdrop özel niteliğini kullanarak) değiştirin ve görünürlüğü showPopover()/hidePopover() yerine bir sınıftan açıp kapatın. Bileşenin geri kalanı (kaydırarak tutturma, kaydırmaya dayalı arka plan animasyonu, IntersectionObserver ve kapatma işleyicileri) değişmez.