```html SwiftUI: How to Build a Toast Notification (iOS 17+, 2026)

How to Build a Toast Notification in SwiftUI

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

Attach a toast to your root view using .overlay(alignment: .top), drive its visibility with a @State boolean, and animate it in/out with withAnimation(.spring). A Task handles the auto-dismiss after a configurable duration.

struct ContentView: View {
    @State private var showToast = false

    var body: some View {
        Button("Show Toast") {
            withAnimation(.spring(duration: 0.4)) { showToast = true }
            Task {
                try? await Task.sleep(for: .seconds(2.5))
                withAnimation(.spring(duration: 0.4)) { showToast = false }
            }
        }
        .overlay(alignment: .top) {
            if showToast {
                ToastView(message: "Saved!")
                    .transition(.move(edge: .top).combined(with: .opacity))
                    .padding(.top, 8)
            }
        }
    }
}

Full implementation

The pattern below separates concerns cleanly: a ToastMessage model carries content and style, a standalone ToastView handles rendering, and a reusable .toast() view modifier handles wiring so any screen can trigger a toast with one line. All animation uses the withAnimation + .transition pairing for smooth spring physics, and the overlay keeps the toast outside the layout flow so it never shifts your content.

import SwiftUI

// MARK: - Model

struct ToastMessage: Equatable {
    enum Style { case info, success, warning, error }
    let text: String
    let systemImage: String
    let style: Style
    var duration: TimeInterval = 2.5

    static let saved     = ToastMessage(text: "Saved",          systemImage: "checkmark.circle.fill", style: .success)
    static let networkErr = ToastMessage(text: "No connection", systemImage: "wifi.slash",             style: .error)
    static let copied    = ToastMessage(text: "Copied",         systemImage: "doc.on.doc.fill",        style: .info)
}

// MARK: - Toast View

struct ToastView: View {
    let message: ToastMessage

    private var background: Color {
        switch message.style {
        case .info:    return Color(.systemGray5)
        case .success: return Color.green.opacity(0.15)
        case .warning: return Color.orange.opacity(0.15)
        case .error:   return Color.red.opacity(0.15)
        }
    }

    private var foreground: Color {
        switch message.style {
        case .info:    return .primary
        case .success: return .green
        case .warning: return .orange
        case .error:   return .red
        }
    }

    var body: some View {
        HStack(spacing: 8) {
            Image(systemName: message.systemImage)
                .font(.system(size: 16, weight: .semibold))
                .foregroundStyle(foreground)
            Text(message.text)
                .font(.subheadline.weight(.medium))
                .foregroundStyle(.primary)
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 12)
        .background(.ultraThinMaterial, in: Capsule())
        .overlay(Capsule().stroke(background, lineWidth: 1.5))
        .shadow(color: .black.opacity(0.08), radius: 12, x: 0, y: 4)
        .accessibilityElement(children: .combine)
        .accessibilityLabel(message.text)
        .accessibilityAddTraits(.isStaticText)
    }
}

// MARK: - View Modifier

struct ToastModifier: ViewModifier {
    @Binding var toast: ToastMessage?

    func body(content: Content) -> some View {
        content
            .overlay(alignment: .top) {
                if let toast {
                    ToastView(message: toast)
                        .transition(.move(edge: .top).combined(with: .opacity))
                        .padding(.top, 8)
                        .zIndex(999)
                        .onTapGesture {
                            withAnimation(.spring(duration: 0.3)) { self.toast = nil }
                        }
                }
            }
            .onChange(of: toast) { _, newValue in
                guard let newValue else { return }
                Task {
                    try? await Task.sleep(for: .seconds(newValue.duration))
                    withAnimation(.spring(duration: 0.4)) { toast = nil }
                }
            }
    }
}

extension View {
    func toast(_ message: Binding<ToastMessage?>) -> some View {
        modifier(ToastModifier(toast: message))
    }
}

// MARK: - Usage Example

struct ContentView: View {
    @State private var currentToast: ToastMessage?

    var body: some View {
        NavigationStack {
            List {
                Button("Show Success") {
                    withAnimation(.spring(duration: 0.4)) {
                        currentToast = .saved
                    }
                }
                Button("Show Error") {
                    withAnimation(.spring(duration: 0.4)) {
                        currentToast = .networkErr
                    }
                }
                Button("Show Copied") {
                    withAnimation(.spring(duration: 0.4)) {
                        currentToast = .copied
                    }
                }
            }
            .navigationTitle("Toast Demo")
        }
        .toast($currentToast)
    }
}

// MARK: - Preview

#Preview("Toast Demo") {
    ContentView()
}

#Preview("Toast Styles") {
    VStack(spacing: 16) {
        ToastView(message: .saved)
        ToastView(message: .networkErr)
        ToastView(message: .copied)
        ToastView(message: ToastMessage(
            text: "Low storage",
            systemImage: "exclamationmark.triangle.fill",
            style: .warning
        ))
    }
    .padding()
    .background(Color(.systemGroupedBackground))
}

How it works

  1. Optional binding drives visibility. @State private var currentToast: ToastMessage? acts as a single source of truth — nil hides the toast, any value shows it. The if let toast inside the overlay unwraps it and re-renders automatically when it changes.
  2. .overlay(alignment: .top) keeps the toast out of the layout flow. Unlike ZStack, overlay never shifts surrounding content. Applying it to NavigationStack at the root ensures the toast sits above navigation bars and sheets.
  3. .transition(.move(edge: .top).combined(with: .opacity)) creates the slide-in effect. SwiftUI automatically plays the transition forward on insert and reversed on removal when both are wrapped in withAnimation.
  4. onChange(of: toast) triggers the auto-dismiss timer. Each time a new ToastMessage is set, a Task fires, sleeps for newValue.duration seconds, then sets the binding back to nil inside withAnimation. Cancellation is free — if a new toast replaces the old one, the previous Task is orphaned but harmlessly sets an already-replaced value.
  5. The tap-to-dismiss gesture on ToastView sets the binding to nil wrapped in withAnimation, allowing users to manually clear a toast with a spring-animated dismissal identical to the auto-dismiss.

Variants

Bottom-anchored toast (like Android Snackbar)

Change the overlay alignment and transition edge — everything else stays the same.

// In ToastModifier, replace the overlay with:
.overlay(alignment: .bottom) {
    if let toast {
        ToastView(message: toast)
            .transition(.move(edge: .bottom).combined(with: .opacity))
            .padding(.bottom, 32)   // clears home indicator
            .zIndex(999)
    }
}

Queue multiple toasts in order

Replace the single ToastMessage? binding with a @State var queue: [ToastMessage] = []. Show queue.first in the overlay, and in the Task call queue.removeFirst() after the sleep. Each removal triggers onChange again, chaining through the queue automatically — no extra timer logic required.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a toast notification system in SwiftUI for iOS 17+.
Use withAnimation and overlay.
Make it accessible (VoiceOver labels, accessibilityAnnouncement on show).
Support .success, .error, .warning, .info styles with SF Symbol icons.
Add a #Preview with realistic sample data showing all four styles.

In Soarias's Build phase, drop this prompt into a new feature file and Claude Code will scaffold the complete modifier, model, and preview — then you iterate on styling without touching the animation logic.

Related

FAQ

Does this work on iOS 16?

Mostly, yes — overlay(alignment:) and withAnimation are available from iOS 15. The .ultraThinMaterial background is iOS 15+. The two-argument onChange(of:) closure signature (capturing oldValue, newValue) requires iOS 17 — on iOS 16 use the single-argument form: .onChange(of: toast) { newValue in … }. The #Preview macro also requires Xcode 15+. Annotate the modifier with @available(iOS 16, *) if you need to support that target.

How do I prevent multiple rapid taps from stacking toasts?

Because the binding is a single ToastMessage?, setting it to a new value immediately replaces the previous one — there's no stacking by default. The only edge case is the orphaned auto-dismiss Task from the first tap eventually firing. Solve it by storing a reference: @State private var dismissTask: Task<Void, Never>? = nil, then call dismissTask?.cancel() at the top of onChange before creating the new task.

What's the UIKit equivalent?

In UIKit you'd add a UILabel (or custom UIView) to the key window's subview hierarchy, animate its transform and alpha properties via UIView.animate(withDuration:), and schedule removal with DispatchQueue.main.asyncAfter. Libraries like Toast-Swift and SPIndicator follow this approach. The SwiftUI overlay + withAnimation pattern is more concise, declarative, and lifecycle-safe.

Last reviewed: 2026-05-11 by the Soarias team.

```