Apple Platform Design System: Animation, Motion, and Interaction
Self-contained. The skill’s rules, then this topic, then anything it relies on from other skills.
Skill rules
SwiftUI only. No UIKit unless explicitly required.
Baseline is iOS 27 / iPadOS 27 / macOS 27 Golden Gate, built with the Xcode 27 SDK. Code and design files both follow the 27 design language. There’s no compatibility opt-out. Version numbers, dates, and what changed since 26 live only in reference/platform-status.md. Read it before quoting a version, before using an API marked (27) in a project that deploys to 26, and for any 26-era code or design file.
Quick Reference
| Topic |
Covers |
Reference |
| Liquid Glass |
Variants, glassEffect(_:in:), GlassEffectContainer, unions, morphing and transitions, glass button styles, the user’s Liquid Glass slider, glass tokens, layering anti-patterns, performance |
reference/liquid-glass.md |
| Color & typography |
Semantic system colors, custom color tokens, Display P3, type scale, Dynamic Type, color on glass, spacing tokens. Palette values come from the color-system skill. |
reference/color-typography.md |
| Motion & interaction |
Apple motion rules, glass motion, symbol effects, gestures, haptics, Reduce Motion. Token names and values come from the motion-design skill. |
reference/motion-interaction.md |
| Navigation & components |
Tab bars and tab roles, search, iPad and Mac sidebars, toolbars (grouping, overflow, pinning, minimization), menus, sheets, floating actions, inactive windows, foldable layout |
reference/navigation-components.md |
| Accessibility |
Reduce Transparency, Increase Contrast, Reduce Motion, the Liquid Glass slider, Dynamic Type, VoiceOver, contrast on glass, touch targets |
reference/accessibility.md |
| Custom rendering |
SwiftUI Canvas, @Animatable shapes, AttributedString typography, shader effects, Metal |
reference/custom-rendering.md |
| Design file frames |
iPhone and iPad frame structure, safe areas, and layer order for Figma work |
reference/design-file-frames.md |
| Platform status |
Current OS, Xcode, and Swift versions with dates, the 26 to 27 design and API delta, names that changed before release, deprecations |
reference/platform-status.md |
Load the one file the task needs. For an API these files don’t show, check the installed SDK. Don’t guess.
Philosophy
Liquid Glass is a navigation-layer material. It floats above content. It does not touch content.
Three layers, in order:
- Content (bottom): lists, media, text, cards. No glass.
- Navigation (middle): toolbars, tab bars, sidebars, floating controls. Liquid Glass lives here.
- Overlay (top): vibrancy, fills, and text rendered on glass surfaces.
If you’re applying glass to content, you’re doing it wrong. Cards are content. The API, the values, and the one system exception are in liquid-glass.md.
Platform Visual Signature
A correct design is immediately recognizable as Apple-made. If it reads as a web app or as Android, it’s wrong. Check this on nearly every task.
What makes it immediately look like iOS:
- Tab bar sits at the bottom of the screen with glass treatment, floating above content
- Content scrolls behind both the status bar at top and the tab bar at bottom (edge-to-edge)
- Navigation is push-based: screens slide left/right. Back button in the upper-left.
- Large title at the top of a navigation stack collapses as the user scrolls
- Partial-height sheets are inset from the screen edges, float with rounded corners and a glass background, and turn more opaque when expanded to full height
- SF Pro font throughout. No third-party fonts unless deliberately part of a brand decision.
- SF Symbols for all iconography. No custom icon libraries.
- Semantic colors. The background shifts automatically between light and dark.
What makes it immediately look like macOS:
- Standard window chrome (traffic light buttons top-left, window title centered or leading)
- Sidebar on the left with icon + label navigation, running edge-to-edge (top, bottom, and side of the window). Part of the window, not an inset floating panel. Icons take the accent color. In an inactive window, icons and text dim.
- Toolbar at the top with glass controls on the system’s uniform toolbar treatment, the same in every app
- Window shape and corner radius are the system’s. Contained elements are concentric with it. An app doesn’t design its own window radius.
- Menus carry icons only where they clarify. Most menu items are text only.
Wrong if:
- Tab bar is at the top (that’s web or Android)
- Navigation bar has a solid opaque background (content should extend behind it)
- Colors are hardcoded instead of semantic (system semantic colors, or the project’s own semantic tokens)
- Icons are not SF Symbols
- Text fields, buttons, or lists look like generic HTML form controls
- There is no glass treatment on the navigation layer
- A partial-height sheet is flush to the screen edges with no inset rounding
- Safe areas are ignored (content or interactive elements under the status bar or in the home indicator zone)
- A Mac sidebar is drawn as an inset floating card, or a Mac toolbar’s transparency is hand-tuned per screen (both are the 26 look)
Topic
Apple-specific motion: what the platform already animates, glass, symbols, gestures, haptics, Reduce Motion.
Motion tokens aren’t defined here. The motion-design skill owns them. Its reference/cross-platform-tokens.md defines Motion.Spring.snappy, .standard, .expressive, .dragRelease and Motion.Ease.enter, .exit, .standard, .fade, .ambient. Its reference/ios-swiftui.md has the SwiftUI animation API (springs, keyframes, phase animators, transitions). No raw spring or duration values at a call site.
Contents
Core Principle
Motion communicates. It orients, confirms, and guides. It doesn’t decorate.
Every animation answers one of these questions:
- Where did that go?
- What just happened?
- What can I do next?
If it doesn’t answer any of those, cut it.
Platform Rules
User-triggered motion gets a spring. System-triggered motion gets an ease token: enter for arrivals, exit for departures.
Match direction to spatial relationships: if a view slides in from the right, it dismisses to the right.
Never cut between states. The weight of the animation matches the weight of the change: snappy for small toggles, standard for layout changes, expressive for expanding and collapsing glass.
Elements materialize. They appear and disappear by modulating the material, not by popping in. Match that in custom transitions with opacity plus scale. No hard cuts.
Navigation pushes, sheets, the tab bar, toolbars, and menus already move correctly. Don’t re-animate them.
Liquid Glass Motion
Liquid Glass has its own physics. .glassEffect(.regular.interactive()) adds scale on press, bounce, shimmer, and touch-point illumination, and on macOS a pointer response. Don’t layer custom animations on top.
Morphing is user-triggered, so it gets a spring:
withAnimation(Motion.Spring.expressive) {
isExpanded.toggle()
}
Set how a glass element enters and leaves with glassEffectTransition(_:) (.matchedGeometry or .materialize), not a hand-built transition. See liquid-glass.md.
Symbol Effects
SF Symbols animate natively. Use the built-in effects before drawing anything custom.
// Swap one symbol for another (state toggle)
Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill")
.contentTransition(.symbolEffect(.replace))
// One-shot effect when a value changes
Image(systemName: "arrow.down.circle")
.symbolEffect(.bounce, value: downloadCount)
// Ongoing effect while a condition holds
Image(systemName: "wifi")
.symbolEffect(.variableColor.iterative, isActive: isConnecting)
// Number changes
Text(count, format: .number)
.contentTransition(.numericText())
Available effects: .bounce, .pulse, .scale, .variableColor, .appear, .disappear, .replace, .wiggle, .rotate, .breathe, .drawOn, .drawOff.
Match the effect to the meaning: bounce confirms, pulse and breathe signal ongoing activity, variable color shows progress or strength, draw on suits completion. An indefinite effect stops when the state it describes ends.
Gestures
Swipe to go back is a system behavior. Never remove or interfere with it.
Track the finger with no animation, then release into a spring so the drag’s velocity carries over:
.gesture(
DragGesture()
.onChanged { value in offset = value.translation }
.onEnded { _ in
withAnimation(Motion.Spring.dragRelease) { offset = .zero }
}
)
Selectable Text has its own selection gestures under the 27 SDK. If a custom gesture on it has to win, use .highPriorityGesture().
Haptics
Use system haptics for confirmations, errors, and selection changes. Never build a custom haptic when a system pattern exists. In SwiftUI that’s sensoryFeedback, driven by the state change itself. No UIKit feedback generators.
// Selection
.sensoryFeedback(.selection, trigger: selectedTab)
// Impact
.sensoryFeedback(.impact(weight: .medium), trigger: dropCount)
// Outcome
.sensoryFeedback(.success, trigger: saveCount)
// Only for some changes
.sensoryFeedback(trigger: phase) { _, new in
new == .failed ? .error : nil
}
One haptic per event the user caused or needs to know about. Standard controls already play their own; don’t double them.
Reduce Motion
Always respect accessibilityReduceMotion. Replace spatial motion with opacity.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var transition: AnyTransition {
reduceMotion ? .opacity : .move(edge: .trailing).combined(with: .opacity)
}
System transitions and Liquid Glass adapt on their own. Custom motion doesn’t: parallax, scroll effects, continuous loops, indefinite symbol effects, TimelineView drawing.
Shared definitions
What this file relies on from other skills, copied in so nothing needs looking up.
From motion-design: Motion Tokens
This is the one definition of the motion token names and their values on every platform. The other files in this skill, and the apple-platform, android-platform, web-platform, and visual-styles skills, use these names and don’t define their own. Change a value here and nowhere else.
Three families, matching the spring-vs-ease rule. Spring tokens for user-triggered motion. Duration and easing tokens, used together, for system-triggered motion. A project’s design files or existing token set override the values. The names stay. A visual style can declare extra tokens in its own file under the same naming rule. A style-local token is a token, not an inline value.
Contents
Spring Tokens
One spring per token, written three ways. All three platforms run the same spring: mass 1, stiffness (2π / duration)², damping ratio 1 – bounce.
| Token |
Use |
Spring |
Swift |
Kotlin |
CSS |
| snappy |
Press states, toggles, controls |
0.25s, bounce 0.15 |
Motion.Spring.snappy |
Motion.Spring.snappy() |
var(--spring-snappy) over var(--spring-snappy-duration) |
| standard |
Default for interactive elements: cards, sheets, menus, navigation |
0.35s, bounce 0 |
Motion.Spring.standard |
Motion.Spring.standard() |
var(--spring-standard) over var(--spring-standard-duration) |
| expressive |
Visible bounce: FABs, notifications, moments meant to delight |
0.4s, bounce 0.3 |
Motion.Spring.expressive |
Motion.Spring.expressive() |
var(--spring-expressive) over var(--spring-expressive-duration) |
| dragRelease |
An element let go after a drag, settling with the gesture’s velocity |
0.3s, bounce 0.28 |
Motion.Spring.dragRelease |
Motion.Spring.dragRelease() |
none: CSS can’t inherit gesture velocity, so drag release on web is driven from JavaScript |
A spring’s duration is perceptual, not its settle time. The CSS --spring-*-duration values (350ms, 520ms, 660ms) are settle times, which is why they’re longer. Each CSS curve is only valid at its paired duration.
Android apps themed with Material 3 Expressive take component and component-like motion from MaterialTheme.motionScheme instead. See android-platform. Motion.Spring.*() is for custom motion the scheme doesn’t cover.
Duration Tokens
For system-triggered motion, always paired with an easing token. Springs don’t take a duration token.
| Token |
Value |
Use |
Swift |
Kotlin |
CSS |
| micro |
83ms |
Feedback that isn’t spring-driven: icon swaps, color flips |
Motion.Duration.micro |
Motion.Duration.micro |
var(--duration-micro) |
| fast |
150ms |
Small state changes, exits, fades |
Motion.Duration.fast |
Motion.Duration.fast |
var(--duration-fast) |
| standard |
250ms |
Most transitions: dropdown open, toast appear, content swap |
Motion.Duration.standard |
Motion.Duration.standard |
var(--duration-standard) |
| slow |
350ms |
Large layout shifts, full-screen transitions |
Motion.Duration.slow |
Motion.Duration.slow |
var(--duration-slow) |
| cinematic |
600ms |
Deliberate editorial moments, ambient loops |
Motion.Duration.cinematic |
Motion.Duration.cinematic |
var(--duration-cinematic) |
Nothing shorter than micro. Below that, make the change instant. Nothing longer than 700ms in product UI. If an animation needs longer to feel right, more motion isn’t the fix. Scroll-scrubbed and ambient WebGL sequences have no duration of their own, so they’re exempt.
Easing Tokens
| Token |
Curve |
Use |
Swift |
Kotlin |
CSS |
| enter |
0, 0, 0.2, 1 |
Entrances: decelerates as it arrives |
Motion.Curve.enter |
Motion.Ease.enter |
var(--ease-enter) |
| exit |
0.4, 0, 1, 1 |
Exits: accelerates as it leaves |
Motion.Curve.exit |
Motion.Ease.exit |
var(--ease-exit) |
| standard |
0.4, 0, 0.2, 1 |
State changes that stay on screen |
Motion.Curve.standard |
Motion.Ease.standard |
var(--ease-standard) |
| emphasized |
0.2, 0, 0, 1 |
Quick start, long settle: hero and container moves |
Motion.Curve.emphasized |
Motion.Ease.emphasized |
var(--ease-emphasized) |
| expo-out |
0.16, 1, 0.3, 1 |
Very fast start, very long tail: reveals |
Motion.Curve.expoOut |
Motion.Ease.expoOut |
var(--ease-expo-out) |
| sine-in-out |
0.45, 0.05, 0.55, 0.95 |
Gentle and symmetrical: slow, ambient |
Motion.Curve.sineInOut |
Motion.Ease.sineInOut |
var(--ease-sine-in-out) |
Linear isn’t a token. It’s only right for continuous loops and scroll-linked motion.
Swift is the one exception to the naming rule. SwiftUI animations carry their duration, so motion/ease/<name> becomes two things: Motion.Curve.<name> is the bare curve, and Motion.Ease.<name> is the ready-to-use Animation, the curve at its default duration. The pairs are Motion.Ease.enter (enter, standard), exit (exit, fast), standard (standard, standard), emphasized (emphasized, slow), expoOut (expo-out, cinematic), fade (enter, fast), and ambient (sine-in-out, cinematic). In Kotlin Motion.Ease.enter, exit, and standard alias Compose’s LinearOutSlowInEasing, FastOutLinearInEasing, and FastOutSlowInEasing. Call sites use the token name.
Definitions per Platform
One definition site per platform. Call sites reference the token, never a raw curve, duration, or spring constant.
:root {
--duration-micro: 83ms;
--duration-fast: 150ms;
--duration-standard: 250ms;
--duration-slow: 350ms;
--duration-cinematic: 600ms;
--ease-enter: cubic-bezier(0, 0, 0.2, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
--ease-expo-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-sine-in-out: cubic-bezier(0.45, 0.05, 0.55, 0.95);
/* Springs as linear() stops: mass 1, stiffness (2π / duration)²,
damping ratio 1 - bounce, sampled to the point the spring settles
within 0.1%. */
/* snappy: duration 0.25s, bounce 0.15 (stiffness 632, damping ratio 0.85) */
--spring-snappy: linear(0, 0.006 1.2%, 0.026 2.8%, 0.057 4.2%, 0.096 5.8%, 0.191 8.8%, 0.413 15%, 0.514 18%, 0.613 21.2%, 0.698 24.5%, 0.765 27.5%, 0.825 30.8%, 0.873 34%, 0.914 37.5%, 0.946 41.2%, 0.971 45.5%, 0.989 50.2%, 1 55.8%, 1.006 67.8%, 1);
--spring-snappy-duration: 350ms;
/* standard: duration 0.35s, bounce 0 (stiffness 322, damping ratio 1) */
--spring-standard: linear(0, 0.004 1%, 0.019 2.2%, 0.043 3.5%, 0.08 5%, 0.156 7.5%, 0.367 13.8%, 0.463 16.8%, 0.557 20%, 0.638 23.2%, 0.707 26.5%, 0.769 30%, 0.819 33.5%, 0.862 37.2%, 0.901 41.8%, 0.93 46.5%, 0.954 52%, 0.972 58.2%, 0.984 65.2%, 0.992 73.5%, 1);
--spring-standard-duration: 520ms;
/* expressive: duration 0.4s, bounce 0.3 (stiffness 247, damping ratio 0.7), 4.6% overshoot */
--spring-expressive: linear(0, 0.008 1.2%, 0.03 2.5%, 0.063 3.8%, 0.114 5.2%, 0.218 7.8%, 0.476 13.2%, 0.587 15.8%, 0.697 18.5%, 0.783 21%, 0.855 23.5%, 0.918 26.2%, 0.966 29%, 1.003 32%, 1.022 34.2%, 1.036 36.8%, 1.044 39.5%, 1.046 42.5%, 1.041 47.8%, 1.012 62.3%, 1.003 70%, 0.998 81.8%, 1);
--spring-expressive-duration: 660ms;
--motion-distance-sm: 12px;
--motion-distance-md: 24px;
}
import SwiftUI
enum Motion {
enum Duration {
static let micro: TimeInterval = 0.083
static let fast: TimeInterval = 0.15
static let standard: TimeInterval = 0.25
static let slow: TimeInterval = 0.35
static let cinematic: TimeInterval = 0.6
}
enum Curve {
static let enter = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0, y: 0), endControlPoint: UnitPoint(x: 0.2, y: 1))
static let exit = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.4, y: 0), endControlPoint: UnitPoint(x: 1, y: 1))
static let standard = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.4, y: 0), endControlPoint: UnitPoint(x: 0.2, y: 1))
static let emphasized = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.2, y: 0), endControlPoint: UnitPoint(x: 0, y: 1))
static let expoOut = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.16, y: 1), endControlPoint: UnitPoint(x: 0.3, y: 1))
static let sineInOut = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.45, y: 0.05), endControlPoint: UnitPoint(x: 0.55, y: 0.95))
}
enum Spring {
static let snappy = Animation.snappy(duration: 0.25) // bounce 0.15
static let standard = Animation.spring(duration: 0.35, bounce: 0)
static let expressive = Animation.spring(duration: 0.4, bounce: 0.3)
static let dragRelease = Animation.spring(duration: 0.3, bounce: 0.28)
}
enum Ease {
static let enter = Animation.timingCurve(Curve.enter, duration: Duration.standard)
static let exit = Animation.timingCurve(Curve.exit, duration: Duration.fast)
static let standard = Animation.timingCurve(Curve.standard, duration: Duration.standard)
static let emphasized = Animation.timingCurve(Curve.emphasized, duration: Duration.slow)
static let expoOut = Animation.timingCurve(Curve.expoOut, duration: Duration.cinematic)
static let fade = Animation.timingCurve(Curve.enter, duration: Duration.fast)
static let ambient = Animation.timingCurve(Curve.sineInOut, duration: Duration.cinematic)
}
}
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.spring
object Motion {
object Spring {
fun <T> snappy(): SpringSpec<T> = spring(dampingRatio = 0.85f, stiffness = 632f)
fun <T> standard(): SpringSpec<T> = spring(dampingRatio = 1f, stiffness = 322f)
fun <T> expressive(): SpringSpec<T> = spring(dampingRatio = 0.7f, stiffness = 247f)
fun <T> dragRelease(): SpringSpec<T> = spring(dampingRatio = 0.72f, stiffness = 439f)
}
object Duration {
const val micro = 83
const val fast = 150
const val standard = 250
const val slow = 350
const val cinematic = 600
}
object Ease {
val enter: Easing = LinearOutSlowInEasing
val exit: Easing = FastOutLinearInEasing
val standard: Easing = FastOutSlowInEasing
val emphasized: Easing = CubicBezierEasing(0.2f, 0f, 0f, 1f)
val expoOut: Easing = CubicBezierEasing(0.16f, 1f, 0.3f, 1f)
val sineInOut: Easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f)
}
}
Usage:
.button { transition: scale var(--spring-snappy-duration) var(--spring-snappy); }
.toast { transition: opacity var(--duration-standard) var(--ease-enter); }
withAnimation(Motion.Spring.standard) { isExpanded.toggle() }
animateFloatAsState(target, animationSpec = Motion.Spring.snappy(), label = "scale")
tween<Float>(durationMillis = Motion.Duration.standard, easing = Motion.Ease.enter)
GSAP registers the easing tokens under the same names ("enter", "expo-out", and so on). See gsap.md.
Reduced Motion Policy
The policy lives here. Each platform file only shows the API that reads the setting. The reasons are in foundations.md.
When the user asks for reduced motion:
- Spatial motion becomes a fade. Replace slides, zooms, scale, and shared-element travel with an opacity change at fast with enter (
Motion.Ease.fade), or an instant change.
- Springs lose their bounce. Where a spring stays (a control following a finger), use standard, never expressive.
- Scroll-linked motion goes. No parallax, no scroll-driven transforms, no pinned or scrubbed sequences. Content sits in its final position. Smooth scrolling is off.
- Autoplaying and looping decoration stops. A spinner that says “working” can stay.
- Feedback stays. Color, opacity, and state changes still happen. Reduced motion never means a control gives no response.
Android’s “Remove animations” goes further. It zeroes the animator duration scale, so Compose animations jump to their end state and no substitute fade runs. See android-compose.md.
Anti-Patterns
Linear motion on interactive elements. Nothing physical moves at constant velocity. Use a spring or an ease.
Identical timing for all properties. Opacity that finishes slightly before position reads as one arrival. Opacity that finishes long before it shows the destination before the journey is over.
Animating layout properties. width, height, top, left, margin, padding trigger layout every frame. Animate render-time properties: transform/translate/scale and opacity on web, scaleEffect/offset in SwiftUI, graphicsLayer in Compose.
Interrupting without velocity handoff. When a user reverses direction mid-flight, the new animation has to pick up the current velocity. Springs do. Duration-based animations restart from rest.
Staggering too much. 30-60ms between items creates rhythm. 150ms makes the UI feel slow. Stagger the first several items and bring the rest in together.
Decorative animation on high-frequency paths. Press feedback is snappy or micro, nothing longer.
Parallax on critical content. It can’t be what positions content a user needs to read.
Inline motion values. A raw cubic-bezier(), a bare millisecond count, or spring constants at a call site.
Not testing interruption. Tap, release, tap again rapidly. Swipe, reverse, swipe again. If it breaks, it’s duration-based where it should be a spring, or it restarts from a hard-coded start value.
Skipping reduced motion. Test every animated surface with the system setting on.
From motion-design: iOS / SwiftUI Motion
SwiftUI animation for iOS, iPadOS, and macOS. Motion.* is the token enum from cross-platform-tokens.md. Use it. No literal springs or durations in views. HIG motion rules, Liquid Glass morphing, and haptics are in the apple-platform skill.
Status
The OS baseline, SDK status, and what changed between releases live in apple-platform/reference/platform-status.md. The .zoom and .crossFade navigation transitions are iOS and iPadOS only. They’re unavailable on macOS.
Contents
Spring API
These are the forms you’ll meet when reading or defining tokens. The presets are fixed bounces on .spring(duration:bounce:), all with a default duration of 0.5:
.smooth // bounce 0
.snappy // bounce 0.15
.bouncy // bounce 0.3
.snappy(duration: 0.2) // each preset takes duration and extraBounce
.bouncy(duration: 0.4, extraBounce: 0.2)
.spring(duration: 0.3, bounce: 0.2) // bounce 0 = critically damped, > 0 overshoots,
// < 0 = overdamped, flatter
// Older form, still common in existing code.
// duration == response, bounce == 1 - dampingFraction.
.spring(response: 0.35, dampingFraction: 0.7)
duration is perceptual, not the settle time. To run code when the motion has actually finished, use the completion form:
withAnimation(Motion.Spring.standard) {
isExpanded = true
} completion: {
focusField()
}
Which Token Where
| Situation |
Token |
| Press feedback, toggles, small controls |
Motion.Spring.snappy |
| Sheets, cards, menus, navigation, list insert and delete |
Motion.Spring.standard |
| Releasing a dragged element |
Motion.Spring.dragRelease |
| Badge pop, playful emphasis |
Motion.Spring.expressive |
| Loading and ambient loops |
Motion.Ease.ambient |
| System-triggered appear and disappear |
Motion.Ease.enter, Motion.Ease.exit |
Implicit vs Explicit Animations
// Implicit: attached to a view, runs when the watched value changes.
// For a single view reacting to its own state.
RoundedRectangle(cornerRadius: Radius.lg)
.scaleEffect(isActive ? 1.05 : 1.0)
.animation(Motion.Spring.snappy, value: isActive)
// Explicit: wraps the state change; everything depending on that state
// animates together. For event-driven, multi-view changes.
Button("Expand") {
withAnimation(Motion.Spring.standard) {
isExpanded.toggle()
}
}
Always pass value: to .animation. The form without it animates every change to the view, including ones you didn’t intend, and is deprecated.
Gestures and Velocity
Springs carry velocity across interruption. Retarget a property that’s mid-spring and it continues from its current speed. Gesture callbacks also track velocity automatically, so a spring started in onEnded begins at the speed the finger was moving.
@State private var offset: CGSize = .zero
card
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation // no animation while tracking the finger
}
.onEnded { _ in
withAnimation(Motion.Spring.dragRelease) {
offset = .zero // starts with the drag's velocity
}
}
)
DragGesture.Value.velocity is there when the release behavior itself depends on speed, like dismissing on a fast flick.
Keyframe Animations
For multi-step choreography a single spring can’t describe. Each track animates one property on its own timeline. The segment durations are the choreography and stay literal.
struct PopValues {
var scale = 1.0
var offsetY = 0.0
}
CardView()
.keyframeAnimator(initialValue: PopValues(), trigger: isActive) { content, value in
content
.scaleEffect(value.scale)
.offset(y: value.offsetY)
} keyframes: { _ in
KeyframeTrack(\.scale) {
CubicKeyframe(0.95, duration: 0.1) // quick compress
SpringKeyframe(1.02, duration: 0.2) // spring past rest
SpringKeyframe(1.0, duration: 0.15) // settle
}
KeyframeTrack(\.offsetY) {
CubicKeyframe(-8, duration: 0.15)
SpringKeyframe(0, duration: 0.3)
}
}
LinearKeyframe interpolates straight, CubicKeyframe curves through its neighbors, SpringKeyframe springs toward the value, MoveKeyframe jumps.
Keyframe animations play to completion and don’t hand off velocity when interrupted. They’re for one-shot choreography, never for something the user can grab mid-flight. The content closure runs every frame. Keep it cheap.
PhaseAnimator
Steps through discrete states, continuously or once per trigger. This is system-driven motion, so it takes an ease token.
// Continuous: loading indicator
LoadingDot()
.phaseAnimator([false, true]) { content, isActive in
content
.scaleEffect(isActive ? 1.3 : 1.0)
.opacity(isActive ? 0.6 : 1.0)
} animation: { _ in
Motion.Ease.ambient
}
// Once per trigger: confirmation pulse
Image(systemName: "checkmark.circle.fill")
.phaseAnimator([1.0, 1.2, 1.0], trigger: saveCount) { content, scale in
content.scaleEffect(scale)
}
Shared Element and Navigation Transitions
matchedGeometryEffect morphs between two views in the same hierarchy that share an ID. Only one of them can be in the hierarchy at a time. Rendering both breaks the animation.
@Namespace private var heroNamespace
if !isExpanded {
Thumbnail(item)
.matchedGeometryEffect(id: item.id, in: heroNamespace)
.onTapGesture {
withAnimation(Motion.Spring.standard) { isExpanded = true }
}
} else {
Detail(item)
.matchedGeometryEffect(id: item.id, in: heroNamespace)
}
Across a navigation push or a presentation, use the zoom navigation transition instead (iOS and iPadOS). It’s interactive, interruptible, and follows the dismiss gesture:
@Namespace private var namespace
NavigationLink {
DetailView(item)
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
} label: {
Thumbnail(item)
.matchedTransitionSource(id: item.id, in: namespace)
}
For a sheet that should appear in place instead of sliding up, use the cross-fade transition (iOS and iPadOS). AnyNavigationTransition type-erases a transition chosen at runtime.
.sheet(isPresented: $showDetail) {
DetailSheet()
.presentationDetents([.medium])
.navigationTransition(.crossFade)
}
Symbol Effects and Content Transitions
SF Symbols animate through symbolEffect. Don’t hand-build scale and rotation for what a symbol effect already does.
// Discrete: plays once each time the value changes
Image(systemName: "bell.fill")
.symbolEffect(.bounce, value: notificationCount)
// Indefinite: runs while active
Image(systemName: "arrow.triangle.2.circlepath")
.symbolEffect(.rotate, isActive: isSyncing)
// Swapping one symbol for another
Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill")
.contentTransition(.symbolEffect(.replace))
// Text that changes value: animate the digits, not a cross-fade of the label
Text(total, format: .number)
.contentTransition(.numericText())
.animation(Motion.Spring.snappy, value: total)
Effects: appear, disappear, bounce, pulse, scale, variableColor, replace, wiggle, rotate, breathe, drawOn, drawOff.
Reordering
Drag-to-reorder works in any stack, grid, list, or custom layout. The system supplies the lift, the moving placeholder, and the drop animation. Don’t build this by hand.
VStack {
ForEach(landmarks) { landmark in
LandmarkRow(landmark)
}
.reorderable()
}
.reorderContainer(for: Landmark.self) { difference in
let moved = landmarks.filter { difference.sources.contains($0.id) }
landmarks.removeAll { difference.sources.contains($0.id) }
switch difference.destination.position {
case .before(let id):
let index = landmarks.firstIndex { $0.id == id } ?? landmarks.endIndex
landmarks.insert(contentsOf: moved, at: index)
case .end:
landmarks.append(contentsOf: moved)
}
}
Containers holding several collections use reorderable(collectionID:) and reorderContainer(for:in:isEnabled:move:).
@Animatable Macro
Synthesizes animatableData from a type’s stored properties, replacing hand-written AnimatablePair nesting:
@Animatable
struct WaveShape: Shape {
var amplitude: Double
var frequency: Double
var phase: Double
@AnimatableIgnored var isMirrored: Bool // excluded from interpolation
func path(in rect: CGRect) -> Path {
Path() // draw using amplitude, frequency, phase
}
}
Performance
Built-in animatable modifiers (.scaleEffect(), .opacity(), .rotationEffect(), .offset()) are cheap. SwiftUI interpolates them without re-running your view code. A custom Animatable type, a keyframe content closure, or a TimelineView re-evaluates on the main thread every frame, at up to 120Hz. Keep layout-heavy subtrees, data access, and formatting out of anything that re-evaluates per frame.
When a parent’s size animates and its children jump instead of following, add .geometryGroup() to the parent.
Reduce Motion
The policy is in cross-platform-tokens.md.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
// Replace spatial motion with a crossfade
var transition: AnyTransition {
reduceMotion ? .opacity : .move(edge: .trailing).combined(with: .opacity)
}
// Swap an expressive spring for the fade token
withAnimation(reduceMotion ? Motion.Ease.fade : Motion.Spring.expressive) {
isPresented = true
}
System navigation and sheet transitions adapt on their own. Custom motion doesn’t. Parallax, scrollTransition effects, continuous PhaseAnimator loops, indefinite symbol effects, and keyframes all need the check.