Skip to content
charmotion

Reduce Motion in SwiftUI and UIKit

By the Charmotion team · Updated

Cover illustration for the iOS & Swift guide: Reduce Motion in SwiftUI and UIKit, with Charmotion demo characters

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

  • accessibilityReduceMotion is available from iOS 13, watchOS 6 and visionOS 1; isReduceMotionEnabled from iOS 8.
  • SwiftUI re-renders automatically when the setting changes; UIKit needs the change notification.
  • Rive's new runtime has .paused(_:) on RiveUIViewRepresentable and isPaused on RiveUIView.
  • 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 motionReduced alternative
Idle breathing or blinking loopStatic idle pose
Loading loopStatic "thinking" pose plus a text or progress indicator
Success / error one-shotSwap to a happy or sad still with a short fade, plus a text label
Enter animation (flies in, bounces)Crossfade in
Launch introSkip, or a still frame while loading
Attention loop for a new itemStatic 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) }
    }
}
success — a trigger; plays once, then returns to the current mood.

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, .slide and .scale for .opacity.
  • Scroll-driven effects (scrollTransition, parallax headers): return the identity effect when reducing motion.
  • matchedGeometryEffect hero animations: keep the state change but drop the animation by passing nil to withAnimation.
  • 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: accessibilityReduceMotion is get-only, so .environment(\.accessibilityReduceMotion, true) does not compile. Read the value in a parent and pass a reduceMotion: Bool parameter 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

Related guides