-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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 |
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.
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
}| 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 |
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() },
]
))
}
}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)
},
]
))
}
}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() { }
}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()
},
]
))
}
}.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() }
},
]
)).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) { },
]
))Toasts are non-blocking — they appear briefly and auto-dismiss. Use them for confirmations, background status updates, and soft error notifications.
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)
}
}// 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() }
)
))| 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)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
}
}
}DFToastQueue manages a FIFO queue. Multiple toasts display sequentially — each for its full duration. To cancel pending toasts: DFToastQueue.shared.cancelAll().
DFButton("Save") {
NoteStore.save(body)
DFToastQueue.shared.show("Note saved", style: .success)
}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"
))
}
}
}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
}
}
}
}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)
}
}
}func archiveMessage(_ message: Message) {
MessageStore.archive(message)
DFToastQueue.shared.show(DFToastMessage(
text: "Archived",
style: .success,
icon: "archivebox",
action: DFToastAction(label: "Undo") {
MessageStore.unarchive(message)
}
))
}DFToastQueue.shared.show(DFToastMessage(
text: "Report exported as PDF",
style: .success,
icon: "doc.richtext",
duration: 5.0,
action: DFToastAction(label: "Share") {
ShareSheet.present(exportedURL)
}
))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()
}
}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+DFSheet(isPresented: $showFilters) {
FilterPickerView()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}Use @Environment(\.dismiss) inside the sheet to dismiss programmatically.
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+).
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) }
}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)
}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)
}func confirmDelete(_ file: File) {
guard !showDeleteAlert else { return }
fileToDelete = file
showDeleteAlert = true
}- Backed by native platform alerts — VoiceOver announces title and actions automatically.
- Keep
titleto a single short phrase;messageis read after. - Never encode urgency in emoji alone.
- Posts accessibility announcements automatically via
UIAccessibility.post. - Keep toast text concise — VoiceOver reads the full
textfield. -
DFToastActionlabels are reachable as accessibility elements while the toast is visible. Use verb-noun labels: "Undo", "Open", "View".
- Focus is trapped inside the overlay automatically.
- Always provide a visible dismiss path — don't rely on swipe-to-dismiss alone.
- On macOS, the tooltip string is set as
accessibilityHinton the wrapped view. - Ensure icon-only controls also have
.accessibilityLabelindependently of the tooltip.
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.
See also: Theming · Buttons · Navigation · Lists-Tables-and-Data · Loading-States