How to Implement a Snackbar in SwiftUI
Attach a custom SnackbarView to any root view using .overlay(alignment: .bottom), then toggle its visibility with a @State boolean inside withAnimation(.spring). A Task auto-dismisses it after a set delay.
struct ContentView: View {
@State private var showSnackbar = false
var body: some View {
Button("Show") { presentSnackbar() }
.overlay(alignment: .bottom) {
if showSnackbar {
SnackbarView(message: "Saved!")
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 24)
}
}
.animation(.spring(duration: 0.35), value: showSnackbar)
}
func presentSnackbar() {
showSnackbar = true
Task {
try? await Task.sleep(for: .seconds(3))
showSnackbar = false
}
}
}
Full implementation
The approach below separates concerns cleanly: a SnackbarData model carries the message and an optional action, a standalone SnackbarView handles layout and styling, and a reusable .snackbar() view modifier wires up the overlay, animation, and auto-dismiss logic. This keeps your feature views free of boilerplate and makes the snackbar trivially reusable across the app.
import SwiftUI
// MARK: - Model
struct SnackbarData: Equatable {
let message: String
var actionTitle: String? = nil
var action: (() -> Void)? = nil
var duration: Duration = .seconds(3)
static func == (lhs: SnackbarData, rhs: SnackbarData) -> Bool {
lhs.message == rhs.message && lhs.actionTitle == rhs.actionTitle
}
}
// MARK: - Snackbar View
struct SnackbarView: View {
let data: SnackbarData
var onDismiss: (() -> Void)? = nil
var body: some View {
HStack(spacing: 12) {
Text(data.message)
.font(.subheadline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityLabel(data.message)
if let title = data.actionTitle {
Button(title) {
data.action?()
onDismiss?()
}
.font(.subheadline.bold())
.foregroundStyle(Color.yellow)
.accessibilityLabel(title)
.accessibilityHint("Activates the snackbar action")
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(Color(white: 0.15), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(.horizontal, 16)
.accessibilityElement(children: .combine)
}
}
// MARK: - View Modifier
struct SnackbarModifier: ViewModifier {
@Binding var item: SnackbarData?
@State private var task: Task? = nil
func body(content: Content) -> some View {
content
.overlay(alignment: .bottom) {
if let data = item {
SnackbarView(data: data) { item = nil }
.transition(
.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .move(edge: .bottom).combined(with: .opacity)
)
)
.padding(.bottom, 24)
.zIndex(999)
.onAppear { scheduleAutoDismiss(duration: data.duration) }
.onDisappear { task?.cancel() }
}
}
.animation(.spring(duration: 0.35, bounce: 0.2), value: item)
}
private func scheduleAutoDismiss(duration: Duration) {
task?.cancel()
task = Task {
try? await Task.sleep(for: duration)
guard !Task.isCancelled else { return }
item = nil
}
}
}
extension View {
func snackbar(item: Binding) -> some View {
modifier(SnackbarModifier(item: item))
}
}
// MARK: - Demo
struct SnackbarDemoView: View {
@State private var snackbar: SnackbarData? = nil
var body: some View {
NavigationStack {
List {
Button("Show plain snackbar") {
snackbar = SnackbarData(message: "Item deleted.")
}
Button("Show snackbar with action") {
snackbar = SnackbarData(
message: "Item deleted.",
actionTitle: "Undo",
action: { print("Undo tapped") }
)
}
Button("Show long snackbar") {
snackbar = SnackbarData(
message: "Your changes have been saved to iCloud.",
duration: .seconds(5)
)
}
}
.navigationTitle("Snackbar Demo")
}
.snackbar(item: $snackbar)
}
}
#Preview {
SnackbarDemoView()
}
How it works
-
SnackbarDatacarries all configuration. The model holds the message, optional action title and closure, and display duration. Making itEquatablelets SwiftUI's.animation(value:)diff on it accurately — a new snackbar always triggers the spring even if the previous one is still visible. -
.overlay(alignment: .bottom)floats the snackbar above all content. Unlike aZStack, the overlay doesn't affect the layout of its parent — the rest of the screen never shifts when the snackbar appears or disappears..zIndex(999)ensures it renders above sheets and navigation bars within the same hierarchy. -
.transition(.asymmetric(...))slides in from the bottom and fades out. Combining.move(edge: .bottom)with.opacitygives the Material-style feel. The asymmetric version lets insertion and removal use the same motion, which reads more naturally than separate transitions. -
Task.sleep(for:)auto-dismisses without timers. Using Swift Concurrency's structuredTaskmeans the auto-dismiss cooperates with cancellation — tapping "Undo" cancels the sleep task immediately viatask?.cancel(), preventing a race where the snackbar disappears right as the user acts. -
The
.snackbar(item:)modifier keeps usage ergonomic. Call it once at the root of aNavigationStackor scene, then setsnackbar = SnackbarData(...)from any child view. Setting it tonil— either manually, via the action button'sonDismiss, or by the auto-dismiss task — triggers the removal animation.
Variants
Snackbar with severity styles (info / warning / error)
enum SnackbarSeverity { case info, warning, error }
extension SnackbarSeverity {
var backgroundColor: Color {
switch self {
case .info: return Color(white: 0.15)
case .warning: return Color(hue: 0.09, saturation: 0.85, brightness: 0.45)
case .error: return Color(hue: 0.0, saturation: 0.75, brightness: 0.45)
}
}
var icon: String {
switch self {
case .info: return "info.circle"
case .warning: return "exclamationmark.triangle"
case .error: return "xmark.circle"
}
}
}
struct StyledSnackbarView: View {
let message: String
let severity: SnackbarSeverity
var body: some View {
HStack(spacing: 10) {
Image(systemName: severity.icon)
.foregroundStyle(.white)
.accessibilityHidden(true)
Text(message)
.font(.subheadline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(severity.backgroundColor,
in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(.horizontal, 16)
}
}
Stacking multiple snackbars (queue-based)
If your app fires rapid events, maintain an @State private var queue: [SnackbarData] = [] and always show queue.first. In the onDisappear of the snackbar, call queue.removeFirst(). This prevents snackbars from clobbering each other and delivers every message in order. For most apps a simple "last-wins" replacement (setting item directly) is perfectly fine — the queue is only necessary when no message can be skipped.
Common pitfalls
-
iOS 16 and below:
Task.sleep(for:)with aDurationvalue requires iOS 16+; for iOS 15 targets useTask.sleep(nanoseconds:)instead. The#Previewmacro and.spring(duration:bounce:)initialiser both require iOS 17+ / Xcode 15+. -
Placing
.snackbar()too deep in the hierarchy: If you attach the modifier inside aListrow or inside a sheet, the overlay clips to that view's frame and the snackbar will appear partially hidden or incorrectly positioned. Always attach it to the rootNavigationStackorWindowGroupbody. -
Forgetting VoiceOver announcement: The snackbar appears without focus, so VoiceOver users won't hear it unless you post an accessibility announcement. Add
AccessibilityNotification.Announcement(data.message).post()(iOS 17 API) insideonAppearon theSnackbarViewto ensure screen-reader users receive the notification. -
Animation value not changing when re-triggering: If you set
itemto the same message twice in quick succession,Equatableconformance means SwiftUI sees no change and skips the animation. Append a UUID or timestamp to the model, or resetitemtonilfirst inside awithAnimationbefore setting the new value.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a snackbar in SwiftUI for iOS 17+. Use overlay and Animation APIs. Make it accessible (VoiceOver labels + AccessibilityNotification.Announcement). Support an optional action button that cancels auto-dismiss. Auto-dismiss after a configurable Duration using Task.sleep. Add a #Preview with realistic sample data showing plain and action variants.
In Soarias's Build phase, paste this prompt into the feature scaffold step so the snackbar modifier is generated alongside your data layer — keeping notification state in one @Observable store rather than scattered across views.
Related
FAQ
Does this work on iOS 16?
Mostly yes — .overlay(alignment:) and withAnimation(.spring) are available from iOS 15. The two iOS 17-only pieces are the #Preview macro (replace with a PreviewProvider struct on iOS 16) and AccessibilityNotification.Announcement (use the older UIAccessibility.post(notification: .announcement, argument:) bridge instead). The Duration type in Task.sleep(for:) requires iOS 16+; on iOS 15 swap it for Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)).
Can the snackbar appear above a presented sheet or full-screen cover?
No — SwiftUI presentations create a new UIWindow layer, so an overlay anchored in the presenting view won't bleed through. The cleanest solution is to also attach .snackbar(item:) inside the sheet's root view, sharing the same binding if both are children of a common @Observable store. Alternatively, you can present the snackbar from a dedicated UIWindow at the UIKit level, though that bypasses SwiftUI animation entirely.
What is the UIKit equivalent?
UIKit has no first-party snackbar. The conventional approach is to add a custom UIView to the key window's root view controller, animate it with UIView.animate(withDuration:) using a CGAffineTransform translation, and call DispatchQueue.main.asyncAfter to remove it. Google's Material Components for iOS (MDCSnackbarManager) provides a pre-built UIKit version. The SwiftUI approach shown on this page is significantly less boilerplate and handles safe-area insets automatically.
Last reviewed: 2026-05-11 by the Soarias team.