| name | ios-motion-and-animation |
| description | iOS motion applied — the SwiftUI animation model (withAnimation, .animation(_:value:)), the modern spring-first defaults (.smooth/.snappy/.bouncy with duration:/bounce:, .spring, interpolatingSpring), transitions + matchedGeometryEffect + the iOS 18 .navigationTransition(.zoom), PhaseAnimator/KeyframeAnimator, rolling counters via .contentTransition(.numericText()), SF Symbol .symbolEffect, scroll-driven .scrollTransition/visualEffect, iOS 26 Liquid Glass morphing motion, haptics-synced .sensoryFeedback, recommended iOS timings, and Reduce Motion fallbacks. Use when animating any state change, transition, or micro-interaction on a native iOS screen. |
| tags | ["ios","motion","animation","swiftui","hig","springs","reduce-motion"] |
iOS motion & animation — spring-first, purposeful, reduce-motion-safe
Source: Apple HIG Motion (developer.apple.com/design/human-interface-guidelines/motion), SwiftUI Animation reference, WWDC23 "Animate with springs" (10158), WWDC25 "Build a SwiftUI app with the new design" (323) + "Get to know the new design system" (356), WWDC26 "What's new in SwiftUI" + "Build powerful drag and drop in SwiftUI" (271) + the iOS 27 Liquid Glass refinements keynote, cross-checked across research passes against technical references (createwithswift, nilcoalescing, GetStream spring catalog, swiftwithmajid, arshtechpro) — June 2026. iOS 26 = shipping baseline; iOS 27 (WWDC 2026, June 8–12 2026) = current cycle — the spring model, transitions, matchedGeometryEffect, .numericText(), .symbolEffect, and Liquid Glass morphing all carry forward; iOS 27 adds Liquid Glass v2 motion tuning + drag-to-reorder/scroll-minimize transition APIs (§§10, 16). Apple's rule: motion is purposeful — it conveys feedback, status, and continuity; it is never decoration; and it is always optional (Reduce Motion).
1. The SwiftUI animation model
Two ways to animate; both interpolate from the current value to the new one when state changes.
withAnimation(.snappy) { isExpanded.toggle() }
ringFill
.animation(.smooth(duration: 0.4), value: caloriesConsumed)
Always use the value: form. The deprecated .animation(_) (no value) animates on every change and is a top source of jank. Animate the smallest view that needs it, not a whole screen. withAnimation returns its closure's result and takes an optional completion: (iOS 17+) for chaining.
2. Curves vs springs — prefer springs
Easing curves (use only for non-spatial, finite changes — opacity, color):
| Curve | SwiftUI | Feel |
|---|
| Linear | .linear(duration:) | constant speed — only for continuous loops/progress |
| Ease-in | .easeIn | accelerates — for exits |
| Ease-out | .easeOut | decelerates — for entrances |
| Ease-in-out | .easeInOut | the classic default; symmetric |
Default static duration for the ease curves is 0.35 s. .default ≈ a system ease-in-out.
Springs are the modern default for anything spatial (position, scale, anything that follows a gesture or a tap). They are interruptible and re-target gracefully mid-flight, which curves cannot.
3. Springs — the WWDC23 duration/bounce model (use this)
WWDC23 reframed springs around two intuitive params, replacing response/dampingFraction guesswork:
duration — perceptual settle time (≈ how long it feels like it takes), default 0.5 s.
bounce — 0 = no overshoot (smooth), ~0.3 = noticeably springy, >0.4 = exaggerated/cartoonish.
Three named presets, all defaulting to duration: 0.5, extraBounce: 0.0, differing only in built-in bounce:
| Preset | Bounce / damping | Use for |
|---|
.smooth | bounce 0, critically damped — no overshoot | most UI: sheets, layout, fades, color-blocked tiles |
.snappy | slightly underdamped, tiny overshoot | taps, toggles, selection — lively but controlled |
.bouncy | clearly underdamped, visible overshoot | celebratory / playful moments only |
withAnimation(.snappy) { … }
withAnimation(.smooth(duration: 0.4)) { … }
withAnimation(.bouncy(duration: 0.5, extraBounce: 0.15)) { … }
withAnimation(.spring(duration: 0.5, bounce: 0.3)) { … }
withAnimation(.interactiveSpring) { … }
interpolatingSpring is additive — overlapping calls stack velocity (use when many rapid changes should compound, e.g. flick-scrolling). The plain presets blend (a new spring smoothly retargets the old). Legacy .spring(response:0.55, dampingFraction:0.825) and .interactiveSpring(response:0.15, dampingFraction:0.86, blendDuration:0.25) still work; prefer the duration/bounce form for new code.
Verify-at-source flag: the exact internal bounce of .snappy (commonly cited ≈ 0.15) and .bouncy (≈ 0.3) is not stated in Apple's reference — only that smooth = 0 and the presets sit at fixed points on the spectrum. Confirm feel on-device; don't quote a precise snappy/bouncy bounce number as Apple-official.
4. Transitions — views entering/leaving the hierarchy
.transition(_) defines how a view animates in/out when inserted/removed inside a withAnimation block.
if showCard {
AdaptationCard()
.transition(.move(edge: .bottom).combined(with: .opacity))
}
withAnimation(.smooth) { showCard = true }
Building blocks: .opacity, .scale(scale:anchor:), .move(edge:), .push(from:), .slide, .blurReplace (iOS 17+), .identity. Use .asymmetric(insertion:removal:) when in ≠ out (common: scale+fade in, fade out). Combine with .combined(with:). .contentTransition(...) handles content within a stable view changing (see §6).
5. matchedGeometryEffect & the iOS 18 zoom transition
matchedGeometryEffect(id:in:) morphs one element into another across a layout change within the same view tree (shared-element). Pair with @Namespace.
iOS 18 .navigationTransition(.zoom) is the simpler, cross-presentation successor — it works across navigation pushes and sheets where matchedGeometryEffect is fragile:
@Namespace private var ns
NavigationLink { MealDetailView() }
.matchedTransitionSource(id: meal.id, in: ns)
MealDetailView().navigationTransition(.zoom(sourceID: meal.id, in: ns))
Use zoom for tapping a meal/photo thumbnail that expands into a full detail screen. Available iOS 18+, carries forward to iOS 27 unchanged. iOS 27 adds a sibling .navigationTransition(.crossFade) — a system cross-dissolve for pushes/presentations where a spatial zoom would be wrong (e.g. switching between peer detail screens, or content with no clear source rect); it's also the natural match for the Reduce Motion cross-fade rule in §13.
6. Rolling numeric counters — .contentTransition(.numericText())
The premium way to animate a calorie/macro number changing — digits roll rather than hard-cut. Must wrap the change in an animation and pin digit width:
Text(calories, format: .number)
.font(.system(.largeTitle, design: .rounded)).monospacedDigit()
.contentTransition(.numericText(value: Double(calories)))
withAnimation(.snappy) { calories = newTotal }
.numericText(countsDown:) for downward counts. Other content transitions: .opacity, .interpolate, .symbolEffect (for SF Symbol swaps).
7. PhaseAnimator & KeyframeAnimator (iOS 17+)
PhaseAnimator — steps through an ordered set of discrete phases, animating between each. For multi-step sequences (e.g. a success checkmark: scale up → settle → glow). Drive once with a trigger, or loop.
KeyframeAnimator — independent property timelines (scale, rotation, offset each on their own keyframe track). For richer choreographed one-shots.
checkmark.phaseAnimator([0.0, 1.2, 1.0], trigger: didLogMeal) { view, scale in
view.scaleEffect(scale)
} animation: { _ in .bouncy(duration: 0.4) }
Reach for these only when a single spring/transition can't express the sequence — most micro-interactions don't need them.
8. SF Symbol motion — .symbolEffect
System-integrated icon animation; prefer over hand-rolling scale/opacity on an Image.
| Effect | Behavior | iOS |
|---|
.bounce | one-shot scale pop (great on tap success) | 17 |
.pulse | opacity throb, repeats | 17 |
.variableColor | layers light in sequence (recording/loading) | 17 |
.scale (.up/.down) | state-held scale | 17 |
.replace | morph one symbol into another | 17 |
.wiggle, .rotate, .breathe | shake / rotate / subtle pulse | 18 |
.appear / .disappear | animated insert/remove | 17 |
Image(systemName: "checkmark.circle.fill").symbolEffect(.bounce, value: didLog)
Image(systemName: isRecording ? "mic.fill" : "mic")
.contentTransition(.symbolEffect(.replace))
Indefinite effects (.pulse, .breathe, .variableColor, .rotate) repeat while active — stop them when the state ends, or they read as nervous UI.
9. Scroll-driven effects — .scrollTransition & visualEffect
.scrollTransition animates a cell as it enters/leaves the viewport; phase is .identity / .topLeading / .bottomTrailing, with a continuous value −1.0 … 1.0.
RecipeCookCard()
.scrollTransition { content, phase in
content.opacity(phase.isIdentity ? 1 : 0.4)
.scaleEffect(phase.isIdentity ? 1 : 0.92)
}
visualEffect { content, proxy in … } reads the view's own geometry (via GeometryProxy) to drive effects without restructuring the tree — e.g. a parallax header. .scrollPosition(id:) / .scrollTargetBehavior(.viewAligned) give snap + position binding. Keep scroll effects subtle — heavy per-cell transforms tank scroll FPS.
10. Liquid Glass — fluid, morphing motion (iOS 26 baseline, iOS 27 v2)
Liquid Glass elements morph (don't cut) as they appear, merge, and separate. Motion is built into the material, not added on top.
- Wrap related glass elements in a
GlassEffectContainer(spacing:); the spacing is the merge threshold — within it, shapes fluidly blend into one during transitions.
- Give morphing pairs a shared
.glassEffectID(_:in:) so SwiftUI animates the glass shape transforming between states.
.interactive() on .glassEffect() adds the system press response: the glass scales, bounces, and shimmers on touch — get this free instead of faking it.
- iOS 26 sheets are inset with a glass background that morphs to opaque + edge-anchored as they grow to full height; partial detents nest into the display's corner radius. Don't override this with custom sheet animation.
GlassEffectContainer(spacing: 24) {
macroTilePink.glassEffect().glassEffectID("protein", in: ns)
macroTileMint.glassEffect().glassEffectID("carbs", in: ns)
}
iOS 27 — Liquid Glass v2 motion + the transparency slider. Apple retuned the material so it diffuses content behind it more and adds more depth/separation — the morph choreography is unchanged, but moving glass reads cleaner over busy content, so heavy custom motion behind glass is even less necessary. iOS 27 adds a system-level transparency slider (Settings + first-run setup) letting users dial glass translucency down to fully opaque; SwiftUI's .glassEffect() and tint respond automatically — apps recompiled on the 2027 SDK pick it up with no code change. Practical motion rule: don't hard-code an assumed glass opacity into a custom animation (e.g. a fade timed to a translucency you guessed) — read state via the appearsActive environment value rather than assuming, since the user may have reduced glass to opaque. This is a currency/readability refinement, not a new motion API.
11. Haptics-synced motion — .sensoryFeedback
Pair a meaningful animation with feedback (iOS 17+ SwiftUI API; no UIFeedbackGenerator needed). Fire on the same state change that drives the animation so touch and motion land together.
.sensoryFeedback(.success, trigger: didLogMeal)
.sensoryFeedback(.selection, trigger: selectedMacro)
Haptics are punctuation, not a soundtrack — one per discrete confirmation, never on every scroll tick. See ios-gestures-and-haptics.
12. Recommended iOS timings
- Micro-feedback (tap, toggle, selection):
.snappy or ~0.2–0.3 s.
- Standard UI (sheets, layout, card in/out, counters):
.smooth ~0.3–0.5 s (presets default to 0.5).
- Large/celebratory: up to ~0.6 s; add bounce only here.
- Continuous (spinners, indeterminate progress):
.linear.repeatForever(autoreverses:false).
Faster than ~0.15 s reads as a glitch; slower than ~0.6 s for routine UI reads as sluggish. Tie speed to spatial distance — bigger move, slightly longer.
13. Reduce Motion (non-negotiable)
~1 in 3 users report motion sensitivity; large position/scale/parallax animation can trigger vestibular discomfort. Honor the system setting:
@Environment(\.accessibilityReduceMotion) private var reduceMotion
withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .bouncy) { … }
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
Rule: replace slide/scale/zoom/spring with a cross-fade (.opacity) or no animation — never with a faster slide. Disable: parallax, looping/auto-playing motion, big zoom/PhaseAnimator celebrations, decorative symbolEffect loops. Counter rolls and a checkmark fade are acceptable; a bouncing zoom is not. (Apple also surfaces "Prefer Cross-Fade Transitions" under Reduce Motion — your code only needs to read accessibilityReduceMotion.) System Liquid Glass + standard transitions already adapt; your custom motion is what you must gate.
14. Mistakes that read as cheap / amateur
Easing curves where springs belong (spatial motion feels dead) · .animation(_) without value: (animates everything, janky) · linear easing on UI (robotic) · over-bouncing — .bouncy/bounce > 0.4 on routine UI · everything ~1 s (sluggish) · hard-cutting numbers instead of .numericText() · indefinite symbolEffect/.pulse left running (nervous UI) · re-implementing Liquid Glass press feedback instead of .interactive() · heavy per-cell scrollTransition transforms (dropped frames) · ignoring Reduce Motion, or "honoring" it with a faster slide instead of a cross-fade.
16. iOS 27 — drag-to-reorder, scroll-minimize & swipe motion (WWDC 2026)
New interaction motion APIs; SwiftUI owns the animation — don't hand-roll the drag/slide.
- Drag-to-reorder, any container — apply
.reorderable() to a ForEach and .reorderContainer(for:_:) to the parent; the lift, gap-open, and settle are driven by SwiftUI. Works beyond List (e.g. LazyVGrid) and reaches watchOS for the first time. The end closure hands you a ReorderDifference (sources + a .before(id) / .end destination) — apply it to your model; the animation is automatic, so don't wrap the data mutation in a competing withAnimation spring.
- Nav-bar minimize on scroll —
.toolbarMinimizeBehavior(.onScrollDown, for: .navigationBar) collapses the navigation bar as content scrolls and restores it on scroll-up; a system transition — let it run rather than animating a custom sticky header.
- Swipe actions beyond List —
.swipeActions() now works in ScrollView / lazy stacks / custom layouts when the scroll container is marked with .swipeActionsContainer(); the reveal/commit motion is system-standard.
Verify-at-source flag: these names match WWDC26 session/community coverage (session 271 "Build powerful drag and drop in SwiftUI") but the exact signatures (.reorderContainer(for:_:) argument shape, appearsActive semantics) shifted across betas — confirm against the iOS 27 SDK / current Apple docs before shipping, and don't quote a precise closure signature as final.
17. Greg application
Cobalt #2C50F0 primary, Figtree Medium display, color-blocked macro tiles (pink/yellow/mint), black photo-detail log screen, metrics on Progress, F21 home-screen widgets.
- Calorie ring fill: animate the trim with
.smooth(duration: 0.4) on value: caloriesConsumed; let the cobalt arc sweep, never snap. The center number rolls via .contentTransition(.numericText()) + .monospacedDigit(), triggered in the same withAnimation.
- Macro tiles (pink/yellow/mint): on log,
.snappy the height/value change; a .symbolEffect(.bounce, value:) on the tile's SF Symbol; light .sensoryFeedback(.selection, ...). Keep bounce subtle — these are data, not toys.
- Adaptation card entrance:
.transition(.move(edge: .bottom).combined(with: .opacity)) inside withAnimation(.smooth); if it's a hero suggestion, a single .snappy settle, not .bouncy.
- Photo-detail log screen (black): tap a meal thumbnail →
.navigationTransition(.zoom(sourceID:in:)) so the photo expands into the detail; pair with matchedTransitionSource on the grid cell.
- Streak celebration (Progress): a one-shot
PhaseAnimator (scale 0→1.2→1) + .bouncy(duration: 0.4) + .sensoryFeedback(.success, ...) — the only place bounce is warranted.
- Widgets (F21): WidgetKit animates value changes automatically on timeline refresh; use
.contentTransition(.numericText()) on the calorie/macro readout. No interactive/looping motion in widgets.
- Reorderable lists (iOS 27, where a list is user-ordered — saved logs, a grocery list): prefer
.reorderable() + .reorderContainer(for:_:) over a hand-built drag gesture; let SwiftUI drive the lift/settle, apply the ReorderDifference to the store, and don't wrap the model write in a competing spring. Reserve for genuinely ordered lists — don't add drag affordances to read-only feeds.
- Reduce Motion fallback (ship in the same slice): gate every spatial animation above — ring fills as a
.opacity cross-fade (no sweep), zoom → fade, streak celebration → a static state, no parallax. Counter rolls may stay (purely textual). Test at the largest a11y text size with Reduce Motion on.
Pairs with: ios-components (sheets/bars motion), ios-color-and-materials (Liquid Glass), ios-gestures-and-haptics (sensoryFeedback + interruptible springs), accessibility-and-inclusive-design (Reduce Motion / vestibular safety).