Reduce Motion in SwiftUI and UIKit
By the Charmotion team · Updated

In SwiftUI, read @Environment(\.accessibilityReduceMotion) var reduceMotion; it updates live when the user changes the setting. In UIKit, check UIAccessibility.isReduceMotionEnabled and observe UIAccessibility.reduceMotionStatusDidChangeNotification. When it is on, replace looping or large movement with a still pose or a crossfade, and pause Rive and Lottie views.
Key takeaways
accessibilityReduceMotionis available from iOS 13, watchOS 6 and visionOS 1;isReduceMotionEnabledfrom iOS 8.- SwiftUI re-renders automatically when the setting changes; UIKit needs the change notification.
- Rive's new runtime has
.paused(_:)onRiveUIViewRepresentableandisPausedonRiveUIView. - Keep state changes, drop movement: a mascot should still show "success" as a pose, not disappear.
- Skip decorative intros and one-shot celebrations entirely.
How do I read Reduce Motion in SwiftUI?
Declare the environment value in any view. It is true when Settings → Accessibility → Motion → Reduce Motion is on, and SwiftUI re-evaluates the body when it changes.
struct PulsingBadge: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var saved = 0
var body: some View {
Image(systemName: "checkmark.seal.fill")
.font(.largeTitle)
.phaseAnimator([false, true], trigger: saved) { content, lifted in
content.scaleEffect(lifted && !reduceMotion ? 1.2 : 1)
}
.onTapGesture { saved += 1 }
}
}
For explicit animations, pick a gentler or no animation instead of branching the whole view:
withAnimation(reduceMotion ? nil : .spring(duration: 0.4, bounce: 0.3)) {
isExpanded.toggle()
}
// Or swap movement for a fade
.transition(reduceMotion ? .opacity : .move(edge: .bottom))
The broader principles (what counts as motion, WCAG 2.3.3) are covered in accessible animation and reduced motion; this guide focuses on Apple-platform code.
How do I read Reduce Motion in UIKit and listen for changes?
UIAccessibility.isReduceMotionEnabled returns the current value. It does not notify you, so observe UIAccessibility.reduceMotionStatusDidChangeNotification to update a screen that is already visible:
import UIKit
import RiveRuntime
final class MascotViewController: UIViewController {
private let riveView = RiveUIView(rive: nil)
private let stillView = UIImageView(image: UIImage(named: "mascot-idle"))
private var motionObserver: NSObjectProtocol?
override func viewDidLoad() {
super.viewDidLoad()
// ... add riveView and stillView in the same frame, load the Rive file ...
applyMotionPreference()
motionObserver = NotificationCenter.default.addObserver(
forName: UIAccessibility.reduceMotionStatusDidChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.applyMotionPreference() }
}
}
deinit {
if let motionObserver { NotificationCenter.default.removeObserver(motionObserver) }
}
private func applyMotionPreference() {
let reduce = UIAccessibility.isReduceMotionEnabled
riveView.isPaused = reduce
riveView.isHidden = reduce
stillView.isHidden = !reduce
}
}
For UIView.animate calls, check the flag at call time and use a shorter cross-dissolve, or set the final state without animation.
What should a mascot do when Reduce Motion is on?
Remove movement but keep meaning. A mascot that reports state ("loading," "done," "something went wrong") should still report it.
| Full motion | Reduced alternative |
|---|---|
| Idle breathing or blinking loop | Static idle pose |
| Loading loop | Static "thinking" pose plus a text or progress indicator |
| Success / error one-shot | Swap to a happy or sad still with a short fade, plus a text label |
| Enter animation (flies in, bounces) | Crossfade in |
| Launch intro | Skip, or a still frame while loading |
| Attention loop for a new item | Static attention pose and a badge |
The easiest way to implement this is a dictionary from mood to still image, using frames from the same character so the reduced version looks intentional:
struct MascotView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var character: CharacterModel // Rive controller that keeps the view model instance
let mood: Int
var body: some View {
ZStack {
if reduceMotion {
Image("mascot-mood-\(mood)")
.resizable()
.scaledToFit()
.id(mood)
.transition(.opacity)
} else {
RiveUIViewRepresentable(rive: character.rive)
}
}
.animation(.easeInOut(duration: 0.2), value: mood)
.onChange(of: mood) { _, newMood in character.setMood(newMood) }
}
}
How do I pause Rive or Lottie under Reduce Motion?
Pausing freezes the current frame, which is acceptable for a decorative loop if that frame reads as a pose. Each runtime has a direct switch:
// Rive, new Apple runtime (SwiftUI)
RiveUIViewRepresentable(rive: character.rive)
.paused(reduceMotion)
// Rive, new runtime (UIKit)
riveView.isPaused = UIAccessibility.isReduceMotionEnabled
// Rive, legacy runtime
reduceMotion ? riveViewModel.pause() : riveViewModel.play()
// Lottie
LottieView(animation: .named("idle"))
.playbackMode(reduceMotion
? .paused(at: .progress(0))
: .playing(.fromProgress(0, toProgress: 1, loopMode: .loop)))
Two cautions:
- A paused frame can be mid-motion. If you pause a Rive view after it has been playing, it may stop on an awkward in-between. Setting the mood to a calm state (idle) before pausing, or showing a still image instead, avoids that.
- Do not fire triggers while paused. A paused state machine does not advance, so one-shot reactions will not play. Show the result in the still image or in text instead.
Lottie 4.3.0 and later can also play a marker named reduced motion automatically when Reduce Motion is on, if the designer includes one in the file.
Should I still show the launch intro under Reduce Motion?
No. An intro is decorative by definition, and skipping it is the clearest win for users who turned the setting on. Gate the overlay on the environment value:
struct RootView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var showIntro = true
var body: some View {
ZStack {
ContentView()
if showIntro && !reduceMotion {
IntroView { withAnimation { showIntro = false } }
.transition(.opacity)
.zIndex(1)
}
}
}
}
The full pattern, including matching the static launch screen, is in animated splash screen in SwiftUI.
If the character has meaning in your app, the still poses can come straight from its animation set: Charmotion delivers each of the 17 animations as MP4, GIF and transparent WebM/MOV next to the .riv, so a frame from each gives you a matching still per mood.
How do I apply Reduce Motion to transitions and scroll effects?
Character animation is only part of the motion in an app. System-provided transitions are the platform's job; every custom effect you add is yours to adapt.
- Insert and remove transitions: swap
.move,.slideand.scalefor.opacity. - Scroll-driven effects (
scrollTransition, parallax headers): return the identity effect when reducing motion. matchedGeometryEffecthero animations: keep the state change but drop the animation by passingniltowithAnimation.- Symbol effects: prefer non-moving effects such as a color change over bounces.
struct CardRow: View {
@Environment(.accessibilityReduceMotion) private var reduceMotion
let title: String
var body: some View {
Text(title)
.padding()
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.6)
.scaleEffect(reduceMotion || phase.isIdentity ? 1 : 0.92)
}
}
}
A small helper keeps the choice consistent across the app:
extension Animation {
static func motionAware(_ animation: Animation, reduceMotion: Bool) -> Animation? {
reduceMotion ? nil : animation
}
}
withAnimation(Animation.motionAware(.bouncy, reduceMotion: reduceMotion)) { selected = item }How do I test Reduce Motion quickly?
Three ways, fastest first:
- SwiftUI previews:
accessibilityReduceMotionis get-only, so.environment(\.accessibilityReduceMotion, true)does not compile. Read the value in a parent and pass areduceMotion: Boolparameter into the view you want to preview, then preview both values side by side. - Accessibility Inspector: Xcode → Open Developer Tool → Accessibility Inspector, select the simulator, and toggle Reduce Motion in its settings panel while the app runs.
- Simulator or device settings: Settings → Accessibility → Motion → Reduce Motion.
Check each screen in both states: that loops stop, that nothing important disappears, and that switching the setting while a screen is open updates it (SwiftUI does this automatically; UIKit only if you observe the notification).
Frequently asked questions
Is accessibilityReduceMotion available on watchOS and visionOS?
Yes. Apple lists the SwiftUI accessibilityReduceMotion environment value for iOS 13 and later, watchOS 6 and later, and visionOS 1. In UIKit, UIAccessibility.isReduceMotionEnabled has been available since iOS 8, and reduceMotionStatusDidChangeNotification tells you when the user changes the setting while your app is running.
Should Reduce Motion stop all animation?
No. It asks you to reduce motion, especially large, looping or parallax movement. Short fades, color changes and state swaps are generally fine. Keep feedback that tells the user something happened, delivered without travel, scaling or bouncing, so nobody loses information because they turned the setting on.
Does Rive respect Reduce Motion automatically?
No. The Rive runtime plays whatever the state machine does. Read accessibilityReduceMotion or UIAccessibility.isReduceMotionEnabled yourself and pause the view with .paused(_:) or isPaused, or replace it with a still image of the current mood. Set a calm mood before pausing so the frozen frame reads as a pose.
Quick answers from this guide
Sources
- accessibilityReduceMotion — Apple
- UIAccessibility.isReduceMotionEnabled — Apple
- UIAccessibility.reduceMotionStatusDidChangeNotification — Apple
- Apple runtime (new and legacy) — Rive
- RiveUIViewRepresentable.swift — Rive (GitHub)
- lottie-ios 4.3.0 release notes — Airbnb (GitHub)