Skip to content

Alerts and Toasts

Daniel S edited this page Jul 2, 2026 · 1 revision

Alerts and Toasts

DesignFoundation provides four distinct feedback mechanisms for communicating with users: alerts for blocking decisions, toasts for non-blocking notifications, modals and sheets for contextual content, and popovers and tooltips for inline hints. Choosing the right primitive matters — the wrong one breaks user flow or gets ignored.

Decision Guide

Situation Use Rationale
Destructive action that cannot be undone DFAlert Requires explicit confirmation before proceeding
User made an error requiring acknowledgement DFAlert Blocks interaction until resolved
Action succeeded in the background DFToastQueue Non-blocking, auto-dismisses
Network or sync failure (non-critical) DFToastQueue .error Informational, does not block
Network failure requiring action DFAlert Forces user to decide: retry or cancel
Multi-step form or content preview DFModal or DFSheet Shows content without navigation
Contextual options for a specific element DFPopover Anchored to the triggering control
Label or hint for an unlabelled control DFTooltip Lightweight, hover/long-press triggered
Sign-out or account deletion DFAlert High-stakes — requires explicit consent
File saved, preference toggled DFToastQueue .success Low-stakes confirmation

DFAlert

Alerts are blocking — the user must respond before continuing. Use them for decisions with consequences: data deletion, navigation away from unsaved changes, irreversible account actions.

Struct Anatomy

public struct DFAlert: Sendable {
    public let title: String           // Required. Short imperative phrase.
    public let message: String?        // Optional. 1–2 sentence explanation.
    public let actions: [DFAlertAction]
}

public struct DFAlertAction: Sendable {
    public let title: String
    public let role: DFAlertActionRole
    public let action: @escaping @Sendable () -> Void
}

public enum DFAlertActionRole: Sendable {
    case `default`    // Standard blue text (iOS) / standard button (macOS)
    case cancel       // Bold text (iOS), placed last / leftmost (macOS)
    case destructive  // Red text (iOS), conveys irreversibility
}

Action Roles Reference

Role iOS rendering macOS rendering When to use
.default Blue text button Standard push button Confirming a safe action, "OK", "Continue", "Retry"
.cancel Bold text, auto-positioned last Leftmost button Always provide one — lets user opt out
.destructive Red text Red-tinted button Delete, Remove, Sign Out, Discard

Modifier Placement

Apply .dfAlert to the view that owns the triggering state:

struct ArticleView: View {
    @State private var showDeleteAlert = false

    var body: some View {
        Button("Delete Article") { showDeleteAlert = true }
            .dfAlert(isPresented: $showDeleteAlert, alert: DFAlert(
                title: "Delete article?",
                message: "This will permanently remove the article and all its revisions.",
                actions: [
                    DFAlertAction(title: "Cancel", role: .cancel) { },
                    DFAlertAction(title: "Delete", role: .destructive) { deleteArticle() },
                ]
            ))
    }
}

Example 1 — Delete Confirmation

struct DocumentRow: View {
    let document: Document
    @State private var showDeleteAlert = false
    @EnvironmentObject private var store: DocumentStore

    var body: some View {
        HStack {
            DFText(document.name, style: .body)
            Spacer()
            DFButton(icon: "trash") { showDeleteAlert = true }
                .dfButtonStyle(.ghost)
        }
        .dfAlert(isPresented: $showDeleteAlert, alert: DFAlert(
            title: "Delete "\(document.name)"?",
            message: "This cannot be undone. Collaborators will immediately lose access.",
            actions: [
                DFAlertAction(title: "Cancel", role: .cancel) { },
                DFAlertAction(title: "Delete", role: .destructive) {
                    store.delete(document)
                },
            ]
        ))
    }
}

Example 2 — Discard Changes

struct EditProfileSheet: View {
    @Binding var isPresented: Bool
    @State private var showDiscardAlert = false
    @State private var name: String = ""
    private var hasChanges: Bool { !name.isEmpty }

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            HStack {
                DFButton("Cancel") {
                    if hasChanges { showDiscardAlert = true }
                    else { isPresented = false }
                }
                .dfButtonStyle(.ghost)
                Spacer()
                DFButton("Save") { saveProfile() }
            }
            DFTextField("Name", text: $name)
        }
        .padding()
        .dfAlert(isPresented: $showDiscardAlert, alert: DFAlert(
            title: "Discard changes?",
            message: "Your edits haven't been saved and will be lost.",
            actions: [
                DFAlertAction(title: "Keep Editing", role: .cancel) { },
                DFAlertAction(title: "Discard", role: .destructive) {
                    isPresented = false
                },
            ]
        ))
    }

    func saveProfile() { }
}

Example 3 — Sign Out

struct SettingsView: View {
    @State private var showSignOutAlert = false
    @EnvironmentObject private var authStore: AuthStore

    var body: some View {
        DFList {
            DFListRow("Sign Out", accessory: .navigation)
                .onTapGesture { showSignOutAlert = true }
        }
        .dfAlert(isPresented: $showSignOutAlert, alert: DFAlert(
            title: "Sign out?",
            message: "You will need to sign in again to access your account.",
            actions: [
                DFAlertAction(title: "Cancel", role: .cancel) { },
                DFAlertAction(title: "Sign Out", role: .destructive) {
                    authStore.signOut()
                },
            ]
        ))
    }
}

Example 4 — Network Error Requiring Retry

.dfAlert(isPresented: $showErrorAlert, alert: DFAlert(
    title: "Sync failed",
    message: syncError.map { "Could not connect to the server. \($0.localizedDescription)" },
    actions: [
        DFAlertAction(title: "Cancel", role: .cancel) { },
        DFAlertAction(title: "Retry", role: .default) {
            Task { try? await SyncEngine.run() }
        },
    ]
))

Example 5 — Multi-Action Alert

.dfAlert(isPresented: $showAlert, alert: DFAlert(
    title: "Save this session?",
    message: "You have unsaved changes. Would you like to save before closing?",
    actions: [
        DFAlertAction(title: "Don't Save", role: .destructive) {
            SessionManager.close(save: false)
        },
        DFAlertAction(title: "Save", role: .default) {
            SessionManager.close(save: true)
        },
        DFAlertAction(title: "Cancel", role: .cancel) { },
    ]
))

DFToastQueue

Toasts are non-blocking — they appear briefly and auto-dismiss. Use them for confirmations, background status updates, and soft error notifications.

@MainActor and Thread Safety

DFToastQueue is @MainActor. All calls to show() must originate on the main thread.

// ✅ From a SwiftUI action (always main thread)
DFButton("Save") {
    DFToastQueue.shared.show("Saved", style: .success)
}

// ✅ From a background Task
Task {
    await upload()
    Task { @MainActor in
        DFToastQueue.shared.show("Upload complete", style: .success)
    }
}

// ✅ Using MainActor.run
Task.detached {
    let count = await processQueue()
    await MainActor.run {
        DFToastQueue.shared.show("\(count) items processed", style: .info)
    }
}

show() Overloads

// Convenience — string + severity
DFToastQueue.shared.show("Message", style: .success)

// Full configuration
DFToastQueue.shared.show(DFToastMessage(
    text: "File exported",
    style: .success,
    icon: "arrow.up.doc.fill",      // optional SF Symbol
    duration: 3.0,                   // seconds; default 2.5
    action: DFToastAction(
        label: "View",
        action: { openExportFolder() }
    )
))

DFToastSeverity Visual Guide

Case Background When to use
.success Green Operation completed successfully
.error Red Failure the user should know about
.info Blue Status update, background progress
.warning Amber Attention needed, but not an error
DFToastQueue.shared.show("Profile saved", style: .success)
DFToastQueue.shared.show("Export failed — disk full", style: .error)
DFToastQueue.shared.show("Syncing in background…", style: .info)
DFToastQueue.shared.show("Storage almost full (92%)", style: .warning)

Root Modifier Setup

Apply .dfToast(queue:) exactly once at the top of your view hierarchy:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .dfToast(queue: DFToastQueue.shared)  // ← here, once
        }
    }
}

Queue Behavior

DFToastQueue manages a FIFO queue. Multiple toasts display sequentially — each for its full duration. To cancel pending toasts: DFToastQueue.shared.cancelAll().

Example 1 — Save Success

DFButton("Save") {
    NoteStore.save(body)
    DFToastQueue.shared.show("Note saved", style: .success)
}

Example 2 — Upload Error

DFButton("Attach File", icon: "paperclip") {
    Task {
        do {
            try await UploadService.upload(url)
            DFToastQueue.shared.show("File attached", style: .success)
        } catch UploadError.fileTooLarge {
            DFToastQueue.shared.show(DFToastMessage(
                text: "File exceeds 25 MB limit",
                style: .error,
                icon: "exclamationmark.circle"
            ))
        }
    }
}

Example 3 — Async Form Submit with Status Progression

struct ContactForm: View {
    @State private var name = ""
    @State private var isSubmitting = false

    var body: some View {
        DFButton("Submit", isLoading: isSubmitting) {
            guard !isSubmitting else { return }
            isSubmitting = true
            DFToastQueue.shared.show("Submitting…", style: .info)

            Task {
                do {
                    try await FormAPI.submit(name: name)
                    DFToastQueue.shared.show("Message sent!", style: .success)
                } catch {
                    DFToastQueue.shared.show(DFToastMessage(
                        text: "Couldn't send — check your connection",
                        style: .error,
                        duration: 4.0
                    ))
                }
                isSubmitting = false
            }
        }
    }
}

Example 4 — Background Sync from Detached Task

Task.detached(priority: .background) {
    do {
        let count = try await self.sync()
        await MainActor.run {
            DFToastQueue.shared.show(DFToastMessage(
                text: "\(count) items synced",
                style: .success,
                icon: "arrow.triangle.2.circlepath"
            ))
        }
    } catch {
        await MainActor.run {
            DFToastQueue.shared.show("Sync failed", style: .error)
        }
    }
}

Example 5 — Toast with Undo Action

func archiveMessage(_ message: Message) {
    MessageStore.archive(message)
    DFToastQueue.shared.show(DFToastMessage(
        text: "Archived",
        style: .success,
        icon: "archivebox",
        action: DFToastAction(label: "Undo") {
            MessageStore.unarchive(message)
        }
    ))
}

Example 6 — Full DFToastMessage

DFToastQueue.shared.show(DFToastMessage(
    text: "Report exported as PDF",
    style: .success,
    icon: "doc.richtext",
    duration: 5.0,
    action: DFToastAction(label: "Share") {
        ShareSheet.present(exportedURL)
    }
))

DFModal

Presents full-size content overlaid on the current view.

DFModal(isPresented: $showModal) { ModalContent() }

// Glass style (iOS 26+ / macOS 26+)
DFModal(isPresented: $showModal) { Content() }
    .dfModalStyle(.glass)

Dismiss from within modal content by passing the binding:

struct ComposeModalContent: View {
    @Binding var isPresented: Bool

    var body: some View {
        VStack {
            HStack {
                DFButton("Cancel") { isPresented = false }
                    .dfButtonStyle(.ghost)
                Spacer()
                DFButton("Send") { send(); isPresented = false }
            }
        }
        .padding()
    }
}

DFSheet

Presents content in a bottom sheet (iOS) or floating panel (macOS).

DFSheet(isPresented: $showSheet) { SheetContent() }
DFSheet(isPresented: $showSheet) { Content() }.dfSheetStyle(.compact)
DFSheet(isPresented: $showSheet) { Content() }.dfSheetStyle(.glass)  // iOS 26+

Presentation Detents (iOS)

DFSheet(isPresented: $showFilters) {
    FilterPickerView()
        .presentationDetents([.medium, .large])
        .presentationDragIndicator(.visible)
}

Use @Environment(\.dismiss) inside the sheet to dismiss programmatically.


DFPopover

Anchors a floating overlay to a specific view. The anchor parameter is a CGRect binding populated from a GeometryReader.

struct TagSelector: View {
    @State private var showPopover = false
    @State private var anchor: CGRect = .zero

    var body: some View {
        DFButton("Tags", icon: "tag") { showPopover = true }
            .background(
                GeometryReader { geo in
                    Color.clear.onAppear { anchor = geo.frame(in: .global) }
                }
            )
            .overlay {
                DFPopover(isPresented: $showPopover, anchor: $anchor) {
                    TagPickerContent(onDismiss: { showPopover = false })
                }
                .dfPopoverStyle(.compact)
            }
    }
}

Styles: .arrow (default), .compact, .glass (iOS 26+).


DFTooltip

Wraps any view and shows a brief hint on hover (macOS) or long-press (iOS).

DFTooltip("Refresh data") {
    DFButton(icon: "arrow.clockwise") { refresh() }
        .dfButtonStyle(.ghost)
        .accessibilityLabel("Refresh")
}

// Toolbar icons
HStack(spacing: 4) {
    DFTooltip("Bold (⌘B)") { DFButton(icon: "bold") { applyBold() }.dfButtonStyle(.ghost) }
    DFTooltip("Insert link") { DFButton(icon: "link") { insertLink() }.dfButtonStyle(.ghost) }
    DFTooltip("Attach file") { DFButton(icon: "paperclip") { attachFile() }.dfButtonStyle(.ghost) }
}

Stacking and Coordination

Toast After Dismissing an Alert

Trigger a toast directly from an action closure — it runs on the main actor:

DFAlertAction(title: "Delete", role: .destructive) {
    ProjectStore.delete(project)
    DFToastQueue.shared.show("Project deleted", style: .success)
}

Alert Inside a Modal

Attach .dfAlert to the content view inside the modal, not to the DFModal call site:

// ✅ Correct — modifier is inside the modal content
DFModal(isPresented: $showModal) {
    EditDocumentView()
        .dfAlert(isPresented: $showDeleteAlert, alert: deleteAlert)
}

Preventing Double-Alerts

func confirmDelete(_ file: File) {
    guard !showDeleteAlert else { return }
    fileToDelete = file
    showDeleteAlert = true
}

Accessibility

DFAlert

  • Backed by native platform alerts — VoiceOver announces title and actions automatically.
  • Keep title to a single short phrase; message is read after.
  • Never encode urgency in emoji alone.

DFToastQueue

  • Posts accessibility announcements automatically via UIAccessibility.post.
  • Keep toast text concise — VoiceOver reads the full text field.
  • DFToastAction labels are reachable as accessibility elements while the toast is visible. Use verb-noun labels: "Undo", "Open", "View".

DFModal and DFSheet

  • Focus is trapped inside the overlay automatically.
  • Always provide a visible dismiss path — don't rely on swipe-to-dismiss alone.

DFTooltip

  • On macOS, the tooltip string is set as accessibilityHint on the wrapped view.
  • Ensure icon-only controls also have .accessibilityLabel independently of the tooltip.

DesignFoundation Pro

Auth screens — sign-in, sign-up, OTP verification, and forgot-password — in DesignFoundation Pro ship with built-in alert and sheet flows that handle error states, loading indicators, and confirmation dialogs out of the box.

DesignFoundation Pro


See also: Theming · Buttons · Navigation · Lists-Tables-and-Data · Loading-States

Clone this wiki locally