SwiftUI animations, @Animatable macro, withAnimation, transitions, PhaseAnimator, KeyframeAnimator, and interactive motion design. Use when user asks about animations, transitions, @Animatable, withAnimation, spring animations, keyframes, or motion design.
Generates SwiftUI animations using @Animatable, PhaseAnimator, KeyframeAnimator, and transitions.
/plugin marketplace add bluewaves-creations/bluewaves-skills/plugin install swift-apple-dev@bluewaves-skillsThis skill is limited to using the following tools:
Comprehensive guide to SwiftUI animations, the new @Animatable macro (iOS 26), transitions, and motion design best practices for modern iOS development.
iOS 26 introduces the @Animatable macro, eliminating the tedious boilerplate previously required for animating custom shapes and views.
// OLD WAY - Lots of boilerplate
struct PieSlice: Shape {
var startAngle: Angle
var endAngle: Angle
// Manual animatableData implementation required
var animatableData: AnimatablePair<Double, Double> {
get {
AnimatablePair(startAngle.radians, endAngle.radians)
}
set {
startAngle = Angle(radians: newValue.first)
endAngle = Angle(radians: newValue.second)
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2
path.move(to: center)
path.addArc(center: center, radius: radius,
startAngle: startAngle, endAngle: endAngle,
clockwise: false)
path.closeSubpath()
return path
}
}
// NEW WAY - Just add @Animatable
@Animatable
struct PieSlice: Shape {
var startAngle: Angle
var endAngle: Angle
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2
path.move(to: center)
path.addArc(center: center, radius: radius,
startAngle: startAngle, endAngle: endAngle,
clockwise: false)
path.closeSubpath()
return path
}
}
Exclude properties from animation:
@Animatable
struct CustomShape: Shape {
var animatedValue: CGFloat
@AnimatableIgnored var staticConfiguration: Bool // Not animated
func path(in rect: CGRect) -> Path {
// Use both values, but only animatedValue will animate
}
}
The @Animatable macro automatically handles:
CGFloat, Double, FloatAngleCGSize, CGPoint, CGRectUnitPointColor (component interpolation)VectorArithmetic@Animatable
struct MorphingShape: Shape {
var cornerRadius: CGFloat
var insetAmount: CGFloat
var rotation: Angle
@AnimatableIgnored var fillColor: Color
func path(in rect: CGRect) -> Path {
let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount)
var path = Path(roundedRect: insetRect, cornerRadius: cornerRadius)
let transform = CGAffineTransform(rotationAngle: rotation.radians)
return path.applying(transform)
}
}
// Usage
struct MorphingView: View {
@State private var isExpanded = false
var body: some View {
MorphingShape(
cornerRadius: isExpanded ? 50 : 10,
insetAmount: isExpanded ? 20 : 50,
rotation: isExpanded ? .degrees(45) : .zero,
fillColor: .blue
)
.fill(.blue)
.frame(width: 200, height: 200)
.onTapGesture {
withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
isExpanded.toggle()
}
}
}
}
struct ContentView: View {
@State private var isExpanded = false
var body: some View {
VStack {
Rectangle()
.frame(width: isExpanded ? 200 : 100,
height: isExpanded ? 200 : 100)
Button("Toggle") {
withAnimation {
isExpanded.toggle()
}
}
}
}
}
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
isExpanded.toggle()
}
withAnimation(.easeInOut(duration: 0.3)) {
opacity = 1.0
}
withAnimation(.linear(duration: 1.0)) {
progress = 1.0
}
withAnimation(.easeInOut(duration: 0.5)) {
showDetails = true
} completion: {
// Called when animation completes
print("Animation finished")
fetchMoreData()
}
Button("Animate") {
withAnimation(.spring(duration: 0.4)) {
isExpanded = true
}
withAnimation(.easeOut(duration: 0.6).delay(0.2)) {
opacity = 1.0
}
}
// Linear - constant speed
.linear(duration: 0.3)
// Ease variants - acceleration/deceleration
.easeIn(duration: 0.3) // Slow start
.easeOut(duration: 0.3) // Slow end
.easeInOut(duration: 0.3) // Slow both
// Default (ease in/out)
.default
// Modern spring with duration and bounce
.spring(duration: 0.5, bounce: 0.3)
// Bounce values:
// 0.0 = no bounce (critically damped)
// 0.5 = medium bounce
// 1.0 = maximum bounce (never settles)
// Extra bounce parameter for overshoot
.spring(duration: 0.5, bounce: 0.4, blendDuration: 0.2)
// Bouncy - playful, energetic
.bouncy
.bouncy(duration: 0.4, extraBounce: 0.1)
// Snappy - quick, responsive
.snappy
.snappy(duration: 0.3, extraBounce: 0.05)
// Smooth - gentle, elegant
.smooth
.smooth(duration: 0.5, extraBounce: 0.0)
// For gesture-driven animations
.interactiveSpring()
.interactiveSpring(response: 0.3, dampingFraction: 0.7)
// Best for drag gestures
.interactiveSpring(response: 0.15, dampingFraction: 0.86, blendDuration: 0.25)
// Bezier curve timing
.timingCurve(0.2, 0.8, 0.2, 1.0, duration: 0.5)
// Parameters: (x1, y1, x2, y2)
// Start: (0, 0), End: (1, 1)
// Control points define the curve shape
// Delay before starting
.spring().delay(0.2)
// Speed multiplier
.spring().speed(2.0) // 2x faster
// Repeat
.linear(duration: 1.0).repeatCount(3)
.linear(duration: 1.0).repeatForever()
// Autoreverse
.easeInOut(duration: 0.5).repeatForever(autoreverses: true)
struct ContentView: View {
@State private var scale = 1.0
var body: some View {
Circle()
.scaleEffect(scale)
// Explicit: animate only when scale changes
.animation(.spring, value: scale)
.onTapGesture {
scale = scale == 1.0 ? 1.5 : 1.0
}
}
}
// AVOID: Implicit animation (animates everything)
Circle()
.animation(.spring) // Deprecated warning in newer iOS
// PREFER: Explicit animation (precise control)
Circle()
.animation(.spring, value: specificValue)
struct MultiAnimatedView: View {
@State private var scale = 1.0
@State private var opacity = 1.0
@State private var rotation = 0.0
var body: some View {
Rectangle()
.scaleEffect(scale)
.opacity(opacity)
.rotationEffect(.degrees(rotation))
// Different animations for different properties
.animation(.bouncy, value: scale)
.animation(.easeOut(duration: 0.2), value: opacity)
.animation(.spring(duration: 1.0), value: rotation)
}
}
struct ContentView: View {
@State private var showDetail = false
var body: some View {
VStack {
if showDetail {
DetailView()
.transition(.slide)
}
Button("Toggle") {
withAnimation {
showDetail.toggle()
}
}
}
}
}
.transition(.opacity) // Fade in/out
.transition(.scale) // Scale from center
.transition(.scale(scale: 0.5)) // Scale from 50%
.transition(.slide) // Slide from leading edge
.transition(.move(edge: .top)) // Move from specific edge
.transition(.push(from: .bottom)) // Push with replacement
.transition(.offset(x: 100, y: 0)) // Custom offset
// Combine multiple transitions
.transition(.scale.combined(with: .opacity))
// Chain combinations
.transition(
.scale(scale: 0.8)
.combined(with: .opacity)
.combined(with: .offset(y: 20))
)
// Different transitions for insert vs removal
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .slide
))
// Common pattern: slide in from one side, out the other
.transition(.asymmetric(
insertion: .push(from: .trailing),
removal: .push(from: .leading)
))
extension AnyTransition {
static var flipFromBottom: AnyTransition {
.modifier(
active: FlipModifier(angle: -90),
identity: FlipModifier(angle: 0)
)
}
}
struct FlipModifier: ViewModifier {
let angle: Double
func body(content: Content) -> some View {
content
.rotation3DEffect(
.degrees(angle),
axis: (x: 1, y: 0, z: 0)
)
.opacity(angle == 0 ? 1 : 0)
}
}
// Usage
DetailView()
.transition(.flipFromBottom)
enum AnimationPhase: CaseIterable {
case initial
case middle
case final
var scale: CGFloat {
switch self {
case .initial: return 1.0
case .middle: return 1.2
case .final: return 1.0
}
}
var opacity: Double {
switch self {
case .initial: return 1.0
case .middle: return 0.5
case .final: return 1.0
}
}
}
struct PulsingView: View {
var body: some View {
PhaseAnimator(AnimationPhase.allCases) { phase in
Circle()
.fill(.blue)
.scaleEffect(phase.scale)
.opacity(phase.opacity)
}
}
}
struct TriggerableAnimation: View {
@State private var trigger = false
var body: some View {
VStack {
PhaseAnimator(
AnimationPhase.allCases,
trigger: trigger
) { phase in
Star()
.scaleEffect(phase.scale)
.rotationEffect(.degrees(phase.rotation))
}
Button("Animate") {
trigger.toggle()
}
}
}
}
PhaseAnimator(AnimationPhase.allCases) { phase in
ContentView(phase: phase)
} animation: { phase in
switch phase {
case .initial: .spring(duration: 0.3)
case .middle: .easeOut(duration: 0.2)
case .final: .bouncy
}
}
struct AnimationValues {
var scale = 1.0
var rotation = 0.0
var yOffset = 0.0
}
struct BouncingView: View {
@State private var trigger = false
var body: some View {
Circle()
.fill(.blue)
.frame(width: 100, height: 100)
.keyframeAnimator(
initialValue: AnimationValues(),
trigger: trigger
) { content, value in
content
.scaleEffect(value.scale)
.rotationEffect(.degrees(value.rotation))
.offset(y: value.yOffset)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.2, duration: 0.2)
SpringKeyframe(0.9, duration: 0.15)
SpringKeyframe(1.0, duration: 0.15)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(0, duration: 0.1)
SpringKeyframe(10, duration: 0.15)
SpringKeyframe(-10, duration: 0.15)
SpringKeyframe(0, duration: 0.1)
}
KeyframeTrack(\.yOffset) {
SpringKeyframe(-30, duration: 0.2)
SpringKeyframe(0, duration: 0.3)
}
}
.onTapGesture {
trigger.toggle()
}
}
}
KeyframeTrack(\.value) {
// Linear interpolation
LinearKeyframe(targetValue, duration: 0.3)
// Spring-based interpolation
SpringKeyframe(targetValue, duration: 0.3)
SpringKeyframe(targetValue, duration: 0.3, spring: .bouncy)
// Cubic bezier interpolation
CubicKeyframe(targetValue, duration: 0.3)
// Move without animation
MoveKeyframe(targetValue)
}
struct ComplexAnimationValues {
var xOffset = 0.0
var yOffset = 0.0
var scale = 1.0
var opacity = 1.0
var blur = 0.0
}
struct ComplexAnimation: View {
@State private var animating = false
var body: some View {
Image(systemName: "star.fill")
.font(.system(size: 50))
.keyframeAnimator(
initialValue: ComplexAnimationValues(),
repeating: animating
) { content, value in
content
.offset(x: value.xOffset, y: value.yOffset)
.scaleEffect(value.scale)
.opacity(value.opacity)
.blur(radius: value.blur)
} keyframes: { _ in
KeyframeTrack(\.xOffset) {
LinearKeyframe(0, duration: 0.25)
LinearKeyframe(100, duration: 0.5)
LinearKeyframe(100, duration: 0.25)
LinearKeyframe(0, duration: 0.5)
}
KeyframeTrack(\.yOffset) {
SpringKeyframe(-50, duration: 0.5)
SpringKeyframe(0, duration: 0.5)
}
KeyframeTrack(\.scale) {
SpringKeyframe(1.5, duration: 0.25)
SpringKeyframe(1.0, duration: 0.25)
SpringKeyframe(1.2, duration: 0.25)
SpringKeyframe(1.0, duration: 0.25)
}
}
.onAppear {
animating = true
}
}
}
struct HeroTransition: View {
@Namespace private var animation
@State private var isExpanded = false
var body: some View {
VStack {
if isExpanded {
// Expanded state
RoundedRectangle(cornerRadius: 20)
.fill(.blue)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 300, height: 400)
} else {
// Collapsed state
RoundedRectangle(cornerRadius: 10)
.fill(.blue)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 100, height: 100)
}
}
.onTapGesture {
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
isExpanded.toggle()
}
}
}
}
struct TabBar: View {
@Namespace private var animation
@State private var selectedTab = 0
let tabs = ["Home", "Search", "Profile"]
var body: some View {
HStack {
ForEach(Array(tabs.enumerated()), id: \.offset) { index, title in
Button(title) {
withAnimation(.spring(duration: 0.3)) {
selectedTab = index
}
}
.padding()
.background {
if selectedTab == index {
Capsule()
.fill(.blue.opacity(0.2))
.matchedGeometryEffect(id: "background", in: animation)
}
}
}
}
}
}
.matchedGeometryEffect(
id: "identifier",
in: namespace,
properties: .frame, // What to match: .frame, .position, .size
anchor: .center, // Anchor point for matching
isSource: true // Whether this is the source of truth
)
struct CounterView: View {
@State private var count = 0
var body: some View {
Text("\(count)")
.font(.largeTitle)
.contentTransition(.numericText())
.onTapGesture {
withAnimation {
count += 1
}
}
}
}
// Numeric text morphing
.contentTransition(.numericText())
.contentTransition(.numericText(value: count))
.contentTransition(.numericText(countsDown: true))
// Interpolate between text
.contentTransition(.interpolate)
// Identity (no transition)
.contentTransition(.identity)
// Opacity crossfade
.contentTransition(.opacity)
// Symbol effect
.contentTransition(.symbolEffect(.replace))
struct SymbolEffectsView: View {
@State private var isActive = false
var body: some View {
VStack(spacing: 30) {
// Bounce effect
Image(systemName: "bell.fill")
.symbolEffect(.bounce, value: isActive)
// Pulse effect
Image(systemName: "heart.fill")
.symbolEffect(.pulse)
// Variable color
Image(systemName: "wifi")
.symbolEffect(.variableColor.iterative)
// Scale effect
Image(systemName: "star.fill")
.symbolEffect(.scale.up, isActive: isActive)
// Replace effect
Image(systemName: isActive ? "checkmark.circle" : "circle")
.contentTransition(.symbolEffect(.replace))
Button("Toggle") {
withAnimation {
isActive.toggle()
}
}
}
.font(.largeTitle)
}
}
// Bounce variations
.symbolEffect(.bounce)
.symbolEffect(.bounce.up)
.symbolEffect(.bounce.down)
.symbolEffect(.bounce.byLayer)
.symbolEffect(.bounce.wholeSymbol)
// Variable color
.symbolEffect(.variableColor)
.symbolEffect(.variableColor.iterative)
.symbolEffect(.variableColor.reversing)
.symbolEffect(.variableColor.cumulative)
// Scale
.symbolEffect(.scale.up)
.symbolEffect(.scale.down)
// Pulse
.symbolEffect(.pulse)
.symbolEffect(.pulse.byLayer)
struct DraggableCard: View {
@State private var offset = CGSize.zero
@State private var isDragging = false
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(.blue)
.frame(width: 200, height: 300)
.offset(offset)
.scaleEffect(isDragging ? 1.05 : 1.0)
.animation(.interactiveSpring(response: 0.3), value: isDragging)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
isDragging = true
}
.onEnded { value in
isDragging = false
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
offset = .zero
}
}
)
}
}
struct SmoothDrag: View {
@GestureState private var dragOffset = CGSize.zero
@State private var position = CGSize.zero
var body: some View {
Circle()
.fill(.blue)
.frame(width: 100, height: 100)
.offset(
x: position.width + dragOffset.width,
y: position.height + dragOffset.height
)
.animation(.interactiveSpring(), value: dragOffset)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation
}
.onEnded { value in
position.width += value.translation.width
position.height += value.translation.height
}
)
}
}
struct VelocityDrag: View {
@State private var offset = CGSize.zero
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(.blue)
.frame(width: 200, height: 300)
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
}
.onEnded { value in
// Use velocity for natural-feeling spring back
let velocity = CGSize(
width: value.predictedEndTranslation.width - value.translation.width,
height: value.predictedEndTranslation.height - value.translation.height
)
withAnimation(.spring(
response: 0.5,
dampingFraction: 0.7,
blendDuration: 0
)) {
offset = .zero
}
}
)
}
}
struct ScrollTransitionView: View {
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
ForEach(0..<20) { index in
RoundedRectangle(cornerRadius: 12)
.fill(.blue.gradient)
.frame(height: 100)
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.5)
.scaleEffect(phase.isIdentity ? 1 : 0.9)
.blur(radius: phase.isIdentity ? 0 : 2)
}
}
}
.padding()
}
}
}
struct ParallaxScroll: View {
var body: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(0..<10) { index in
Image("photo\(index)")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 300)
.clipped()
.visualEffect { content, proxy in
content
.offset(y: parallaxOffset(proxy))
}
}
}
}
}
func parallaxOffset(_ proxy: GeometryProxy) -> CGFloat {
let frame = proxy.frame(in: .scrollView)
return -frame.minY * 0.3
}
}
// GOOD: Explicit animation tied to specific value
.animation(.spring, value: isExpanded)
// AVOID: Implicit animation (deprecated, less performant)
.animation(.spring)
// GOOD: Animate transforms (GPU-accelerated)
.scaleEffect(scale)
.rotationEffect(.degrees(rotation))
.offset(x: offsetX, y: offsetY)
.opacity(opacity)
// AVOID: Animating layout-affecting properties when possible
// (These trigger re-layout each frame)
.frame(width: animatedWidth)
.padding(animatedPadding)
// GOOD: Animation on specific view
ChildView()
.animation(.spring, value: childState)
// AVOID: Animation on parent affecting all children
ParentView()
.animation(.spring, value: anyChange) // All children animate
// For complex composited views
ComplexAnimatedShape()
.drawingGroup() // Renders to offscreen buffer
// RECOMMENDED: Under 0.4 seconds for UI feedback
.spring(duration: 0.3, bounce: 0.2)
// Reserve longer animations for:
// - Onboarding flows
// - Celebrations
// - State transitions
// Simulator timing is NOT accurate
// Always test animations on physical device
// Different devices have different performance characteristics
struct LoadingSpinner: View {
@State private var isAnimating = false
var body: some View {
Circle()
.trim(from: 0, to: 0.7)
.stroke(.blue, lineWidth: 4)
.frame(width: 40, height: 40)
.rotationEffect(.degrees(isAnimating ? 360 : 0))
.animation(
.linear(duration: 1.0).repeatForever(autoreverses: false),
value: isAnimating
)
.onAppear {
isAnimating = true
}
}
}
struct PulsingDot: View {
@State private var isPulsing = false
var body: some View {
Circle()
.fill(.green)
.frame(width: 12, height: 12)
.scaleEffect(isPulsing ? 1.2 : 1.0)
.opacity(isPulsing ? 0.6 : 1.0)
.animation(
.easeInOut(duration: 0.8).repeatForever(autoreverses: true),
value: isPulsing
)
.onAppear {
isPulsing = true
}
}
}
struct ShakeEffect: GeometryEffect {
var amount: CGFloat = 10
var shakesPerUnit = 3
var animatableData: CGFloat
func effectValue(size: CGSize) -> ProjectionTransform {
ProjectionTransform(CGAffineTransform(translationX:
amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)),
y: 0))
}
}
// Usage
TextField("Email", text: $email)
.modifier(ShakeEffect(animatableData: shakeAmount))
.onChange(of: hasError) {
withAnimation(.default) {
shakeAmount = hasError ? 1 : 0
}
}
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.