NewThe Flutter Kit — Flutter boilerplate$149$69
Tutorial

SwiftUI Sheets, Modals, Bottom Sheets & Half Sheets: 2026 Field Guide

Every way to present a sheet, modal, bottom sheet, action sheet, or popover in SwiftUI. Working code, iOS 26 Liquid Glass support, common bugs and how to fix them, and a decision tree for picking the right pattern.

Ahmed GaganAhmed Gagan
14 min read

Skip 100+ hours of setup. Get The Swift Kit $149 $99 one-time

Get it now →

The 30-second answer

SwiftUI has seven ways to present a sheet like view: sheet, fullScreenCover, popover, alert, confirmationDialog, presentationDetents, and a custom ZStack overlay. The right one depends on intent. For forms, settings, and detail views use sheet. For onboarding flows use fullScreenCover. For pickers on iPad use popover. For half sheets use presentationDetents on iOS 16 and later. iOS 26 added Liquid Glass material support via presentationBackground(.glass).

This guide is the definitive reference for presenting modal content in SwiftUI in 2026. It covers all seven patterns with working code, the iOS 26 Liquid Glass treatment, the most common bugs (sheet not dismissing, sheet inside sheet, lost bindings), a decision tree, and a custom bottom sheet implementation when you need full control.

The Seven Sheet Patterns at a Glance

Pick the pattern that matches your intent, not the pattern that looks closest visually.

PatternWhen to useiOS support
sheetForms, settings, detail views, paywalls (default modal)iOS 13 plus
fullScreenCoverOnboarding, immersive content, mandatory flowsiOS 14 plus
popoverPickers and quick menus on iPad (sheet on iPhone)iOS 13 plus
alertShort blocking messages with 1 to 2 actionsiOS 15 plus (modern)
confirmationDialogAction sheets with 3 plus options or destructive actionsiOS 15 plus
presentationDetentsHalf sheets, custom heights, multi snap bottom sheetsiOS 16 plus
Custom ZStackBrand specific motion, drag interactions, snap points iOS 14 to 15All iOS

Pattern 1: sheet (the workhorse)

The default modal in SwiftUI. Covers most of the screen, swipe down to dismiss, has a card style. Use for forms, settings panels, detail views, and paywalls.

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

    var body: some View {
        Button("Open Settings") {
            showSettings = true
        }
        .sheet(isPresented: $showSettings) {
            SettingsView()
        }
    }
}

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

    var body: some View {
        NavigationStack {
            Form {
                Section("Account") {
                    Text("Profile")
                    Text("Privacy")
                }
            }
            .navigationTitle("Settings")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("Done") { dismiss() }
                }
            }
        }
    }
}

Use the @Environment(\.dismiss) environment value inside the sheet content rather than mutating the parent isPresented Bool. This pattern is reliable across iOS versions and works inside deeply nested views.

Sheet with Item Binding

When the sheet content depends on which item the user tapped, use the item form:

@State private var selectedRecipe: Recipe?

List(recipes) { recipe in
    Button(recipe.name) {
        selectedRecipe = recipe
    }
}
.sheet(item: $selectedRecipe) { recipe in
    RecipeDetailView(recipe: recipe)
}

The sheet appears when selectedRecipe is non nil and dismisses when the user sets it to nil. This pattern avoids the bug where you toggle a Bool but the previously selected item lingers in your sheet content.

Pattern 2: fullScreenCover

Covers the entire screen with no swipe to dismiss. Use for onboarding flows, mandatory paywalls, or any flow where the user must complete or explicitly cancel.

struct RootView: View {
    @State private var hasOnboarded = false

    var body: some View {
        MainAppView()
            .fullScreenCover(isPresented: .constant(!hasOnboarded)) {
                OnboardingView(onComplete: { hasOnboarded = true })
            }
    }
}

Add an explicit dismiss action inside the cover. Apple App Review will reject any fullScreenCover that has no visible way to back out unless it is gated behind a successful action like a sign in or a purchase.

Pattern 3: popover

Renders an anchored bubble on iPad (with an arrow pointing at the source view) and a sheet on iPhone. Use for contextual menus, quick pickers, or info tooltips on iPad apps.

@State private var showInfo = false

Button("Info") { showInfo = true }
    .popover(isPresented: $showInfo, arrowEdge: .bottom) {
        InfoCard()
            .frame(width: 280, height: 200)
            .presentationCompactAdaptation(.popover)
    }

The presentationCompactAdaptation(.popover) modifier (iOS 16.4 plus) forces popover styling even on iPhone, useful for compact pickers like a date or color selector.

Pattern 4: alert (modern)

Short blocking messages with 1 to 2 actions. The iOS 15 plus alert API is far cleaner than the deprecated init that took an Alert struct.

@State private var showDeleteAlert = false

Button("Delete Account", role: .destructive) {
    showDeleteAlert = true
}
.alert("Delete account?", isPresented: $showDeleteAlert) {
    Button("Delete", role: .destructive) { deleteAccount() }
    Button("Cancel", role: .cancel) { }
} message: {
    Text("This cannot be undone. All your data will be permanently removed.")
}

Use role: .destructive for delete actions and role: .cancel for cancel. The system tints destructive buttons red and renders cancel as bold by default.

Pattern 5: confirmationDialog

Action sheets with three plus options or destructive actions. Adapts to popover on iPad automatically.

@State private var showOptions = false

Button("Share") { showOptions = true }
    .confirmationDialog("Share via", isPresented: $showOptions, titleVisibility: .visible) {
        Button("Copy Link") { copyLink() }
        Button("Share to Messages") { shareToMessages() }
        Button("Email") { email() }
        Button("Delete Post", role: .destructive) { deletePost() }
        Button("Cancel", role: .cancel) { }
    } message: {
        Text("Pick a destination")
    }

Confirmation dialogs are the right pick whenever you have a destructive option mixed with normal options, or three plus choices that do not warrant a full sheet.

Pattern 6: presentationDetents (the half sheet)

The biggest sheet upgrade of the last few years. iOS 16 added native half sheet support without resorting to UIKit hacks.

@State private var showFilters = false

Button("Filters") { showFilters = true }
    .sheet(isPresented: $showFilters) {
        FiltersView()
            .presentationDetents([.medium, .large])
            .presentationDragIndicator(.visible)
            .presentationBackgroundInteraction(.enabled(upThrough: .medium))
    }

Detent options:

  • .medium: roughly 50 percent of screen height.
  • .large: full sheet (covers most of the screen).
  • .height(300): explicit point height.
  • .fraction(0.4): 40 percent of available height.
  • Custom: implement CustomPresentationDetent for dynamic heights.

The presentationDragIndicator(.visible) modifier shows the grabber on top. presentationBackgroundInteraction(.enabled(upThrough: .medium)) lets the user tap through the underlying view while the sheet is at the medium detent. This is the right pattern for a Maps style sheet that lists results while the map remains interactive.

iOS 26 Additions: Liquid Glass on Sheets

iOS 26 added Liquid Glass material support to sheets and refined the corner radius API.

.sheet(isPresented: $showSheet) {
    SheetContent()
        .presentationDetents([.medium, .large])
        .presentationCornerRadius(28)
        .presentationBackground(.glass)
        .presentationContentInteraction(.scrolls)
}

presentationCornerRadius accepts any value (previously locked to system defaults). presentationBackground(.glass) opts the sheet into the Liquid Glass treatment with automatic blur and tint that adapts to the underlying content. presentationContentInteraction(.scrolls) ensures content scrolls before the sheet resizes when the user drags inside the content.

For the deeper Liquid Glass treatment, see Liquid Glass UI in SwiftUI.

Pattern 7: Custom Bottom Sheet (Maximum Control)

When presentationDetents does not give enough control (custom drag physics, snap to arbitrary heights, brand specific corner morph), build it yourself with a ZStack plus DragGesture.

struct CustomBottomSheet<Content: View>: View {
    @Binding var isPresented: Bool
    let content: () -> Content

    @State private var dragOffset: CGFloat = 0
    private let sheetHeight: CGFloat = 400

    var body: some View {
        ZStack(alignment: .bottom) {
            if isPresented {
                Color.black.opacity(0.4)
                    .ignoresSafeArea()
                    .transition(.opacity)
                    .onTapGesture {
                        withAnimation(.spring(response: 0.4)) {
                            isPresented = false
                        }
                    }

                VStack(spacing: 0) {
                    Capsule()
                        .fill(Color.gray.opacity(0.4))
                        .frame(width: 40, height: 5)
                        .padding(.top, 8)

                    content()
                        .padding()
                }
                .frame(maxWidth: .infinity)
                .frame(height: sheetHeight)
                .background(.ultraThinMaterial)
                .clipShape(.rect(topLeadingRadius: 24, topTrailingRadius: 24))
                .offset(y: dragOffset)
                .gesture(
                    DragGesture()
                        .onChanged { value in
                            dragOffset = max(0, value.translation.height)
                        }
                        .onEnded { value in
                            if value.translation.height > 100 {
                                withAnimation(.spring(response: 0.4)) {
                                    isPresented = false
                                }
                            } else {
                                withAnimation(.spring(response: 0.4)) {
                                    dragOffset = 0
                                }
                            }
                        }
                )
                .transition(.move(edge: .bottom))
            }
        }
        .animation(.spring(response: 0.4, dampingFraction: 0.85), value: isPresented)
    }
}

Use it as a modifier or wrap your view tree:

@State private var showSheet = false

ZStack {
    MainContent()

    CustomBottomSheet(isPresented: $showSheet) {
        VStack {
            Text("Custom sheet content")
            Button("Done") { showSheet = false }
        }
    }
}

For most apps, presentationDetents on iOS 16 plus is the right answer. Reserve the custom ZStack approach for cases where you genuinely need brand specific behavior or you target iOS 14 and 15.

Common Bugs and How to Fix Them

Sheet not dismissing

Three usual causes:

  1. You are mutating the isPresented Bool from inside the sheet content rather than calling dismiss() from the environment. Use @Environment(\.dismiss) consistently.
  2. The Bool binding is on a transient view that gets recreated. Hoist the state to a parent that survives recreation, like a view model or scene level state.
  3. You added onDismiss that resets state in a way that immediately re presents the sheet. Double check the dismiss closure does not trigger the binding back to true.

Cannot present a sheet from inside a sheet on iOS 14

iOS 14 only allowed one sheet at a time. iOS 15 plus fully supports nested sheets. If you must support iOS 14, dismiss the first sheet, then present the second after a short delay using DispatchQueue.main.asyncAfter.

Sheet content lost when navigating inside

If your sheet contains a NavigationStack and you push views, then dismiss, then re present, the stack resets. This is correct behavior. If you need to persist state across sheet presentations, store it in a view model that survives sheet lifecycle.

presentationDetents not snapping correctly

Two patterns. Pattern one, you set custom detents but the content height does not match, causing snap drift. Use presentationContentInteraction(.scrolls) so the content scrolls inside the detent rather than expanding it. Pattern two, you set a single detent like .medium but the user can drag past it. Pass an array of detents and the system constrains drag to those values.

Multiple stacked sheets cannot dismiss together

Two solutions. Solution one, hoist the route to an enum bound to .sheet(item:), and setting it to nil dismisses everything. Solution two, use the DismissAction at the right environment level by passing a custom EnvironmentKey.

enum AppRoute: Identifiable {
    case settings
    case profile
    case paywall

    var id: String { String(describing: self) }
}

@State private var route: AppRoute?

MainView()
    .sheet(item: $route) { current in
        switch current {
        case .settings: SettingsView()
        case .profile: ProfileView()
        case .paywall: PaywallView()
        }
    }

Setting route = nil from anywhere dismisses the sheet entirely. This is the cleanest production pattern.

Decision Tree: Picking the Right Pattern

Use this flow chart whenever you reach for a sheet:

User intentPattern
View or edit content, can be dismissedsheet
Onboarding or mandatory flowfullScreenCover
Quick picker or contextual menu on iPadpopover
1 or 2 action confirmationalert
3 plus actions or destructive choiceconfirmationDialog
Half sheet with selectable detentssheet + presentationDetents
Custom drag physics, brand motionCustom ZStack overlay

Accessibility on SwiftUI Sheets

Sheets respect VoiceOver, Dynamic Type, and Reduce Motion automatically when you use the native modifiers. A few patterns to confirm:

  • Always include a visible Done or Close button in the toolbar so users do not depend on swipe to dismiss for accessibility.
  • Set navigationTitle on sheet content so VoiceOver announces what just appeared.
  • Test with VoiceOver on. Sheets that present from a Button without a label are confusing.
  • Use accessibilityAddTraits(.isModal) on custom ZStack overlays so VoiceOver knows it is a modal.

The Swift Kit Ships These Patterns Pre Wired

The Swift Kit ships every sheet pattern in this guide as a reusable component: a settings sheet with the standard NavigationStack plus toolbar pattern, a half sheet for filters with the right detents, a confirmation dialog for destructive actions, and a custom bottom sheet for brand specific moments. Edit one design system file to retheme the corner radius, drag indicator color, and Liquid Glass behavior across every sheet in the app.

Frequently Asked Questions

What is the difference between sheet and fullScreenCover?

A sheet shows a card that covers most of the screen and the user can dismiss with a swipe down. A fullScreenCover takes the entire screen and has no swipe to dismiss. Use sheet for routine modal content and fullScreenCover only for mandatory flows.

Does presentationDetents work on iOS 15?

No. The native presentationDetents modifier requires iOS 16. For iOS 15 and earlier, use UIKit interop with UISheetPresentationController or a custom ZStack implementation.

Can I have multiple sheets stacked?

Yes on iOS 15 and later. Each sheet attaches its own .sheet modifier and the system manages the stack. The cleanest pattern for complex stacks is an enum based route bound to a single .sheet(item:).

How do I prevent the sheet from being swiped away?

Use .interactiveDismissDisabled(true) to block swipe to dismiss. Apple App Review still requires an explicit close button in the toolbar so always include one.

Where to Go Next

Sheets are one piece of SwiftUI navigation. The complementary patterns:

Share this article
Limited-time · price rises to $149 soon

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and a full design system. Stop rebuilding boilerplate — start building your product.

$149$99one-time · save $50
  • Full source code
  • Unlimited projects
  • Lifetime updates
  • 50+ makers shipping