From aa6592bdfdfbe859daf4458ed8a240489a331119 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 9 Nov 2026 14:08:32 -0800 Subject: [PATCH 1/4] fix: Respect reduce motion a11y preference --- packages/blockly/core/block_animations.ts | 16 +++++++++---- packages/blockly/core/css.ts | 8 +++++++ packages/blockly/core/toast.ts | 6 +++++ packages/blockly/core/trashcan.ts | 7 ++++++ packages/blockly/core/utils.ts | 2 ++ packages/blockly/core/utils/accessibility.ts | 23 +++++++++++++++++++ .../src/ContinuousFlyout.ts | 2 +- 7 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 packages/blockly/core/utils/accessibility.ts diff --git a/packages/blockly/core/block_animations.ts b/packages/blockly/core/block_animations.ts index 2dbf90777ac..7871c8946f5 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.reduceMotion() + ) { + 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.reduceMotion()) { 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.reduceMotion()) { 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..b98f4002862 --- /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 shouldReduceMotion: MediaQueryList | undefined = undefined; + +/** + * Returns true if motion and animations should be minimized. + */ +export function reduceMotion(): boolean { + if (shouldReduceMotion === undefined) { + if (typeof window === 'undefined') { + // No point in animating if there's no window, i.e. we're running under + // Node. + return true; + } + + shouldReduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + } + return shouldReduceMotion.matches; +} diff --git a/packages/plugins/continuous-toolbox/src/ContinuousFlyout.ts b/packages/plugins/continuous-toolbox/src/ContinuousFlyout.ts index 27f1c63ac0d..2d103a87b95 100644 --- a/packages/plugins/continuous-toolbox/src/ContinuousFlyout.ts +++ b/packages/plugins/continuous-toolbox/src/ContinuousFlyout.ts @@ -220,7 +220,7 @@ 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.reduceMotion()) { this.getWorkspace().scrollbar?.setY(this.scrollTarget); this.scrollTarget = undefined; return; From 3331f659b9fab3a79f4fc981ecbfd53674592a67 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 9 Nov 2026 14:46:30 -0800 Subject: [PATCH 2/4] fix: Make feature check more robust --- packages/blockly/core/utils/accessibility.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/core/utils/accessibility.ts b/packages/blockly/core/utils/accessibility.ts index b98f4002862..f2f652224ec 100644 --- a/packages/blockly/core/utils/accessibility.ts +++ b/packages/blockly/core/utils/accessibility.ts @@ -11,7 +11,7 @@ let shouldReduceMotion: MediaQueryList | undefined = undefined; */ export function reduceMotion(): boolean { if (shouldReduceMotion === undefined) { - if (typeof window === '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; From f91323b4213c430be080637eae294d9fde1ee669 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 10 Nov 2026 14:37:14 -0800 Subject: [PATCH 3/4] chore: Make function name clearer --- packages/blockly/core/block_animations.ts | 6 +++--- packages/blockly/core/utils/accessibility.ts | 2 +- packages/plugins/continuous-toolbox/src/ContinuousFlyout.ts | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/blockly/core/block_animations.ts b/packages/blockly/core/block_animations.ts index 7871c8946f5..faf408665a9 100644 --- a/packages/blockly/core/block_animations.ts +++ b/packages/blockly/core/block_animations.ts @@ -39,7 +39,7 @@ export function disposeUiEffect(block: BlockSvg) { // Disposing is going to take so long the animation won't play anyway. if ( block.getDescendants(false).length > 100 || - accessibility.reduceMotion() + accessibility.shouldReduceMotion() ) { return; } @@ -103,7 +103,7 @@ export function connectionUiEffect(block: BlockSvg) { const workspace = block.workspace; const scale = workspace.scale; workspace.getAudioManager().play('click'); - if (scale < 1 || accessibility.reduceMotion()) { + if (scale < 1 || accessibility.shouldReduceMotion()) { return; // Too small to care about visual effects. } // Determine the absolute coordinates of the inferior block. @@ -169,7 +169,7 @@ export function connectionUiEffect(block: BlockSvg) { export function disconnectUiEffect(block: BlockSvg) { disconnectUiStop(); block.workspace.getAudioManager().play('disconnect'); - if (block.workspace.scale < 1 || accessibility.reduceMotion()) { + 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/utils/accessibility.ts b/packages/blockly/core/utils/accessibility.ts index f2f652224ec..9a8c02aef49 100644 --- a/packages/blockly/core/utils/accessibility.ts +++ b/packages/blockly/core/utils/accessibility.ts @@ -9,7 +9,7 @@ let shouldReduceMotion: MediaQueryList | undefined = undefined; /** * Returns true if motion and animations should be minimized. */ -export function reduceMotion(): boolean { +export function shouldReduceMotion(): boolean { if (shouldReduceMotion === undefined) { if (typeof window === 'undefined' || !('matchMedia' in window)) { // No point in animating if there's no window, i.e. we're running under diff --git a/packages/plugins/continuous-toolbox/src/ContinuousFlyout.ts b/packages/plugins/continuous-toolbox/src/ContinuousFlyout.ts index 2d103a87b95..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 || Blockly.utils.accessibility.reduceMotion()) { + if ( + Math.abs(diff) < 1 || + Blockly.utils.accessibility.shouldReduceMotion() + ) { this.getWorkspace().scrollbar?.setY(this.scrollTarget); this.scrollTarget = undefined; return; From 58f07bc050eb7397bc8d51aa71ca124022f8f3fc Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 10 Nov 2026 14:59:01 -0800 Subject: [PATCH 4/4] chore: Fix name collision --- packages/blockly/core/utils/accessibility.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/blockly/core/utils/accessibility.ts b/packages/blockly/core/utils/accessibility.ts index 9a8c02aef49..aaa3f822075 100644 --- a/packages/blockly/core/utils/accessibility.ts +++ b/packages/blockly/core/utils/accessibility.ts @@ -4,20 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -let shouldReduceMotion: MediaQueryList | undefined = undefined; +let reduceMotion: MediaQueryList | undefined = undefined; /** * Returns true if motion and animations should be minimized. */ export function shouldReduceMotion(): boolean { - if (shouldReduceMotion === undefined) { + 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; } - shouldReduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); } - return shouldReduceMotion.matches; + return reduceMotion.matches; }