二维平移

当用户滚动二维滚动容器时,浏览器通常会应用滚动轴锁定启发法(也称为“栏杆”)。如果手势开始时沿一个轴的移动量明显大于垂直轴,浏览器会将滚动锁定到该单个水平或垂直轴,并忽略垂直增量,通常在手势的其余部分也是如此。

虽然轴锁定可防止一维阅读流中出现意外的侧向漂移,但它会在二维平移表面(例如交互式地图、缩放图片、无限画布、节点图和大型数据网格)上产生摩擦。由于锁定会在整个手势过程中持续存在,因此会影响任何涉及两个方向的平移,无论是同时沿两个轴平移(对角线平移),还是在单个手势中先后沿两个轴平移(例如曲线、圆形或 L 形路径)。CSS scroll-axis-lock 属性可直接在原生滚动容器上停用此启发法,从而允许不受限制的二维平移,同时保留合成器线程滚动、动量物理、原生滚动条和键盘导航。

实现

1. 在二维滚动容器上停用轴锁定并限制滚动回弹

将 scroll-axis-lock: none 直接应用于建立二维滚动容器(overflow: auto 或 overflow: scroll)的元素。虽然二维滚动容器在默认 auto 值下已经可以沿两个轴更新滚动增量,但 auto 允许在手势主要沿一个轴开始时启动浏览器的栏杆启发法。设置 scroll-axis-lock: none 可防止该限制在触摸屏、触控板和鼠标滚轮上生效。

在滚动容器上将 scroll-axis-lock: none 与 overscroll-behavior: none(或 overscroll-behavior: contain)搭配使用,这样一来,当滚动到边缘时,就不会将滚动操作链接到父级页面,也不会触发意外的浏览器滚动回弹导航(例如水平滑动后退/前进导航或下拉刷新)。

2. 保留键盘焦点和无障碍功能

由于 2D 平移界面通常缺少可聚焦的子控件(例如地图或缩放后的图片),因此请确保键盘用户可以聚焦容器并使用箭头键平移容器,方法是添加 tabindex="0"、适当的 role 和无障碍标签,以及可见的 :focus-visible 指示器。

示例代码

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

最佳实践和限制

  • 将其应用于滚动容器本身,而不是祖先封装容器:scroll-axis-lock 不会继承,因此在封装容器上设置它不会对后代滚动容器产生任何影响。
  • 限制滚动回弹并防止意外导航:向 2D 滚动容器添加 overscroll-behavior: none 或 overscroll-behavior: contain,这样平移到边界时就不会链接到父级页面,也不会触发浏览器后退/前进滑动导航和下拉刷新手势。
  • 嵌套的一维面板会自动保持栏杆效果:由于 scroll-axis-lock 不会继承,因此嵌套在 .pan-viewport 内的边栏或检查器面板会保留默认的 scroll-axis-lock: auto 行为。
  • 在标准垂直信息流、文章和一维列表中保持 auto:轴锁定可防止单向内容中出现意外的垂直漂移。
  • 请勿无条件设置 touch-action: none:touch-action 控制浏览器是否处理触摸手势(对触控板或滚轮输入没有影响),因此设置它会停用原生合成器触摸滚动。在实现 JavaScript 指针后备时,仅在 @supports not (scroll-axis-lock: none) 内应用。
  • 使用 scrollLeft / scrollTop 平移,切勿使用 CSS transform:使用 transform 平移表面会使元素无法用作滚动容器,从而破坏原生滚动条、边界限制、键盘箭头键滚动、scroll 事件、IntersectionObserver 和滚动驱动的动画。

后备策略

根据应用的要求,在渐进增强和 JavaScript 后备之间进行选择:

选项 1:渐进增强(无需 JavaScript)

如果保留原生合成器线程触摸滚动、惯性滑动和双指张合缩放比在不受支持的浏览器中消除轴锁定更重要,请使用渐进增强:

  • 支持的浏览器:浏览器会阻止其轴锁定(“栏杆”)启发式算法在滚动容器上执行滚动操作期间生效。
  • 不受支持的浏览器:无法识别 scroll-axis-lock: none 的浏览器会安全地忽略该声明,并保持其默认的 auto 2D 滚动行为,即在两个轴上原生滚动,同时仍会限制主要沿单个轴开始的手势。

选项 2:JavaScript 后备(wheel + pointermove,使用 scrollLeft/scrollTop)

如果需要在所有浏览器中进行不受限制的二维平移,请使用 @supports not (scroll-axis-lock: none) 和 CSS.supports('scroll-axis-lock', 'none') 有条件地拦截 wheel 和指针拖动手势。

由于 wheel (e.deltaX, e.deltaY) 和 pointermove (e.clientX, e.clientY) 会在浏览器的滚动引擎应用轴锁定之前公开原始、未锁定的 2D 增量,因此您可以将这些增量直接应用于 scrollLeft 和 scrollTop:

  • 为什么使用 scrollLeft/scrollTop 而不是 CSS transform:更新 scrollLeft 和 scrollTop 可使元素保持为真正的滚动容器。浏览器会自动将滚动位置限制在 [0, scrollWidth - clientWidth] 和 [0, scrollHeight - clientHeight] 之间,原生滚动条保持同步,键盘箭头键滚动继续有效,scroll 事件和 IntersectionObserver 正常触发。
  • 权衡:使用 { passive: false } 拦截 wheel 会将滚动操作移至主线程,而在触摸屏上设置 touch-action: none 会在 pointerup 之后停用原生惯性滑动(以及原生双指张合缩放)。
/* 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);
}