swiftui-motion
SwiftUI animation foundations - withAnimation, transitions, matchedGeometryEffect, PhaseAnimator, KeyframeAnimator, springs, gestures.
git clone --depth 1 https://github.com/AThevon/genjutsu /tmp/swiftui-motion && cp -r /tmp/swiftui-motion/skills/_jutsu/swiftui-motion ~/.claude/skills/swiftui-motionSKILL.md
# SwiftUI Motion
> SwiftUI animation core. Loaded for any SwiftUI project (iOS, macOS, multi-target Apple).
> Concise rules here. Deep-dive in `references/`.
> Pair with `../motion-principles/SKILL.md` (foundation) and `../mobile-principles/SKILL.md` (touch UX).
---
## Animation API decision tree
| Need | API |
|---|---|
| Single value over time | `withAnimation { } + @State` or `.animation(_, value:)` |
| Multiple coordinated states | `PhaseAnimator(phases)` (iOS 17+) |
| Time-based keyframes | `KeyframeAnimator(initialValue:repeating:content:)` (iOS 17+) |
| Custom property animations | `@Animatable` macro (iOS 26+) or `Animatable` protocol (iOS 13+) |
| Shared element transitions | `matchedGeometryEffect(id:in:)` |
| Gesture-driven | `DragGesture` / `MagnifyGesture` + `.offset` / `.scaleEffect` |
| Loop forever | `.animation(.linear.repeatForever(autoreverses: true), value: ...)` or `.phaseAnimator` |
**Rule:** start with `withAnimation`. Reach for `PhaseAnimator` only when you have 3+ ordered states. Reach for `KeyframeAnimator` only when you need parallel time-based tracks.
---
## Springs (the only easing you should care about)
SwiftUI ships 4 named springs (iOS 17+). Use them. Tune `response` / `dampingFraction` only when a preset is wrong.
| Preset (iOS 17+) | Equivalent | Mood |
|---|---|---|
| `.snappy` | `.spring(response: 0.5, dampingFraction: 0.85)` | UI snappy |
| `.bouncy` | `.spring(response: 0.5, dampingFraction: 0.7)` | playful |
| `.smooth` | `.spring(response: 0.5, dampingFraction: 1.0)` | calm, no bounce |
| `.interactiveSpring()` | `.spring(response: 0.15, dampingFraction: 0.86)` | gesture follow |
`response` is the time the spring takes to settle (lower = snappier, higher = softer). `dampingFraction` is the overshoot intensity in `0...1` (1 = no overshoot, 0 = perpetual oscillation - never use 0). For UI work, stay in `response: 0.2...0.5` and `dampingFraction: 0.7...1.0`. Deep-dive: `references/springs-cheatsheet.md`.
iOS 17+ also exposes `.spring(duration:bounce:)` where `bounce` is `0...1` (0 = critically damped, 1 = full bounce). It's the same spring, exposed in a more designer-friendly way:
```swift
.animation(.spring(duration: 0.4, bounce: 0.3), value: state)
```
---
## Implicit vs explicit animations
```swift
// Implicit - via .animation modifier (binds to a value)
Circle()
.scaleEffect(scale)
.animation(.spring(.snappy), value: scale)
```
```swift
// Explicit - via withAnimation block
Button("Grow") {
withAnimation(.smooth) { scale = 1.5 }
}
```
**Rule:** prefer explicit (`withAnimation`) for state changes triggered by user actions; use implicit when *any* change to a value should always animate (e.g., a progress bar that updates from anywhere). Never both on the same property - the outer `withAnimation` wins, but the implicit `.animation` modifier still runs and stacks confusingly.
---
## Transitions
Transitions drive insertion / removal of views inside an `if`, `switch`, or `ForEach`. They run when the parent's animation context fires (so wrap state mutations in `withAnimation`).
```swift
if visible {
Card().transition(.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .opacity.animation(.easeIn(duration: 0.15))
))
}
```
**BAD - vanish into a black hole:**
```swift
Card().transition(.scale) // scales to 0, the universal "broken" feel
```
**GOOD - never scale to 0:**
```swift
Card().transition(
.scale(scale: 0.95).combined(with: .opacity)
)
```
iOS 17+ also has the `.transition(_:)` modifier with custom transitions via the `Transition` protocol - useful for shared timing across many views. For 90% of work, the built-in combinators (`.move`, `.opacity`, `.scale`, `.slide`, `.push`, `.asymmetric`, `.combined(with:)`) are enough.
---
## matchedGeometryEffect (hero animations)
Tag two views with the same `id` in the same `Namespace`. SwiftUI interpolates frame and position when the source view is replaced.
```swift
struct Gallery: View {
@Namespace private var ns
@State private var expanded = false
var body: some View {
ZStack {
if expanded {
LargeCard()
.matchedGeometryEffect(id: "card", in: ns)
.onTapGesture { withAnimation(.spring(.smooth)) { expanded = false } }
} else {
SmallCard()
.matchedGeometryEffect(id: "card", in: ns)
.onTapGesture { withAnimation(.spring(.smooth)) { expanded = true } }
}
}
}
}
```
`isSource: true` (default on the source-of-truth view) tells SwiftUI which frame to interpolate from. Common gotchas: id collisions across unrelated namespaces, view identity instability (use stable ids, not array indices), and animating out of an `if` branch where the destination view doesn't exist yet (wrap both branches inside the same parent, use opacity to hide instead of removing).
---
## PhaseAnimator (iOS 17+)
For ordered state choreography. Define a `CaseIterable + Hashable` enum, SwiftUI walks through phases sequentially, settling on the last one.
```swift
enum SuccessPhase: CaseIterable { case start, scaleUp, rotate, settle }
struct SuccessCheck: View {
@State private var trigger = false
var body: some View {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.green)
.phaseAnimator(SuccessPhase.allCases, trigger: trigger) { view, phase in
view
.scaleEffect(phase == .start ? 0 : phase == .settle ? 1 : 1.2)
.rotationEffect(.degrees(phase == .rotate ? 360 : 0))
.opacity(phase == .start ? 0 : 1)
} animation: { phase in
switch phase {
case .start: .smooth(duration: 0.05)
case .scaleUp: .spring(.bouncy, blendDuration: 0.25)
case .rotateAlgorithmic and generative art with Canvas 2D - particles, flow fields, noise, fractals, L-systems.
Advanced Compose visuals - Material 3 Expressive motion physics, AGSL shaders (Android 13+), Canvas/DrawScope generative, graphicsLayer effects.
Jetpack Compose animation foundations - animate*AsState, AnimatedVisibility, Crossfade, updateTransition, SharedTransitionLayout, gestures.
Compose Multiplatform / KMP patterns - expect/actual composables, platform-specific code, density and font handling cross-target, iOS/Android/Desktop interop.
Zero-dependency animations and visual techniques - scroll-driven, View Transitions, @starting-style, modern CSS.
Design audit checklist - motion gaps, accessibility, color consistency, responsive, performance.
Desktop-specific UX principles - hover states, pointer precision, keyboard shortcuts, multi-window, focus management. Covers macOS, Windows, Linux, web desktop.
Framer Motion / Motion sub-skill - AnimatePresence, layout animations, gestures, motion values.