Respecting prefers-reduced-motion in Flutter Web Apps
What the prefers-reduced-motion CSS media feature does, how Flutter 3.44 surfaces it, and how to gate AnimatedContainer and AnimationController animations behind a reduced-motion check.
Published on • October 29, 2026
AI Assistant

Motion is one of the easiest ways to make an interface feel premium — and one of the easiest ways to make a person nauseous. Large-scale movement, panning, parallax, and fast scaling can trigger real vestibular discomfort. That is why every major platform ships a “reduce motion” setting, and why the web standardized a way to read it: the prefers-reduced-motion CSS media feature.
Until recently, a Flutter web app had no automatic relationship with that browser setting. Flutter 3.44 closed the gap. In this post you will learn what the media feature actually does, what Flutter now does with it, and — most importantly — how to gate your own animations so they behave properly when a user has asked for less motion.
What prefers-reduced-motion actually is
prefers-reduced-motion is a CSS media feature from Media Queries Level 5. It takes one of two values:
reduce— the user has asked the OS to minimize non-essential animation.no-preference— no preference has been expressed.
Shorthand like @media (prefers-reduced-motion) is equivalent to prefers-reduced-motion: reduce. The feature has been Baseline, widely available, since January 2020, so browser support is a non-issue.
The setting it reads is an operating-system one. On Android 9+ it is Settings > Accessibility > Remove animations; on iOS it is Reduce Motion; on GNOME it is Settings > Accessibility > Seeing > Reduced animation. MDN is explicit about why it exists: animations such as scaling or panning large objects can be vestibular motion triggers.
On the standards side, the W3C documents Technique C39 — using the prefers-reduced-motion CSS media query to prevent motion — as a sufficient technique for WCAG 2.3.3 Animation from Interactions. It is not a hard requirement, but it is the accepted way to satisfy that criterion.
What Flutter 3.44 added
The What’s new in Flutter 3.44 post lists it under Web > Accessibility:
Accessibility and user preference parity have also been greatly enhanced, featuring out-of-the-box support for the browser’s
prefers-reduced-motionsetting to automatically disable animations, alongside immediate screen reader feedback for form validation errors usingaria-description.
In practice: a Flutter web app now honors the browser’s reduced-motion preference without you writing a single media query. The same release also added three new iOS motion preferences — auto-play animated images, auto-play video previews, and a non-blinking cursor — all exposed through the AccessibilityFeatures object.
How Flutter surfaces the preference
Flutter’s framework-level reduced-motion signal is MediaQueryData.disableAnimations:
final bool disabled = MediaQuery.disableAnimationsOf(context);
The API docs are worth reading carefully, because they constrain what you can do with it:
- It corresponds to Android’s “Remove animations” accessibility setting. On iOS, reduced motion is exposed separately via
dart:ui.AccessibilityFeatures.reduceMotionand does not set this flag. - It is read directly from the engine via
SemanticsBinding.disableAnimations, so it is used by framework-level animation APIs such asAnimationControllerand cannot be overridden usingMediaQuery. Wrapping your tree in aMediaQuerywith a forced value will not change whatAnimationControllerdoes. - It is still useful for custom widgets that explicitly read it — and for testing.
- The docs’ own guidance: “When implementing custom explicit animations, you should check this property and adjust behavior accordingly (for example, by reducing duration or skipping non-essential animations when it is true).”
Prefer MediaQuery.disableAnimationsOf(context) over MediaQuery.of(context).disableAnimations: it rebuilds your context only when that specific attribute changes, not when any MediaQuery value changes.
If you need the raw browser-level signal — say, for CSS or DOM behavior outside the Flutter canvas — you can read the media feature directly with package:web:
import 'package:web/web.dart' as web;
bool get prefersReducedMotion =>
web.window.matchMedia('(prefers-reduced-motion: reduce)').matches;
Gating implicit animations
Implicit animations like AnimatedContainer, AnimatedOpacity, and AnimatedAlign run whenever their configuration changes. When motion should be reduced, the cleanest approach is to make the target state apply instantly rather than tweening to it — and to do it with a plain conditional widget swap, so no animation object is even created:
class HeroCard extends StatelessWidget {
const HeroCard({super.key, required this.expanded});
final bool expanded;
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final size = expanded ? const Size(320, 200) : const Size(200, 120);
if (reduceMotion) {
// No tween: jump straight to the final geometry.
return SizedBox(width: size.width, height: size.height, child: const Card());
}
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
width: size.width,
height: size.height,
child: const Card(),
);
}
}
This keeps the layout identical and only removes the interpolation. Note that the framework already does something similar for its own controllers: AnimationController adjusts its playback behavior when the flag is active.
Gating explicit animations
For an AnimationController, you have two levers: the AnimationBehavior enum, and your own duration math.
AnimationBehavior configures how a controller behaves when animations are disabled:
AnimationBehavior.normal— the controller reduces its duration whenAccessibilityFeatures.disableAnimationsis true. This is the behavior you almost always want for decorative motion.AnimationBehavior.preserve— the controller keeps its original behavior. This is the default for repeating animations, specifically to prevent them from flashing rapidly on screen if the widget doesn’t take the flag into account.
class Pulse extends StatefulWidget {
const Pulse({super.key});
@override
State<Pulse> createState() => _PulseState();
}
class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
animationBehavior: AnimationBehavior.normal,
);
@override
void initState() {
super.initState();
_controller.repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (MediaQuery.disableAnimationsOf(context)) {
// Static indicator instead of a pulse.
return const Opacity(opacity: 0.7, child: FlutterLogo(size: 64));
}
return FadeTransition(opacity: _controller, child: const FlutterLogo(size: 64));
}
}
For anything hand-rolled — a custom TweenAnimationBuilder chain, a shader-driven effect, a parallax offset — read the flag yourself and either shorten the duration or skip the effect entirely.
The accessibility rationale
Flutter’s accessibility guidance frames accessibility as a release-checklist item, not a nice-to-have: screen reader testing with TalkBack and VoiceOver, 4.5:1 contrast, 48×48 pixel tap targets, and no unsolicited context switches. Reduced motion sits in the same category — it is a user preference the OS already exposes, and ignoring it means your app can be physically unpleasant to use.
Two practical notes for testing:
- You can override the flag for tests and debugging via
debugSemanticsDisableAnimations, whichSemanticsBinding.disableAnimationsconsults. - On the web, Flutter’s accessibility tree is opt-in — either press the invisible button labeled
Enable accessibility, or callSemanticsBinding.instance.ensureSemantics()whenkIsWebis true.
Conclusion & Next Steps
prefers-reduced-motion is a small, standardized, widely supported signal that a user has asked for less motion. Since Flutter 3.44 your Flutter web app reads it automatically; your job is to make sure your own animations respond — swap implicit animations for static layout, and let AnimationBehavior.normal (or your own duration check) handle explicit controllers.
Next Steps: audit your app for looping and parallax effects, run your browser with reduced motion enabled and click through every animated transition, and add a widget test that flips debugSemanticsDisableAnimations so the reduced-motion path stays covered in CI.
Sources
- Flutter Blog — What’s new in Flutter 3.44. https://flutter.dev/blog/whats-new-in-flutter-3-44
- Flutter Docs — Accessibility. https://docs.flutter.dev/ui/accessibility
- MDN —
prefers-reduced-motionCSS media feature. https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion - W3C — Technique C39: Using the CSS
prefers-reduced-motionmedia query to prevent motion. https://www.w3.org/WAI/WCAG22/Techniques/css/C39 - Flutter API —
MediaQueryData.disableAnimations. https://api.flutter.dev/flutter/widgets/MediaQueryData/disableAnimations.html - Flutter API —
MediaQuery.disableAnimationsOf. https://api.flutter.dev/flutter/widgets/MediaQuery/disableAnimationsOf.html - Flutter API —
AnimationBehavior. https://api.flutter.dev/flutter/animation/AnimationBehavior.html - Flutter API —
AccessibilityFeatures.reduceMotion. https://api.flutter.dev/flutter/dart-ui/AccessibilityFeatures/reduceMotion.html - Flutter Docs — Web accessibility. https://docs.flutter.dev/ui/accessibility/web-accessibility
- package:web —
Window.matchMedia. https://pub.dev/documentation/web/latest/web/Window/matchMedia.html