SwiftUI Animation Techniques
A comprehensive guide to creating smooth and engaging animations in SwiftUI
A comprehensive guide to creating smooth and engaging animations in SwiftUI
Note: This post is outdated. Please check out the 2026 Update for iOS 27 for the latest SwiftUI animation techniques.
I animate almost everything in SwiftUI apps. Buttons that don't scale on press feel dead. Lists that pop in without a transition feel like a web page from 2008. These are the patterns I copy from project to project when I want motion that reads instantly and doesn't fight the user.
SwiftUI sits on top of Core Animation. A @State flip is not just a re-render. It is an instruction to interpolate from A to B. That mental model is what made animations stop feeling like magic incantations for me.
Roughly what happens when state changes:
@State, @Binding, and other observable propertiesI use two styles day to day:
Implicit Animations (using .animation() modifier):
// SwiftUI automatically animates any state changes
.animation(.easeInOut, value: someState)
Explicit Animations (using withAnimation):
// You control exactly when and how the animation occurs
withAnimation(.spring()) {
// State changes here are animated
}
Child views inherit the parent's animation unless you override it:
VStack {
// This inherits the parent's animation
Text("Inherited Animation")
// This overrides the parent's animation
Text("Custom Animation")
.animation(.spring(), value: someState)
}
.animation(.easeInOut, value: parentState)
I usually start with .animation() and keep the first version tiny:
@State private var scale: CGFloat = 1.0
var body: some View {
Button("Tap me") {
scale = scale == 1.0 ? 1.5 : 1.0
}
.scaleEffect(scale)
.animation(.easeInOut(duration: 0.5), value: scale)
}
Multiple properties can animate together with one state change:
@State private var isExpanded = false
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(Color.blue)
.frame(width: isExpanded ? 200 : 100, height: isExpanded ? 200 : 100)
.scaleEffect(isExpanded ? 1.2 : 1.0)
.rotationEffect(.degrees(isExpanded ? 180 : 0))
.opacity(isExpanded ? 0.8 : 1.0)
.animation(.easeInOut(duration: 0.8), value: isExpanded)
.onTapGesture {
isExpanded.toggle()
}
}
value parameterThe value parameter is the performance win most people skip. It tells SwiftUI which state change should trigger animation:
// Only animates when 'scale' changes
.animation(.spring(), value: scale)
// Only animates when 'isVisible' changes
.animation(.easeInOut, value: isVisible)
// Animates when ANY state changes (avoid this)
.animation(.spring()) // ❌ Performance issue
Four APIs cover most of what I ship.
.animation() Modifier.animation() is where I start most of the time:
// Animate all changes
.animation(.easeInOut, value: someState)
// Animate specific properties
.animation(.spring(), value: scale)
.animation(.linear(duration: 2), value: rotation)
withAnimation BlockswithAnimation when several properties need to move together inside an action:
Button("Animate") {
withAnimation(.easeInOut(duration: 1.0)) {
// All state changes inside this block will be animated
scale = 2.0
opacity = 0.5
rotation = 360
}
}
Pass nil to skip animation for a specific state flip:
@State private var shouldAnimate = false
var body: some View {
Circle()
.fill(Color.red)
.frame(width: 100, height: 100)
.scaleEffect(scale)
.animation(shouldAnimate ? .spring() : nil, value: scale)
}
You can run follow up logic after animation completes with a completion handler:
withAnimation(.easeInOut(duration: 1.0)) {
// Animated changes
} completion: {
// This runs when animation completes
print("Animation finished")
}
Springs are my default for anything the user touches. Buttons, sheets, drag-to-dismiss. Linear easing on a tap feels wrong in a way I cannot unsee.
.animation(.spring(), value: someState)
Spring animations have several parameters that control their behavior:
.animation(.spring(
response: 0.5, // How quickly the animation responds (seconds)
dampingFraction: 0.6, // How much the spring bounces (0-1, lower = more bounce)
blendDuration: 0.25 // How long to blend between animations (seconds)
), value: someState)
What the knobs actually do:
Interactive springs are optimized for user interactions like dragging:
.animation(.interactiveSpring(
response: 0.6,
dampingFraction: 0.8,
blendDuration: 0
), value: someState)
.interactiveSpring defaults are tuned for finger tracking. I use it on drag-heavy views.
@State private var isPressed = false
var body: some View {
Button(action: {
isPressed.toggle()
}) {
Text("Spring Button")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPressed)
}
Timing curves are the personality of the motion. Same distance, totally different vibe.
Linear animations progress at a constant rate:
.animation(.linear(duration: 1.0), value: someState)
Linear animations are best for:
Ease animations provide natural acceleration and deceleration:
// Ease in - starts slow, ends fast
.animation(.easeIn(duration: 1.0), value: someState)
// Ease out - starts fast, ends slow
.animation(.easeOut(duration: 1.0), value: someState)
// Ease in out - smooth acceleration and deceleration
.animation(.easeInOut(duration: 1.0), value: someState)
My rough defaults:
If you want exact motion timing, define a custom Bézier curve:
.animation(.timingCurve(0.2, 0.8, 0.2, 1.0, duration: 1.0), value: someState)
These parameters are control points for a cubic Bézier curve: (x1, y1, x2, y2).
.speed() scales duration. Pair it with Reduce Motion checks when you care about accessibility:
// Respects user's "Reduce Motion" setting
.animation(.easeInOut(duration: 1.0).speed(0.5), value: someState))
// Ignores "Reduce Motion" setting
.animation(.easeInOut(duration: 1.0).speed(0.5).repeatCount(3), value: someState)
State-driven animation is the whole pitch of SwiftUI. Flip a bool, the UI follows.
@State private var isLoading = false
var body: some View {
VStack {
Circle()
.trim(from: 0, to: isLoading ? 1 : 0)
.stroke(Color.blue, lineWidth: 4)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1).repeatForever(autoreverses: false), value: isLoading)
Button("Toggle Loading") {
isLoading.toggle()
}
}
}
@State private var email = ""
@State private var isValidEmail = false
var body: some View {
VStack {
TextField("Email", text: $email)
.textFieldStyle(RoundedBorderTextFieldStyle())
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isValidEmail ? Color.green : Color.red, lineWidth: 2)
)
.animation(.easeInOut(duration: 0.3), value: isValidEmail)
if !isValidEmail && !email.isEmpty {
Text("Please enter a valid email")
.foregroundColor(.red)
.font(.caption)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.onChange(of: email) { newValue in
isValidEmail = newValue.contains("@") && newValue.contains(".")
}
}
@State private var networkStatus: NetworkStatus = .idle
enum NetworkStatus {
case idle, loading, success, error
}
var body: some View {
VStack {
Circle()
.fill(networkStatus == .success ? Color.green :
networkStatus == .error ? Color.red : Color.blue)
.frame(width: 20, height: 20)
.scaleEffect(networkStatus == .loading ? 1.2 : 1.0)
.animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true),
value: networkStatus == .loading)
Text(statusText)
.animation(.easeInOut(duration: 0.3), value: networkStatus)
}
}
private var statusText: String {
switch networkStatus {
case .idle: return "Ready"
case .loading: return "Loading..."
case .success: return "Success!"
case .error: return "Error"
}
}
Transitions are how views arrive and leave. Without them, if showDetail feels like a hard cut.
@State private var showDetail = false
var body: some View {
VStack {
if showDetail {
DetailView()
.transition(.opacity)
}
Button("Toggle Detail") {
withAnimation(.easeInOut(duration: 0.5)) {
showDetail.toggle()
}
}
}
}
Built-in options:
// Fade in/out
.transition(.opacity)
// Slide from edge
.transition(.slide)
// Scale in/out
.transition(.scale)
// Move from edge
.transition(.move(edge: .leading))
// Asymmetric transitions
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .slide.combined(with: .opacity)
))
Stack transitions when one effect is not enough:
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .slide.combined(with: .opacity)
))
When stock transitions are not weird enough:
struct SlideTransition: ViewModifier {
let edge: Edge
let isActive: Bool
func body(content: Content) -> some View {
content
.offset(x: isActive ? 0 : (edge == .leading ? -UIScreen.main.bounds.width : UIScreen.main.bounds.width))
.opacity(isActive ? 1 : 0)
}
}
extension AnyTransition {
static func slide(edge: Edge) -> AnyTransition {
.modifier(
active: SlideTransition(edge: edge, isActive: false),
identity: SlideTransition(edge: edge, isActive: true)
)
}
}
Transition timing can be controlled independently:
if showContent {
ContentView()
.transition(.opacity.animation(.easeInOut(duration: 0.3)))
}
Drag-to-move is where animation pays off. The view should follow your finger and spring back when you let go.
@State private var dragOffset = CGSize.zero
@State private var isDragging = false
var body: some View {
Circle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.offset(dragOffset)
.scaleEffect(isDragging ? 1.2 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isDragging)
.gesture(
DragGesture()
.onChanged { value in
dragOffset = value.translation
isDragging = true
}
.onEnded { _ in
withAnimation(.spring()) {
dragOffset = .zero
isDragging = false
}
}
)
}
@State private var isPressed = false
var body: some View {
Rectangle()
.fill(Color.green)
.frame(width: 200, height: 100)
.scaleEffect(isPressed ? 0.9 : 1.0)
.animation(.easeInOut(duration: 0.1), value: isPressed)
.onLongPressGesture(minimumDuration: 0.1, maximumDistance: .infinity) {
// Action on release
} onPressingChanged: { pressing in
isPressed = pressing
}
}
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
var body: some View {
Image("photo")
.scaleEffect(scale)
.gesture(
MagnificationGesture()
.onChanged { value in
let delta = value / lastScale
lastScale = value
scale = scale * delta
}
.onEnded { _ in
withAnimation(.spring()) {
scale = max(1.0, min(scale, 3.0)) // Clamp between 1x and 3x
}
lastScale = 1.0
}
)
}
@State private var rotation: Angle = .zero
@State private var lastRotation: Angle = .zero
var body: some View {
Rectangle()
.fill(Color.orange)
.frame(width: 100, height: 100)
.rotationEffect(rotation)
.gesture(
RotationGesture()
.onChanged { value in
let delta = value - lastRotation
lastRotation = value
rotation += delta
}
.onEnded { _ in
withAnimation(.spring()) {
rotation = .zero // Snap back to original rotation
}
lastRotation = .zero
}
)
}
matchedGeometryEffect is the one I reach for when two views are "the same thing" in different layouts. Hero transitions, expanding cards, that kind of polish.
@Namespace private var animation
@State private var isExpanded = false
var body: some View {
VStack {
if isExpanded {
RoundedRectangle(cornerRadius: 20)
.fill(Color.blue)
.frame(width: 300, height: 400)
.matchedGeometryEffect(id: "shape", in: animation)
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color.blue)
.frame(width: 100, height: 100)
.matchedGeometryEffect(id: "shape", in: animation)
}
Button("Toggle") {
withAnimation(.spring()) {
isExpanded.toggle()
}
}
}
}
You need a @Namespace and matching id values on both views. Frame, corner radius, position. Anything animatable can morph. I have burned an afternoon on a typo in the id string. Check that first.
Delays and sequencing when one motion should lead the next:
@State private var showElements = false
var body: some View {
VStack(spacing: 20) {
ForEach(0..<3) { index in
Circle()
.fill(Color.blue)
.frame(width: 50, height: 50)
.opacity(showElements ? 1 : 0)
.animation(.easeInOut(duration: 0.5).delay(Double(index) * 0.2), value: showElements)
}
}
.onAppear {
showElements = true
}
}
Custom ViewModifiers keep pulse and shake effects consistent app-wide:
struct PulseAnimation: ViewModifier {
@State private var isPulsing = false
func body(content: Content) -> some View {
content
.scaleEffect(isPulsing ? 1.1 : 1.0)
.animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: isPulsing)
.onAppear {
isPulsing = true
}
}
}
struct ShakeAnimation: ViewModifier {
@State private var isShaking = false
func body(content: Content) -> some View {
content
.offset(x: isShaking ? 10 : 0)
.animation(.easeInOut(duration: 0.1).repeatCount(3, autoreverses: true), value: isShaking)
.onAppear {
isShaking = true
}
}
}
extension View {
func pulseAnimation() -> some View {
modifier(PulseAnimation())
}
func shakeAnimation() -> some View {
modifier(ShakeAnimation())
}
}
Stagger when a row of things should appear one beat at a time, not all at once:
@State private var animateItems = false
var body: some View {
HStack(spacing: 10) {
ForEach(0..<5) { index in
Rectangle()
.fill(Color.blue)
.frame(width: 20, height: 60)
.scaleEffect(y: animateItems ? 1.0 : 0.3)
.animation(
.easeInOut(duration: 0.6)
.delay(Double(index) * 0.1)
.repeatForever(autoreverses: true),
value: animateItems
)
}
}
.onAppear {
animateItems = true
}
}
Shared enum state when two views need to animate in lockstep:
@State private var animationPhase: AnimationPhase = .initial
enum AnimationPhase {
case initial, expanding, final
}
var body: some View {
VStack {
Circle()
.fill(Color.blue)
.frame(width: animationPhase == .expanding ? 200 : 100)
.opacity(animationPhase == .final ? 0.5 : 1.0)
.animation(.easeInOut(duration: 0.5), value: animationPhase)
Rectangle()
.fill(Color.green)
.frame(width: 100, height: animationPhase == .expanding ? 200 : 100)
.animation(.easeInOut(duration: 0.5).delay(0.2), value: animationPhase)
Button("Animate") {
withAnimation {
animationPhase = .expanding
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation {
animationPhase = .final
}
}
}
}
}
Card flips for games, flashcards, anything with a hidden back:
@State private var isFlipped = false
@State private var rotation: Double = 0
var body: some View {
ZStack {
// Front of card
RoundedRectangle(cornerRadius: 20)
.fill(Color.blue)
.frame(width: 200, height: 300)
.overlay(
Text("Front")
.font(.title)
.foregroundColor(.white)
)
.opacity(isFlipped ? 0 : 1)
.rotation3DEffect(
.degrees(rotation),
axis: (x: 0, y: 1, z: 0)
)
// Back of card
RoundedRectangle(cornerRadius: 20)
.fill(Color.red)
.frame(width: 200, height: 300)
.overlay(
Text("Back")
.font(.title)
.foregroundColor(.white)
)
.opacity(isFlipped ? 1 : 0)
.rotation3DEffect(
.degrees(rotation - 180),
axis: (x: 0, y: 1, z: 0)
)
}
.onTapGesture {
withAnimation(.easeInOut(duration: 0.6)) {
rotation += 180
isFlipped.toggle()
}
}
}
FAB with a fan-out menu. Gmail-style. Still works:
@State private var isExpanded = false
@State private var selectedAction: String?
var body: some View {
ZStack {
// Background overlay
if isExpanded {
Color.black.opacity(0.3)
.ignoresSafeArea()
.onTapGesture {
withAnimation(.spring()) {
isExpanded = false
}
}
}
VStack {
Spacer()
HStack {
Spacer()
VStack(spacing: 20) {
// Menu items
if isExpanded {
ForEach(["Add Photo", "Add Note", "Add Contact"], id: \.self) { action in
Button(action: {
selectedAction = action
withAnimation(.spring()) {
isExpanded = false
}
}) {
Text(action)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.white)
.foregroundColor(.blue)
.cornerRadius(20)
.shadow(radius: 4)
}
.transition(.scale.combined(with: .opacity))
}
}
// Main FAB
Button(action: {
withAnimation(.spring()) {
isExpanded.toggle()
}
}) {
Image(systemName: isExpanded ? "xmark" : "plus")
.font(.title2)
.foregroundColor(.white)
.frame(width: 56, height: 56)
.background(Color.blue)
.clipShape(Circle())
.shadow(radius: 8)
}
.rotationEffect(.degrees(isExpanded ? 45 : 0))
}
.padding(.trailing, 20)
.padding(.bottom, 20)
}
}
}
}
Onboarding steps. Wizards. Anything with a progress bar that should not jump:
@State private var currentStep = 0
let totalSteps = 4
var body: some View {
VStack(spacing: 30) {
// Progress bar
HStack(spacing: 0) {
ForEach(0..<totalSteps, id: \.self) { step in
Rectangle()
.fill(step <= currentStep ? Color.green : Color.gray.opacity(0.3))
.frame(height: 4)
.animation(.easeInOut(duration: 0.5), value: currentStep)
}
}
.cornerRadius(2)
// Step indicators
HStack(spacing: 20) {
ForEach(0..<totalSteps, id: \.self) { step in
Circle()
.fill(step <= currentStep ? Color.green : Color.gray.opacity(0.3))
.frame(width: 30, height: 30)
.overlay(
Text("\(step + 1)")
.font(.caption)
.foregroundColor(.white)
.fontWeight(.bold)
)
.scaleEffect(step == currentStep ? 1.2 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: currentStep)
}
}
// Step content
VStack {
Text("Step \(currentStep + 1)")
.font(.title2)
.fontWeight(.bold)
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
Text("This is the content for step \(currentStep + 1)")
.multilineTextAlignment(.center)
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
}
.animation(.easeInOut(duration: 0.3), value: currentStep)
// Navigation buttons
HStack(spacing: 20) {
Button("Previous") {
if currentStep > 0 {
withAnimation(.easeInOut(duration: 0.3)) {
currentStep -= 1
}
}
}
.disabled(currentStep == 0)
.opacity(currentStep == 0 ? 0.5 : 1.0)
Button("Next") {
if currentStep < totalSteps - 1 {
withAnimation(.easeInOut(duration: 0.3)) {
currentStep += 1
}
}
}
.disabled(currentStep == totalSteps - 1)
.opacity(currentStep == totalSteps - 1 ? 0.5 : 1.0)
}
}
.padding()
}
Compact search bar that expands on focus. Classic pattern, easy to over-animate:
@State private var isSearching = false
@State private var searchText = ""
var body: some View {
HStack {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
.scaleEffect(isSearching ? 1.1 : 1.0)
.animation(.easeInOut(duration: 0.2), value: isSearching)
TextField("Search...", text: $searchText)
.textFieldStyle(PlainTextFieldStyle())
.frame(width: isSearching ? 200 : 0)
.opacity(isSearching ? 1 : 0)
.animation(.easeInOut(duration: 0.3), value: isSearching)
if isSearching && !searchText.isEmpty {
Button(action: {
searchText = ""
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
}
.transition(.scale.combined(with: .opacity))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.gray.opacity(0.1))
.cornerRadius(20)
.scaleEffect(isSearching ? 1.05 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSearching)
if isSearching {
Button("Cancel") {
withAnimation(.easeInOut(duration: 0.3)) {
isSearching = false
searchText = ""
}
}
.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
.onTapGesture {
if !isSearching {
withAnimation(.easeInOut(duration: 0.3)) {
isSearching = true
}
}
}
}
A tiny bounce when the count ticks up. Subtle. Easy to make obnoxious:
@State private var notificationCount = 0
@State private var isAnimating = false
var body: some View {
VStack(spacing: 30) {
// Notification icon with badge
ZStack {
Image(systemName: "bell.fill")
.font(.title)
.foregroundColor(.blue)
if notificationCount > 0 {
Text("\(notificationCount)")
.font(.caption)
.fontWeight(.bold)
.foregroundColor(.white)
.frame(width: 20, height: 20)
.background(Color.red)
.clipShape(Circle())
.offset(x: 10, y: -10)
.scaleEffect(isAnimating ? 1.3 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.5), value: isAnimating)
}
}
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isAnimating)
// Control buttons
HStack(spacing: 20) {
Button("Add Notification") {
notificationCount += 1
triggerAnimation()
}
Button("Clear All") {
withAnimation(.easeInOut(duration: 0.3)) {
notificationCount = 0
}
}
.disabled(notificationCount == 0)
}
}
.padding()
}
private func triggerAnimation() {
isAnimating = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isAnimating = false
}
}
Expandable weather card. Good sandbox for combining scale, rotation, and reveal transitions:
@State private var isExpanded = false
@State private var temperature = 72
@State private var weatherCondition = "sun.max.fill"
var body: some View {
VStack(spacing: 0) {
// Main weather display
VStack(spacing: 15) {
Image(systemName: weatherCondition)
.font(.system(size: isExpanded ? 80 : 60))
.foregroundColor(.orange)
.rotationEffect(.degrees(isExpanded ? 360 : 0))
.animation(.easeInOut(duration: 2.0).repeatForever(autoreverses: false), value: isExpanded)
Text("\(temperature)°")
.font(.system(size: isExpanded ? 48 : 36, weight: .bold))
.foregroundColor(.primary)
Text("Sunny")
.font(.title3)
.foregroundColor(.secondary)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Color.blue.opacity(0.1))
)
.scaleEffect(isExpanded ? 1.05 : 1.0)
.animation(.spring(response: 0.5, dampingFraction: 0.7), value: isExpanded)
// Expanded details
if isExpanded {
VStack(spacing: 15) {
HStack {
WeatherDetail(icon: "humidity", value: "65%", label: "Humidity")
Spacer()
WeatherDetail(icon: "wind", value: "12 mph", label: "Wind")
}
HStack {
WeatherDetail(icon: "thermometer", value: "78°", label: "High")
Spacer()
WeatherDetail(icon: "thermometer.low", value: "65°", label: "Low")
}
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(15)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.padding()
.background(Color.white)
.cornerRadius(25)
.shadow(radius: 10)
.onTapGesture {
withAnimation(.spring()) {
isExpanded.toggle()
}
}
}
struct WeatherDetail: View {
let icon: String
let value: String
let label: String
var body: some View {
VStack(spacing: 5) {
Image(systemName: icon)
.font(.title2)
.foregroundColor(.blue)
Text(value)
.font(.headline)
.fontWeight(.semibold)
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Play/pause spin on album art. The kind of motion users expect even when the audio API is fake:
@State private var isPlaying = false
@State private var currentTime: Double = 0
@State private var duration: Double = 180 // 3 minutes
@State private var volume: Double = 0.7
var body: some View {
VStack(spacing: 30) {
// Album artwork
RoundedRectangle(cornerRadius: 20)
.fill(Color.blue)
.frame(width: 200, height: 200)
.overlay(
Image(systemName: "music.note")
.font(.system(size: 60))
.foregroundColor(.white)
)
.rotationEffect(.degrees(isPlaying ? 360 : 0))
.animation(.linear(duration: 3).repeatForever(autoreverses: false), value: isPlaying)
// Song info
VStack(spacing: 5) {
Text("Amazing Song")
.font(.title2)
.fontWeight(.bold)
Text("Amazing Artist")
.font(.subheadline)
.foregroundColor(.secondary)
}
// Progress bar
VStack(spacing: 10) {
ProgressView(value: currentTime, total: duration)
.progressViewStyle(LinearProgressViewStyle(tint: .blue))
.scaleEffect(y: 2)
HStack {
Text(formatTime(currentTime))
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(formatTime(duration))
.font(.caption)
.foregroundColor(.secondary)
}
}
// Control buttons
HStack(spacing: 40) {
Button(action: {}) {
Image(systemName: "backward.fill")
.font(.title)
.foregroundColor(.primary)
}
Button(action: {
withAnimation(.spring()) {
isPlaying.toggle()
}
}) {
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 60))
.foregroundColor(.blue)
.scaleEffect(isPlaying ? 1.1 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPlaying)
}
Button(action: {}) {
Image(systemName: "forward.fill")
.font(.title)
.foregroundColor(.primary)
}
}
// Volume control
HStack {
Image(systemName: "speaker.fill")
.foregroundColor(.secondary)
Slider(value: $volume, in: 0...1)
.accentColor(.blue)
Image(systemName: "speaker.wave.3.fill")
.foregroundColor(.secondary)
}
}
.padding()
}
private func formatTime(_ time: Double) -> String {
let minutes = Int(time) / 60
let seconds = Int(time) % 60
return String(format: "%d:%02d", minutes, seconds)
}
You do not need to memorize this. It helps when something animates twice, or not at all, and you are staring at the wrong modifier.
Every animation carries timing (duration, curve, delay), state (from/to values), and environment (device, Reduce Motion, etc.).
SwiftUI interpolates values using the selected timing curve:
// SwiftUI internally does something like this:
let progress = timingCurve.evaluate(at: currentTime / duration)
let currentValue = startValue + (endValue - startValue) * progress
Multiple changes inside one withAnimation block run as a single transaction:
// These are batched into one animation
withAnimation(.spring()) {
scale = 2.0
opacity = 0.5
rotation = 360
}
A new animation kills the old one mid-flight and retargets:
@State private var scale: CGFloat = 1.0
var body: some View {
Circle()
.scaleEffect(scale)
.animation(.spring(), value: scale)
.onTapGesture {
// This cancels any ongoing animation
scale = 2.0
// This would also cancel the previous animation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scale = 0.5
}
}
}
Short version: parent .animation() flows down, child modifiers and withAnimation can override.
VStack {
// Inherits parent animation
Text("Inherited")
// Overrides parent animation
Text("Custom")
.animation(.spring(), value: someState)
}
.animation(.easeInOut, value: parentState)
Most jank I have caused came from animating everything instead of the one property that changed.
value ParameterAlways pass value unless you have a reason not to:
// Good - only animates when scale changes
.animation(.spring(), value: scale)
// Avoid - animates all changes
.animation(.spring())
Twenty things easing at once will stutter on an older phone:
// Use delays to stagger animations so they don't all run at once
.animation(.easeInOut(duration: 0.3).delay(Double(index) * 0.05), value: showItems)
Linear for spinners. Spring for taps. EaseInOut when I am not sure yet.
Long animated lists belong in LazyVStack, not a eager VStack with fifty children:
// Use LazyVStack for large lists
LazyVStack {
ForEach(items) { item in
AnimatedItem(item)
.animation(.easeInOut.delay(Double(item.id) * 0.1), value: showItems)
}
}
Check accessibilityReduceMotion and kill decorative motion when it is on:
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
Circle()
.scaleEffect(scale)
.animation(reduceMotion ? nil : .spring(), value: scale)
}
Infinite .repeatForever loops need an onDisappear kill switch or they keep chewing CPU after navigation:
// Good - stops when view disappears
.onAppear {
startAnimation()
}
.onDisappear {
stopAnimation()
}
// Avoid - might continue after view disappears
.onAppear {
startInfiniteAnimation()
}
What I actually check before shipping.
// Good - subtle scale change
.scaleEffect(isPressed ? 0.98 : 1.0)
// Avoid - dramatic scale change
.scaleEffect(isPressed ? 0.5 : 1.0)
I define three durations per app and stick to them:
// Define constants for consistent timing
struct AnimationConstants {
static let quick = 0.2
static let standard = 0.3
static let slow = 0.5
}
// Use them consistently
.animation(.easeInOut(duration: AnimationConstants.standard), value: someState)
Buttons should visibly react when pressed. If nothing moves, users wonder if the tap registered.
Button(action: {
// Action
}) {
Text("Submit")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(.easeInOut(duration: 0.1), value: isPressed)
.onLongPressGesture(minimumDuration: 0) {
// Action
} onPressingChanged: { pressing in
isPressed = pressing
}
I always test on a real phone, not just the simulator. Simulator frame rates lie.
Motion should explain state. Spinning means loading. Scaling means pressed. Random rotation means you got bored.
// Good - animation shows loading state
.rotationEffect(.degrees(isLoading ? 360 : 0))
.animation(.linear(duration: 1).repeatForever(autoreverses: false), value: isLoading)
// Avoid - animation for animation's sake
.rotationEffect(.degrees(360))
.animation(.linear(duration: 1).repeatForever(autoreverses: false))
Navigation pushes slide from the trailing edge. Modals fade and scale. List inserts slide. Mixing those up feels off even if you cannot name why.
// Navigation transitions
.transition(.asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading)
))
// Modal presentations
.transition(.opacity.combined(with: .scale))
// List updates
.transition(.slide)
When timing feels wrong, I duplicate the .animation() modifier once to make SwiftUI's inspector easier to read. When logic feels wrong, I log inside withAnimation completion handlers.
// Add this modifier to see animation timing
.animation(.easeInOut(duration: 1.0), value: someState)
.animation(.easeInOut(duration: 1.0), value: someState) // Duplicate for debugging
withAnimation(.spring()) {
print("Animation started at: \(Date())")
someState = newValue
} completion: {
print("Animation completed at: \(Date())")
}
@State private var animationPhase = "idle"
var body: some View {
VStack {
Text("Animation Phase: \(animationPhase)")
Circle()
.scaleEffect(scale)
.animation(.spring(), value: scale)
.onChange(of: scale) { newValue in
animationPhase = "scale changed to \(newValue)"
}
}
}
import os.log
let animationLogger = Logger(subsystem: "com.yourapp", category: "animation")
withAnimation(.spring()) {
animationLogger.debug("Starting spring animation")
someState = newValue
}
Pick one interaction in your app this week and animate it properly. A spring on a button. A transition on a sheet. Ship that before you add Lottie.
Most of the examples above are copy-paste starting points. Tune the durations until they feel right on hardware you actually carry.
I stick with built-in SwiftUI animation first. I only add a package when I have a concrete need that native APIs will not cover well.
If you do need extras, these are the ones I see most often:
My rule: native SwiftUI first. Lottie or Rive only when design hands me an asset native APIs cannot reproduce.