SwiftUI Toast Component

A transient message, presented as a modifier so it overlays the whole screen instead of being wedged into your layout.

Saved to your library

Variants: Info, Success, Warning, Error

Install the free SwiftUI toast

  1. Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
  2. Copy SKToast.swift below into your project. It needs nothing else.

Usage

Usage.swift
.skToast($toast)
toast = SKToast("Saved", style: .success)

Full source · 146 lines · MIT

SKToast.swift
//
//  SKToast.swift
//  Swift Kit UI
//
//  Requires: SwiftKitUI.swift
//
//  A transient message, presented as a modifier so it overlays the whole screen
//  instead of being wedged into your layout.
//
//  Swipe up to dismiss early. The auto-dismiss timer is a Task tied to the
//  toast's identity, so replacing a toast cancels the previous timer — without
//  that, a rapid second toast inherits the first one's countdown and vanishes
//  almost immediately.
//
//  Usage:
//      .skToast($toast)
//      toast = SKToast("Saved", style: .success)
//

import SwiftUI

public struct SKToast: Equatable, Identifiable, Sendable {
    public enum Style: Sendable {
        case info, success, warning, error

        var tint: Color {
            switch self {
            case .info: SK.Palette.accent
            case .success: SK.Palette.success
            case .warning: SK.Palette.warning
            case .error: SK.Palette.danger
            }
        }

        var symbol: String {
            switch self {
            case .info: "info.circle.fill"
            case .success: "checkmark.circle.fill"
            case .warning: "exclamationmark.triangle.fill"
            case .error: "xmark.octagon.fill"
            }
        }

        var haptic: SK.Haptic.Style {
            switch self {
            case .info: .light
            case .success: .success
            case .warning: .warning
            case .error: .error
            }
        }
    }

    public let id = UUID()
    public let message: String
    public let style: Style
    public let duration: Double

    public init(_ message: String, style: Style = .info, duration: Double = 2.4) {
        self.message = message
        self.style = style
        self.duration = duration
    }

    public static func == (a: SKToast, b: SKToast) -> Bool { a.id == b.id }
}

public enum SKToastEdge: Sendable { case top, bottom }

private struct SKToastModifier: ViewModifier {
    @Binding var toast: SKToast?
    let edge: SKToastEdge

    func body(content: Content) -> some View {
        content
            .overlay(alignment: edge == .top ? .top : .bottom) {
                if let toast {
                    banner(toast)
                        .padding(.horizontal, SK.Space.lg)
                        .padding(edge == .top ? .top : .bottom, SK.Space.sm)
                        .transition(.move(edge: edge == .top ? .top : .bottom)
                            .combined(with: .opacity))
                }
            }
            .skAnimation(.settle, value: toast)
            // Keyed on the toast id: a new toast starts a fresh timer and the
            // old one is cancelled automatically.
            .task(id: toast?.id) {
                guard let current = toast else { return }
                SK.Haptic.play(current.style.haptic)
                try? await Task.sleep(for: .seconds(current.duration))
                guard !Task.isCancelled else { return }
                toast = nil
            }
    }

    private func banner(_ toast: SKToast) -> some View {
        HStack(spacing: SK.Space.md) {
            Image(systemName: toast.style.symbol)
                .font(SK.Icon.symbol(SK.Icon.md))
                .foregroundStyle(toast.style.tint)
            Text(toast.message)
                .font(SK.Type_.callout)
                .foregroundStyle(SK.Palette.label)
                .fixedSize(horizontal: false, vertical: true)
            Spacer(minLength: 0)
        }
        .padding(.horizontal, SK.Space.lg)
        .padding(.vertical, SK.Space.md)
        .skSurface(.elevated, radius: SK.Radius.lg)
        .gesture(
            DragGesture(minimumDistance: 8).onEnded { g in
                let dismissed = edge == .top ? g.translation.height < -20 : g.translation.height > 20
                if dismissed { self.toast = nil }
            }
        )
        .accessibilityElement(children: .combine)
        .accessibilityAddTraits(.isStaticText)
    }
}

extension View {
    /// Presents `toast` over this view, auto-dismissing after its duration.
    public func skToast(_ toast: Binding<SKToast?>, edge: SKToastEdge = .top) -> some View {
        modifier(SKToastModifier(toast: toast, edge: edge))
    }
}

#Preview("SKToast") {
    @Previewable @State var toast: SKToast?

    VStack(spacing: SK.Space.md) {
        Button("Info") { toast = SKToast("Synced 42 items") }
        Button("Success") { toast = SKToast("Saved", style: .success) }
        Button("Warning") { toast = SKToast("Running low on space", style: .warning) }
        Button("Error") { toast = SKToast("Could not connect", style: .error) }
    }
    .buttonStyle(.skPress)
    .font(SK.Type_.callout)
    .foregroundStyle(SK.Palette.accent)
    .padding(SK.Space.xl)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(SK.Palette.surfaceSunken)
    .skToast($toast)
}

Questions

Is this SwiftUI toast free for commercial apps?
Yes. Swift Kit UI is MIT licensed, so you can use it in personal and commercial apps with no payment or sign-up. Keep the licence notice with the code.
What does it depend on?
SKToast.swift depends only on SwiftKitUI.swift, a single foundation file with the design tokens and motion curves. There are no third-party packages.
Which iOS versions does it support?
iOS 17 and later, plus macOS 14.

Related components

Building a whole app? The Swift Kit adds auth, payments and onboarding, ready to ship.