Greetings, traveler!
SwiftUI still doesn’t provide a native toast component. Alerts and sheets exist, but both are modal by design. A toast solves a different problem: it delivers short-lived feedback without interrupting the user’s flow.
In this article, I’ll walk through a practical way to implement a toast in SwiftUI. The solution is lightweight, reusable, and works above the entire app UI.
What a Toast Is (and What It Isn’t)
A toast is a transient message that:
- appears for a short period of time,
- does not block interaction with the underlying UI,
- dismisses itself automatically,
- can often be dismissed manually with a gesture.
Why a Simple .overlay Is Often Not Enough
The most common SwiftUI approach is to attach a toast via .overlay somewhere in the view hierarchy. This works for simple cases, but it has limitations:
- the toast is constrained to the current view tree,
- it may appear behind navigation bars, tab bars, or sheets,
- it requires a “root container” to work reliably.
If you want a toast that consistently appears above everything — navigation, tabs, modals — you need a different approach.
The Core Idea: A Temporary Overlay Window
This implementation uses a short-lived UIWindow placed above the app’s main window.
The idea is simple:
- When a toast is requested, create a transparent window above the app.
- Render SwiftUI content inside that window using
UIHostingController. - Make only the toast interactive; let all other touches pass through.
- Animate the toast in and out.
- Destroy the window when the toast is dismissed.
This keeps the toast visually global while remaining decoupled from the app’s view hierarchy.
Public API
The API is intentionally minimal:
.toast(
isPresented: $isPresented,
message: "Saved successfully",
duration: 2,
edge: .top
)Or, if you need full control over the content:
.toast(
isPresented: $isPresented
) {
CustomToastView()
}The modifier is applied to any view, but the toast itself is not rendered inside that view. It lives in its own overlay window.
Step 1: The View Modifier Entry Point
The public toast modifiers are thin wrappers. Their only responsibility is to forward configuration to an internal ToastWindowModifier.
This keeps the API surface clean while allowing the implementation to evolve independently.
private struct ToastWindowModifier<T: View>: ViewModifier {
@Binding var isPresented: Bool
let duration: TimeInterval?
let edge: VerticalEdge
let onDismiss: (() -> Void)?
let toastView: () -> T
@State private var overlay = OverlayWindow()
@State private var isToastPresented: Bool = false
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
content
.onChange(of: isPresented) { _, newValue in
if newValue {
overlay.show {
Color.clear
.toastOverlay(
isPresented: $isToastPresented,
duration: duration,
edge: edge,
onDismiss: {
overlay.hide()
onDismiss?()
},
content: toastView
)
.preferredColorScheme(colorScheme)
}
withAnimation {
isToastPresented = true
}
} else {
isToastPresented = false
}
}
.onChange(of: isToastPresented) { _, newValue in
if !newValue { isPresented = false }
}
}
}Step 2: Rendering the Toast UI
The actual toast UI lives in a separate modifier, ToastOverlayModifier.
This layer handles:
- positioning at the top or bottom of the screen,
- entry and exit animations,
- automatic dismissal using a
Task, - drag-to-dismiss gestures with threshold handling.
The toast is added via .overlay(alignment:) and animated using a directional transition, matching the chosen edge.
Manual dismissal feels natural: dragging in the correct direction fades and moves the toast until it crosses a threshold.
private struct ToastOverlayModifier<T: View>: ViewModifier {
@Binding var isPresented: Bool
let duration: TimeInterval?
let edge: VerticalEdge
let onDismiss: (() -> Void)?
let toastView: () -> T
private let animationDuration: TimeInterval = 0.3
@State private var dismissTask: Task<Void, Never>? = nil
@State private var dragOffsetY: CGFloat = 0
private var aligment: Alignment {
switch edge {
case .top: .top
case .bottom: .bottom
}
}
private var transitionEdge: Edge {
switch edge {
case .top: .top
case .bottom: .bottom
}
}
func body(content: Content) -> some View {
content
.onChange(of: isPresented) { _, newValue in
guard !newValue else { return }
cancelAutoDismiss()
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(animationDuration * 1_000_000_000))
onDismiss?()
}
}
.overlay(alignment: aligment) {
if isPresented {
toastView()
.offset(y: dragOffsetY)
.opacity(Double(max(CGFloat(0.5), 1 - abs(dragOffsetY) / 200)))
.gesture(
DragGesture(minimumDistance: 5, coordinateSpace: .local)
.onChanged { value in
cancelAutoDismiss()
// Only track vertical drag in the correct direction relative to edge
let dy = value.translation.height
switch edge {
case .bottom:
// Allow dragging down (positive dy); clamp upwards movement to zero to avoid jitter
dragOffsetY = max(0, dy)
case .top:
// Allow dragging up (negative dy); clamp downwards movement to zero
dragOffsetY = min(0, dy)
}
}
.onEnded { value in
let threshold: CGFloat = 30
let dy = value.translation.height
var shouldDismiss = false
switch edge {
case .bottom:
if dy > threshold { shouldDismiss = true }
case .top:
if dy < -threshold { shouldDismiss = true }
}
if shouldDismiss {
// Trigger dismiss and reset offset
withAnimation(.bouncy(duration: animationDuration)) {
isPresented = false
}
} else {
scheduleAutoDismiss()
// Snap back
withAnimation(.bouncy(duration: animationDuration)) {
dragOffsetY = 0
}
}
}
)
.transition(.move(edge: transitionEdge).combined(with: .blurReplace))
.onAppear {
scheduleAutoDismiss()
}
}
}
.animation(.bouncy, value: isPresented)
}
private func scheduleAutoDismiss() {
guard let duration, duration > 0 else { return }
cancelAutoDismiss()
dismissTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
if !Task.isCancelled && isPresented {
isPresented = false
}
}
}
private func cancelAutoDismiss() {
dismissTask?.cancel()
dismissTask = nil
}
}ToastWindowModifier is responsible for showing and hiding the overlay window.
When isPresented becomes true:
- a new transparent
UIWindowis created, - a
UIHostingControllerrenders the SwiftUI content, - the window is placed above the app using a high window level,
- the toast overlay is activated.
When the toast dismisses:
- the overlay animates out,
- the window is hidden and released,
- state is synchronized back to the original binding.
This separation avoids animation glitches and keeps dismissal logic predictable.
Step 4: Passing Touches Through the Window
A standard UIWindow intercepts all touches, even if it’s transparent. That would block interaction with the app underneath.
To solve this, the implementation uses a custom UIWindow subclass that overrides hitTest. Touches are only handled if they land on an interactive subview (the toast). Everything else passes through to the app below.
The tricky part is to handle different iOS versions and the new .glassEffect modifier. With .glassEffect applied, the layer name of "@1" and .glassProminent has a layer name of "@2". Therefore, we can check the prefix to detect the Liquid Glass effect.
public final class PassThroughWindow: UIWindow {
private var handledEvents = Set<UIEvent>()
public override init(frame: CGRect) {
super.init(frame: frame)
}
public convenience init() {
self.init(frame: .zero)
}
@available(iOS 13.0, *)
public override init(windowScene: UIWindowScene) {
super.init(windowScene: windowScene)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override final func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let rootViewController, let rootView = rootViewController.view else { return nil }
guard let event else {
return super.hitTest(point, with: nil)
}
guard let hitView = super.hitTest(point, with: event) else {
handledEvents.removeAll()
return nil
}
if handledEvents.contains(event) {
handledEvents.removeAll()
return hitView
} else if #available(iOS 26, *) {
handledEvents.insert(event)
let name = rootView.layer.hitTest(point)?.name
if name == nil {
return hitView
} else if name?.starts(with: "@") == true { // Liquid Glass Detection
if let realHit = deepestHitView(in: rootView, at: point, with: event) {
if realHit === rootView {
return nil
} else {
return realHit
}
} else {
return nil
}
} else {
return nil
}
} else if #available(iOS 18, *) {
handledEvents.insert(event)
return hitView
} else {
return hitView
}
}
private func deepestHitView(in root: UIView, at point: CGPoint, with event: UIEvent?) -> UIView? {
guard !root.isHidden, root.alpha > 0.01, root.isUserInteractionEnabled else { return nil }
for subview in root.subviews.reversed() {
let pointInSubview = subview.convert(point, from: root)
if let hit = deepestHitView(in: subview, at: pointInSubview, with: event) {
return hit
}
}
return root.point(inside: point, with: event) ? root : nil
}
}You can read more about this component here.
Step 5: Scene and Focus Safety
The overlay window is attached to the currently active scene rather than assuming a single window app.
It also avoids becoming the key window, preventing side effects with the first responder, keyboard, or focus handling.
@MainActor
private final class OverlayWindow {
private var window: UIWindow?
func show<Content: View>(@ViewBuilder content: () -> Content) {
let scenes = UIApplication
.shared
.connectedScenes
.compactMap { $0 as? UIWindowScene }
let scene = scenes.first(where: { $0.activationState == .foregroundActive })
?? scenes.first(where: { $0.activationState == .foregroundInactive })
?? scenes.first
guard let scene else { return }
let window = PassThroughWindow(windowScene: scene)
let controller = UIHostingController(rootView: content())
controller.view.backgroundColor = .clear
window.windowLevel = .alert + 1
window.backgroundColor = .clear
window.rootViewController = controller
window.isHidden = false
self.window = window
}
func hide() {
window?.isHidden = true
window = nil
}
}Example
Finally, let’s consider an example of usage:
import SwiftUI
struct ToastDemo: View {
@State private var isToastPresented: Bool = false
var body: some View {
Button("Show Toast") {
isToastPresented.toggle()
}
.toast(
isPresented: $isToastPresented,
duration: 2,
edge: .top
) {
HStack(spacing: 12) {
Image(systemName: "bell.fill")
.foregroundStyle(.yellow)
Text("Custom content toast")
.font(.callout)
.fontWeight(.semibold)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
)
}
}
}Conclusion
The final component has the properties we want:
- works anywhere in the app,
- stays above all UI layers,
- doesn’t block interaction,
- supports gestures and auto-dismiss,
- remains simple to use.
Most importantly, it behaves like a toast should — informative, unobtrusive, and temporary.
The full implementation is available on GitHub.
