diff --git a/packages/blockly/core/block_animations.ts b/packages/blockly/core/block_animations.ts index 2dbf90777ac..faf408665a9 100644 --- a/packages/blockly/core/block_animations.ts +++ b/packages/blockly/core/block_animations.ts @@ -7,6 +7,7 @@ // Former goog.module ID: Blockly.blockAnimations import type {BlockSvg} from './block_svg.js'; +import * as accessibility from './utils/accessibility.js'; import * as dom from './utils/dom.js'; import {Svg} from './utils/svg.js'; @@ -31,13 +32,18 @@ let wobblingBlock: BlockSvg | null = null; * @internal */ export function disposeUiEffect(block: BlockSvg) { - // Disposing is going to take so long the animation won't play anyway. - if (block.getDescendants(false).length > 100) return; - const workspace = block.workspace; const svgGroup = block.getSvgRoot(); workspace.getAudioManager().play('delete'); + // Disposing is going to take so long the animation won't play anyway. + if ( + block.getDescendants(false).length > 100 || + accessibility.shouldReduceMotion() + ) { + return; + } + const xy = block.getRelativeToSurfaceXY(); // Deeply clone the current block. const clone: SVGGElement = svgGroup.cloneNode(true) as SVGGElement; @@ -97,7 +103,7 @@ export function connectionUiEffect(block: BlockSvg) { const workspace = block.workspace; const scale = workspace.scale; workspace.getAudioManager().play('click'); - if (scale < 1) { + if (scale < 1 || accessibility.shouldReduceMotion()) { return; // Too small to care about visual effects. } // Determine the absolute coordinates of the inferior block. @@ -163,7 +169,7 @@ export function connectionUiEffect(block: BlockSvg) { export function disconnectUiEffect(block: BlockSvg) { disconnectUiStop(); block.workspace.getAudioManager().play('disconnect'); - if (block.workspace.scale < 1) { + if (block.workspace.scale < 1 || accessibility.shouldReduceMotion()) { return; // Too small to care about visual effects. } // Horizontal distance for bottom of block to wiggle. diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index a5261cafd38..4e066525d68 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -715,4 +715,12 @@ input[type=number] { flex-direction: ${userAgent.MOBILE || userAgent.APPLE ? 'row-reverse' : 'row'}; column-gap: 8px; } + +@media (prefers-reduced-motion: reduce) { + .blocklyBlockCanvas.blocklyCanvasTransitioning, + .blocklyBubbleCanvas.blocklyCanvasTransitioning, + .blocklyDropDownDiv { + transition: none !important; + } +} `; diff --git a/packages/blockly/core/toast.ts b/packages/blockly/core/toast.ts index 0efc817c7b0..ded7597b093 100644 --- a/packages/blockly/core/toast.ts +++ b/packages/blockly/core/toast.ts @@ -218,4 +218,10 @@ Css.register(` border: none; cursor: pointer; } + +@media (prefers-reduced-motion: reduce) { + .${CLASS_NAME} { + transition: none; + } +} `); diff --git a/packages/blockly/core/trashcan.ts b/packages/blockly/core/trashcan.ts index b0dd1e922f2..e2d853873ce 100644 --- a/packages/blockly/core/trashcan.ts +++ b/packages/blockly/core/trashcan.ts @@ -741,4 +741,11 @@ Css.register(` .blocklyRTL .blocklyTrash.blocklyTrashFull:focus .blocklyTrashLid { rotate: -45deg; } + + @media (prefers-reduced-motion: reduce) { + .blocklyTrash > image, + .blocklyTrashLid { + transition: none; + } + } `); diff --git a/packages/blockly/core/utils.ts b/packages/blockly/core/utils.ts index 9dd1c2a0913..0e6e45943eb 100644 --- a/packages/blockly/core/utils.ts +++ b/packages/blockly/core/utils.ts @@ -8,6 +8,7 @@ import * as browserEvents from './browser_events.js'; import * as extensions from './extensions.js'; +import * as accessibility from './utils/accessibility.js'; import * as aria from './utils/aria.js'; import * as arrayUtils from './utils/array.js'; import * as colour from './utils/colour.js'; @@ -32,6 +33,7 @@ import * as userAgent from './utils/useragent.js'; import * as xml from './utils/xml.js'; export { + accessibility, aria, arrayUtils as array, browserEvents, diff --git a/packages/blockly/core/utils/accessibility.ts b/packages/blockly/core/utils/accessibility.ts new file mode 100644 index 00000000000..aaa3f822075 --- /dev/null +++ b/packages/blockly/core/utils/accessibility.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +let reduceMotion: MediaQueryList | undefined = undefined; + +/** + * Returns true if motion and animations should be minimized. + */ +export function shouldReduceMotion(): boolean { + if (reduceMotion === undefined) { + if (typeof window === 'undefined' || !('matchMedia' in window)) { + // No point in animating if there's no window, i.e. we're running under + // Node. + return true; + } + + reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + } + return reduceMotion.matches; +} diff --git a/packages/plugins/continuous-toolbox/src/ContinuousFlyout.ts b/packages/plugins/continuous-toolbox/src/ContinuousFlyout.ts index 27f1c63ac0d..f1878157fa6 100644 --- a/packages/plugins/continuous-toolbox/src/ContinuousFlyout.ts +++ b/packages/plugins/continuous-toolbox/src/ContinuousFlyout.ts @@ -220,7 +220,10 @@ export class ContinuousFlyout extends Blockly.VerticalFlyout { if (this.scrollTarget === undefined) return; const currentScrollPos = -this.getWorkspace().scrollY; const diff = this.scrollTarget - currentScrollPos; - if (Math.abs(diff) < 1) { + if ( + Math.abs(diff) < 1 || + Blockly.utils.accessibility.shouldReduceMotion() + ) { this.getWorkspace().scrollbar?.setY(this.scrollTarget); this.scrollTarget = undefined; return;