How to Build a Toast Notification in SwiftUI
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
-
Optional binding drives visibility.
@State private var currentToast: ToastMessage?acts as a single source of truth —nilhides the toast, any value shows it. Theif let toastinside the overlay unwraps it and re-renders automatically when it changes. -
.overlay(alignment: .top)keeps the toast out of the layout flow. UnlikeZStack, overlay never shifts surrounding content. Applying it toNavigationStackat the root ensures the toast sits above navigation bars and sheets. -
.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 inwithAnimation. -
onChange(of: toast)triggers the auto-dismiss timer. Each time a newToastMessageis set, aTaskfires, sleeps fornewValue.durationseconds, then sets the binding back tonilinsidewithAnimation. Cancellation is free — if a new toast replaces the old one, the previousTaskis orphaned but harmlessly sets an already-replaced value. -
The tap-to-dismiss gesture on
ToastViewsets the binding tonilwrapped inwithAnimation, 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
-
Forgetting
withAnimationon both show and hide. Settingtoast = nilwithout wrapping inwithAnimationcauses the toast to vanish instantly — the transition only plays when the state mutation is animated. Always pair them. -
Placing
.toast()too low in the hierarchy. If applied to a view inside aNavigationStackrather than wrapping it, the overlay clips to that view's frame and can be hidden behind the navigation bar. Apply it to the outermost container — typically just outside yourNavigationStackorTabView. -
No VoiceOver announcement for dynamically appearing content. SwiftUI won't automatically focus VoiceOver on a new overlay element. Add
.accessibilityAnnouncement(message.text)(iOS 17 API) inside theonChangehandler so screen reader users hear the message when it appears.
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.