行业资讯
📅 2026/9/8 18:22:42
在 tldraw 自定义形状中实现「减弱动态效果」:usePrefersReducedMotion 使用指南
在 tldraw 自定义形状中实现「减弱动态效果」usePrefersReducedMotion 使用指南【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. Worlds best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw本文基于 tldraw 仓库中 configuration/reduced-motion 示例展开讲解如何用官方 HookusePrefersReducedMotion在自定义图形custom shape内部感知用户的减弱动态偏好并用 CSS 动画/静态样式两种形态响应它。读完本文你将掌握 tldraw 用户偏好体系animationSpeed与系统prefers-reduced-motion媒体查询的合并规则能写出对前庭障碍与运动敏感用户友好的自定义图形并复刻示例中的一键切换全部动画调试面板。为什么自定义形状也需要关心动态效果动画、脉冲、滚动、缩放过渡等运动效果会让部分用户感到眩晕或不适尤其是患有前庭障碍vestibular disorders与运动敏感motion sensitivity的人群。操作系统的无障碍设置中普遍提供减弱动态效果选项Web 侧对应prefers-reduced-motion: reduce媒体查询而 tldraw 编辑器自身也提供了一级Reduce motion用户偏好。关键点在于内置的偏好判断入口如何让开发者自己写的自定义形状也能低成本地享受到。tldraw 官方给出的答案是暴露usePrefersReducedMotion()Hook让自定义形状的 React 渲染层直接拿到合并后的最终结论。仓库中该示例的目的即演示这一用法——脉冲图形在正常偏好下持续脉动在减弱动态偏好下退化为静止的灰色圆点且顶部面板可以随时在两种状态间切换。两层偏好如何合并成一个布尔值用户偏好层animationSpeedtldraw 将是否减弱动态编码为用户偏好animationSpeed动画速度。它的取值为数字其中0表示关闭动画等价于开启reduce motion1表示正常速度默认其他数值用于中间的动画速度档位。该字段被定义在 TLUserPreferences.ts 的userTypeValidator中为T.number.nullable().optional()属于可持久化的用户偏好快照。默认值并非写死的1而是动态计算// packages/editor/src/lib/config/TLUserPreferences.ts export const defaultUserPreferences Object.freeze({ // ... animationSpeed: userPrefersReducedMotion() ? 0 : 1, // ... })也就是说新建编辑器时如果操作系统已经开启减弱动态tldraw 会默认把animationSpeed初始化为0。实时回退层系统媒体查询需要注意一个细节若用户从未显式设置过animationSpeed其值在存储中是null。此时 tldraw 并不会直接沿用模块加载时捕获的默认值而是实时跟随系统设置。这在 UserPreferencesManager.ts 的getAnimationSpeed()中可以看到computed getAnimationSpeed() { // Until the user explicitly sets a speed, follow the OS reduce-motion setting live rather // than the value defaultUserPreferences captured at module load. return ( this.user.userPreferences.get().animationSpeed ?? (this.systemPrefersReducedMotion.get() ? 0 : 1) ) }注释明确说明在用户显式设置速度之前应实时跟随操作系统的减弱动态设置而不是使用模块加载时的快照值。Hook 层的合并逻辑usePrefersReducedMotion()的实现位于 usePrefersReducedMotion.tsx其判定规则为若 tldraw 的animationSpeed已设置不等于undefined返回animationSpeed 0否则编辑器偏好未设置回退监听系统媒体查询(prefers-reduced-motion: reduce)返回其匹配结果并在系统设置变化时自动更新。export function usePrefersReducedMotion() { const editor useMaybeEditor() const animationSpeed useValue(animationSpeed, () editor?.user.getAnimationSpeed(), [editor]) const [prefersReducedMotion, setPrefersReducedMotion] useState(false) useEffect(() { if (animationSpeed ! undefined) { setPrefersReducedMotion(animationSpeed 0 ? true : false) return } const win editor?.getContainerWindow() ?? window if (!(matchMedia in win)) return const mql win.matchMedia((prefers-reduced-motion: reduce)) const handler () setPrefersReducedMotion(mql.matches) handler() mql.addEventListener(change, handler) return () mql.removeEventListener(change, handler) }, [animationSpeed, editor]) return prefersReducedMotion }值得注意的实现细节它通过useValue订阅编辑器的animationSpeed因此偏好变化时组件会自动重渲染无需手动通知它通过useMaybeEditor()而非useEditor()获取编辑器当组件不在编辑器上下文中editor 为undefined时仍可回退到全局window.matchMedia独立工作系统层判定监听change事件并在卸载时清理监听器避免泄漏。因此可把usePrefersReducedMotion()当作一个纯粹的响应式布尔开关tldraw 偏好一旦设定就优先于系统设置未设定则跟随系统实时状态。内置入口用户在菜单里怎么开启这项设置普通用户不需要写任何代码。编辑器主菜单中提供Preferences Accessibility Reduce motion选项其组件实现位于 menu-items.tsx 的ToggleReduceMotionItemexport function ToggleReduceMotionItem() { const editor useEditor() const animationSpeed useValue(animationSpeed, () editor.user.getAnimationSpeed(), [editor]) return ( TldrawUiMenuActionCheckboxItem actionIdtoggle-reduce-motion checked{animationSpeed 0} / ) }这个复选项勾选时执行的正是把animationSpeed设为0的动作。正因如此通过官方菜单开启的设置与你程序里调用editor.user.updateUserPreferences({ animationSpeed: 0 })是完全等价的——Hook 与所有使用它的形状都会以相同方式响应。另外animationSpeed属于可迁移的用户偏好快照字段仓库中TLUserPreferences.ts的版本迁移逻辑里Versions.AddAnimationSpeed会为旧版本快照补写默认值因此持久化的偏好数据在版本升级后依然兼容。实战解剖脉冲形状示例完整示例文件为 ReducedMotionExample.tsx配套样式在 reduced-motion.css。下面按四个步骤拆解。第 1 步注册自定义形状的类型与 props示例定义了一个类型名为pulse-shape、props 为{ w: number; h: number }的形状。为了让editor.createShape({ type: PULSE_SHAPE_TYPE })通过类型检查需要把 props 注册进全局映射const PULSE_SHAPE_TYPE pulse-shape declare module tldraw { export interface TLGlobalShapePropsMap { [PULSE_SHAPE_TYPE]: { w: number; h: number } } } type PulseShape TLShapetypeof PULSE_SHAPE_TYPEShapeUtil基类只要求最少的几何与渲染实现export class PulseShapeUtil extends ShapeUtilPulseShape { static override type PULSE_SHAPE_TYPE static override props: RecordPropsPulseShape { w: T.number, h: T.number, } getDefaultProps(): PulseShape[props] { return { w: 200, h: 200 } } getGeometry(shape: PulseShape): Geometry2d { return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true, }) } component() { return PulseShapeComponent / } getIndicatorPath(shape: PulseShape) { const path new Path2D() path.rect(0, 0, shape.props.w, shape.props.h) return path } }渲染逻辑全部收敛在component()返回的 React 组件中——这是能否使用 Hook 的前提。第 2 步在形状组件中调用 Hook 并切换形态形状的视觉主体是一个普通 React 组件usePrefersReducedMotion()在这里被调用返回值直接决定渲染哪个 CSS 类与标签文案function PulseShapeComponent() { const prefersReducedMotion usePrefersReducedMotion() return ( HTMLContainer classNamepulse-shape div classNamepulse-shape__content div className{prefersReducedMotion ? pulse-indicator--static : pulse-indicator} / div classNamepulse-shape__label {prefersReducedMotion ? Static mode : Animated mode} /div /div /HTMLContainer ) }第 3 步用 CSS 定义动画版与静态版CSS 中两种形态共用同一个圆点尺寸仅背景色与动画不同动画版使用keyframes pulse持续缩放并降低透明度静态版则是稳定的灰色圆点见 reduced-motion.css.pulse-indicator { background-color: var(--tl-color-primary); animation: pulse 2s ease-in-out infinite; } .pulse-indicator--static { background-color: var(--tl-color-text-3); } keyframes pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.2); opacity: 0.7; } }当偏好变化时Hook 触发组件重渲染pulse-indicator与pulse-indicator--static两个类互相替换动画类被移除脉冲动画随之停止。整个动画与静态替换不需要在 JS 中操作动画帧或定时器纯 CSS 类切换即可完成逻辑极薄。第 4 步挂载示例并放置三个脉冲形状const shapeUtils [PulseShapeUtil] export default function ReducedMotionExample() { return ( div classNametldraw__editor Tldraw shapeUtils{shapeUtils} components{components} onMount{(editor) { editor.createShape({ type: PULSE_SHAPE_TYPE, x: 200, y: 200 }) editor.createShape({ type: PULSE_SHAPE_TYPE, x: 450, y: 200 }) editor.createShape({ type: PULSE_SHAPE_TYPE, x: 325, y: 450 }) }} / /div ) }从源码结构看usePrefersReducedMotion之所以能触发所有形状同步切换是因为每个 PulseShapeComponent 各自独立订阅了同一份编辑器偏好状态面板按钮改变animationSpeed后useValue依赖失效画布上所有使用该 Hook 的形状组件几乎同时重渲染。程序化切换偏好顶栏 Toggle 按钮示例在顶栏TopPanel渲染了一个MotionToggle组件用来演示如何编程式切换动画偏好。它先读取当前速度再在0与1之间翻转function MotionToggle() { const editor useEditor() const prefersReducedMotion usePrefersReducedMotion() const toggleMotion () { const currentSpeed editor.user.getAnimationSpeed() editor.user.updateUserPreferences({ animationSpeed: currentSpeed 0 ? 1 : 0, }) } return ( div classNametlui-menu motion-toggle span classNamemotion-toggle__label Motion: {prefersReducedMotion ? Reduced : Normal} /span TldrawUiButton typeprimary onClick{toggleMotion} Toggle /TldrawUiButton /div ) } const components: TLComponents { TopPanel: MotionToggle, }涉及的两个 API 语义如下editor.user.getAnimationSpeed()返回当前生效的动画速度可能来自显式偏好也可能来自系统设置的实时回退值见上文getAnimationSpeededitor.user.updateUserPreferences({ animationSpeed })写回偏好。实现位于 UserPreferencesManager.ts是读取当前完整偏好再与传入片段合并的更新方式因此animationSpeed设为0与内置菜单的Reduce motion行为完全一致。注意示例中标签同时展示Motion: Reduced / Normal能直观看出当前 Hook 的真实返回值方便在演示时确认系统设置、tldraw 偏好与 UI 三者的同步关系。官方内置形状也在使用这个 HookusePrefersReducedMotion不是仅供示例使用的演示 APItldraw 自身的图形就在应用它。一个直接的例证是视频图形 VideoShapeUtil.tsx其组件先调用该 Hook再把结果并入自动播放条件const prefersReducedMotion usePrefersReducedMotion() // ... autoPlay{shape.props.autoplay !prefersReducedMotion}即用户在系统或 tldraw 偏好中开启减弱动态后即使视频标记为自动播放也不会自动开始——这正说明尊重用户动画偏好应该贯穿到编辑器内所有会产生运动的角落而不仅限于 UI 动画。同样引入该 Hook 的还有图片形状 ImageShapeUtil.tsx 等内置实现验证了它作为共享无障碍工具的通用性。最佳实践小结把 Hook 放在形状的 React 组件里usePrefersReducedMotion是标准 React Hook只能被component()返回的组件或组件树内调用形状 util 本身无法使用示例的PulseShapeUtil保持极简、只做几何与渲染正是为了让渲染逻辑可挂载 Hook。优先用 CSS 类切换而非条件渲染动画帧用两种形态的 CSS 类表达动 / 静偏好变化只是换类渲染成本与心智成本都最低。动画尽量用 CSSanimation表达移除类即停止动画天然遵守偏好无需手动管理 rAF 循环。tldraw 偏好与系统设置的关系显式的animationSpeed优先于系统prefers-reduced-motion两者都未定义默认值时应使用实时跟随系统的回退这正是getAnimationSpeed的行为不要把模块加载时的快照当作长期答案。尊重偏好的代价很低把检查收敛在usePrefersReducedMotion()一处任意数量的自定义形状、内置图形与 UI 动画都可复用同一结论对运动敏感用户的无障碍价值则很高。仓库中与本文主题直接相关的完整参考材料包括示例 README、示例 TSX、示例 CSS、Hook 实现、用户偏好定义与迁移、UserPreferencesManager以及使用该 Hook 的内置视频形状仓库亦有相关测试如 animationSpeed.test.ts 覆盖该行为。若需在本地运行该示例可参照 apps/examples 目录的启动方式运行 examples 应用并打开 Reduced motion preferences 用例。【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. Worlds best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考