swiftui-motion — Claude Code skill: what it does, how to install
Provides SwiftUI animation rules and decision trees for coding agents. Helps configure springs, phase animators, and clean transitions without visual bugs.
npx skills add AThevon/genjutsu --skill swiftui-motion -a claude-codeWhat swiftui-motion does
The skill provides a strict decision tree for SwiftUI motion APIs. It guides the agent when to use simple withAnimation, when to reach for PhaseAnimator or KeyframeAnimator on iOS 17+, and how to build hero transitions via matchedGeometryEffect.
It defines exact tuning for spring presets like .snappy, .bouncy, and .smooth, advising against setting damping to zero. Mixing explicit withAnimation and implicit .animation on the same property is strictly forbidden because it causes modifier stacking conflicts. Note that compiling custom properties with the @Animatable macro requires Xcode 26.
What it helps with
- select the right spring preset instead of guessing damping values
- build shared element transitions using
matchedGeometryEffect - sequence multi-step state changes with
PhaseAnimator - prevent views from collapsing to zero scale during removal transitions
Example prompts for the agent
- Build a card expand animation using matchedGeometryEffect and a smooth spring
- Animate this checkmark through multiple states with PhaseAnimator
- Add an asymmetric transition to this modal without scaling to zero
How to install swiftui-motion
1Skills CLI (Vercel)
npx skills add AThevon/genjutsu --skill swiftui-motion -a claude-code2Manually: put the files in place
~/.claude/skills/swiftui-motion/SKILL.mdCopy the whole skill folder; for one project use .claude/skills/swiftui-motion/
1Skills CLI (Vercel)
npx skills add AThevon/genjutsu --skill swiftui-motion -a codex2Manually: put the files in place
.agents/skills/swiftui-motion/SKILL.mdFor all projects use ~/.agents/skills/
1Skills CLI (Vercel)
npx skills add AThevon/genjutsu --skill swiftui-motion -a cursor2Manually: put the files in place
.cursor/skills/swiftui-motion/SKILL.mdCursor also reads .agents/skills/ and .claude/skills/
1GitHub CLI
gh skill install AThevon/genjutsu swiftui-motion2Manually: put the files in place
.github/skills/swiftui-motion/SKILL.md1Skills CLI (Vercel)
npx skills add AThevon/genjutsu --skill swiftui-motion -a gemini-cli2Platform CLI
gemini skills install https://github.com/AThevon/genjutsu --path skills/_jutsu/swiftui-motionManually: put the files in place
.devin/skills/swiftui-motion/SKILL.mdThe legacy .windsurf/skills/ path still works
Skills CLI (Vercel)
npx skills add AThevon/genjutsu --skill swiftui-motion -a opencodeClaude.ai
Zip the folder and upload it in Settings → Capabilities → Skills
Step by step, with commands for each platform: How to install skills in Claude Code: five ways
Questions about swiftui-motion
How to install swiftui-motion in Claude Code?
Run npx skills add AThevon/genjutsu --skill swiftui-motion -a claude-code in your terminal.
What does swiftui-motion do?
It equips your AI assistant with guidelines for SwiftUI animations, covering spring physics, phase animators, and explicit versus implicit state transitions.
Can withAnimation and .animation be used on the same property?
No, explicit withAnimation wins while the implicit modifier still fires, causing modifier stacking conflicts that produce unexpected motion bugs.
Source text
SKILL.md · MIT
---
name: swiftui-motion
description: "SwiftUI animation foundations - withAnimation, transitions, matchedGeometryEffect, PhaseAnimator, KeyframeAnimator, springs, gestures."
metadata:
internal: true
---
> **Version-sensitive.** Every API name, SDK gate and browser-support claim below was
> verified on **2026-09-08** against primary sources. What against, and when, is in
> `_jutsu/VERSIONS.md`. If that date is old, re-verify before acting on a version number.
# 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 (needs Xcode 26 / SwiftUI 2025 SDK to compile; **runtime availability iOS 13.0+ / macOS 10.15+ / watchOS 6.0+**) or the `Animatable` protocol by hand (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 named spring presets (iOS 17+): `.snappy`, `.bouncy`, `.smooth`, plus the parameterised
`.spring(duration:bounce:blendDuration:)` and `.interactiveSpring(...)`. Each of the three named ones
also takes `(duration:extraBounce:)` overloads. 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 spring's **period** - its stiffness expressed as a duration, i.e. how long one
oscillation would take. It is **not** the settling time: a `.spring(response: 0.5, dampingFraction: 1.0)`
keeps moving well past 0.5s. 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. The API takes `some Sequence` of a phase type constrained to `Equatable` (not `Hashable`); `CaseIterable` is only a convenience so you can pass `Phase.allCases`. SwiftUI walks the sequence in order and settles on the last element.
```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 .rotate: .spring(response: 0.4, dampingFraction: 0.8)
case .settle: .smooth(duration: 0.2)
}
}
.onTapGesture { trigger.toggle() }
}
}
```
`trigger:` is optional - omit it to advance through phases automatically once on appear. Use it when you need an external signal (button tap, model update). Phases run sequentially, never in parallel - if you need parallelism, use `KeyframeAnimator`.
---
## KeyframeAnimator (iOS 17+)
For continuous, time-based animations with parallel tracks. Each `KeyframeTrack` animates one keypath independently; SwiftUI runs them all together.
```swift
struct AnimationValues {
var scale: Double = 1
var rotation: Angle = .zero
var opacity: Double = 1
}
struct HeartTap: View {
@State private var counter = 0
var body: some View {
Image(systemName: "heart.fill")
.font(.system(size: 64))
.foregroundStyle(.pink)
.keyframeAnimator(initialValue: AnimationValues(), trigger: counter) { content, values in
content
.scaleEffect(values.scale)
.rotationEffect(values.rotation)
.opacity(values.opacity)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.3, duration: 0.15)
SpringKeyframe(1.0, duration: 0.3, spring: .bouncy)
}
KeyframeTrack(\.rotation) {
CubicKeyframe(.degrees(15), duration: 0.1)
CubicKeyframe(.degrees(-15), duration: 0.2)
CubicKeyframe(.degrees(0), duration: 0.15)
}
}
.onTapGesture { counter += 1 }
}
}
```
Four keyframe types: `LinearKeyframe` (constant velocity between points), `SpringKeyframe` (settles with spring), `CubicKeyframe` (cubic bezier ease), `MoveKeyframe` (jump cut, no interpolation). Trigger on a value change to re-run the animation. Deep-dive: `references/phase-keyframe-deep.md`.
---
## Animatable / @Animatable
For custom drawing that needs interpolation. The `@Animatable` macro (introduced with the iOS 26 SDK at WWDC25, so it needs Xcode 26+; its declared availability is iOS 13.0+ / macOS 10.15+ / watchOS 6.0+, so it back-deploys) attaches to a struct/class/enum and synthesizes both the `Animatable` conformance and `animatableData` from the stored properties that are themselves animatable (`Double`, `CGFloat`, `Angle`, `CGSize`, `CGPoint`, `UnitPoint`, any `VectorArithmetic`). Mark a stored property `@AnimatableIgnored` (same availability) to keep it out of the synthesized data. Writing `animatableData` by hand still works and is what you do on an older toolchain.
```swift
struct ProgressRing: Shape {
var progress: Double // 0...1
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func path(in rect: CGRect) -> Path {
var p = Path()
p.addArc(
center: CGPoint(x: rect.midX, y: rect.midY),
radius: rect.width / 2,
startAngle: .degrees(-90),
endAngle: .degrees(-90 + 360 * progress),
clockwise: false
)
return p
}
}
ProgressRing(progress: progress)
.stroke(.tint, lineWidth: 6)
.animation(.spring(.smooth), value: progress)
```
For multi-property shapes use `AnimatablePair<A, B>` (or nested pairs) as `animatableData`. The `@Animatable` macro removes that boilerplate: annotate the shape and every stored property conforming to `VectorArithmetic` (`Double`, `CGFloat`, `Angle`, `CGSize`, `CGPoint`, `UnitPoint`, ...) is folded into the synthesized `animatableData` automatically; opt a property out with `@AnimatableIgnored`.
---
## Gestures
| Gesture | Type | Use |
|---|---|---|
| `TapGesture` | discrete | tap, double-tap (`count: 2`) |
| `LongPressGesture` | discrete + continuous (`onEnded`/`onChanged`) | context menus, hold-to-record |
| `DragGesture` | continuous | drag, swipe-to-dismiss |
| `MagnifyGesture` | continuous | pinch-zoom (iOS 17+, replaces `MagnificationGesture`) |
| `RotateGesture` | continuous | rotate (iOS 17+, replaces `RotationGesture`) |
| `SpatialTapGesture` (iOS 16+, macOS 13+) | discrete | tap with location info |
```swift
let tap = TapGesture().onEnded { print("tap") }
let drag = DragGesture().onChanged { value in offset = value.translation }
ZStack { ... }
.gesture(tap.simultaneously(with: drag))
```
Three composition operators: `.simultaneously(with:)` (parallel recognition), `.sequenced(before:)` (one must complete first), `.exclusively(before:)` (one or the other, not both). For fine-grained control over when child views can claim the gesture, use `.simultaneousGesture(_, including: GestureMask)` with `.gesture`, `.subviews`, `.all`, or `.none`. Deep-dive: `references/gestures-swiftui.md`.
---
## Anti-Patterns (BAD / GOOD)
### 1. Deprecated `.animation` form (no value binding)
```swift
// BAD - implicit-anim-everywhere, deprecated in iOS 15+
Circle().scaleEffect(scale).animation(.easeInOut)
```
```swift
// GOOD - bind to a specific value
Circle().scaleEffect(scale).animation(.easeInOut, value: scale)
// OR
withAnimation(.easeInOut) { scale = 1.5 }
```
### 2. Animating frame size directly
```swift
// BAD - .frame() drives layout pass every frame, drops fps under load
Card().frame(height: expanded ? 400 : 100)
.animation(.spring(), value: expanded)
```
```swift
// GOOD - animate transform-equivalents (scale, offset) that the compositor handles
Card()
.frame(height: 400)
.scaleEffect(expanded ? 1 : 0.4, anchor: .top)
.animation(.spring(), value: expanded)
// OR for actual layout transitions, use matchedGeometryEffect
```
### 3. Scale to 0 (the vanish-into-nothing trap)
```swift
// BAD - element vanishes into a black hole, feels broken
Card().transition(.scale)
```
```swift
// GOOD - minimum scale 0.9-0.95 + opacity
Card().transition(.scale(scale: 0.95).combined(with: .opacity))
```
### 4. `withAnimation` inside `body`
```swift
// BAD - body runs on every render, animation re-fires arbitrarily
var body: some View {
let _ = withAnimation(.spring()) { scale = 1.2 } // never do this
Circle().scaleEffect(scale)
}
```
```swift
// GOOD - trigger from user actions or .onChange
var body: some View {
Circle()
.scaleEffect(scale)
.onTapGesture {
withAnimation(.spring()) { scale = scale == 1 ? 1.2 : 1 }
}
}
```
---
## Reduced motion respect
Mandatory. SwiftUI exposes the iOS / macOS "Reduce Motion" accessibility setting via the environment. See `../motion-principles/SKILL.md` for the cross-platform rationale.
```swift
struct Hero: View {
@Environment(\.accessibilityReduceMotion) var reduceMotion
@State private var shown = false
var body: some View {
Text("Welcome")
.opacity(shown ? 1 : 0)
.offset(y: shown ? 0 : (reduceMotion ? 0 : 20))
.animation(reduceMotion ? .none : .spring(.smooth), value: shown)
.onAppear { shown = true }
}
}
```
Rule: cross-fades and opacity are still allowed under reduced motion; large translations, scale-from-zero, parallax, and looping motion must be neutralized.
---
## Quick Reference: Loading sub-skills
| Need | Load |
|---|---|
| Springs deep-dive (response/dampingFraction tuning, mood) | `references/springs-cheatsheet.md` |
| PhaseAnimator + KeyframeAnimator complex sequences | `references/phase-keyframe-deep.md` |
| Gesture composition + conflict resolution | `references/gestures-swiftui.md` |
| Advanced visuals (Metal, Liquid Glass) | `../swiftui-graphics/SKILL.md` |
| Cross-platform UX (mobile, desktop) | `../mobile-principles/SKILL.md`, `../desktop-principles/SKILL.md` |
| Foundation (timing, easing, a11y) | `../motion-principles/SKILL.md` |
---
## Sources
- [twostraws/SwiftUI-Agent-Skill](https://github.com/twostraws/SwiftUI-Agent-Skill)
- [GetStream/swiftui-spring-animations](https://github.com/GetStream/swiftui-spring-animations)
- [amosgyamfi/open-swiftui-animations](https://github.com/amosgyamfi/open-swiftui-animations)
- [AvdLee/SwiftUI-Agent-Skill](https://github.com/AvdLee/SwiftUI-Agent-Skill)
- [Apple SwiftUI Animations](https://developer.apple.com/documentation/swiftui/animations)
- [Apple PhaseAnimator](https://developer.apple.com/documentation/swiftui/phaseanimator)
- [Apple KeyframeAnimator](https://developer.apple.com/documentation/swiftui/keyframeanimator)
- [WWDC23 Animate with springs](https://developer.apple.com/videos/play/wwdc2023/10158/)
- [WWDC23 Wind your way through advanced animations](https://developer.apple.com/videos/play/wwdc2023/10157/)
© AThevon · MIT · AThevon/genjutsu