How to implement reduced motion in SwiftUI
Read @Environment(\.accessibilityReduceMotion) inside your view and pass nil instead of an Animation when it is true. That single swap respects the user's System Settings → Accessibility → Motion preference with zero extra dependencies.
struct PulsingBadge: View {
@Environment(\.accessibilityReduceMotion) var reduceMotion
@State private var scaled = false
var body: some View {
Circle()
.fill(.red)
.frame(width: 24, height: 24)
.scaleEffect(scaled ? 1.3 : 1.0)
.onAppear {
guard !reduceMotion else { return }
withAnimation(.easeInOut(duration: 0.8).repeatForever()) {
scaled = true
}
}
}
}
Full implementation
The pattern below wraps the environment value in a small helper extension so you can call .animation(reduceMotion ? nil : myAnimation, value:) everywhere in your app without repeating the conditional. The example demonstrates a card that slides in on appearance and a toggle that controls a colour change — both honour the user's motion preference.
import SwiftUI
// MARK: - Helpers
extension Animation {
/// Returns nil when the user has enabled Reduce Motion,
/// otherwise returns `self`. Attach with .animation(myAnim.ifAllowed, value:)
func ifAllowed(reduceMotion: Bool) -> Animation? {
reduceMotion ? nil : self
}
}
// MARK: - Demo screen
struct ReducedMotionDemoView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isVisible = false
@State private var isHighlight = false
/// Centralise the motion-aware animation so it's easy to update.
private var slideAnimation: Animation? {
Animation.spring(response: 0.5, dampingFraction: 0.7)
.ifAllowed(reduceMotion: reduceMotion)
}
var body: some View {
VStack(spacing: 32) {
// 1. Animated card that slides in from the bottom
if isVisible {
RoundedRectangle(cornerRadius: 20)
.fill(isHighlight ? Color.indigo : Color.blue)
.frame(height: 140)
.overlay(
Text(reduceMotion ? "Motion reduced ✓" : "Slide animation")
.font(.headline)
.foregroundStyle(.white)
)
.transition(
reduceMotion
? .identity // instant — no movement
: .move(edge: .bottom).combined(with: .opacity)
)
}
// 2. Toggle that triggers a colour change
Toggle("Highlight card", isOn: $isHighlight)
.padding(.horizontal)
.animation(
Animation.easeInOut(duration: 0.3)
.ifAllowed(reduceMotion: reduceMotion),
value: isHighlight
)
// 3. Appear / disappear button
Button(isVisible ? "Hide card" : "Show card") {
withAnimation(slideAnimation) {
isVisible.toggle()
}
}
.buttonStyle(.borderedProminent)
// 4. Status label for debugging
Text(reduceMotion
? "Reduce Motion: ON — animations disabled"
: "Reduce Motion: OFF — animations enabled")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.padding()
.navigationTitle("Reduced Motion")
.onAppear {
withAnimation(slideAnimation) {
isVisible = true
}
}
}
}
// MARK: - Preview
#Preview("Reduce Motion OFF") {
NavigationStack {
ReducedMotionDemoView()
}
}
#Preview("Reduce Motion ON") {
NavigationStack {
ReducedMotionDemoView()
.environment(\.accessibilityReduceMotion, true)
}
}
How it works
-
@Environment(\.accessibilityReduceMotion)— SwiftUI injects the current value of the OS "Reduce Motion" toggle (Settings → Accessibility → Motion) directly into your view hierarchy. It updates automatically when the user changes the setting, causing affected views to re-render. -
Animation.ifAllowed(reduceMotion:)— The helper extension converts anyAnimationinto an optional. Passingnilto.animation(_:value:)orwithAnimation(_:)makes SwiftUI apply the state change instantly with no interpolation, which is exactly what users with vestibular disorders need. -
.transition(.identity)vs.move(edge:)— Transitions are separate from animations in SwiftUI. WhenreduceMotionistrue, the card uses.identity(appear/disappear in place), bypassing the bottom-slide movement that can trigger motion sickness. -
Inline status label — The
Textat the bottom surfaces the current environment value so you can verify the setting during development. Remove it (or wrap it in#if DEBUG) before shipping. -
Two
#Previewmacros — Using.environment(\.accessibilityReduceMotion, true)on the second preview lets you QA both states without touching device Settings, shortcutting the review cycle inside Soarias.
Variants
App-wide animation modifier via ViewModifier
If you want every view in the app to honour reduced motion without reading the environment individually, apply a ViewModifier at the root and let it propagate.
struct ReducedMotionModifier: ViewModifier {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func body(content: Content) -> some View {
content
// Overrides the transaction for the entire subtree
.transaction { transaction in
if reduceMotion {
transaction.animation = nil
transaction.disablesAnimations = true
}
}
}
}
extension View {
func respectsReducedMotion() -> some View {
modifier(ReducedMotionModifier())
}
}
// Usage in your root view:
// ContentView()
// .respectsReducedMotion()
Crossfade instead of no animation
Some designers prefer a subtle opacity fade over a hard cut because it feels intentional rather than broken. Replace nil with .easeIn(duration: 0.15) only for opacity-based transitions — crossfades involve no spatial movement, so they are generally safe for motion-sensitive users. Consult your accessibility auditor before shipping this variant, as guidelines can vary per context.
Common pitfalls
-
iOS version gate:
\.accessibilityReduceMotionis available from iOS 13, so there's no iOS 17 gate needed — but the#Previewmacro and.transactionmodifier used here require Xcode 15+ / iOS 17+ to compile correctly. Don't lower the deployment target expecting everything to just work. -
Forgetting
.transition(): SettingwithAnimation(nil)removes animation, but SwiftUI still uses the default transition (fade) for inserted/removed views unless you also override.transition(.identity). Always pair the two when toggling visibility. -
Lottie / third-party animations: The environment value only governs SwiftUI's own animation system. If you embed Lottie, RealityKit, or
UIViewRepresentableanimations, you must passreduceMotioninto those wrappers manually and stop their playback there — SwiftUI cannot reach across the bridge automatically.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement reduced motion support in SwiftUI for iOS 17+. Use accessibilityReduceMotion from the SwiftUI environment. Replace all spring/easeInOut animations with nil when reduceMotion is true. Replace .move() and .scale() transitions with .identity. Make it accessible (VoiceOver labels on all interactive elements). Add a #Preview with realistic sample data and a second #Preview that injects .environment(\.accessibilityReduceMotion, true).
Paste this prompt in Soarias's Build phase after your screen scaffolding is in place — Claude Code will audit every animated modifier in the file and patch them in one pass.
Related
FAQ
Does this work on iOS 16?
Yes — \.accessibilityReduceMotion has been available since iOS 13. However, the #Preview macro requires Xcode 15 / iOS 17 SDK to compile. If you need to support iOS 16, replace #Preview with struct MyView_Previews: PreviewProvider and inject the environment value there instead.
How do I test reduced motion in the simulator?
Go to Settings → Accessibility → Motion → Reduce Motion on your simulator or device and toggle it on. Alternatively, the fastest approach during development is the second #Preview shown in the full implementation above — it injects .environment(\.accessibilityReduceMotion, true) so you can see both states side by side in Xcode's canvas without touching Settings at all.
What's the UIKit equivalent?
In UIKit, check UIAccessibility.isReduceMotionEnabled (a static Bool) and observe UIAccessibility.reduceMotionStatusDidChangeNotification via NotificationCenter to react to live changes. SwiftUI's environment value does all of this automatically, updating your view whenever the setting changes without any manual observation code.
Last reviewed: 2026-05-11 by the Soarias team.