Skip to content

Overlays and Feedback

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

Overlays and Feedback

DesignFoundation ships six overlay and feedback components: DFModal, DFSheet, DFPopover, DFTooltip, DFAlert, and DFToast. Each one is platform-adaptive, theme-aware, and follows the same style protocol pattern as the rest of the library.

Related pages: Getting-Started · Theming · Buttons · Forms-and-Validation


DFModal

DFModal presents a focused full-screen or center-stage overlay that requires explicit dismissal. Use for multi-step flows, detail editing, or any interaction that should block the underlying content.

Modifier Overloads

// 1. Boolean binding
func dfModal(isPresented: Binding<Bool>, @ViewBuilder content: () -> Content) -> some View

// 2. Optional item binding (dismisses when item becomes nil)
func dfModal<Item: Identifiable>(
    item: Binding<Item?>,
    @ViewBuilder content: (Item) -> Content
) -> some View

Styles

Style Token Description
DFDefaultModalStyle .default Centered card on macOS; full sheet on iOS.
DFFullScreenModalStyle .fullScreen Fills the entire screen on all platforms.
DFGlassModalStyle .glass .regularMaterial backing. iOS 26+ / macOS 26+.

Apply with .dfModalStyle(_:).

Dismiss Behavior

By default DFModal dismisses when the user taps the scrim. Disable scrim-tap dismissal:

YourView()
    .dfModal(isPresented: $showModal) {
        ConfirmationModal()
    }
    .dfModalDismissesOnScrimTap(false)

Examples

Basic modal

DFButton("Add Project") { showProjectForm = true }
    .dfModal(isPresented: $showProjectForm) {
        ProjectFormView(onSave: { project in
            save(project)
            showProjectForm = false
        })
    }

Item-driven modal

DFList(items) { item in
    DFListRow(item.title)
        .onTapGesture { selectedItem = item }
}
.dfModal(item: $selectedItem) { item in
    ItemDetailModal(item: item, onDismiss: { selectedItem = nil })
}

Full-screen editing flow

DFButton("Edit Profile") { showEditor = true }
    .dfModal(isPresented: $showEditor) {
        ProfileEditorView(onComplete: { showEditor = false })
            .dfNavigationBar(title: "Edit Profile", trailing: {
                DFButton("Save") { saveProfile(); showEditor = false }
            })
    }
    .dfModalStyle(.fullScreen)

Glass modal (iOS 26+)

DFButton("Preview") { showPreview = true }
    .dfModal(isPresented: $showPreview) {
        PreviewContent()
    }
    .dfModalStyle(.glass)

Non-dismissable confirmation

DFButton("Start Import", role: .destructive) { showImport = true }
    .dfModal(isPresented: $showImport) {
        ImportProgressView(onComplete: { showImport = false })
    }
    .dfModalDismissesOnScrimTap(false)

DFSheet

DFSheet presents content from the bottom edge of the screen on iOS and as a popover on macOS. Ideal for contextual controls, quick editing, and supplementary content that doesn't need full-screen prominence.

Initializer

func dfSheet(isPresented: Binding<Bool>, @ViewBuilder content: () -> Content) -> some View

Detent Styles

Detent Token Description
.medium default on iOS Sheet rests at 50% of screen height. Draggable to .large.
.large .large Sheet covers most of the screen.
.fraction(CGFloat) .fraction(_:) Custom fraction of screen height.

Apply detent with .dfSheetDetent(_:).

Examples

Action sheet

DFListRow("Archive")
    .onTapGesture { showArchiveOptions = true }
    .dfSheet(isPresented: $showArchiveOptions) {
        VStack(spacing: 16) {
            DFText("Archive Options", style: .headline)
            DFButton("Archive immediately") { archiveNow() }
            DFButton("Schedule for later") { showScheduler = true }
            DFButton("Cancel", role: .cancel) { showArchiveOptions = false }
                .dfButtonStyle(.outlined)
        }
        .padding()
    }

Quick profile sheet

.dfSheet(isPresented: $showProfileSheet) {
    ProfileQuickViewSheet(user: user, onFollow: { follow(user) })
}
.dfSheetDetent(.medium)

Detail sheet with large detent

.dfSheet(isPresented: $showDetailSheet) {
    ItemDetailSheet(item: item)
}
.dfSheetDetent(.large)

Custom height

.dfSheet(isPresented: $showFilterPanel) {
    FilterPanelView(filters: $filters)
}
.dfSheetDetent(.fraction(0.4))

DFPopover

DFPopover shows a floating panel anchored to a specific view. On iOS it appears as a popover bubble; on macOS it renders as a proper popover with an arrow pointing to the anchor element.

Initializer

func dfPopover<Content: View>(
    isPresented: Binding<Bool>,
    arrowEdge: Edge = .top,
    @ViewBuilder content: () -> Content
) -> some View

Styles

Style Token Description
DFDefaultPopoverStyle .default Themed card with shadow.
DFTintedPopoverStyle .tinted Lightly color-tinted background.
DFGlassPopoverStyle .glass Material backing. iOS 26+ / macOS 26+.

Apply with .dfPopoverStyle(_:).

Examples

Info tooltip popover

DFButton("?") { showInfo.toggle() }
    .dfButtonStyle(.ghost)
    .dfPopover(isPresented: $showInfo, arrowEdge: .bottom) {
        VStack(alignment: .leading, spacing: 8) {
            DFText("Why is this required?", style: .headline)
            DFText(
                "We use your billing address to calculate applicable taxes.",
                style: .body
            )
            .frame(maxWidth: 260)
        }
        .padding()
    }

Color picker popover

DFButton(action: { showColorPicker.toggle() }) {
    Circle().fill(selectedColor).frame(width: 20, height: 20)
}
.dfButtonStyle(.ghost)
.dfPopover(isPresented: $showColorPicker, arrowEdge: .top) {
    ColorPickerGrid(selection: $selectedColor)
        .frame(width: 200, height: 200)
        .padding()
}

Filter panel popover

DFButton("Filters") { showFilters.toggle() }
    .dfButtonStyle(.outlined)
    .dfPopover(isPresented: $showFilters, arrowEdge: .top) {
        FilterOptionsView(filters: $activeFilters) {
            showFilters = false
        }
        .frame(width: 280)
    }
    .dfPopoverStyle(.glass)

Context menu popover

DFIcon("ellipsis")
    .onTapGesture { showContextMenu.toggle() }
    .dfPopover(isPresented: $showContextMenu, arrowEdge: .trailing) {
        VStack(alignment: .leading, spacing: 4) {
            DFButton("Edit")     { edit();   showContextMenu = false }.dfButtonStyle(.ghost)
            DFButton("Duplicate") { dupe(); showContextMenu = false }.dfButtonStyle(.ghost)
            DFDivider()
            DFButton("Delete", role: .destructive) {
                delete()
                showContextMenu = false
            }.dfButtonStyle(.ghost)
        }
        .padding(.horizontal, 8)
        .padding(.vertical, 4)
    }

DFTooltip

DFTooltip is platform-adaptive: on macOS it renders as a native .help() modifier; on iOS it intercepts long-press gestures to show a floating label — no #if os() guard required.

Initializer

public struct DFTooltip<Content: View>: View {
    public init(
        _ text: String,
        placement: DFTooltipPlacement = .automatic,
        @ViewBuilder content: () -> Content
    )
}

Placement

public enum DFTooltipPlacement: Sendable {
    case automatic, top, bottom, leading, trailing
}

Styles

Style Token Description
.default default Solid dark (or light) rounded rectangle label.
.glass .glass Material-backed tooltip. iOS 26+ / macOS 26+.

Apply with .dfTooltipStyle(_:).

Examples

// Basic
DFTooltip("Export to CSV") {
    DFIcon("square.and.arrow.up")
}

// Placement override
DFTooltip("Opens in browser", placement: .bottom) {
    DFButton("Open Link") { openLink() }
        .dfButtonStyle(.ghost)
}

// Tooltip on a disabled button
DFTooltip("You don't have permission to publish") {
    DFButton("Publish") { publish() }
        .disabled(!canPublish)
}

DFAlert

DFAlert drives modal alert dialogs through a view modifier. All actions and their roles are defined declaratively — no imperative UIAlertController required.

Data Types

public struct DFAlert: Sendable {
    public let title:   String
    public let message: String?
    public let actions: [DFAlertAction]
}

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

public enum DFAlertActionRole: Sendable {
    case cancel
    case destructive
}

Modifier Overloads

// 1. Boolean binding
func dfAlert(isPresented: Binding<Bool>, alert: DFAlert) -> some View

// 2. Optional item binding
func dfAlert<Item: Identifiable & Sendable>(
    item: Binding<Item?>,
    alert: (Item) -> DFAlert
) -> some View

Examples

Simple confirmation

DFButton("Log Out", role: .destructive) { showLogOut = true }
    .dfAlert(
        isPresented: $showLogOut,
        alert: DFAlert(
            title:   "Log out?",
            message: "You'll need to sign in again.",
            actions: [
                DFAlertAction(title: "Cancel",  role: .cancel)      { },
                DFAlertAction(title: "Log Out", role: .destructive)  { logOut() },
            ]
        )
    )

Item-driven — per-row delete confirmation

DFList(projects) { project in
    DFListRow(project.name)
        .onTapGesture { projectToDelete = project }
}
.dfAlert(item: $projectToDelete) { project in
    DFAlert(
        title:   "Delete \"\(project.name)\"?",
        message: "All data will be permanently removed.",
        actions: [
            DFAlertAction(title: "Cancel", role: .cancel) { },
            DFAlertAction(title: "Delete", role: .destructive) { delete(project) },
        ]
    )
}

Multi-action alert

.dfAlert(
    isPresented: $showSaveOptions,
    alert: DFAlert(
        title: "Unsaved Changes",
        message: "What would you like to do?",
        actions: [
            DFAlertAction(title: "Save & Exit")  { saveAndExit() },
            DFAlertAction(title: "Discard",   role: .destructive) { discardAndExit() },
            DFAlertAction(title: "Keep Editing", role: .cancel)   { },
        ]
    )
)

Single-action info alert

.dfAlert(
    isPresented: $showSuccess,
    alert: DFAlert(
        title:   "Account Created",
        message: "Welcome! You can now sign in.",
        actions: [DFAlertAction(title: "Got it", role: .cancel) { }]
    )
)

DFToast

DFToast provides non-blocking, auto-dismissing feedback messages. Toasts are shown imperatively via DFToastQueue.shared from anywhere in the app — including async tasks — and rendered at the scene root via a single .dfToast(queue:) modifier.

Setup (scene root)

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .dfThemePreset(.slate)
                .dfToast(queue: DFToastQueue.shared)
        }
    }
}

Showing Toasts

DFToastQueue.shared.show("Profile updated",  style: .success)
DFToastQueue.shared.show("Upload failed",    style: .error)
DFToastQueue.shared.show("Syncing…",         style: .info)
DFToastQueue.shared.show("Low storage",      style: .warning)

Data Types

public enum DFToastSeverity: Sendable {
    case success, error, info, warning, neutral
}

public struct DFToastMessage: Identifiable, Sendable {
    public let id:        UUID
    public let message:   String
    public let severity:  DFToastSeverity
    public let duration:  TimeInterval   // default 3.0 s; set to 0 for persistent
    public let action:    DFToastAction? // optional tap action button
}

public struct DFToastAction: Sendable {
    public let title:  String
    public let action: @Sendable () -> Void
}

Full API

// Show with default 3-second duration
DFToastQueue.shared.show("Saved",     style: .success)

// Show with custom duration (seconds)
DFToastQueue.shared.show("Indexing…", style: .info, duration: 10)

// Persistent (does not auto-dismiss)
DFToastQueue.shared.show("Offline mode active", style: .warning, duration: 0)

// With tap action
DFToastQueue.shared.show(
    "New message from Alex",
    style: .info,
    action: DFToastAction(title: "View") { navigateToMessages() }
)

// Dismiss all queued toasts
DFToastQueue.shared.dismissAll()

Examples

Network response feedback

func uploadPhoto(_ photo: Photo) async {
    do {
        try await api.upload(photo)
        DFToastQueue.shared.show("Photo uploaded", style: .success)
    } catch {
        DFToastQueue.shared.show(
            "Upload failed",
            style: .error,
            action: DFToastAction(title: "Retry") { Task { await uploadPhoto(photo) } }
        )
    }
}

Clipboard confirmation

DFButton("Copy Link") {
    UIPasteboard.general.url = shareURL
    DFToastQueue.shared.show("Link copied", style: .success, duration: 2)
}
.dfButtonStyle(.tinted)

Offline state indicator

.onChange(of: networkMonitor.isConnected) { _, connected in
    if !connected {
        DFToastQueue.shared.show("You're offline", style: .warning, duration: 0)
    } else {
        DFToastQueue.shared.dismissAll()
        DFToastQueue.shared.show("Back online", style: .success, duration: 2)
    }
}

Feedback Pattern Guide — Alert vs. Toast

Scenario Use
Destructive action requires explicit confirmation DFAlert with .destructive role
Save/discard decision before leaving a screen DFAlert with multiple named actions
Non-blocking success confirmation DFToast .success
Non-blocking transient error DFToast .error with optional Retry action
Background operation progress DFToast .info with long or persistent duration
An error that changes app behavior until resolved DFToast .warning persistent
Focused multi-step flow DFModal or DFSheet
Contextual help text DFTooltip or DFPopover

Custom Overlay Styles

All six components follow the style protocol pattern:

// Example: custom toast style
struct BannerToastStyle: DFToastStyle, Sendable {
    func makeBody(configuration: DFToastStyleConfiguration) -> some View {
        HStack(spacing: 12) {
            DFIcon(configuration.severity.icon, color: configuration.severity.color(configuration.theme))
            Text(configuration.message)
                .font(configuration.theme.typography.label.font)
            Spacer()
            if let action = configuration.action {
                Button(action.title, action: action.action)
                    .buttonStyle(.plain)
                    .foregroundColor(configuration.theme.colors.primary)
            }
        }
        .padding(configuration.theme.spacing.md)
        .background(configuration.theme.colors.surfaceElevated)
        .cornerRadius(configuration.theme.radius.md)
        .shadow(
            color: configuration.theme.shadows.md.color,
            radius: configuration.theme.shadows.md.radius,
            y: configuration.theme.shadows.md.y
        )
    }
}

See also: Buttons · Forms-and-Validation · Theming · Getting-Started

Clone this wiki locally