How to Build a Modal Dialog in SwiftUI
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
-
@State private var showModal = false — A single boolean drives the entire presentation lifecycle. SwiftUI observes this value; flipping it to
truetriggers the sheet animation automatically. No manual view controller presentation required. - .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.
- .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.
-
@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 resetsshowModalback tofalseautomatically. - .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
-
Multiple .sheet modifiers on the same view (iOS 16 and earlier): Before iOS 16.4, only the last
.sheeton a view was honoured. In iOS 17+ multiple sheets on sibling views work correctly, but the safest pattern remains one sheet per view — use.sheet(item:)to vary content instead of stacking modifiers. -
Toggling showModal inside onAppear: Setting
showModal = trueinside.onAppearcan cause an immediate present/dismiss loop if the parent view is re-rendered. Guard the assignment behind a condition, or use.taskwith anawait Task.yield()to defer past the render cycle. -
Accessibility — missing modal role announcement: SwiftUI automatically announces a sheet as a modal to VoiceOver, but if you build a custom overlay with
ZStack, add.accessibilityAddTraits(.isModal)to the overlay container and.accessibilityViewIsModal(true)to hide background content from the accessibility tree.
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.