算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 尊重「减少动态效果」 待审核 35 / 47
prefers-reduced-motion · a11y · 降级

尊重「减少动态效果」

操作系统提供了一个无障碍偏好:减少动态效果。开启它的用户往往对大幅运动敏感——前庭功能障碍者会因屏幕上的滑动、缩放、旋转产生眩晕与恶心。Web 端通过 matchMedia('(prefers-reduced-motion: reduce)') 读到这一偏好。

1 · 同一个入场的两种走法

图 1-1 · 同一张卡片入场在完整模式与减少模式下的对照。可用开关模拟偏好;上方徽标始终显示真实的 matchMedia 检测结果。

2 · 降级不等于静止

开启这个偏好的用户依然需要知道发生了什么。直接删掉过渡会让界面状态突变、丢失因果线索。正确做法是保留必要反馈——淡入、颜色变化、短促的 opacity 过渡——只移除让人眩晕的大幅运动:长距离 translate、整体 scalerotate、视差。

本页给了两档降级策略:退化为温和的 opacity 淡入,或对极敏感的循环与抖动动画直接瞬时到位。

注 · 页内的模拟开关与系统偏好取「或」而非「覆盖」。这不只是为了演示方便:真实产品里通常也会给用户一个应用内的动效开关,而它应当只能加强限制、不能推翻系统设置——用户在系统里说了「减少动态」,应用不该用自己的开关把它打开。

3 · 偏好会在运行时变化

matchMedia(query) 返回的 MediaQueryList 不只能即时读 .matches,还能监听 change 事件。用户在系统设置里切换偏好时事件会触发,界面应据此即时调整后续动画——而不是只在页面加载时读一次。

import { Animation, easingFns } from '@vega/anim';

const mql = matchMedia('(prefers-reduced-motion: reduce)');

function entrance() {
  if (!mql.matches) {                 // 完整动态: 大幅运动
    new Animation({
      keyframes: [0, 1], duration: 720, ease: easingFns.backOut,
      onUpdate: (p) => {
        card.style.transform =
          `translateY(${(1 - p) * -120}px) scale(${0.4 + p * 0.6}) rotate(${(1 - p) * -180}deg)`;
      },
    });
    return;
  }
  // 减少动态: 去掉位移/缩放/旋转, 退化为温和淡入 (或直接 set 到位)
  new Animation({
    keyframes: [0, 1], duration: 360, ease: easingFns.easeOut,
    onUpdate: (o) => { card.style.transform = 'none'; card.style.opacity = o; },
  });
}

mql.addEventListener('change', entrance);   // 偏好变化即时响应
entrance();

决定降级后淡入节奏的曲线见缓动;完整模式里位移、缩放、旋转三条轨迹的编排见关键帧