
Ios Animations
- 269 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
ios-animations: A skill for development. This provides functionality for development workflows.
Key points
- ios-animations
Ios Animations by the numbers
- 269 all-time installs (skills.sh)
- +10 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,443 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill ios-animationsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 269 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use ios-animations for development tasks?
Use ios-animations for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with ios-animations.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use ios-animations for development tasks, or when ios-animations: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to ios-animations: ios-animations.
Files
dot-skills — Airbnb iOS SwiftUI Animations Best Practices
Opinionated, strict animation craft guide for SwiftUI iOS 26 / Swift 6.2 apps. Contains 50 rules across 8 categories, prioritized by impact. Derived from Airbnb Engineering motion patterns, Apple WWDC sessions, and Apple Human Interface Guidelines. Mandates @Equatable on every animated view, motion tokens for all spring/timing values, and design system tokens for layout.
Mandated Architecture Alignment
This skill is designed to work alongside swift-ui-architect and ios-design-system. All code examples follow the same non-negotiable constraints:
@Equatablemacro on every view (Airbnb measured 15% scroll hitch reduction)@Observablefor complex animation state that involves business logic triggers@Statefor view-owned animation state (toggle booleans, drag offsets)- Design system tokens:
Spacing.xs/sm/md/lg,Radius.sm/md/lg— zero hardcoded layout numbers - Motion tokens:
Motion.standard/responsive/playful— zero scattered spring literals - Semantic colors:
.backgroundSurface,.accentPrimary— no raw Color literals in views - Feature animations remain presentation-only; data/network concerns stay in Data package
Scope & Relationship to Sibling Skills
This skill is the motion layer — it teaches how to BUILD fluid, performant animations. When loaded alongside sibling skills:
| Sibling Skill | Its Focus | This Skill's Focus |
|---|---|---|
swift-ui-architect | Architecture (modular MVVM-C, route shells, protocol boundaries) | Animation architecture (motion tokens, spring selection, orchestration) |
ios-design-system | Design system infrastructure (tokens, styles, governance) | Motion tokens and animated component patterns |
ios-hig | HIG compliance patterns | Motion-specific HIG (reduce motion, spatial continuity) |
Out of scope: Designer-authored vector animations (use Lottie for After Effects exports). Complex UIKit transition controllers (see Airbnb's declarative transition framework). This skill covers programmatic SwiftUI animations only.
Clinic Architecture Contract (iOS 26 / Swift 6.2)
All guidance in this skill assumes the clinic modular MVVM-C architecture:
- Feature modules import
Domain+DesignSystemonly (neverData, never sibling features) - App target is the convergence point and owns
DependencyContainer, concrete coordinators, and Route Shell wiring Domainstays pure Swift and defines models plus repository,*Coordinating,ErrorRouting, andAppErrorcontractsDataowns SwiftData/network/sync/retry/background I/O and implements Domain protocols- Read/write flow defaults to stale-while-revalidate reads and optimistic queued writes
- ViewModels call repository protocols directly (no default use-case/interactor layer)
When to Apply
Reference these guidelines when:
- Adding animations to SwiftUI views or transitions
- Building gesture-driven interactions (drag, swipe, pan)
- Connecting views with spatial transitions (expand/collapse, navigation morphs)
- Designing micro-interactions (button press, toggle, loading states)
- Making content changes feel physical (number rolls, symbol replacements)
- Choreographing multi-element animation sequences (KeyframeAnimator, PhaseAnimator)
- Defining motion tokens for a design system
- Reviewing animation code for performance, accessibility, and architecture compliance
Rule Categories by Priority
| Priority | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | Spring Physics | CRITICAL | spring- | 8 |
| 2 | Timing & Feel | CRITICAL | feel- | 6 |
| 3 | Gesture Continuity | HIGH | gesture- | 7 |
| 4 | Spatial Transitions | HIGH | spatial- | 6 |
| 5 | Micro-interactions | HIGH | micro- | 6 |
| 6 | Orchestration | HIGH | orch- | 5 |
| 7 | Craft & Polish | HIGH | craft- | 5 |
| 8 | Content Motion | MEDIUM-HIGH | content- | 5 |
Quick Reference
1. Spring Physics (CRITICAL)
- `spring-motion-tokens` — Define motion tokens as a caseless enum for all spring presets
- `spring-smooth-default` — Default to .smooth spring for all UI transitions
- `spring-snappy-responsive` — Use .snappy spring for responsive interactions
- `spring-bouncy-celebration` — Use .bouncy spring for playful and celebratory moments
- `spring-custom-parameters` — Tune custom springs with response and dampingFraction
- `spring-velocity-preservation` — Springs preserve velocity on interruption
- `spring-never-linear` — Never use linear or easeInOut for interactive UI
- `spring-completion-chaining` — Use withAnimation completion for chained sequences
2. Timing & Feel (CRITICAL)
- `feel-250ms-max` — Keep UI animations under 250ms
- `feel-faster-better` — Faster animations almost always feel better
- `feel-asymmetric-enter-exit` — Use asymmetric timing for enter and exit
- `feel-distance-proportional` — Match duration to distance traveled
- `feel-haptic-sync` — Sync haptic feedback to visual animation keyframes
- `feel-stagger-timing` — Stagger reveals at 30-50ms intervals
3. Gesture Continuity (HIGH)
- `gesture-rubber-band` — Rubber band at drag boundaries
- `gesture-momentum-dismiss` — Dismiss on velocity OR distance threshold
- `gesture-snap-points` — Use velocity-aware snap points
- `gesture-interruptible` — Make all gesture animations interruptible
- `gesture-scroll-drag-conflict` — Resolve scroll and drag gesture conflicts
- `gesture-state-transient` — Use GestureState for transient drag state
- `gesture-projected-landing` — Project gesture velocity for natural landing position
4. Spatial Transitions (HIGH)
- `spatial-matched-geometry` — Use matchedGeometryEffect for expand/collapse morphs
- `spatial-zoom-navigation` — Use zoom navigation transition for collection detail (iOS 18)
- `spatial-transition-origin` — Anchor transitions to their trigger location
- `spatial-hero-shared-element` — Share multiple element IDs for rich hero animations
- `spatial-sheet-morph` — Use matchedGeometryEffect for sheet presentations
- `spatial-tab-continuity` — Maintain spatial direction in tab transitions
5. Micro-interactions (HIGH)
- `micro-button-press-scale` — Scale buttons to 0.97 on press for tactile feedback
- `micro-haptic-pairing` — Pair every visual state change with haptic feedback
- `micro-symbol-effect` — Use symbolEffect for SF Symbol animations
- `micro-toggle-bounce` — Add bounce to toggle state changes
- `micro-long-press-fill` — Animate progressive fill for long press actions
- `micro-loading-phase` — Use repeating spring for organic loading states
6. Orchestration (HIGH)
- `orch-phase-animator` — Use PhaseAnimator for multi-step sequences
- `orch-keyframe-animator` — Use KeyframeAnimator for timeline-precise motion
- `orch-stagger-children` — Stagger child elements for orchestrated reveals
- `orch-coordinated-entrance` — Coordinate multi-element entrances with shared trigger
- `orch-timeline-view` — Use TimelineView for continuous repeating animations
7. Craft & Polish (HIGH)
- `craft-reduce-motion` — Respect accessibilityReduceMotion with crossfade fallback
- `craft-blur-bridge` — Use blur to bridge imperfect transition states
- `craft-drawing-group` — Use drawingGroup() for Metal-backed complex animations
- `craft-geometry-group` — Use geometryGroup() to isolate layout animation propagation
- `craft-transaction-debug` — Use Transaction to debug and override animation behavior
8. Content Motion (MEDIUM-HIGH)
- `content-numeric-text` — Use contentTransition(.numericText) for number changes
- `content-scroll-transition` — Use scrollTransition for scroll-position effects
- `content-visual-effect` — Use visualEffect for position-aware animations
- `content-symbol-replace` — Animate symbol replacement with contentTransition
- `content-text-renderer` — Use Text Renderer for character-level animation (iOS 18)
How to Use
Read individual reference files for detailed explanations with incorrect/correct code examples:
- Section definitions — Category structure and impact levels
- Rule template — Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
{Rule Title}
{1-3 sentences explaining WHY this matters. Focus on what goes wrong without this pattern and how the user perceives the difference. Be specific about the iOS animation system behavior.}
Incorrect ({what's wrong — e.g., "linear easing for interactive element"}):
{Bad code example — production-realistic SwiftUI view, not strawman}
{// Comments explaining the cost or broken behavior}Correct ({what's right — e.g., "spring with velocity preservation"}):
{Good code example — minimal diff from incorrect}
{// Comments explaining the benefit}{Optional sections as needed:}
Alternative ({context — e.g., "view modifier form"}):
{Alternative approach when applicable}When NOT to use this pattern:
- {Exception 1}
- {Exception 2}
Reference: {WWDC session or Apple docs title}
{
"version": "1.0.7",
"organization": "dot-skills",
"technology": "iOS 26 / Swift 6.2 SwiftUI Animations",
"date": "February 2026",
"abstract": "Airbnb-aligned animation craft guide for SwiftUI iOS 26 / Swift 6.2 apps, designed for AI agents and LLMs. Contains 50 rules across 8 categories, prioritized by impact. Enforces @Equatable on all animated views, motion tokens for spring/timing values, and design system tokens for layout. Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics. Coherent with ios-design-system and modular MVVM-C swift-ui-architect sibling skills. Aligned with the iOS 26 / Swift 6.2 clinic modular MVVM-C architecture.",
"references": [
"https://developer.apple.com/documentation/swiftui/animation",
"https://developer.apple.com/documentation/swiftui/spring",
"https://developer.apple.com/documentation/swiftui/phaseanimator",
"https://developer.apple.com/documentation/swiftui/keyframeanimator",
"https://developer.apple.com/documentation/swiftui/matchedgeometryeffect",
"https://developer.apple.com/documentation/swiftui/scrolltransition",
"https://developer.apple.com/documentation/swiftui/visualeffect",
"https://developer.apple.com/documentation/swiftui/sensory-feedback",
"https://developer.apple.com/documentation/swiftui/symboleffect",
"https://developer.apple.com/wwdc23/10156",
"https://developer.apple.com/wwdc23/10157",
"https://developer.apple.com/wwdc23/10158",
"https://developer.apple.com/wwdc23/10160",
"https://developer.apple.com/wwdc24/10145",
"https://developer.apple.com/wwdc24/10151",
"https://medium.com/airbnb-engineering/motion-engineering-at-scale-5ffabfc878",
"https://medium.com/airbnb-engineering/animations-bringing-the-host-passport-to-life-on-ios-72856aea68a7",
"https://airbnb.tech/uncategorized/understanding-and-improving-swiftui-performance/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Spring Physics (spring)
Impact: CRITICAL Description: Springs are the universal iOS animation primitive — the only animation type that preserves velocity on interruption. Motion tokens (enum Motion) standardize spring selection across the app, preventing scattered .smooth/.snappy/.bouncy literals. Wrong spring choice cascades through the entire app's tactile quality. Airbnb tokenizes motion values as part of their DLS.
2. Timing & Feel (feel)
Impact: CRITICAL Description: Duration and rhythm determine perceived responsiveness. Animations over 250ms feel sluggish, symmetric enter/exit feels robotic, and unsynchronized haptics break immersion. Getting timing wrong makes a fast app feel slow.
3. Gesture Continuity (gesture)
Impact: HIGH Description: The touch-to-motion connection is what makes iOS feel alive. Rubber banding, velocity preservation, interruptibility, and momentum dismissal transform rigid state machines into fluid physical interfaces.
4. Spatial Transitions (spatial)
Impact: HIGH Description: Every view change needs a spatial origin. Without one, elements teleport — breaking the user's mental model of where content lives. matchedGeometryEffect, zoom transitions, and navigation morphs maintain spatial continuity.
5. Micro-interactions (micro)
Impact: HIGH Description: Button press scale, haptic pairing, symbol effects, and toggle animations. These sub-second moments accumulate into the overall sensation of quality — or cheapness. Apple's own apps obsess over these details.
6. Orchestration (orch)
Impact: HIGH Description: When multiple elements animate, they must form a choreography — not a mob. PhaseAnimator, KeyframeAnimator, and staggered reveals create the composed, intentional motion that distinguishes premium apps like Airbnb's Host Passport. Complex animation state orchestration should use @Observable ViewModels per swift-ui-architect constraints.
7. Craft & Polish (craft)
Impact: HIGH Description: Respecting reduce motion (accessibility requirement, not polish), blur bridging imperfect states, Metal-backed rendering for complex scenes, and layout animation isolation. These patterns are essential for production-quality animation — 35% of adults experience motion sensitivity.
8. Content Motion (content)
Impact: MEDIUM-HIGH Description: Numbers that roll, text that morphs, symbols that replace, scroll items that react to position. Making content changes feel physical rather than digital eliminates the "database on a screen" feel.
Use contentTransition(.numericText) for Number Changes
When numeric values change — scores, prices, step counters, timers — users expect the transition to feel meaningful. A number snapping from "42" to "43" reads as a database update; a number where individual digits roll into place reads as a live, physical counter. Apple uses this effect throughout Fitness rings, Weather temperatures, and the Lock Screen clock. The .numericText content transition tells SwiftUI to diff the text character by character and animate only the digits that changed, producing a smooth rolling effect.
The key modifier is .contentTransition(.numericText(value:)), where the value parameter tells SwiftUI the direction of change (counting up vs. down) so digits roll in the correct direction. Pair it with .animation(.snappy, value:) or wrap the state change in withAnimation(.snappy) to drive the transition.
Incorrect (number snaps instantly — no visual continuity between values):
struct StepCounter: View {
@State private var steps = 4280
var body: some View {
VStack(spacing: 16) {
// Text updates instantly — the number teleports from one value
// to the next with no sense of counting
Text("\(steps)")
.font(.system(size: 48, weight: .bold, design: .rounded))
.monospacedDigit()
Button("Add Steps") {
steps += Int.random(in: 50...200)
}
.buttonStyle(.borderedProminent)
}
}
}Correct (.numericText rolls individual digits into place):
@Equatable
struct StepCounter: View {
@State private var steps = 4280
var body: some View {
VStack(spacing: Spacing.md) {
// Each digit independently rolls to its new value,
// creating the polished counter effect from Apple Fitness
Text("\(steps)")
.font(.system(size: 48, weight: .bold, design: .rounded))
.monospacedDigit()
.contentTransition(.numericText(value: Double(steps)))
Button("Add Steps") {
withAnimation(.snappy) {
steps += Int.random(in: 50...200)
}
}
.buttonStyle(.borderedProminent)
}
}
}Production example — price display with currency formatting:
@Equatable
struct PriceLabel: View {
let amount: Decimal
var body: some View {
Text(amount, format: .currency(code: "USD"))
.font(.title.bold())
.monospacedDigit()
.contentTransition(.numericText(value: Double(truncating: amount as NSDecimalNumber)))
.animation(.snappy, value: amount)
}
}
@Equatable
struct CartTotalView: View {
@State private var total: Decimal = 29.99
var body: some View {
VStack(spacing: Spacing.lg) {
PriceLabel(amount: total)
HStack(spacing: Spacing.sm) {
Button("Add Item") {
withAnimation(.snappy) {
total += Decimal(Int.random(in: 5...25))
}
}
Button("Remove Item") {
withAnimation(.snappy) {
total = max(0, total - Decimal(Int.random(in: 5...15)))
}
}
}
.buttonStyle(.bordered)
}
}
}When NOT to use `.numericText`:
| Scenario | Use instead |
|---|---|
| 60fps timer (stopwatch, live clock seconds) | .monospacedDigit() only — rolling animation cannot keep up |
| Large number jumps (0 to 10,000) | Standard .animation — too many digits changing looks chaotic |
| Non-numeric text changes | .contentTransition(.interpolate) or standard transitions |
Warning: do not combine .numericText with rapidly updating values (more than ~4 updates per second). The rolling animation queues up and creates visual noise. For high-frequency counters, use .monospacedDigit() to prevent layout shifts and let the number snap.
Reference: WWDC 2023 — Animate with springs
Use scrollTransition for Scroll-Position Effects
When items in a ScrollView should react to their scroll position — fading in as they enter, scaling down as they leave, rotating for a carousel — the traditional approach is wrapping each item in a GeometryReader and manually calculating offsets relative to the scroll view's coordinate space. This works but is expensive: every frame of scrolling triggers a layout pass in every visible GeometryReader, and the math to convert between coordinate spaces is error-prone.
.scrollTransition (iOS 17+) replaces all of this with a single modifier. It provides a ScrollTransitionPhase that tells you whether the view is fully visible (.identity), entering from the top/leading edge (.topLeading), or leaving from the bottom/trailing edge (.bottomTrailing). You apply visual effects based on the phase — no GeometryReader, no coordinate math, no layout overhead.
Incorrect (GeometryReader inside ScrollView — expensive layout per frame):
struct CardScrollView: View {
let items = Array(0..<20)
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(items, id: \.self) { index in
// GeometryReader in every cell triggers layout on every scroll frame.
// With 20 items visible, that is 20 layout passes per frame.
GeometryReader { proxy in
let midY = proxy.frame(in: .global).midY
let screenMidY = UIScreen.main.bounds.height / 2
let distance = abs(midY - screenMidY)
let scale = max(0.85, 1 - (distance / 1000))
RoundedRectangle(cornerRadius: 16)
.fill(Color.blue.gradient)
.frame(height: 120)
.overlay {
Text("Card \(index)")
.font(.headline)
.foregroundStyle(.white)
}
.scaleEffect(scale)
.opacity(Double(scale))
}
.frame(height: 120)
}
}
.padding()
}
}
}Correct (.scrollTransition — zero layout cost, declarative phases):
@Equatable
struct CardScrollView: View {
let items = Array(0..<20)
var body: some View {
ScrollView {
LazyVStack(spacing: Spacing.md) {
ForEach(items, id: \.self) { index in
RoundedRectangle(cornerRadius: Radius.md)
.fill(Color.blue.gradient)
.frame(height: 120)
.overlay {
Text("Card \(index)")
.font(.headline)
.foregroundStyle(.white)
}
// Applies visual effects based on scroll position
// without triggering any layout recalculation
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.3)
.scaleEffect(phase.isIdentity ? 1 : 0.85)
}
}
}
.padding()
}
}
}Directional effects using phase values (carousel rotation):
@Equatable
struct CarouselView: View {
let items = Array(0..<15)
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: Spacing.md) {
ForEach(items, id: \.self) { index in
RoundedRectangle(cornerRadius: Radius.lg)
.fill(
LinearGradient(
colors: [.purple, .blue],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 260, height: 340)
.overlay {
Text("Item \(index)")
.font(.title2.bold())
.foregroundStyle(.white)
}
.scrollTransition(.animated(.smooth)) { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.5)
.scaleEffect(
y: phase.isIdentity ? 1 : 0.9
)
// phase.value is -1 (top/leading), 0 (identity), +1 (bottom/trailing)
.rotation3DEffect(
.degrees(phase.value * 15),
axis: (x: 0, y: 1, z: 0)
)
}
}
}
.padding(.horizontal, 40)
}
.scrollTargetBehavior(.viewAligned)
}
}Keep effects subtle — recommended ranges:
| Property | Identity value | Edge value | Notes |
|---|---|---|---|
| Opacity | 1.0 | 0.3–0.5 | Below 0.2 looks like items vanish |
| Scale | 1.0 | 0.85–0.95 | Below 0.8 feels like items are collapsing |
| Rotation | 0 degrees | 10–20 degrees | Above 30 degrees causes clipping artifacts |
| Blur | 0 | 2–4 points | More than 6pt obscures content |
Note: .scrollTransition only applies visual-layer effects (transforms, opacity, blur). It cannot change layout properties like frame size or padding. For layout-dependent scroll effects, GeometryReader remains necessary — but those cases are rare.
Reference: WWDC 2023 — Beyond scroll views
Animate Symbol Replacement with contentTransition
When an SF Symbol toggles between two states — play/pause, heart/heart.fill, bookmark/bookmark.fill — the transition between them should feel like a morph, not a swap. Without a content transition, the symbol simply pops from one to the other, which at best looks unpolished and at worst looks like a rendering glitch. .contentTransition(.symbolEffect(.replace)) tells SwiftUI to animate the replacement with a smooth crossfade or directional morph that maintains the spatial position of the symbol.
This works because SF Symbols are vector-based and SwiftUI can interpolate between their paths. The .replace effect has directional variants — .downUp, .offUp, .upUp — that control the direction the old symbol exits and the new symbol enters.
Incorrect (conditional symbol name with no transition — icon pops):
struct PlaybackButton: View {
@State private var isPlaying = false
var body: some View {
Button {
isPlaying.toggle()
} label: {
// The symbol name changes but there is no transition.
// The icon pops from play to pause instantly, looking
// like a broken state change rather than a smooth toggle.
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.title)
.foregroundStyle(.primary)
.frame(width: 60, height: 60)
.background(.ultraThinMaterial, in: Circle())
}
}
}Correct (.symbolEffect(.replace) morphs between symbols):
@Equatable
struct PlaybackButton: View {
@State private var isPlaying = false
var body: some View {
Button {
withAnimation {
isPlaying.toggle()
}
} label: {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.title)
.foregroundStyle(.primary)
// Smoothly morphs from play to pause and back.
// The symbol cross-fades in place, maintaining spatial stability.
.contentTransition(.symbolEffect(.replace))
.frame(width: 60, height: 60)
.background(.ultraThinMaterial, in: Circle())
}
}
}Directional variants for contextual meaning:
@Equatable
struct VolumeControl: View {
@State private var isMuted = false
var body: some View {
Button {
withAnimation {
isMuted.toggle()
}
} label: {
// .downUp: old symbol exits downward, new enters from above
// Gives a "pushing down" feel appropriate for muting
Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill")
.font(.title2)
.contentTransition(.symbolEffect(.replace.downUp))
.frame(width: 44, height: 44)
}
}
}
@Equatable
struct BookmarkToggle: View {
@State private var isBookmarked = false
var body: some View {
Button {
withAnimation {
isBookmarked.toggle()
}
} label: {
// .offUp: old symbol fades out, new slides up from below
// Natural "adding" gesture for bookmarking
Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark")
.font(.title3)
.foregroundStyle(isBookmarked ? .yellow : .secondary)
.contentTransition(.symbolEffect(.replace.offUp))
.frame(width: 44, height: 44)
}
}
}Complete toolbar example with multiple toggling symbols:
@Equatable
struct MediaToolbar: View {
@State private var isPlaying = false
@State private var isFavorite = false
@State private var repeatMode: RepeatMode = .off
var body: some View {
HStack(spacing: Spacing.lg) {
Button {
withAnimation {
repeatMode = repeatMode.next
}
} label: {
Image(systemName: repeatMode.symbolName)
.foregroundStyle(repeatMode == .off ? .secondary : .primary)
.contentTransition(.symbolEffect(.replace))
}
Button {
withAnimation {
isPlaying.toggle()
}
} label: {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.title)
.contentTransition(.symbolEffect(.replace))
}
Button {
withAnimation {
isFavorite.toggle()
}
} label: {
Image(systemName: isFavorite ? "heart.fill" : "heart")
.foregroundStyle(isFavorite ? .red : .secondary)
.contentTransition(.symbolEffect(.replace.downUp))
}
}
.font(.title3)
.padding()
}
}Supporting enum for repeat mode:
enum RepeatMode: CaseIterable {
case off, all, one
var symbolName: String {
switch self {
case .off: return "repeat"
case .all: return "repeat"
case .one: return "repeat.1"
}
}
var next: RepeatMode {
let all = RepeatMode.allCases
let idx = all.firstIndex(of: self)!
return all[(idx + 1) % all.count]
}
}Replace direction variants:
| Variant | Old symbol | New symbol | Best for |
|---|---|---|---|
.replace (default) | Fades out | Fades in | General toggles |
.replace.downUp | Exits downward | Enters from above | Mute, disable, decrease |
.replace.upUp | Exits upward | Enters from below | Enable, increase, level up |
.replace.offUp | Fades out | Slides up | Add, bookmark, favorite |
Note: .contentTransition(.symbolEffect(.replace)) requires the Image to use SF Symbols. It will not animate between custom image assets — use standard .transition for those.
Reference: WWDC 2023 — Animate symbols in your app
Use Text Renderer for Character-Level Animation (iOS 18)
Standard text animations in SwiftUI operate on the entire Text view — it fades in, slides, or scales as a single unit. But some effects require per-character control: typewriter reveals, wave animations where each letter oscillates with a phase offset, or blur-in effects where characters sharpen one at a time. Before iOS 18, achieving this required splitting text into individual Text views — which broke accessibility, localization, and text layout.
iOS 18 introduces the TextRenderer protocol, which gives you access to individual text runs and glyphs at render time. You implement draw(layout:in:) and iterate over lines, runs, and individual glyphs, applying transforms to each. Because this happens at the render layer (not the layout layer), text retains its full accessibility tree, correct line wrapping, and localization support.
Incorrect (animating entire Text opacity — all-or-nothing reveal):
struct WelcomeMessage: View {
@State private var isVisible = false
var body: some View {
VStack(spacing: 24) {
// The entire text fades in as one block.
// No character-level drama — just a flat fade.
Text("Welcome back, Sarah")
.font(.largeTitle.bold())
.opacity(isVisible ? 1 : 0)
.animation(.smooth(duration: 0.5), value: isVisible)
Button("Show") {
isVisible = true
}
}
.padding()
}
}Correct (TextRenderer reveals characters one at a time with a wave effect):
struct WaveTextRenderer: TextRenderer {
var progress: Double
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
let totalGlyphs = layout.flatMap { $0 }.reduce(0) { count, run in
count + run.count
}
guard totalGlyphs > 0 else { return }
var glyphIndex = 0
for line in layout {
for run in line {
for glyph in run {
let normalizedIndex = Double(glyphIndex) / Double(totalGlyphs)
// Each character reaches full visibility based on progress
let characterProgress = max(0, min(1, (progress - normalizedIndex) * Double(totalGlyphs) / 6.0))
var copy = context
// Vertical wave offset that settles as progress advances
let waveOffset = (1 - characterProgress) * -12
copy.translateBy(x: 0, y: waveOffset)
copy.opacity = characterProgress
copy.draw(glyph)
glyphIndex += 1
}
}
}
}
}
@Equatable
struct WelcomeMessage: View {
@State private var progress: Double = 0
var body: some View {
VStack(spacing: Spacing.lg) {
Text("Welcome back, Sarah")
.font(.largeTitle.bold())
.textRenderer(WaveTextRenderer(progress: progress))
Button("Animate") {
progress = 0
withAnimation(.easeOut(duration: 1.2)) {
progress = 1
}
}
}
.padding()
}
}Typewriter effect — characters appear one at a time with a cursor feel:
struct TypewriterRenderer: TextRenderer {
var progress: Double
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
let totalGlyphs = layout.flatMap { $0 }.reduce(0) { count, run in
count + run.count
}
guard totalGlyphs > 0 else { return }
let visibleCount = Int(Double(totalGlyphs) * progress)
var glyphIndex = 0
for line in layout {
for run in line {
for glyph in run {
if glyphIndex < visibleCount {
context.draw(glyph)
}
glyphIndex += 1
}
}
}
}
}
@Equatable
struct TypewriterDemo: View {
@State private var progress: Double = 0
var body: some View {
VStack(spacing: Spacing.lg) {
Text("The quick brown fox jumps over the lazy dog.")
.font(.title3)
.textRenderer(TypewriterRenderer(progress: progress))
Button("Type") {
progress = 0
withAnimation(.linear(duration: 2.0)) {
progress = 1
}
}
}
.padding()
}
}When to use TextRenderer vs. simpler alternatives:
| Effect needed | Approach |
|---|---|
| Fade entire text in/out | .opacity + .animation |
| Number digit rolling | .contentTransition(.numericText) |
| Text morph between two strings | .contentTransition(.interpolate) |
| Per-character wave, typewriter, blur | TextRenderer (iOS 18+) |
Key constraints:
TextRendereris available starting iOS 18.- The
draw(layout:in:)method runs every frame during animation — keep it lightweight. - Do not perform allocations or complex calculations inside the draw method. Pre-compute values where possible.
animatableDatamust be declared for SwiftUI to interpolate your progress value.
Note: this is an advanced API. For most text animation needs, .contentTransition(.numericText) or .contentTransition(.interpolate) are simpler and sufficient. Reach for TextRenderer only when you need individual glyph control.
Reference: WWDC 2024 — Create custom visual effects with SwiftUI
Use visualEffect for Position-Aware Animations
When you need to apply visual transformations based on a view's position — parallax scrolling, position-dependent rotation, or distance-based scaling — the traditional tool is GeometryReader. But GeometryReader participates in the layout system: it proposes sizes to children, reads their geometry, and triggers re-layout when values change. For purely visual effects that do not alter layout, this overhead is unnecessary.
.visualEffect (iOS 17+) provides a GeometryProxy without entering the layout system. The closure receives the content and a proxy, and you return visual modifications — offset, scale, rotation, opacity, blur. Because SwiftUI knows these are visual-only, it can apply them as render-tree transforms without invalidating layout. The result is the same visual outcome with fewer layout passes and no risk of the infinite-layout-loop bugs that GeometryReader sometimes causes.
Incorrect (GeometryReader for parallax — triggers layout recalculation):
struct ParallaxHeader: View {
let imageName: String
let title: String
var body: some View {
ScrollView {
VStack(spacing: 0) {
// GeometryReader triggers layout every scroll frame
// and forces you to manage the proposed size manually
GeometryReader { proxy in
let minY = proxy.frame(in: .global).minY
Image(imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(
width: proxy.size.width,
height: 300 + max(0, minY)
)
.offset(y: minY > 0 ? -minY * 0.5 : 0)
.clipped()
}
.frame(height: 300)
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.largeTitle.bold())
Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit.")
.foregroundStyle(.secondary)
}
.padding()
}
}
}
}Correct (.visualEffect applies parallax with zero layout cost):
@Equatable
struct ParallaxHeader: View {
let imageName: String
let title: String
var body: some View {
ScrollView {
VStack(spacing: 0) {
Image(imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 300)
.clipped()
// visualEffect provides geometry without layout overhead.
// The parallax offset is purely visual — layout stays fixed.
.visualEffect { content, proxy in
content
.offset(y: proxy.frame(in: .global).minY * 0.3)
}
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(title)
.font(.largeTitle.bold())
Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit.")
.foregroundStyle(.secondary)
}
.padding()
}
}
}
}Position-dependent glow effect (distance from center):
@Equatable
struct GlowGrid: View {
let columns = Array(repeating: GridItem(.flexible(), spacing: Spacing.sm), count: 3)
let items = Array(0..<12)
var body: some View {
LazyVGrid(columns: columns, spacing: Spacing.sm) {
ForEach(items, id: \.self) { index in
RoundedRectangle(cornerRadius: Radius.sm)
.fill(.blue.opacity(0.2))
.frame(height: 100)
.overlay {
Text("\(index)")
.font(.headline)
}
.visualEffect { content, proxy in
// Calculate distance from center of the grid coordinate space
let frame = proxy.frame(in: .named("grid"))
let gridCenter = CGPoint(
x: frame.width / 2,
y: frame.height / 2
)
let distance = hypot(
frame.midX - gridCenter.x,
frame.midY - gridCenter.y
)
// Items closer to center glow brighter
let normalizedDistance = min(distance / 400, 1.0)
content
.opacity(1.0 - normalizedDistance * 0.4)
.scaleEffect(1.0 - normalizedDistance * 0.08)
}
}
}
.coordinateSpace(.named("grid"))
.padding()
}
}What `.visualEffect` can and cannot do:
| Allowed (visual-only) | Not allowed (affects layout) |
|---|---|
.offset() | .frame() |
.scaleEffect() | .padding() |
.rotationEffect() | Conditional content (if/else) |
.opacity() | .fixedSize() |
.blur() | .layoutPriority() |
.rotation3DEffect() | Child view insertion/removal |
Key insight: if you find yourself using GeometryReader purely to read a position and apply transforms, .visualEffect is the replacement. Reserve GeometryReader for cases where you genuinely need to change the layout based on available space.
Reference: WWDC 2023 — Demystify SwiftUI performance
Use Blur to Bridge Imperfect Transition States
Some transitions have an inherent visual seam — a moment where the layout jumps because two states have different sizes, or content swaps between two differently shaped elements. No amount of easing tuning can hide a 40px height difference between state A and state B. The eye catches the discontinuity during the crossfade, and it reads as a glitch.
A subtle Gaussian blur (4–8px) during the transition midpoint masks this seam. The blur softens both the outgoing and incoming states so the eye cannot track the exact moment of the layout shift. Once the transition completes, the blur animates back to zero and the final state appears crisp. This technique is used extensively in iOS system transitions — the app switcher blurs apps as they rearrange, and Spotlight blurs results during filtering.
Incorrect (hard crossfade between different-sized content — visible layout jump):
struct ContentCard: View {
@State private var isDetailed = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Project Status")
.font(.headline)
Spacer()
Button(isDetailed ? "Less" : "More") {
withAnimation(.smooth) {
isDetailed.toggle()
}
}
.font(.subheadline)
}
if isDetailed {
// Detailed view: 3 rows of stats + chart placeholder
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Completed", value: "73%")
LabeledContent("In Progress", value: "18%")
LabeledContent("Blocked", value: "9%")
RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1))
.frame(height: 120)
.overlay {
Text("Chart Placeholder")
.foregroundStyle(.secondary)
}
}
// The detailed view is ~200pt taller than the summary.
// During crossfade, the height jumps and content below
// this card snaps into a new position — jarring.
} else {
// Summary view: single progress bar
ProgressView(value: 0.73)
.tint(.blue)
}
}
.padding()
.background(.background, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
}
}Correct (blur bridges the layout jump during transition):
@Equatable
struct ContentCard: View {
@State private var isDetailed = false
@State private var isTransitioning = false
var body: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
HStack {
Text("Project Status")
.font(.headline)
Spacer()
Button(isDetailed ? "Less" : "More") {
withAnimation(.smooth(duration: 0.15)) {
isTransitioning = true
}
withAnimation(.smooth(duration: 0.3)) {
isDetailed.toggle()
}
withAnimation(.smooth(duration: 0.2).delay(0.25)) {
isTransitioning = false
}
}
.font(.subheadline)
}
Group {
if isDetailed {
VStack(alignment: .leading, spacing: Spacing.sm) {
LabeledContent("Completed", value: "73%")
LabeledContent("In Progress", value: "18%")
LabeledContent("Blocked", value: "9%")
RoundedRectangle(cornerRadius: Radius.sm)
.fill(.blue.opacity(0.1))
.frame(height: 120)
}
} else {
ProgressView(value: 0.73)
.tint(.blue)
}
}
.blur(radius: isTransitioning ? 6 : 0)
}
.padding()
.background(.background, in: RoundedRectangle(cornerRadius: Radius.lg))
}
}Reusable blur bridge modifier:
struct BlurBridge: ViewModifier {
let isActive: Bool
let radius: CGFloat
func body(content: Content) -> some View {
content
.blur(radius: isActive ? radius : 0)
.animation(.smooth(duration: 0.15), value: isActive)
}
}
extension View {
func blurBridge(isActive: Bool, radius: CGFloat = 6) -> some View {
modifier(BlurBridge(isActive: isActive, radius: radius))
}
}Coordinated blur bridge with state helper:
struct BlurTransitionHelper {
/// Executes a state change wrapped in a blur bridge.
/// The blur activates, the change happens, then the blur clears.
static func perform(
blurBinding: Binding<Bool>,
blurDuration: Double = 0.15,
changeDuration: Double = 0.3,
clearDelay: Double = 0.25,
change: @escaping () -> Void
) {
// Blur on
withAnimation(.smooth(duration: blurDuration)) {
blurBinding.wrappedValue = true
} // Content change
withAnimation(.smooth(duration: changeDuration)) {
change()
}
// Blur off
withAnimation(.smooth(duration: blurDuration).delay(clearDelay)) {
blurBinding.wrappedValue = false
}
}
}Blur radius guidelines:
| Scenario | Blur radius | Duration | Notes |
|---|---|---|---|
| Small content swap (text change) | 4px | 100ms on/off | Just enough to soften text edges |
| Medium layout change (card resize) | 6px | 150ms on/off | Masks height jumps up to ~100pt |
| Large layout change (full reflow) | 8px | 200ms on/off | Maximum — more feels like frosted glass |
| Image content swap | 4px | 100ms on/off | Images tolerate less blur before looking wrong |
Warning: do not leave blur on for more than ~300ms. Prolonged blur makes users think the content is loading or broken. The blur should be imperceptible as a technique — the user should notice only that the transition felt smooth, not that blur was involved. Also note that .blur() can be expensive on complex view hierarchies — consider pairing with .drawingGroup() if Instruments shows frame drops.
Use drawingGroup() for Metal-Backed Complex Animations
SwiftUI renders views through Core Animation by default, compositing each view as a separate CALayer. For most UI this is efficient — Core Animation is optimized for discrete rectangles and text. But when you animate dozens of overlapping shapes — wave animations, particle effects, complex gradient meshes, or layered circles — Core Animation struggles. Each shape becomes its own layer, the compositor must resolve overlapping transparency per frame, and you start dropping frames on older devices.
.drawingGroup() tells SwiftUI to flatten the entire view subtree into a single Metal texture before compositing. Instead of 50 separate layers, the GPU renders one texture. This dramatically reduces compositing overhead for complex, overlapping visual effects. The tradeoff is that the entire subtree is rasterized — some effects like shadow and blur may render differently because they no longer operate on individual layers.
Incorrect (50 overlapping Circle views without drawingGroup — dropped frames):
struct OverlappingCircles: View {
var body: some View {
TimelineView(.animation) { context in
let time = context.date.timeIntervalSinceReferenceDate
ZStack {
ForEach(0..<50, id: \.self) { i in
let t = Double(i) / 50.0
let yOffset = sin(time * 2 + t * .pi * 4) * 40
Circle()
.fill(.blue.opacity(0.3))
.frame(width: 30, height: 30)
.offset(
x: CGFloat(i) * 6 - 150,
y: yOffset
)
}
}
.frame(width: 300, height: 200)
// Without drawingGroup: 50 CALayers composited per frame.
// GPU compositor chokes on overlapping transparency.
// On iPhone 12 and older, this drops to ~40fps.
}
}
}Correct (drawingGroup flattens to a single Metal texture):
@Equatable
struct OverlappingCircles: View {
var body: some View {
TimelineView(.animation) { context in
let time = context.date.timeIntervalSinceReferenceDate
ZStack {
ForEach(0..<50, id: \.self) { i in
let t = Double(i) / 50.0
let yOffset = sin(time * 2 + t * .pi * 4) * 40
Circle()
.fill(.blue.opacity(0.3))
.frame(width: 30, height: 30)
.offset(
x: CGFloat(i) * 6 - 150,
y: yOffset
)
}
}
.frame(width: 300, height: 200)
// drawingGroup: all 50 circles are rasterized into a single
// Metal texture. The compositor sees one layer instead of 50.
// Consistent 60fps even on older devices.
.drawingGroup()
}
}
}Production example — animated gradient orb:
@Equatable
struct GradientOrb: View {
var body: some View {
TimelineView(.animation) { context in
let time = context.date.timeIntervalSinceReferenceDate
ZStack {
ForEach(0..<6, id: \.self) { i in
let angle = Double(i) * (.pi / 3) + time * 0.5
let radius: CGFloat = 40
Circle()
.fill(
RadialGradient(
colors: [
[.blue, .purple, .pink, .orange, .cyan, .mint][i],
.clear
],
center: .center,
startRadius: 0,
endRadius: 80
)
) .frame(width: 160, height: 160)
.offset(
x: cos(angle) * radius,
y: sin(angle) * radius
)
.blendMode(.screen)
}
}
.frame(width: 200, height: 200)
// Essential for overlapping blend modes — without drawingGroup,
// blend modes apply per-layer in unexpected order
.drawingGroup()
.clipShape(Circle())
}
}
}When to use and when to avoid drawingGroup:
| Scenario | Use drawingGroup? | Why |
|---|---|---|
| 20+ overlapping animated shapes | Yes | Reduces compositing from N layers to 1 |
| Animated blend modes (.screen, .multiply) | Yes | Blend modes need correct compositing order |
| Animated gradients with many stops | Yes | Gradient interpolation is GPU-heavy |
| Simple button with shadow | No | Core Animation handles this efficiently |
| Text with blur effect | No | drawingGroup can break text rendering quality |
Views with .shadow modifier | Caution | Shadow renders on the flattened texture, not individual shapes |
How to diagnose when you need drawingGroup:
1. Open Instruments with the "Core Animation" template 2. Enable "Color Blended Layers" — red areas are expensive overlapping transparency 3. If the animation area is solid red with multiple layers, add .drawingGroup() 4. Check the GPU frame time — it should drop significantly
Warning: do not apply .drawingGroup() by default. It adds overhead for simple views because the GPU must rasterize the texture before compositing. Only add it when profiling confirms that overlapping layer compositing is the bottleneck. Also note that .drawingGroup() flattens the accessibility tree for the subtree — ensure important accessible elements are outside the drawing group or have explicit accessibility labels.
Reference: WWDC 2021 — Demystify SwiftUI
Use geometryGroup() to Isolate Layout Animation Propagation
When a parent view changes size or position and its children have their own animations, something unexpected happens: the parent's geometry change propagates into the children's coordinate space. A child that is supposed to scale in place instead scales while sliding because the parent is also moving. An icon that should bounce at its current position instead bounces while drifting to a new anchor point. This is the most common cause of "my animation goes to the wrong place" bugs in SwiftUI.
.geometryGroup() (iOS 17+) resolves the parent's geometry change before passing the resolved frame to children. Children receive their new position as a fait accompli — they do not see the parent's interpolation. This lets child animations run in their own local coordinate space, uncontaminated by parent motion.
Incorrect (parent resize corrupts child animation position):
struct ExpandableToolbar: View {
@State private var isExpanded = false
@State private var showBadge = false
var body: some View {
HStack(spacing: isExpanded ? 24 : 12) {
Button(action: { showBadge.toggle() }) {
ZStack(alignment: .topTrailing) {
Image(systemName: "bell.fill")
.font(.title2)
.frame(width: 44, height: 44)
if showBadge {
Circle()
.fill(.red)
.frame(width: 10, height: 10)
// This badge is supposed to scale in from zero
// at its top-right position. But when the toolbar
// is also expanding (isExpanded changes), the badge
// scales in while drifting sideways because the parent
// HStack spacing is animating.
.transition(.scale)
}
}
}
Button(action: {}) {
Image(systemName: "gear")
.font(.title2)
.frame(width: 44, height: 44)
}
if isExpanded {
Button(action: {}) {
Image(systemName: "person.fill")
.font(.title2)
.frame(width: 44, height: 44)
}
}
}
.padding()
.background(.ultraThinMaterial, in: Capsule())
.animation(.smooth, value: isExpanded)
.animation(.snappy, value: showBadge)
.onTapGesture {
isExpanded.toggle()
}
}
}Correct (geometryGroup isolates child from parent's geometry change):
@Equatable
struct ExpandableToolbar: View {
@State private var isExpanded = false
@State private var showBadge = false
var body: some View {
HStack(spacing: isExpanded ? Spacing.lg : Spacing.sm) {
Button(action: { showBadge.toggle() }) {
ZStack(alignment: .topTrailing) {
Image(systemName: "bell.fill")
.font(.title2)
.frame(width: 44, height: 44)
if showBadge {
Circle()
.fill(.red)
.frame(width: 10, height: 10)
// Now the badge scales in at its final position,
// not along the parent's interpolation path.
.transition(.scale)
}
}
}
Button(action: {}) {
Image(systemName: "gear")
.font(.title2)
.frame(width: 44, height: 44)
}
if isExpanded {
Button(action: {}) {
Image(systemName: "person.fill")
.font(.title2)
.frame(width: 44, height: 44)
}
}
}
.padding()
.background(.ultraThinMaterial, in: Capsule())
// geometryGroup resolves the HStack's final geometry before
// passing it to children. Child animations (badge scale) run
// in the resolved coordinate space, not the interpolating one.
.geometryGroup()
.animation(.smooth, value: isExpanded)
.animation(.snappy, value: showBadge)
.onTapGesture {
isExpanded.toggle()
}
}
}Common scenario — card resize with inner animated elements:
@Equatable
struct ResizableCard: View {
@State private var isLarge = false
@State private var isHighlighted = false
var body: some View {
VStack(spacing: Spacing.md) {
ZStack(alignment: .topTrailing) {
RoundedRectangle(cornerRadius: Radius.lg)
.fill(.blue.gradient)
.frame(
width: isLarge ? 320 : 200,
height: isLarge ? 240 : 160
)
// Star icon bounces independently of card resize
Image(systemName: isHighlighted ? "star.fill" : "star")
.font(.title2)
.foregroundStyle(.yellow)
.scaleEffect(isHighlighted ? 1.2 : 1.0)
.animation(.bouncy, value: isHighlighted)
.padding(Spacing.sm)
}
// geometryGroup prevents the card's size animation from
// pulling the star icon along an interpolation path
.geometryGroup()
.animation(.smooth(duration: 0.4), value: isLarge)
HStack(spacing: Spacing.md) {
Button("Resize") {
isLarge.toggle()
}
Button("Star") {
isHighlighted.toggle()
}
}
.buttonStyle(.bordered)
}
}
}When to apply geometryGroup:
| Symptom | Cause | Fix |
|---|---|---|
| Child animates to wrong position | Parent size/position is also animating | .geometryGroup() on parent |
| Badge flies across screen during resize | Badge transition interpolates in parent's moving frame | .geometryGroup() on badge's container |
| Icon bounces while drifting sideways | Parent HStack spacing is animating | .geometryGroup() on HStack |
| Transition starts from wrong origin | Parent layout is not yet resolved when child appears | .geometryGroup() on parent |
Where to place `.geometryGroup()`:
Place it on the view whose geometry change you want to resolve before it reaches children. Typically this is the immediate parent of the elements whose animations are being corrupted.
// Place on the container whose size/position changes
VStack {
// children with their own animations
}
.geometryGroup() // <-- here, between container and its animation
.animation(.smooth, value: someState)Note: .geometryGroup() has a small cost — it forces an extra layout resolution pass. Do not apply it everywhere preemptively. Add it when you observe the specific symptom of child animations following the wrong interpolation path.
Reference: WWDC 2023 — Demystify SwiftUI performance
Respect accessibilityReduceMotion with Crossfade Fallback
Approximately 35% of adults experience some form of motion sensitivity. When a user enables "Reduce Motion" in iOS Settings, they are telling the system that sliding, zooming, and bouncing animations cause them physical discomfort — dizziness, nausea, or headaches. SwiftUI does not automatically respect this preference for custom animations. The .animation(.default) modifier and withAnimation still apply full spring or easing animations regardless of the setting. You must explicitly check @Environment(\.accessibilityReduceMotion) and substitute movement-based animations with opacity crossfades.
The key insight is that you should not remove all animation when reduce motion is enabled. Opacity crossfades are universally safe — they provide visual continuity without triggering vestibular responses. The problematic animations are those involving spatial movement: slides, zooms, rotations, and parallax effects.
Incorrect (ignoring reduce motion — full animations always play):
struct CardStack: View {
@State private var selectedTab = 0
var body: some View {
VStack {
TabView(selection: $selectedTab) {
ForEach(0..<3, id: \.self) { index in
RoundedRectangle(cornerRadius: 16)
.fill(
[Color.blue, .purple, .orange][index].gradient
)
.padding()
.tag(index)
}
}
.tabViewStyle(.page)
// Custom animated indicator
HStack(spacing: 8) {
ForEach(0..<3, id: \.self) { index in
Circle()
.fill(index == selectedTab ? .primary : .secondary)
.frame(width: 8, height: 8)
// This scale + offset animation plays for ALL users,
// including those who enabled Reduce Motion to avoid
// exactly this kind of movement.
.scaleEffect(index == selectedTab ? 1.3 : 1.0)
.offset(y: index == selectedTab ? -2 : 0)
.animation(.bouncy, value: selectedTab)
}
}
}
}
}Correct (crossfade fallback when reduce motion is enabled):
@Equatable
struct CardStack: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var selectedTab = 0
var body: some View {
VStack {
TabView(selection: $selectedTab) {
ForEach(0..<3, id: \.self) { index in
RoundedRectangle(cornerRadius: Radius.lg)
.fill(
[Color.blue, .purple, .orange][index].gradient
)
.padding()
.tag(index)
}
}
.tabViewStyle(.page)
HStack(spacing: Spacing.sm) {
ForEach(0..<3, id: \.self) { index in
Circle()
.fill(index == selectedTab ? .primary : .secondary)
.frame(width: 8, height: 8)
.scaleEffect(
// When reduce motion is on, skip the scale bounce —
// just change the fill color (already handled above)
!reduceMotion && index == selectedTab ? 1.3 : 1.0
)
.animation(
reduceMotion ? .none : .bouncy,
value: selectedTab
)
}
}
}
}
}Reusable conditional animation helper:
extension Animation {
/// Returns `.opacity` crossfade when reduce motion is on,
/// the provided animation otherwise.
static func motionSafe(
_ animation: Animation,
reduceMotion: Bool
) -> Animation {
reduceMotion ? .smooth(duration: 0.2) : animation
}
}
extension AnyTransition {
/// Returns an opacity-only transition when reduce motion is on,
/// the provided transition otherwise.
static func motionSafe(
_ transition: AnyTransition,
reduceMotion: Bool
) -> AnyTransition {
reduceMotion ? .opacity : transition
}
}Production example — screen entrance with reduce motion awareness:
@Equatable
struct OnboardingCard: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isVisible = false
let title: String
let description: String
let iconName: String
var body: some View {
VStack(spacing: Spacing.md) {
Image(systemName: iconName)
.font(.system(size: 56))
.foregroundStyle(.blue)
// Movement: scale up from small
.scaleEffect(isVisible ? 1 : (reduceMotion ? 1 : 0.5))
// Safe: opacity always animates
.opacity(isVisible ? 1 : 0)
.animation(
reduceMotion
? .smooth(duration: 0.3) // crossfade only
: .spring(duration: 0.5, bounce: 0.2), // full spring
value: isVisible
)
Text(title)
.font(.title2.bold())
// Movement: slides up 20pt (skipped for reduce motion)
.offset(y: isVisible ? 0 : (reduceMotion ? 0 : 20))
.opacity(isVisible ? 1 : 0)
.animation(
reduceMotion
? .smooth(duration: 0.3).delay(0.05)
: .smooth(duration: 0.4).delay(0.1),
value: isVisible
)
Text(description)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.opacity(isVisible ? 1 : 0)
.animation(
.smooth(duration: 0.3).delay(reduceMotion ? 0.1 : 0.2),
value: isVisible
)
}
.padding(Spacing.xl)
.task {
try? await Task.sleep(for: .milliseconds(50))
isVisible = true
}
}
}What is safe and what is not:
| Animation type | Reduce motion ON | Reduce motion OFF |
|---|---|---|
| Opacity crossfade | Safe — always use | Safe |
| Color change | Safe — always use | Safe |
| Scale (subtle, < 1.1x) | Borderline — prefer skip | Safe |
| Slide / offset | Unsafe — replace with fade | Safe |
| Rotation | Unsafe — replace with fade | Safe |
| Parallax scroll | Unsafe — disable entirely | Safe |
| Zoom transition | Unsafe — replace with fade | Safe |
| Spring bounce | Unsafe — use linear fade | Safe |
Important: SwiftUI's built-in .animation(.default) and withAnimation do NOT automatically respect reduce motion. The matchedTransitionSource zoom transition (iOS 18) does respect it natively, but custom animations require manual checking. Always test your app with Settings > Accessibility > Motion > Reduce Motion enabled.
Reference: WWDC 2019 — Visual Design and Accessibility and Apple Human Interface Guidelines — Motion
Use Transaction to Debug and Override Animation Behavior
When an animation does not behave as expected — a view animates when it should not, an animation uses the wrong curve, or a child inherits a parent's animation — the root cause is almost always the animation Transaction. Every state change in SwiftUI creates a Transaction that carries an optional Animation. This transaction propagates down the view tree, and any view that reads the changed state picks up the transaction's animation. Understanding and intercepting transactions is the key to debugging and overriding animation behavior.
The .transaction modifier lets you inspect, modify, or replace the animation in the current transaction. Combined with .animation(nil) to strip inherited animations from specific properties, these tools give you precise control over which animations apply where.
Incorrect (adding print statements trying to guess animation timing):
struct DebugView: View {
@State private var isExpanded = false
@State private var badgeCount = 3
var body: some View {
VStack(spacing: 20) {
// The badge count text animates with a spring when isExpanded changes,
// even though we only wanted the expansion to animate.
// Adding prints does not help because the animation system is opaque.
Text("Notifications: \(badgeCount)")
.font(.headline)
RoundedRectangle(cornerRadius: 12)
.fill(.blue)
.frame(height: isExpanded ? 200 : 80)
Button("Expand") {
print("Before animation") // Does not help debug the animation system
withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
isExpanded.toggle()
// This state change is inside the same withAnimation block,
// so badgeCount also picks up the spring animation —
// the number wobbles, which looks broken
badgeCount += 1
}
print("After animation") // Also does not help
}
}
.padding()
}
}Correct (using .transaction and .animation(nil) to control exactly what animates):
@Equatable
struct DebugView: View {
@State private var isExpanded = false
@State private var badgeCount = 3
var body: some View {
VStack(spacing: Spacing.md) {
Text("Notifications: \(badgeCount)")
.font(.headline)
// Strip any inherited animation from this text.
// The badge count updates instantly even when the parent
// triggers a spring animation for the expansion.
.animation(nil, value: badgeCount)
.contentTransition(.numericText(value: Double(badgeCount)))
RoundedRectangle(cornerRadius: Radius.md)
.fill(.blue)
.frame(height: isExpanded ? 200 : 80)
.animation(.spring(duration: 0.4, bounce: 0.2), value: isExpanded)
Button("Expand") {
isExpanded.toggle()
badgeCount += 1
}
}
.padding()
}
}Inspecting the current transaction for debugging:
@Equatable
struct TransactionInspector: View {
@State private var isActive = false
var body: some View {
VStack(spacing: Spacing.md) {
Circle()
.fill(isActive ? .green : .gray)
.frame(width: 60, height: 60)
.scaleEffect(isActive ? 1.2 : 1.0)
// Inspect what animation is driving this view's changes
.transaction { transaction in
// Print the current animation for debugging
if transaction.animation != nil {
print("Animation: \(String(describing: transaction.animation))")
}
// You can also check if this is a continuous animation:
print("Is continuous: \(transaction.isContinuous)")
}
Button("Toggle") {
withAnimation(.bouncy) {
isActive.toggle()
}
}
}
}
}Overriding inherited animation on specific properties:
@Equatable
struct OverrideExample: View {
@State private var isExpanded = false
var body: some View {
VStack(spacing: Spacing.md) {
HStack {
// This icon should NOT animate — it represents the current state
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
// .animation(nil) strips ALL inherited animations from
// changes to isExpanded on this specific view
.animation(nil, value: isExpanded)
Text("Details")
.font(.headline)
Spacer()
}
if isExpanded {
Text("Here are the expanded details that slide in smoothly.")
.font(.body)
.foregroundStyle(.secondary)
}
}
.padding()
.background(.background, in: RoundedRectangle(cornerRadius: Radius.md))
.animation(.smooth, value: isExpanded)
.onTapGesture {
isExpanded.toggle()
}
}
}Disabling animation for a specific state change:
@Equatable
struct ImmediateUpdate: View {
@State private var selectedTab = 0
@State private var scrollOffset: CGFloat = 0
var body: some View {
VStack {
// Tab content with scroll
ScrollView {
Text("Content for tab \(selectedTab)")
.padding()
}
// Tab bar
HStack {
ForEach(0..<4, id: \.self) { index in
Button(action: {
// The tab selection should animate (underline slides)
withAnimation(.snappy) {
selectedTab = index
}
// But the scroll offset should reset immediately —
// no animation. Use a Transaction with nil animation.
var transaction = Transaction()
transaction.animation = nil
withTransaction(transaction) {
scrollOffset = 0
}
}) {
Text("Tab \(index)")
.padding(.vertical, Spacing.sm)
.padding(.horizontal, Spacing.md)
}
}
}
}
}
}Transaction debugging cheat sheet:
| Problem | Diagnosis | Fix |
|---|---|---|
| View animates when it shouldn't | Inheriting parent's animation via transaction | .animation(nil, value: state) on the view |
| Wrong animation curve on a view | Transaction carries a different animation | .transaction { $0.animation = .smooth } |
| All children animate the same way | Single withAnimation drives everything | Move state changes outside withAnimation or use .animation(nil) |
| Animation feels "doubled" | Two overlapping transactions both animate | .transaction { $0.animation = nil } on one source |
| Need to confirm which animation runs | Cannot tell from visual inspection | .transaction { print($0.animation) } to log |
Key insight — `.animation(nil, value:)` vs `.transaction { $0.animation = nil }`:
// .animation(nil, value:) — strips animation for changes to a SPECIFIC value
Text("\(count)")
.animation(nil, value: count) // Only count changes are un-animated
// .transaction — intercepts ALL animations passing through this view
Text("\(count)")
.transaction { $0.animation = nil } // ALL changes are un-animatedUse .animation(nil, value:) when you want surgical precision. Use .transaction when you want to override everything on a subtree.
Reference: WWDC 2023 — Explore SwiftUI animation and SwiftUI documentation on Transaction
Keep UI Animations Under 250ms
250ms is the perceptual boundary where animation shifts from "feedback" to "waiting". Button presses, tab switches, toggles, and menu selections must complete under this threshold. Beyond 250ms, the user's brain decouples the action from the result — the animation is no longer perceived as a direct consequence of their touch but as a system delay. Miller's 1968 research on response time perception established this boundary: under 100ms feels instantaneous, 100-250ms feels responsive, 250ms+ feels like the system is working.
Incorrect (tab switch animation exceeds 250ms — feels sluggish):
struct MainTabView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
HomeView()
.tag(0)
.tabItem { Label("Home", systemImage: "house") }
SearchView()
.tag(1)
.tabItem { Label("Search", systemImage: "magnifyingglass") }
ProfileView()
.tag(2)
.tabItem { Label("Profile", systemImage: "person") }
}
// 500ms spring on a tab switch — user taps and waits
.animation(.spring(duration: 0.5), value: selectedTab)
}
}Correct (tab switch completes under 250ms — feels responsive):
@Equatable
struct MainTabView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
HomeView()
.tag(0)
.tabItem { Label("Home", systemImage: "house") }
SearchView()
.tag(1)
.tabItem { Label("Search", systemImage: "magnifyingglass") }
ProfileView()
.tag(2)
.tabItem { Label("Profile", systemImage: "person") }
}
// .snappy completes in ~200ms — feels like direct manipulation
.animation(.snappy, value: selectedTab)
}
}Incorrect (toggle animation is too slow):
struct SettingsRow: View {
@Binding var isEnabled: Bool
var body: some View {
Toggle("Notifications", isOn: $isEnabled)
// 400ms for a toggle feels broken
.animation(.smooth(duration: 0.4), value: isEnabled)
}
}Correct (toggle responds immediately):
@Equatable
struct SettingsRow: View {
@Binding var isEnabled: Bool
var body: some View {
Toggle("Notifications", isOn: $isEnabled)
// 200ms — user sees the toggle track as a direct extension of their thumb
.animation(.smooth(duration: 0.2), value: isEnabled)
}
}Exception: deliberate cinematic transitions CAN exceed 250ms. Full-screen hero morphs, onboarding sequences, and shared-element navigations are not direct-manipulation feedback — the user expects a spatial journey. These can run 400-600ms without feeling slow because the motion itself IS the content.
@Equatable
struct PhotoGrid: View {
@Namespace private var heroNamespace
@State private var selectedPhoto: Photo?
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: Spacing.sm) {
ForEach(photos) { photo in
Image(photo.name)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: Radius.sm))
.matchedGeometryEffect(id: photo.id, in: heroNamespace)
.onTapGesture { selectedPhoto = photo }
}
}
}
.overlay {
if let photo = selectedPhoto {
Image(photo.name)
.resizable()
.aspectRatio(contentMode: .fit)
.matchedGeometryEffect(id: photo.id, in: heroNamespace)
.onTapGesture { selectedPhoto = nil }
// 500ms is acceptable here — the spatial morph IS the experience
.animation(.smooth(duration: 0.5), value: selectedPhoto)
}
}
}
}Duration guideline by interaction type:
| Interaction | Max Duration | Rationale |
|---|---|---|
| Button press feedback | 100ms | Must feel instantaneous |
| Toggle / switch | 200ms | Direct manipulation |
| Tab switch | 200ms | Context switch, not a journey |
| Dropdown / popover | 200ms | Expanding in-place |
| Sheet presentation | 350ms | Spatial transition |
| Full-screen hero morph | 500ms | Cinematic, spatial |
| Onboarding sequence | 600ms | Narrative, not reactive |
Reference: Miller, R.B. (1968). "Response time in man-computer conversational transactions." WWDC 2023 "Animate with springs" — Apple's spring presets (.snappy, .bouncy, .smooth) are all calibrated to settle within 200-300ms for this reason.
Use Asymmetric Timing for Enter and Exit
Entering views should take slightly longer than exiting ones. Entry builds spatial awareness — the user needs to register where new content is coming from and where it now lives. Exit should get out of the way quickly — the user has already decided to dismiss, and lingering departure animations block them from their next action. Symmetric enter/exit durations feel robotic because nothing in the physical world appears and disappears at the same rate. A door swings open with weight; it clicks shut quickly.
Incorrect (same timing for appear and disappear — feels mechanical):
struct ToastView: View {
@Binding var isVisible: Bool
let message: String
var body: some View {
if isVisible {
HStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(message)
.font(.subheadline.weight(.medium))
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(.ultraThinMaterial, in: Capsule())
// Same .smooth for both appear and disappear
// Disappearing toast hangs around too long
.transition(.move(edge: .top).combined(with: .opacity))
.animation(.smooth(duration: 0.35), value: isVisible)
}
}
}Correct (slower entrance, faster exit using asymmetric transition):
@Equatable
struct ToastView: View {
@Binding var isVisible: Bool
let message: String
var body: some View {
if isVisible {
HStack(spacing: Spacing.sm) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(message)
.font(.subheadline.weight(.medium))
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
.background(.ultraThinMaterial, in: Capsule())
.transition(.asymmetric(
// Entry: 350ms — slides in with weight, user registers position
insertion: .move(edge: .top).combined(with: .opacity)
.animation(.smooth(duration: 0.35)),
// Exit: 200ms — snaps away, clears the screen quickly
removal: .move(edge: .top).combined(with: .opacity)
.animation(.smooth(duration: 0.2))
))
}
}
}Incorrect (action sheet with symmetric timing):
struct ActionSheetView: View {
@State private var showActions = false
var body: some View {
ZStack(alignment: .bottom) {
Color.black.opacity(showActions ? 0.3 : 0)
.ignoresSafeArea()
.onTapGesture {
withAnimation(.spring(duration: 0.3)) {
showActions = false
}
}
if showActions {
VStack(spacing: 8) {
Button("Share") { }
Button("Copy Link") { }
Button("Cancel", role: .cancel) {
withAnimation(.spring(duration: 0.3)) {
showActions = false
}
}
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.padding()
// Same 300ms in both directions
.transition(.move(edge: .bottom))
.animation(.spring(duration: 0.3), value: showActions)
}
}
}
}Correct (action sheet with asymmetric timing via conditional animation):
@Equatable
struct ActionSheetView: View {
@State private var showActions = false
var body: some View {
ZStack(alignment: .bottom) {
Color.black.opacity(showActions ? 0.3 : 0)
.ignoresSafeArea()
.onTapGesture { dismiss() }
if showActions {
VStack(spacing: Spacing.sm) {
Button("Share") { }
Button("Copy Link") { }
Button("Cancel", role: .cancel) { dismiss() }
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: Radius.md))
.padding()
.transition(.asymmetric(
// Entry: slides up with a satisfying spring, 350ms
insertion: .move(edge: .bottom)
.animation(.spring(duration: 0.35, bounce: 0.15)),
// Exit: fast slide down, 200ms, no bounce
removal: .move(edge: .bottom)
.animation(.smooth(duration: 0.2))
))
}
}
}
private func dismiss() {
withAnimation {
showActions = false
}
}
private func present() {
withAnimation {
showActions = true
}
}
}Asymmetric timing guideline:
| Element | Enter Duration | Exit Duration | Ratio |
|---|---|---|---|
| Toast / snackbar | 350ms | 200ms | 1.75x |
| Bottom sheet | 400ms | 250ms | 1.6x |
| Action menu | 350ms | 200ms | 1.75x |
| Modal overlay | 350ms | 200ms | 1.75x |
| Popover | 250ms | 150ms | 1.67x |
Rule of thumb: exit duration should be 55-65% of enter duration. The user needs time to parse incoming content but wants outgoing content gone immediately.
Reference: Material Design motion guidelines document this same principle as "deceleration" (entering) vs "acceleration" (exiting). Apple's native sheet presentation follows asymmetric timing — presenting takes longer than dismissing.
Match Duration to Distance Traveled
A button scale effect (tiny distance) needs 150ms. A sheet sliding up the full screen needs 400ms. Duration should scale with distance traveled — this mirrors physical reality where heavier, farther-moving objects take longer to settle. When you apply the same 300ms to a 10pt button press scale and an 800pt full-screen sheet slide, the button feels sluggish and the sheet feels teleported. The brain expects proportionality.
Incorrect (same 300ms for a button press and a full-screen slide):
struct ProductView: View {
@State private var isPressed = false
@State private var showDetail = false
var body: some View {
VStack {
// Button: 10pt of travel (scale 1.0 -> 0.95)
Button {
showDetail = true
} label: {
Text("View Details")
.padding(.horizontal, 24)
.padding(.vertical, 14)
.background(.blue, in: Capsule())
.foregroundStyle(.white)
}
// 300ms for 10pt scale change — feels like slow motion
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(.smooth(duration: 0.3), value: isPressed)
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
isPressed = pressing
}, perform: {})
}
.fullScreenCover(isPresented: $showDetail) {
DetailSheet()
// 300ms for 800pt slide — feels like teleportation
.transition(.move(edge: .bottom))
.animation(.smooth(duration: 0.3), value: showDetail)
}
}
}Correct (duration proportional to travel distance):
@Equatable
struct ProductView: View {
@State private var isPressed = false
@State private var showDetail = false
var body: some View {
VStack {
Button {
showDetail = true
} label: {
Text("View Details")
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
.background(.blue, in: Capsule())
.foregroundStyle(.white)
}
// 10pt scale: 150ms — snappy, proportional to tiny distance
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(.snappy(duration: 0.15), value: isPressed)
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
isPressed = pressing
}, perform: {})
}
.fullScreenCover(isPresented: $showDetail) {
DetailSheet()
// 800pt slide: 400ms — gives the eye time to track the motion
.transition(.move(edge: .bottom))
.animation(.smooth(duration: 0.4), value: showDetail)
}
}
}Incorrect (expanding card with disproportionate timing):
struct ExpandableCard: View {
@State private var isExpanded = false
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 16)
.fill(.blue.gradient)
// Expands 180pt (120 -> 300) at same speed as
// the chevron rotating 180 degrees (tiny visual distance)
.frame(height: isExpanded ? 300 : 120)
.animation(.spring(duration: 0.25), value: isExpanded)
.overlay(alignment: .topTrailing) {
Image(systemName: "chevron.down")
.rotationEffect(.degrees(isExpanded ? 180 : 0))
// Chevron rotation is tiny — same duration feels laggy
.animation(.spring(duration: 0.25), value: isExpanded)
.padding()
}
.onTapGesture { isExpanded.toggle() }
}
.padding()
}
}Correct (proportional timing for different travel distances):
@Equatable
struct ExpandableCard: View {
@State private var isExpanded = false
var body: some View {
VStack {
RoundedRectangle(cornerRadius: Radius.md)
.fill(.blue.gradient)
// 180pt height expansion: 300ms
.frame(height: isExpanded ? 300 : 120)
.animation(.spring(duration: 0.3, bounce: 0.1), value: isExpanded)
.overlay(alignment: .topTrailing) {
Image(systemName: "chevron.down")
.rotationEffect(.degrees(isExpanded ? 180 : 0))
// Chevron flip is small: 150ms, snappy
.animation(.snappy(duration: 0.15), value: isExpanded)
.padding()
}
.onTapGesture { isExpanded.toggle() }
}
.padding()
}
}Duration-to-distance heuristic (~1ms per point of travel):
| Motion | Approximate Distance | Recommended Duration |
|---|---|---|
| Button scale (1.0 to 0.95) | ~5-10pt | 100-150ms |
| Icon rotation (180 degrees) | ~20pt visual arc | 150ms |
| Chevron flip | ~15pt | 150ms |
| Card expand (120pt to 300pt) | ~180pt | 250-300ms |
| Half-sheet slide | ~400pt | 300-350ms |
| Full-sheet slide | ~800pt | 400ms |
| Full-screen morph | ~900pt | 400-500ms |
The ~1ms per point rule is a starting heuristic, not a formula. It breaks down at extremes — a 2pt opacity fade should not take 2ms (imperceptible), and a 2000pt scroll animation should not take 2 seconds (painfully slow). Below 100ms, round up to 100ms. Above 500ms, cap at 500ms unless the animation is cinematic.
Reference: Material Design documents this principle as "duration is determined by the distance an element travels" with similar proportional scaling. Apple's native animations follow this pattern — NavigationStack push (~350pt) uses ~350ms, while sheet presentation (~800pt) uses ~400ms.
Faster Animations Almost Always Feel Better
When in doubt, make it faster. The single most common animation mistake is making things too slow. Developers overestimate how much time users need to "see" an animation. In practice, reducing duration by 50ms almost always improves perceived quality — the animation reads as crisper, snappier, more confident. Slow animations feel tentative, like the app is unsure of itself. The brain fills in the motion; you do not need to show every frame at human-readable speed.
Incorrect (dropdown menu at 400ms — feels like it is wading through honey):
struct FilterMenuView: View {
@State private var isMenuOpen = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(.spring(duration: 0.4)) {
isMenuOpen.toggle()
}
} label: {
HStack {
Text("Sort by")
Image(systemName: "chevron.down")
.rotationEffect(.degrees(isMenuOpen ? 180 : 0))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.ultraThinMaterial, in: Capsule())
}
if isMenuOpen {
VStack(alignment: .leading, spacing: 0) {
ForEach(["Newest", "Popular", "Price"], id: \.self) { option in
Button(option) { selectOption(option) }
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
// 400ms — user is waiting for the menu to finish opening
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
private func selectOption(_ option: String) {
withAnimation(.spring(duration: 0.4)) {
isMenuOpen = false
}
}
}Correct (dropdown menu at 200ms — feels decisive and crisp):
@Equatable
struct FilterMenuView: View {
@State private var isMenuOpen = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(.snappy(duration: 0.2)) {
isMenuOpen.toggle()
}
} label: {
HStack {
Text("Sort by")
Image(systemName: "chevron.down")
.rotationEffect(.degrees(isMenuOpen ? 180 : 0))
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.background(.ultraThinMaterial, in: Capsule())
}
if isMenuOpen {
VStack(alignment: .leading, spacing: 0) {
ForEach(["Newest", "Popular", "Price"], id: \.self) { option in
Button(option) { selectOption(option) }
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
}
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: Radius.md))
// 200ms — menu snaps open, user can start reading immediately
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
private func selectOption(_ option: String) {
withAnimation(.snappy(duration: 0.2)) {
isMenuOpen = false
}
}
}Incorrect (tooltip lingers into view):
struct TooltipView: View {
@State private var showTooltip = false
var body: some View {
Button("Info") { showTooltip.toggle() }
.overlay(alignment: .top) {
if showTooltip {
Text("Tap to learn more")
.font(.caption)
.padding(8)
.background(.black.opacity(0.8), in: RoundedRectangle(cornerRadius: 6))
.foregroundStyle(.white)
.offset(y: -40)
// 350ms for a tooltip is painful
.transition(.opacity.animation(.smooth(duration: 0.35)))
}
}
}
}Correct (tooltip appears crisply):
@Equatable
struct TooltipView: View {
@State private var showTooltip = false
var body: some View {
Button("Info") { showTooltip.toggle() }
.overlay(alignment: .top) {
if showTooltip {
Text("Tap to learn more")
.font(.caption)
.padding(Spacing.sm)
.background(.black.opacity(0.8), in: RoundedRectangle(cornerRadius: Radius.sm))
.foregroundStyle(.white)
.offset(y: -40)
// 150ms — tooltip pops without demanding attention
.transition(.opacity.animation(.smooth(duration: 0.15)))
}
}
}
}Recommended duration reference table:
| Element | Recommended Duration | Why |
|---|---|---|
| Tooltip | 150ms | Information aid, not a feature |
| Button state change | 100ms | Tactile feedback |
| Toggle / switch | 200ms | Direct manipulation |
| Dropdown menu | 200ms | Functional reveal |
| Navigation push | 250ms | Spatial context shift |
| Bottom sheet (half) | 300ms | Moderate spatial distance |
| Bottom sheet (full) | 350ms | Longer travel distance |
| Full-screen transition | 400-500ms | Cinematic, covers entire viewport |
| Onboarding sequence | 500-600ms | Deliberate storytelling |
The 50ms test: If you are unsure about a duration, try subtracting 50ms. If it still reads clearly, ship the shorter version. Repeat until the animation feels rushed, then add back one increment. You will almost always end up shorter than your first instinct.
Reference: Apple's spring presets (.snappy, .smooth, .bouncy) default to durations in the 200-350ms range. The preset names themselves tell you the design intent — "snappy" is meant to feel fast.
Sync Haptic Feedback to Visual Animation Keyframes
Haptic feedback must fire at the exact moment of visual impact — not at the start of the animation, not at the end, but at the perceptual peak. When a toggle snaps into place, the haptic fires at the snap point. When a card drops into position, the haptic fires on landing. A 50ms gap between visual and tactile feedback is enough to break the illusion — the two feel like separate events instead of one unified interaction.
Incorrect (haptic fires before the animation starts — disconnected):
struct FavoriteButton: View {
@State private var isFavorite = false
var body: some View {
Button {
// Haptic fires HERE, before the visual animation begins
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.impactOccurred()
withAnimation(.spring(duration: 0.3, bounce: 0.4)) {
isFavorite.toggle()
}
} label: {
Image(systemName: isFavorite ? "heart.fill" : "heart")
.font(.title2)
.foregroundStyle(isFavorite ? .red : .gray)
.scaleEffect(isFavorite ? 1.2 : 1.0)
}
}
}Correct (haptic synchronized with visual state change using sensoryFeedback):
@Equatable
struct FavoriteButton: View {
@State private var isFavorite = false
var body: some View {
Button {
withAnimation(.spring(duration: 0.3, bounce: 0.4)) {
isFavorite.toggle()
}
} label: {
Image(systemName: isFavorite ? "heart.fill" : "heart")
.font(.title2)
.foregroundStyle(isFavorite ? .red : .gray)
.scaleEffect(isFavorite ? 1.2 : 1.0)
}
// Haptic fires when isFavorite changes — synchronized with the visual snap
.sensoryFeedback(.impact(weight: .medium), trigger: isFavorite)
}
}Incorrect (manual haptic on drag end — fires at wrong moment):
struct DismissableCard: View {
@State private var offset: CGFloat = 0
@State private var isDismissed = false
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(.blue.gradient)
.frame(height: 200)
.offset(y: offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation.height
}
.onEnded { value in
// Haptic fires immediately on finger lift
// but the card hasn't landed yet
let generator = UIImpactFeedbackGenerator(style: .heavy)
generator.impactOccurred()
if value.translation.height > 200 {
withAnimation(.spring) {
isDismissed = true
}
} else {
withAnimation(.spring(bounce: 0.3)) {
offset = 0
}
}
}
)
}
}Correct (haptic fires at the moment of commitment — when the decision is made):
@Equatable
struct DismissableCard: View {
@State private var offset: CGFloat = 0
@State private var isDismissed = false
@State private var didSnap = false
var body: some View {
RoundedRectangle(cornerRadius: Radius.md)
.fill(.blue.gradient)
.frame(height: 200)
.offset(y: offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation.height
}
.onEnded { value in
if value.translation.height > 200 {
withAnimation(.spring) {
isDismissed = true
}
didSnap.toggle()
} else {
withAnimation(.spring(bounce: 0.3)) {
offset = 0
}
didSnap.toggle()
}
}
)
// Haptic fires when didSnap changes — at the moment the gesture
// decides to dismiss or snap back, not after the spring settles
.sensoryFeedback(.impact(weight: .heavy), trigger: didSnap)
}
}Correct (different haptic types for different interactions):
@Equatable
struct InteractiveListRow: View {
@State private var isComplete = false
@State private var deleteConfirmed = false
var body: some View {
HStack {
Button {
withAnimation(.snappy) {
isComplete.toggle()
}
} label: {
Image(systemName: isComplete ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isComplete ? .green : .secondary)
}
// .success for completing a task — two taps, feels rewarding
.sensoryFeedback(.success, trigger: isComplete)
Text("Buy groceries")
.strikethrough(isComplete)
Spacer()
Button {
withAnimation(.snappy) {
deleteConfirmed = true
}
} label: {
Image(systemName: "trash")
.foregroundStyle(.red)
}
// .warning for destructive action — sharp buzz, feels consequential
.sensoryFeedback(.warning, trigger: deleteConfirmed)
}
.padding()
}
}Haptic type guide:
| Haptic Type | When to Use | Feel |
|---|---|---|
.impact(weight: .light) | Button taps, small toggles | Subtle tick |
.impact(weight: .medium) | Favorites, selections | Solid tap |
.impact(weight: .heavy) | Card drops, snaps into place | Thud |
.selection | Scrolling through picker values | Soft detent |
.success | Task completion, save confirmed | Double-tap, rewarding |
.warning | Delete confirmation, destructive action | Sharp buzz |
.error | Validation failure, blocked action | Triple-buzz, alarming |
Key principle: Use .sensoryFeedback(_:trigger:) (iOS 17+) instead of manually creating UIImpactFeedbackGenerator. The SwiftUI modifier fires the haptic when the trigger value changes, which is inherently synchronized with the state change driving the visual animation. Manual generators require you to time the call yourself, which almost always drifts.
Reference: Apple Human Interface Guidelines — "Playing haptics" section emphasizes that haptic timing must align with visual feedback. WWDC 2023 introduced .sensoryFeedback specifically to solve the synchronization problem.
Stagger Reveals at 30-50ms Intervals
When multiple items appear at once (list items, grid cells, menu options), stagger them at 30-50ms intervals. Simultaneous appearance of many elements overwhelms — the brain cannot parse 10 items appearing in one frame. Staggering creates a cascade that guides the eye from first to last, building a sense of orchestrated motion. More than 50ms between items feels laggy and draws too much attention to the stagger itself. Less than 20ms is imperceptible — effectively simultaneous.
Incorrect (all items appear simultaneously — feels like a flash):
struct NotificationListView: View {
@State private var notifications: [NotificationItem] = []
@State private var isVisible = false
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(Array(notifications.enumerated()), id: \.element.id) { index, item in
NotificationRow(item: item)
.opacity(isVisible ? 1 : 0)
.offset(y: isVisible ? 0 : 20)
}
}
.padding()
}
.onAppear {
// All items animate at the same time — no visual flow
withAnimation(.smooth(duration: 0.3)) {
isVisible = true
}
}
}
}
struct NotificationRow: View {
let item: NotificationItem
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(.blue)
.frame(width: 40, height: 40)
VStack(alignment: .leading) {
Text(item.title).font(.subheadline.weight(.semibold))
Text(item.subtitle).font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}Correct (items staggered at 40ms intervals — feels orchestrated):
@Equatable
struct NotificationListView: View {
@State private var notifications: [NotificationItem] = []
@State private var visibleItems: Set<UUID> = []
var body: some View {
ScrollView {
LazyVStack(spacing: Spacing.sm) {
ForEach(Array(notifications.enumerated()), id: \.element.id) { index, item in
NotificationRow(item: item)
.opacity(visibleItems.contains(item.id) ? 1 : 0)
.offset(y: visibleItems.contains(item.id) ? 0 : 20)
.animation(
.smooth(duration: 0.3)
// 40ms stagger per item — sweet spot for readability
.delay(Double(index) * 0.04),
value: visibleItems.contains(item.id)
)
}
}
.padding()
}
.onAppear {
for item in notifications {
visibleItems.insert(item.id)
}
}
}
}Incorrect (stagger interval too large — feels laggy and distracting):
struct MenuOptionsView: View {
let options = ["Profile", "Settings", "Help", "Sign Out"]
@State private var isVisible = false
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(options.enumerated()), id: \.element) { index, option in
Button(option) { }
.padding(.horizontal, 16)
.padding(.vertical, 12)
.opacity(isVisible ? 1 : 0)
.offset(x: isVisible ? 0 : -20)
.animation(
.smooth(duration: 0.25)
// 120ms between items — user notices the delay between each
.delay(Double(index) * 0.12),
value: isVisible
)
}
}
.onAppear {
isVisible = true
}
}
}Correct (stagger interval at 35ms — feels choreographed, not delayed):
@Equatable
struct MenuOptionsView: View {
let options = ["Profile", "Settings", "Help", "Sign Out"]
@State private var isVisible = false
var body: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
ForEach(Array(options.enumerated()), id: \.element) { index, option in
Button(option) { }
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.opacity(isVisible ? 1 : 0)
.offset(x: isVisible ? 0 : -20)
.animation(
.smooth(duration: 0.25)
// 35ms stagger — cascade is visible but doesn't slow the reveal
.delay(Double(index) * 0.035),
value: isVisible
)
}
}
.onAppear {
isVisible = true
}
}
}Correct (grid stagger with capped total duration):
@Equatable
struct PhotoGridView: View {
let photos: [Photo]
@State private var visiblePhotos: Set<UUID> = []
private let columns = [GridItem(.adaptive(minimum: 100), spacing: Spacing.sm)]
// Cap stagger at 8 items to keep total cascade under 300ms
private let maxStaggerCount = 8
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: Spacing.sm) {
ForEach(Array(photos.enumerated()), id: \.element.id) { index, photo in
let staggerIndex = min(index, maxStaggerCount)
RoundedRectangle(cornerRadius: Radius.sm)
.fill(.gray.opacity(0.3))
.aspectRatio(1, contentMode: .fit)
.overlay {
Image(photo.name)
.resizable()
.aspectRatio(contentMode: .fill)
}
.clipShape(RoundedRectangle(cornerRadius: Radius.sm))
.scaleEffect(visiblePhotos.contains(photo.id) ? 1 : 0.8)
.opacity(visiblePhotos.contains(photo.id) ? 1 : 0)
.animation(
.spring(duration: 0.3, bounce: 0.1)
// 40ms stagger, capped at 8 items (320ms total)
.delay(Double(staggerIndex) * 0.04),
value: visiblePhotos.contains(photo.id)
)
}
}
.padding()
}
.onAppear {
for photo in photos {
visiblePhotos.insert(photo.id)
}
}
}
}Stagger interval guide:
| Item Count | Interval | Total Cascade | Notes |
|---|---|---|---|
| 3-4 items | 40-50ms | 120-200ms | Full stagger, all clearly visible |
| 5-8 items | 35-40ms | 175-320ms | Tight cascade, under 300ms limit |
| 9-12 items | 30-35ms | 270-420ms | Start capping at 8 |
| 13+ items | 30ms, cap at 8 | 240ms max | Items beyond cap appear simultaneously |
Critical constraint: keep total stagger duration under 300ms. A 20-item list at 40ms per item would take 800ms — the last items appear almost a full second after the first, which feels broken. Cap the stagger at 8 items (320ms at 40ms interval). Items beyond the cap animate simultaneously with the 8th item.
Reference: Material Design recommends 20-40ms stagger intervals. Apple's iOS Home Screen icon rearrangement uses a similar cascading pattern with ~30ms intervals. The 300ms total cap aligns with the general principle that UI animations should not exceed 300ms for non-cinematic interactions.
Make All Gesture Animations Interruptible
When a user lifts their finger and an animation begins settling to a target, they must be able to grab the element again and redirect it. This is what separates native iOS feel from web-style transitions. Easing curves like .easeInOut cannot be interrupted — they have a fixed duration and velocity profile, so grabbing the element mid-flight causes it to jump or stutter. Springs, by contrast, are stateful: they track current position and velocity at all times, so a new gesture can take over smoothly from wherever the spring currently is.
This is the single most important reason Apple made springs the default animation type in iOS 17. Every gesture completion animation should use a spring.
Incorrect (easing curve locks the animation — grabbing mid-flight stutters):
struct DraggablePanel: View {
@State private var offset: CGFloat = 0
@State private var lastOffset: CGFloat = 0
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(.blue.gradient)
.frame(width: 300, height: 200)
.offset(y: offset)
.gesture(
DragGesture()
.onChanged { value in
offset = lastOffset + value.translation.height
}
.onEnded { _ in
// easeInOut: runs for exactly 0.35s with a fixed
// velocity curve. If the user taps again at 0.2s,
// the animation restarts from zero velocity — visible jank
withAnimation(.easeInOut(duration: 0.35)) {
offset = 0
lastOffset = 0
}
}
)
}
}Correct (spring preserves velocity — grabbing mid-flight feels continuous):
@Equatable
struct DraggablePanel: View {
@State private var offset: CGFloat = 0
@State private var lastOffset: CGFloat = 0
var body: some View {
RoundedRectangle(cornerRadius: Radius.md)
.fill(.tint.gradient)
.frame(width: 300, height: 200)
.offset(y: offset)
.gesture(
DragGesture()
.onChanged { value in
offset = lastOffset + value.translation.height
}
.onEnded { _ in
// .smooth spring: if the user grabs the panel while
// it is settling, the spring smoothly redirects
// to the new drag position with no velocity discontinuity
withAnimation(.smooth) {
offset = 0
lastOffset = 0
}
}
)
}
}`@GestureState` with spring reset makes this pattern automatic:
@Equatable
struct InterruptibleCard: View {
@GestureState private var dragOffset: CGFloat = 0
var body: some View {
RoundedRectangle(cornerRadius: Radius.md)
.fill(.tint.gradient)
.frame(width: 280, height: 160)
.offset(y: dragOffset)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, transaction in
state = value.translation.height
// The transaction's animation applies both during
// the drag AND on the auto-reset when the gesture ends
transaction.animation = .smooth
}
)
// When the finger lifts, @GestureState resets dragOffset to 0
// using the spring from the transaction — fully interruptible.
// If the user grabs again mid-settle, the spring redirects smoothly.
}
}Why easing curves break on interruption — the physics:
// Easing curve: position is a pure function of time.
// At t=0.2s of a 0.35s animation, the velocity is predetermined.
// Interrupting forces a restart from t=0, causing a velocity jump.
// Spring: position is a function of (target, currentVelocity, currentPosition).
// Changing the target mid-flight preserves the current velocity.
// No jump — the spring smoothly curves toward the new target.
// This is why .animation(.smooth) and withAnimation(.smooth) exist:
// they produce interruptible animations by default.Benefits:
- Users can grab, redirect, and release without any stutter or jump
- Rapid taps during settle feel natural — each tap smoothly redirects the spring
- No need to track or cancel in-flight animations manually
- Spring parameters (
.smooth,.snappy,.bouncy) control the settle feel without sacrificing interruptibility
Reference: WWDC 2023 — Animate with springs, WWDC 2018 — Designing Fluid Interfaces
Related skills
FAQ
What does ios-animations do?
ios-animations: A skill for development. This provides functionality for development workflows.
When should I use ios-animations?
When you need to use ios-animations for development tasks, or when ios-animations: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
ios-animations.