How to implement Lottie animation in SwiftUI
SwiftUI has no native Lottie view, so bridge LottieAnimationView into SwiftUI using UIViewRepresentable. Pass your .json filename, loop mode, and an isPlaying binding — the wrapper handles the rest.
import Lottie
import SwiftUI
struct LottieView: UIViewRepresentable {
let name: String
var loopMode: LottieLoopMode = .loop
var isPlaying: Bool = true
func makeUIView(context: Context) -> LottieAnimationView {
let view = LottieAnimationView(name: name)
view.loopMode = loopMode
view.contentMode = .scaleAspectFit
if isPlaying { view.play() }
return view
}
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
isPlaying ? uiView.play() : uiView.pause()
}
}
Full implementation
The wrapper below adds speed and a completion callback so you can chain animations or respond when a one-shot finishes. A parent ContentView toggles playback with a button, demonstrating full declarative control.
import Lottie
import SwiftUI
// MARK: - UIViewRepresentable wrapper
struct LottieView: UIViewRepresentable {
let name: String
var loopMode: LottieLoopMode = .loop
var speed: CGFloat = 1.0
@Binding var isPlaying: Bool
var onComplete: (() -> Void)?
func makeUIView(context: Context) -> LottieAnimationView {
let view = LottieAnimationView(name: name)
view.loopMode = loopMode
view.animationSpeed = speed
view.contentMode = .scaleAspectFit
view.setContentCompressionResistancePriority(.fittingSizeLevel, for: .horizontal)
view.setContentCompressionResistancePriority(.fittingSizeLevel, for: .vertical)
play(view)
return view
}
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
uiView.animationSpeed = speed
uiView.loopMode = loopMode
if isPlaying {
guard !uiView.isAnimationPlaying else { return }
play(uiView)
} else {
uiView.pause()
}
}
// MARK: Private helpers
private func play(_ view: LottieAnimationView) {
guard isPlaying else { return }
if loopMode == .playOnce {
view.play { finished in
if finished {
DispatchQueue.main.async {
isPlaying = false
onComplete?()
}
}
}
} else {
view.play()
}
}
}
// MARK: - Demo parent view
struct LottieAnimationDemoView: View {
@State private var isPlaying = true
@State private var speed: CGFloat = 1.0
var body: some View {
VStack(spacing: 24) {
LottieView(
name: "confetti", // confetti.json in main bundle
loopMode: .loop,
speed: speed,
isPlaying: $isPlaying
)
.frame(width: 260, height: 260)
.accessibilityLabel("Confetti animation")
.accessibilityValue(isPlaying ? "Playing" : "Paused")
HStack(spacing: 20) {
Button(isPlaying ? "Pause" : "Play") {
isPlaying.toggle()
}
.buttonStyle(.borderedProminent)
Slider(value: $speed, in: 0.25...3.0) {
Text("Speed")
} minimumValueLabel: {
Text("0.25×").font(.caption)
} maximumValueLabel: {
Text("3×").font(.caption)
}
.frame(maxWidth: 200)
}
Text("Speed: \(speed, specifier: "%.2f")×")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.navigationTitle("Lottie Demo")
}
}
// MARK: - Preview
#Preview {
NavigationStack {
LottieAnimationDemoView()
}
}
How it works
-
makeUIView — called once when SwiftUI first places the view. Here we create the
LottieAnimationView, configureloopMode,contentMode, and start playback. Setting both compression-resistance priorities to.fittingSizeLevellets SwiftUI's.framemodifier win over Lottie's intrinsic size. -
updateUIView — called on every SwiftUI re-render. The guard
!uiView.isAnimationPlayingprevents restarting a looping animation from the beginning each time an unrelated state change triggers a re-render. -
play(_:) with completion — for
.playOncemode, the Lottie callback fires on the main thread, flipsisPlayingback tofalse, and calls the optionalonCompleteclosure so the caller can navigate or mutate state. - @Binding isPlaying — two-way binding means both the parent view (via the Play/Pause button) and the wrapper itself (via the completion handler) can update playback state without tight coupling.
-
Accessibility —
accessibilityLabelnames the animation andaccessibilityValueannounces the current playback state so VoiceOver users get meaningful feedback.
Variants
Load animation from a remote URL
Lottie 4 supports async URL loading natively. Swap LottieAnimationView(name:) for the URL initialiser and configure on load:
struct RemoteLottieView: UIViewRepresentable {
let url: URL
@Binding var isPlaying: Bool
func makeUIView(context: Context) -> LottieAnimationView {
let view = LottieAnimationView()
view.contentMode = .scaleAspectFit
view.setContentCompressionResistancePriority(.fittingSizeLevel, for: .horizontal)
view.setContentCompressionResistancePriority(.fittingSizeLevel, for: .vertical)
LottieAnimation.loadedFrom(url: url, closure: { animation in
view.animation = animation
if self.isPlaying { view.play() }
}, animationCache: DefaultAnimationCache.sharedCache)
return view
}
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
isPlaying ? uiView.play() : uiView.pause()
}
}
// Usage:
RemoteLottieView(
url: URL(string: "https://assets.soarias.com/animations/success.json")!,
isPlaying: $isPlaying
)
.frame(width: 200, height: 200)
Scrub to a specific progress frame
Instead of playing the full timeline, drive the animation manually with a @Binding var progress: AnimationProgressTime and set uiView.currentProgress = progress inside updateUIView. Pair this with a Slider for a scrubber UI, or tie it to a scroll offset for scroll-driven animation effects similar to Lottie's play(fromProgress:toProgress:).
Common pitfalls
-
⚠️ iOS 16 and Lottie 4.x — Lottie 4 raised its minimum deployment target to iOS 14, but its Metal-accelerated renderer requires iOS 16+. On iOS 17 you get the new
CoreAnimationLayerrendering engine by default; don't forcerenderingEngine = .coreAnimationunless you've tested rendering fidelity for your specific JSON. -
⚠️ Re-render loop restarting animations — If
updateUIViewcallsplay()unconditionally, every parent re-render jumps the animation back to frame 0. Always guard with!uiView.isAnimationPlayingfor looping modes. -
⚠️ Memory: cache large animations —
LottieAnimationView(name:)usesDefaultAnimationCacheautomatically for bundle assets. For URL-loaded animations, always passanimationCache: DefaultAnimationCache.sharedCacheto avoid redundant network fetches and large in-memory duplicates when the same animation appears in a list. -
⚠️ SwiftUI layout ignoring .frame — Lottie's UIView has a large intrinsic content size. If your
.frame()modifier seems ignored, set both UILayoutPriority axes to.fittingSizeLevelinmakeUIViewso UIKit defers to SwiftUI's layout system.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement lottie animation in SwiftUI for iOS 17+. Use Lottie/UIViewRepresentable. Wrap LottieAnimationView with UIViewRepresentable, expose loopMode, speed, and an isPlaying Binding. Make it accessible (VoiceOver labels for play/pause state). Add a #Preview with realistic sample data.
Drop this prompt into the Soarias Build phase after your screen mockups are locked — Claude Code will scaffold the wrapper, wire it to your SwiftData model, and surface playback controls without leaving your project.
Related
FAQ
Does this work on iOS 16?
Lottie 4.x supports iOS 14+ at the package level, so yes — but the new Metal/CoreAnimation rendering pipeline introduced in Lottie 4.3 performs best on iOS 16+. If you need iOS 15 support, set LottieConfiguration.shared.renderingEngine = .mainThread at app launch as a fallback; be aware this is CPU-bound and will hurt battery on complex animations.
How do I play only part of a Lottie animation?
Use view.play(fromProgress: 0.0, toProgress: 0.5, loopMode: .playOnce) inside play(_:). Lottie progress values range from 0.0 to 1.0. You can also use frame numbers via play(fromFrame:toFrame:) if your motion designer exports named markers.
What's the UIKit equivalent?
In UIKit you use LottieAnimationView directly — add it as a subview, pin it with Auto Layout, then call animationView.play(). The UIViewRepresentable wrapper shown on this page is essentially that UIKit setup packaged for SwiftUI's view lifecycle.
Last reviewed: 2026-05-11 by the Soarias team.