```html SwiftUI: How to Implement Snackbar (iOS 17+, 2026)

How to Implement a Snackbar in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: overlay / Animation Updated: May 11, 2026
TL;DR

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

  1. SnackbarData carries all configuration. The model holds the message, optional action title and closure, and display duration. Making it Equatable lets SwiftUI's .animation(value:) diff on it accurately — a new snackbar always triggers the spring even if the previous one is still visible.
  2. .overlay(alignment: .bottom) floats the snackbar above all content. Unlike a ZStack, 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.
  3. .transition(.asymmetric(...)) slides in from the bottom and fades out. Combining .move(edge: .bottom) with .opacity gives the Material-style feel. The asymmetric version lets insertion and removal use the same motion, which reads more naturally than separate transitions.
  4. Task.sleep(for:) auto-dismisses without timers. Using Swift Concurrency's structured Task means the auto-dismiss cooperates with cancellation — tapping "Undo" cancels the sleep task immediately via task?.cancel(), preventing a race where the snackbar disappears right as the user acts.
  5. The .snackbar(item:) modifier keeps usage ergonomic. Call it once at the root of a NavigationStack or scene, then set snackbar = SnackbarData(...) from any child view. Setting it to nil — either manually, via the action button's onDismiss, 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

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.

```