Penggeseran Dua Dimensi

Saat pengguna men-scroll penampung scroll dua dimensi, browser biasanya menerapkan heuristik penguncian sumbu scroll (juga dikenal sebagai "railing"). Jika gestur dimulai dengan gerakan yang jauh lebih banyak di sepanjang satu sumbu daripada sumbu tegak lurus, browser akan mengunci scroll ke sumbu horizontal atau vertikal tunggal tersebut dan mengabaikan delta tegak lurus—sering kali untuk sisa gestur.

Meskipun penguncian sumbu mencegah pergeseran ke samping yang tidak disengaja dalam alur pembacaan satu dimensi, penguncian ini menimbulkan gesekan pada permukaan geser dua dimensi seperti peta interaktif, gambar yang diperbesar, kanvas tanpa batas, grafik node, dan petak data besar. Karena penguncian tetap ada di seluruh gestur, penguncian ini memengaruhi penggeseran yang melibatkan dua arah—baik kedua sumbu digeser secara bersamaan (penggeseran diagonal) atau satu per satu dalam satu gestur (seperti jalur melengkung, melingkar, atau berbentuk L). Properti scroll-axis-lock CSS menonaktifkan heuristik ini langsung di penampung scroll native, sehingga memungkinkan penggeseran dua dimensi tanpa batas sekaligus mempertahankan scroll thread compositor, fisika momentum, scrollbar native, dan navigasi keyboard.

Penerapan

1. Menonaktifkan penguncian sumbu dan membatasi overscroll pada penampung scroll dua dimensi

Terapkan scroll-axis-lock: none langsung ke elemen yang membuat penampung scroll dua dimensi (overflow: auto atau overflow: scroll). Meskipun penampung scroll 2D sudah dapat memperbarui delta scroll di kedua sumbu berdasarkan nilai auto default, auto memungkinkan heuristik railing browser berfungsi saat gestur dimulai terutama di satu sumbu. Menyetel scroll-axis-lock: none akan mencegah railing tersebut aktif di layar sentuh, trackpad, dan roda mouse.

Pasangkan scroll-axis-lock: none dengan overscroll-behavior: none (atau overscroll-behavior: contain) pada penampung scroll sehingga saat mencapai tepi, scroll tidak akan berantai ke halaman induk atau memicu navigasi overscroll browser yang tidak disengaja (seperti navigasi geser horizontal ke belakang/ke depan atau tarik untuk memuat ulang).

2. Mempertahankan fokus keyboard dan aksesibilitas

Karena platform penggeseran 2D sering kali tidak memiliki kontrol turunan yang dapat difokuskan (misalnya, peta atau gambar yang diperbesar), pastikan pengguna keyboard dapat memfokuskan penampung dan menggesernya dengan tombol Panah dengan menambahkan tabindex="0", role yang sesuai dan label yang dapat diakses, serta indikator :focus-visible yang terlihat.

Kode Contoh

<!-- Make the 2D scroll container keyboard-focusable and labeled so keyboard
     users can focus the viewport and pan in all directions with Arrow keys. -->
<div class="pan-viewport" tabindex="0" role="region" aria-label="Interactive canvas">
  <div class="pan-surface">
    <!-- 2D overflowing content (map tiles, zoomed image, canvas nodes, or grid) -->
  </div>
</div>
.pan-viewport {
  /* Example viewport dimensions; adjust to fit your layout. */
  inline-size: 100%;
  block-size: 500px;

  /* MANDATORY: Establish a scroll container that can overflow on both axes. */
  overflow: auto;

  /* MANDATORY: Prevent browser scroll axis-locking ("railing") from kicking in
     so 2D gestures never get locked to a single axis. */
  scroll-axis-lock: none;

  /* Contain scrolling and prevent accidental swipe navigations at edges
     (use `none` or `contain`). */
  overscroll-behavior: none;
}

/* Provide a clear focus indicator when keyboard users focus the viewport. */
.pan-viewport:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

.pan-surface {
  /* Example surface dimensions larger than .pan-viewport in both axes. */
  inline-size: 3000px;
  block-size: 3000px;
}

Praktik Terbaik & Batasan

  • Terapkan ke container scroll itu sendiri, bukan wrapper ancestor: scroll-axis-lock tidak diwariskan, sehingga menyetelnya di wrapper tidak akan berpengaruh pada container scroll turunan.
  • Membatasi overscroll dan mencegah navigasi yang tidak disengaja: Tambahkan overscroll-behavior: none atau overscroll-behavior: contain ke penampung scroll 2D sehingga menggeser ke batas tidak akan terhubung ke halaman induk atau memicu navigasi geser mundur/maju browser dan gestur tarik untuk memuat ulang.
  • Panel satu dimensi bertingkat mempertahankan railing secara otomatis: Karena scroll-axis-lock tidak diwariskan, sidebar atau panel pemeriksa yang bertingkat di dalam .pan-viewport mempertahankan perilaku scroll-axis-lock: auto default.
  • Pertahankan auto pada feed vertikal standar, artikel, dan daftar satu dimensi: Penguncian sumbu mencegah pergeseran tegak lurus yang tidak disengaja dalam konten searah.
  • Jangan tetapkan touch-action: none tanpa syarat: touch-action mengontrol apakah browser menangani gestur sentuh sama sekali (dan tidak berpengaruh pada input trackpad atau roda), sehingga menyetelnya akan menonaktifkan scroll sentuh compositor native. Hanya terapkan di dalam @supports not (scroll-axis-lock: none) saat menerapkan penggantian pointer JavaScript.
  • Geser dengan scrollLeft/scrollTop, jangan pernah menggunakan transform CSS: Mentransformasi platform dengan transform akan menghentikan fungsi elemen sebagai penampung scroll—merusak scrollbar native, penjepitan batas, scroll tombol Panah keyboard, peristiwa scroll, IntersectionObserver, dan animasi berbasis scroll.

Strategi penggantian

Pilih antara progressive enhancement dan penggantian JavaScript berdasarkan persyaratan aplikasi Anda:

Opsi 1: Progressive Enhancement (Tanpa JavaScript)

Gunakan progressive enhancement jika mempertahankan scroll sentuh thread compositor native, momentum fling inersia, dan cubit untuk zoom lebih penting daripada menghilangkan penguncian sumbu di browser yang tidak didukung:

  • Browser pendukung: Browser mencegah heuristik penguncian sumbu ("railing") diaktifkan pada penampung scroll 2D selama gestur scroll.
  • Browser yang tidak didukung: Browser yang tidak mengenali scroll-axis-lock: none akan mengabaikan deklarasi dengan aman dan mempertahankan perilaku scroll 2D auto default-nya—men-scroll di kedua sumbu secara native, sambil tetap membatasi gestur yang dimulai terutama di sepanjang satu sumbu.

Opsi 2: Penggantian JavaScript (wheel + pointermove dengan scrollLeft/scrollTop)

Jika diperlukan penggeseran dua dimensi tanpa batas di semua browser, gunakan @supports not (scroll-axis-lock: none) dan CSS.supports('scroll-axis-lock', 'none') untuk mencegat gestur tarik pointer dan wheel secara bersyarat.

Karena wheel (e.deltaX, e.deltaY) dan pointermove (e.clientX, e.clientY) mengekspos delta 2D mentah yang tidak terkunci sebelum mesin scroll browser menerapkan penguncian sumbu, Anda dapat menerapkan delta tersebut langsung ke scrollLeft dan scrollTop:

  • Alasan menggunakan scrollLeft/scrollTop, bukan transform CSS: Memperbarui scrollLeft dan scrollTop akan mempertahankan elemen sebagai penampung scroll yang sebenarnya. Browser otomatis membatasi posisi scroll ke [0, scrollWidth - clientWidth] dan [0, scrollHeight - clientHeight], scrollbar native tetap disinkronkan, scroll tombol Panah keyboard terus berfungsi, dan peristiwa scroll serta IntersectionObserver diaktifkan secara normal.
  • Kompromi: Mencegat wheel dengan { passive: false } akan memindahkan scroll ke thread utama, dan menyetel touch-action: none di layar sentuh akan menonaktifkan momentum lemparan inersia native setelah pointerup (serta cubit untuk zoom native).
/* Only disable native touch scrolling when scroll-axis-lock is unsupported
   and the JavaScript pointer fallback below is active. */
@supports not (scroll-axis-lock: none) {
  .pan-viewport {
    touch-action: none;
  }
}
// Only attach the main-thread gesture fallback when native support is missing
if (!CSS.supports('scroll-axis-lock', 'none')) {
  const viewport = document.querySelector('.pan-viewport');
  let lastPointer = null;

  // 1. Trackpad and mouse wheel fallback
  viewport.addEventListener(
    'wheel',
    (e) => {
      e.preventDefault();
      viewport.scrollLeft += e.deltaX;
      viewport.scrollTop += e.deltaY;
    },
    { passive: false },
  );

  // 2. Touch and pointer drag fallback
  viewport.addEventListener('pointerdown', (e) => {
    // Ignore non-primary buttons and clicks on native scrollbars
    const rect = viewport.getBoundingClientRect();
    if (
      !e.isPrimary ||
      e.button !== 0 ||
      e.clientX - rect.left - viewport.clientLeft >= viewport.clientWidth ||
      e.clientY - rect.top - viewport.clientTop >= viewport.clientHeight
    ) {
      return;
    }
    lastPointer = { x: e.clientX, y: e.clientY };
    viewport.setPointerCapture(e.pointerId);
  });

  viewport.addEventListener('pointermove', (e) => {
    if (!lastPointer) return;
    viewport.scrollLeft -= e.clientX - lastPointer.x;
    viewport.scrollTop -= e.clientY - lastPointer.y;
    lastPointer = { x: e.clientX, y: e.clientY };
  });

  const endPointerPan = () => {
    lastPointer = null;
  };
  viewport.addEventListener('pointerup', endPointerPan);
  viewport.addEventListener('pointercancel', endPointerPan);
}