accessible inclusive ios animations

Provide ways to limit animations/motion people find jarring in your apps. This repo demonstrates accessible and inclusive iOS animations/motion with practical examples and best practices.

108
7
Swift

Accessible and Inclusive iOS/SwiftUI Animation/Motion Cheatsheet For Developers

Animations and motion can be dizzying for some app users. Let’s fix that with these practical examples and following the motion accessibility guidelines outlined in this repo. Do you see something missing? Contact @amos_gyamfi and @stefanjblos on Twitter.

Accessible and inclusive iOS animations


Resources we have


Why use animations? Example
Delight and playfulness (Duolingo) Duolingo animation
State change: Hamburger to close icon Hamburger to close icon
Draw user’s attention Draw attention
Guidance: Replace telling with showing Guidance animation

Build your animations with:

  • Symbol Effects, Phase, Keyframe, Spring:

Animation types


Types of Animations Example
Programmatically initiated: Loading Loading animation
User initiated: Gestural-based User initiated

How add your animations

Implicit animation: .animation:

import SwiftUI

struct Implicit: View {
    @State private var starting = false
    @State private var ending = false
    @State private var rotating = false
    
    var body: some View {
        
        VStack {
            Circle()
                .trim(from: starting ? 1/3 : 1/9, to: ending ? 2/5 : 1)
                .stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round))
                .animation(.easeOut(duration: 1).delay(0.5).repeatForever(autoreverses: true), value: starting)
                .animation(.easeInOut(duration: 1).delay(1).repeatForever(autoreverses: true), value: ending)
                .frame(width: 50, height: 50)
                .rotationEffect(.degrees(rotating ? 360 : 0))
                .animation(.linear(duration: 1).repeatForever(autoreverses: false), value: rotating)
                .accessibilityLabel("Loading Animation")
                .onAppear {
                    starting.toggle()
                    rotating.toggle()
                    ending.toggle()
                }
            
            Image(.bmcLogo)
        } //
    }
}

#Preview {
    Implicit()
}

Explicit animation: withAnimation():

import SwiftUI

struct Explicit: View {
    @State private var starting = false
    @State private var ending = false
    @State private var rotating = false
    
    var body: some View {
        
        VStack {
            Circle()
                .trim(from: starting ? 1/3 : 1/9, to: ending ? 2/5 : 1)
                .stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round))
                .frame(width: 50, height: 50)
                .rotationEffect(.degrees(rotating ? 360 : 0))
                .accessibilityLabel("Loading Animation")
                .onAppear {
                    withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
                        rotating.toggle()
                    }
                    
                    withAnimation(.easeOut(duration: 1).delay(0.5).repeatForever(autoreverses: true)) {
                        starting.toggle()
                    }
                    
                    withAnimation(.easeInOut(duration: 1).delay(1).repeatForever(autoreverses: true)) {
                        ending.toggle()
                    }
                }
            
            Image(.bmcLogo)
        } //
    }
}

#Preview {
    Explicit()
}

The implicit and explicit code samples above result in the same animation

Loading animation


How things move

  • Standard Easing: default, linear, easeIn, easeOut, easeInOut
  • Timing Curves (custom): easeInOutBack
  • Springs: bouncy, smooth, snappy. Visit Purposeful SwiftUI Animation to learn more.

What animations/motion may be distractive?

  • Frequent particle animations: Rain, clouds, slowly moving stars, thunder
  • Parallax: Multi-speed & multi-direction (can cause mismatch)
  • UIMotionEffect: Creates a perception of depth
  • Fun to use but can be disorienting
  • Can cause motion sickness
  • Persistent background and foreground effects: Stars and clouds
What animations/motion may be distractive? Example
Rain, clouds, slowly moving stars, thunder Weather animations
Parallax: Multi-speed & multi-direction Parallax
Zooming and scaling animations: App icon throwing animation on iOS Zooming
Spinning or rotating effects Spinning
Bouncy & swoopy effects Swoopy effects
Bouncing & wave-like movement Bouncing & wave-like movement
Animating depth changes: Z-axis layers and multi-axis. Card flip animation Depth changes
Multi-sliding animations: Moving in the opposite direction to the user’s scroll direction Multi-sliding animations
Intense Animations: Glitching and flicking effects. Example: HoloVista Intense Animations
Blinking animation: Can cause epileptic episodes Blinking animation

Guide 1: Pause, Play, Hide

  • Autoplaying GIFs and videos: show play/
  • pause buttons
  • Background animations: Hide buttons
  • Animated illustration: Play/pause controls
  • (looping more than 5x)

Pause and hide


Guide 2: Don’t blink/flash more than 3 in 1 sec

  • People with visual disabilities: Distracting and not useful
  • Blinking: Can cause seizures
  • Great example: iOS Screen recording animation (Dynamic Island)
  • Replace flashing: With varieties of SF Symbol animations to convey information

Guide 3: User-initiated animations

  • Provide a way to disable animations
  • Great example: Animated bouncy reactions in Telegram

User-initiated animations


Reduce Motion: General Settings

  • Provide unnoticeable or reduced behavior for animations
  • Does not mean removing all animations
  • GIFs and videos: Use image image-switching technique
  • App Store: Horizontal card scrolling animation

scrolling animation


Reduce Motion: General Settings

  • Settings App: limit animations/motion in all apps
  • Scaling and zooming animation: Throwing animation of launching an iOS app icon

Scaling and zooming animation


Reduce Motion: Prefer Cross-Fade (iOS 14)

  • What?: SwiftUI NavigationLink -> Cross-fade transition
  • Push segues with slide-in/out animations: UIs appearing/disappearing

Prefer Cross-Fade


NavigationLink {
                    PreJoinScreen()
                } label: {
                    VStack(alignment: .leading) {
                        HStack {
                            Image(systemName: "person.circle.fill")
                                .font(.title2)
                            Spacer()
                            Image(systemName: "ellipsis")
                                .rotationEffect(.degrees(90))
                        }
                        
                        Spacer()
                        
                        Text("New Meeting")
                    }
                    .padding()
                    .frame(width: 160, height: 160)
                    .background(.ultraThinMaterial)
                    .cornerRadius(20)
                }

Reduce Motion: Prefer Cross-Fade (iOS 14)

  • When to use: When there is no suitable replacement animation

Prefer Cross-Fade


Reduce Motion:** Prefer Cross-Fade (iOS 14)

  • Enabled: Replaces sliding transitions with a subtle fade
  • Use NavigationLink to get it for free
  • Example: Settings App

Prefer Cross-Fade


Reduce Motion: Per-app Settings

  • Settings App: Remove some animations for specific apps
  • App Store: Autoplay animated images and video previews
  • Example: Downloading Headspace

Headspace animation


Accessible & Inclusive Animations

  • Stitch Game: Solve number puzzles by filling out patterns
  • Reduce Motion enabled: Disables all sudden movements

Reduce Motion enabled


Use Motion: In-app Settings

  • Use Motion: In-app Settings
  • Reduce Motion: Does not stop all problematic animations
  • Control what should stop and not
  • Great example: PCalc

Reduce Motion Off

PCalc animation

Reduce Motion On

PCalc animation


Adopting Reduce Motion

  • Bouncy Transition: Reduce Motion Off

//
//  ReduceMotionAnimationNil.swift
//  Hamburger to Close
//

import SwiftUI

struct ReduceMotionAnimationSubtleFeel: View {
    
    @State private var isRotating = false
    @State private var isHidden = false
    
    // Reduce Motion On
    let subtleFeel = Animation.snappy
    
    // Reduce Motion OFF
    let bouncyFeel = Animation.bouncy(duration: 0.4, extraBounce: 0.2)
    
    // Detect and respond to reduce motion
    @Environment(\.accessibilityReduceMotion) var reduceMotion
    
    var body: some View {
        VStack(spacing: 14){
            
            Rectangle() // top
                .frame(width: 64, height: 10)
                .cornerRadius(4)
                .rotationEffect(.degrees(isRotating ? 48 : 0), anchor: .leading)
            
            Rectangle() // middle
                .frame(width: 64, height: 10)
                .cornerRadius(4)
                .scaleEffect(isHidden ? 0 : 1, anchor: isHidden ? .trailing: .leading)
                .opacity(isHidden ? 0 : 1)
            
            Rectangle() // bottom
                .frame(width: 64, height: 10)
                .cornerRadius(4)
                .rotationEffect(.degrees(isRotating ? -48 : 0), anchor: .leading)
        }
        .accessibilityElement(children: .combine)
        .accessibilityAddTraits(.isButton)
        .accessibilityLabel("Menu and close icon transition")
        .onTapGesture {
            withAnimation(reduceMotion ? subtleFeel : bouncyFeel) {
                isRotating.toggle()
                isHidden.toggle()
            }
        }
        
    }
}

#Preview {
    ReduceMotionAnimationSubtleFeel()
        .preferredColorScheme(.dark)
}

Bouncy Transition


Adopting Reduce Motion

  • Alternative animation duration: Short enough to make it unnoticeable (0 sec)
  • Remove animation altogether: nil

//
//  ReduceMotionAnimationNil.swift
//  Hamburger to Close
//

import SwiftUI

struct ReduceMotionAnimationNil: View {
    
    @State private var isRotating = false
    @State private var isHidden = false
    
    // Reduce Motion On
    let subtleFeel = Animation.snappy
    
    // Reduce Motion OFF
    let bouncyFeel = Animation.bouncy(duration: 0.4, extraBounce: 0.2)
    
    // Detect and respond to reduce motion
    @Environment(\.accessibilityReduceMotion) var reduceMotion
    
    var body: some View {
        VStack(spacing: 14){
            
            Rectangle() // top
                .frame(width: 64, height: 10)
                .cornerRadius(4)
                .rotationEffect(.degrees(isRotating ? 48 : 0), anchor: .leading)
            
            Rectangle() // middle
                .frame(width: 64, height: 10)
                .cornerRadius(4)
                .scaleEffect(isHidden ? 0 : 1, anchor: isHidden ? .trailing: .leading)
                .opacity(isHidden ? 0 : 1)
            
            Rectangle() // bottom
                .frame(width: 64, height: 10)
                .cornerRadius(4)
                .rotationEffect(.degrees(isRotating ? -48 : 0), anchor: .leading)
        }
        .accessibilityElement(children: .combine)
        .accessibilityAddTraits(.isButton)
        .accessibilityLabel("Menu and close icon transition")
        .onTapGesture {
            withAnimation(reduceMotion ? nil : bouncyFeel) {
                isRotating.toggle()
                isHidden.toggle()
            }
        }
        
    }
}

#Preview {
    ReduceMotionAnimationSubtleFeel()
        .preferredColorScheme(.dark)
}

Remove animation altogether


Adopting Reduce Motion

  • Provide an alternative reduced behavior
  • Example: Switch a bouncy hamburger/close icon with a gentle one

//
//  ReduceMotionAnimationNil.swift
//  Hamburger to Close
//

import SwiftUI

struct ReduceMotionAnimationSubtleFeel: View {
    
    @State private var isRotating = false
    @State private var isHidden = false
    
    // Reduce Motion On
    let subtleFeel = Animation.snappy
    
    // Reduce Motion OFF
    let bouncyFeel = Animation.bouncy(duration: 0.4, extraBounce: 0.2)
    
    // Detect and respond to reduce motion
    @Environment(\.accessibilityReduceMotion) var reduceMotion
    
    var body: some View {
        VStack(spacing: 14){
            
            Rectangle() // top
                .frame(width: 64, height: 10)
                .cornerRadius(4)
                .rotationEffect(.degrees(isRotating ? 48 : 0), anchor: .leading)
            
            Rectangle() // middle
                .frame(width: 64, height: 10)
                .cornerRadius(4)
                .scaleEffect(isHidden ? 0 : 1, anchor: isHidden ? .trailing: .leading)
                .opacity(isHidden ? 0 : 1)
            
            Rectangle() // bottom
                .frame(width: 64, height: 10)
                .cornerRadius(4)
                .rotationEffect(.degrees(isRotating ? -48 : 0), anchor: .leading)
        }
        .accessibilityElement(children: .combine)
        .accessibilityAddTraits(.isButton)
        .accessibilityLabel("Menu and close icon transition")
        .onTapGesture {
            withAnimation(reduceMotion ? subtleFeel : bouncyFeel) {
                isRotating.toggle()
                isHidden.toggle()
            }
        }
        
    }
}

#Preview {
    ReduceMotionAnimationSubtleFeel()
        .preferredColorScheme(.dark)
}

Provide an alternative reduced behavior


Adopting Reduce Motion

  • Setting animation duration to 0

//
//  ReduceMotionDurationZero.swift
//  Hamburger to Close
//

import SwiftUI

struct ReduceMotionDurationZero: View {
    
    @State private var isRotating = false
    @State private var isHidden = false
    
    // Reduce Motion On
    let durationZero = Animation.snappy(duration: 0)
    
    // Reduce Motion OFF
    let bouncyFeel = Animation.bouncy(duration: 0.4, extraBounce: 0.2)
    
    // Detect and respond to reduce motion
    @Environment(\.accessibilityReduceMotion) var reduceMotion
    
    var body: some View {
        VStack(spacing: 14){
            
            Rectangle() // top
                .frame(width: 64, height: 10)
                .cornerRadius(4)
                .rotationEffect(.degrees(isRotating ? 48 : 0), anchor: .leading)
            
            Rectangle() // middle
                .frame(width: 64, height: 10)
                .cornerRadius(4)
                .scaleEffect(isHidden ? 0 : 1, anchor: isHidden ? .trailing: .leading)
                .opacity(isHidden ? 0 : 1)
            
            Rectangle() // bottom
                .frame(width: 64, height: 10)
                .cornerRadius(4)
                .rotationEffect(.degrees(isRotating ? -48 : 0), anchor: .leading)
        }
        .accessibilityElement(children: .combine)
        .accessibilityAddTraits(.isButton)
        .accessibilityLabel("Menu and close icon transition")
        .onTapGesture {
            withAnimation(reduceMotion ? durationZero : bouncyFeel) {
                isRotating.toggle()
                isHidden.toggle()
            }
        }
        
    }
}

#Preview {
    ReduceMotionDurationZero()
        .preferredColorScheme(.dark)
}

Setting animation duration to 0


Animation Description and VoiceOver

  • Test animations: Ask Siri to “Turn on VoiceOver.”
  • Hide decorative decorative animations

VoiceOver testing


VoiceOver: Animation Without a Label

  • Navigate with a swipe gesture
  • VoiceOver skips the animation
Circle()
    .trim(from: starting ? 1/3 : 1/9, to: ending ? 2/5 : 1)
    .stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round))
    .frame(width: 50, height: 50)
    .rotationEffect(.degrees(rotating ? 360 : 0))
    .onAppear {
        withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
            rotating.toggle()
        }
                    
        withAnimation(.easeOut(duration: 1).delay(0.5).repeatForever(autoreverses: true)) {
            starting.toggle()
        }
    }

Animation Without a Label

Checkout the sound version on Vimeo


VoiceOver: Animation With a Label

  • Add labels for animations that have meaning
Circle()
    .trim(from: starting ? 1/3 : 1/9, to: ending ? 2/5 : 1)
    .stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round))
    .frame(width: 50, height: 50)
    .rotationEffect(.degrees(rotating ? 360 : 0))
    .accessibilityLabel("Loading Animation")
    //.accessibilityAddTraits()
    .accessibilityValue("Animation")
    .onAppear {
        withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
            rotating.toggle()
        }
                    
        withAnimation(.easeOut(duration: 1).delay(0.5).repeatForever(autoreverses: true)) {
            starting.toggle()
        }
    }

Animation With a Label

Checkout the sound version on Vimeo


Adding Haptic Feedback To Animations

  • Mimicking physical touch and drag:
  • Example: Stitch

Mimicking physical touch and drag


Adding Haptic Feedback To Animations

  • Silent Mode On: Emulate the absence of sound

  • Example: Reporting an incoming or outgoing call

Outgoing call animation


Follow the Basic Accessibility Guidelines

  • Screen flashing can cause headaches and seizure
  • Provide similar visual effects without requiring motion
  • Excessive motion can cause discomfort, dizziness
  • Example: Parallax, sliding animations

Wrap Up

  • Be mindful of motion usage
  • Prefer using NavigationLink: Avoid custom slide transitions
  • Reduce Motion: Provide options to limit ****animation and motion effects
  • Is VoiceOver enabled? Think of how you can clearly translate animations
  • Spent time on what can be dizzying/jarring
  • Use subtle motion effects
  • Can this animation cause discomfort?
  • How can people with motion sensitivities enjoy my app?
  • What if the user’s reduced motion setting is on?
  • Will springiness/bounciness feel out of place?

Where To Go From Here?