```html SwiftUI: How to Build a Modal Dialog (iOS 17+, 2026)
Soarias SwiftUI Guides

How to Build a Modal Dialog in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: sheet Updated: May 11, 2026
TL;DR

Add a @State boolean to track presentation, then attach .sheet(isPresented:) to any view. Inside the sheet, call @Environment(\.dismiss) to close it programmatically.

struct ContentView: View {
    @State private var showModal = false

    var body: some View {
        Button("Open Modal") { showModal = true }
            .sheet(isPresented: $showModal) {
                ModalView()
            }
    }
}

struct ModalView: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Button("Close") { dismiss() }
            .padding()
    }
}

Full implementation

The example below presents a styled modal sheet when a button is tapped. The sheet contains a header, descriptive body text, a primary action button, and a dismiss control — the pattern you'll reach for in nearly every iOS app. It uses .presentationDetents to set a medium-height sheet and .presentationDragIndicator(.visible) for the familiar drag handle, both available since iOS 16 and polished further in iOS 17.

import SwiftUI

// MARK: - Content View

struct ContentView: View {
    @State private var showModal = false

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                Image(systemName: "square.and.arrow.up.circle.fill")
                    .font(.system(size: 64))
                    .foregroundStyle(.indigo)
                    .accessibilityHidden(true)

                Text("Ready to share?")
                    .font(.title2.bold())

                Button {
                    showModal = true
                } label: {
                    Label("Share Options", systemImage: "square.and.arrow.up")
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(.indigo)
                        .foregroundStyle(.white)
                        .clipShape(RoundedRectangle(cornerRadius: 14))
                }
                .accessibilityLabel("Open share options modal")
            }
            .padding(32)
            .navigationTitle("Home")
        }
        .sheet(isPresented: $showModal) {
            ShareModalView()
                .presentationDetents([.medium, .large])
                .presentationDragIndicator(.visible)
                .presentationCornerRadius(24)
        }
    }
}

// MARK: - Modal View

struct ShareModalView: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack(alignment: .leading, spacing: 20) {
            HStack {
                Text("Share Options")
                    .font(.title3.bold())
                Spacer()
                Button {
                    dismiss()
                } label: {
                    Image(systemName: "xmark.circle.fill")
                        .font(.title2)
                        .foregroundStyle(.secondary)
                }
                .accessibilityLabel("Close modal")
            }

            Text("Choose how you'd like to share this item with others.")
                .font(.subheadline)
                .foregroundStyle(.secondary)

            Divider()

            ForEach(ShareOption.allCases) { option in
                Button {
                    dismiss()
                } label: {
                    Label(option.title, systemImage: option.icon)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .padding()
                        .background(.secondary.opacity(0.08))
                        .clipShape(RoundedRectangle(cornerRadius: 12))
                }
                .foregroundStyle(.primary)
                .accessibilityLabel("Share via \(option.title)")
            }

            Spacer()
        }
        .padding(24)
    }
}

// MARK: - Supporting Types

enum ShareOption: String, CaseIterable, Identifiable {
    case message, mail, link

    var id: String { rawValue }

    var title: String {
        switch self {
        case .message: return "Send Message"
        case .mail:    return "Email"
        case .link:    return "Copy Link"
        }
    }

    var icon: String {
        switch self {
        case .message: return "message.fill"
        case .mail:    return "envelope.fill"
        case .link:    return "link"
        }
    }
}

// MARK: - Preview

#Preview("Default") {
    ContentView()
}

#Preview("Modal Only") {
    ShareModalView()
        .presentationDetents([.medium])
}

How it works

  1. @State private var showModal = false — A single boolean drives the entire presentation lifecycle. SwiftUI observes this value; flipping it to true triggers the sheet animation automatically. No manual view controller presentation required.
  2. .sheet(isPresented: $showModal) — The sheet modifier is attached to the root view, not the button. The trailing closure returns the view to present. Placing it here rather than inside the button keeps your view hierarchy clean and avoids unexpected dismissal bugs.
  3. .presentationDetents([.medium, .large]) — Applied inside the sheet closure, this pins the modal to 50 % of screen height by default but lets the user drag it full-screen. Omit it to get the default full-sheet behaviour on iOS 16 and earlier.
  4. @Environment(\.dismiss) private var dismiss — The environment-provided dismiss action is the preferred way to close a sheet from within. Calling dismiss() in the Close button and each share option triggers the sheet's disappearance and resets showModal back to false automatically.
  5. .presentationCornerRadius(24) — An iOS 16.4+ modifier that gives the sheet a custom corner radius matching your app's design language. It is safe to include here because the deployment target is iOS 17+.

Variants

Item-driven sheet (present from a model object)

When you want to present different content depending on which item was tapped, use .sheet(item:) with an optional Identifiable binding. Setting the item to nil dismisses the sheet.

struct Note: Identifiable {
    let id = UUID()
    var title: String
}

struct NoteListView: View {
    @State private var selectedNote: Note?

    let notes = [
        Note(title: "Grocery list"),
        Note(title: "Meeting agenda"),
        Note(title: "Trip itinerary"),
    ]

    var body: some View {
        List(notes) { note in
            Button(note.title) { selectedNote = note }
        }
        .sheet(item: $selectedNote) { note in
            NoteDetailModal(note: note)
                .presentationDetents([.medium])
                .presentationDragIndicator(.visible)
        }
    }
}

struct NoteDetailModal: View {
    @Environment(\.dismiss) private var dismiss
    let note: Note

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text(note.title).font(.title2.bold())
            Spacer()
            Button("Done") { dismiss() }
                .buttonStyle(.borderedProminent)
        }
        .padding(24)
    }
}

Preventing interactive dismiss

If your modal contains an unsaved form, add .interactiveDismissDisabled(isDirty) inside the sheet to block the drag-to-dismiss gesture while the user has unsaved changes. Pair it with an explicit Cancel button that resets state and calls dismiss() so the user is never trapped.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a modal dialog in SwiftUI for iOS 17+.
Use .sheet(isPresented:) and @Environment(\.dismiss).
Add .presentationDetents([.medium, .large]) and a drag indicator.
Make it accessible (VoiceOver labels on all interactive elements).
Add a #Preview with realistic sample data.

Drop this prompt into the Soarias Build phase after your screens are scaffolded to wire up modal presentation logic across your app in one pass — no manual plumbing needed.

Related

FAQ

Does this work on iOS 16?

The core .sheet modifier and @Environment(\.dismiss) work all the way back to iOS 15. The .presentationDetents modifier requires iOS 16+, and .presentationCornerRadius requires iOS 16.4+. If your deployment target is iOS 16, wrap those modifiers in .if { ... } or an @available check. For iOS 17+ targets (as shown here), everything compiles without guards.

How do I pass data back from the modal to the parent view?

The cleanest approach is to share an @Observable view model between parent and modal, or pass a @Binding into the sheet. For simple confirmation patterns, an onDismiss closure on .sheet(isPresented:onDismiss:) lets you read mutated state after the sheet closes. Avoid passing @EnvironmentObject through the sheet unless the object is already in the environment — sheets inherit the environment of their presenting view.

What's the UIKit equivalent?

In UIKit you'd call present(_:animated:completion:) on a UIViewController, setting modalPresentationStyle to .pageSheet or .formSheet and configuring a UISheetPresentationController for detents. SwiftUI's .sheet modifier wraps all of that boilerplate into a single, declarative line.

Last reviewed: 2026-05-11 by the Soarias team.

```