SwiftUI Tutorial · iOS 16+ · iOS 26 Liquid Glass

SwiftUI Sheet, Modal & Bottom Sheet — Complete 2026 Field Guide

Sheets, bottom sheets, half sheets, full-screen covers, custom modal transitions. All five SwiftUI presentation APIs in one production-ready reference.

Last updated: 2026-08-19 11 min read By Ahmed Gagan, iOS Engineer
Quick Answer

SwiftUI has five modal APIs: .sheet (slide-up partial), .fullScreenCover (full screen), .popover (anchored on iPad), .alert/.confirmationDialog (system dialogs), and the iOS 16+ .presentationDetents which converts a sheet into a resizable bottom sheet. For most apps in 2026, target iOS 16+ to use .presentationDetents([.medium, .large]) — it replaces every third-party bottom-sheet library and matches Apple's own UX (Maps, Wallet, AirDrop).

Min iOS
iOS 15 (basic) · iOS 16 (detents) · iOS 17 (interaction APIs)
Bottom sheet API
.presentationDetents([.medium, .large])
Dismiss control
.interactiveDismissDisabled(true)
Swift Kit support
✓ Paywall + onboarding sheets ready

7 SwiftUI Sheet Variants, Running Live

Detents, custom heights, interactive backgrounds, blocked dismissal and full-screen covers — each preview animates the behaviour the code beneath it produces.

Large
Row 1
Row 2
Row 3

Detents (Half / Full)

iOS 16.0

The core iOS 16 sheet API — snap points plus a visible grabber.

struct DetentSheet: View {
    @State private var show = false

    var body: some View {
        Button("Open") { show = true }
            .sheet(isPresented: $show) {
                Text("Sheet content")
                    .presentationDetents([.medium, .large])
                    .presentationDragIndicator(.visible)
            }
    }
}
.fraction(0.35)

Custom Height Detent

iOS 16.0

A fraction or fixed height when medium is the wrong size.

.sheet(isPresented: $show) {
    FilterPanel()
        // .fraction for proportional, .height for a fixed point value.
        .presentationDetents([.fraction(0.35), .height(520), .large])
        .presentationDragIndicator(.visible)
}
backdrop stays tappable

Interactive Background

iOS 16.4

Keep the view behind tappable — essential for a map or player sheet.

.sheet(isPresented: $show) {
    NearbyList()
        .presentationDetents([.height(180), .medium, .large])
        // Below .medium the backdrop is not dimmed, so the map stays usable.
        .presentationBackgroundInteraction(.enabled(upThrough: .medium))
        .presentationDragIndicator(.visible)
}
Radius 28 · material
Row 1
Row 2

Corner Radius & Background

iOS 16.4

Restyle the sheet chrome itself — radius and a material backdrop.

.sheet(isPresented: $show) {
    Composer()
        .presentationDetents([.medium])
        .presentationCornerRadius(28)
        .presentationBackground(.ultraThinMaterial)
}
Discard changes?
Discard
Keep editing

Block Interactive Dismiss

iOS 16.0

Stop a swipe-away mid-form, and confirm instead of losing input.

struct FormSheet: View {
    @State private var show = false
    @State private var hasEdits = true
    @State private var confirmDiscard = false

    var body: some View {
        Button("Edit") { show = true }
            .sheet(isPresented: $show) {
                EditForm()
                    // Disables the swipe-down ONLY while there is work to lose.
                    .interactiveDismissDisabled(hasEdits)
                    .confirmationDialog("Discard changes?", isPresented: $confirmDiscard) {
                        Button("Discard", role: .destructive) { show = false }
                        Button("Keep editing", role: .cancel) {}
                    }
            }
    }
}
Go Pro
No swipe to dismiss

Full Screen Cover

iOS 16.0

For onboarding and paywalls — no swipe-to-dismiss, you control the exit.

struct PaywallHost: View {
    @State private var showPaywall = false

    var body: some View {
        Button("Go Pro") { showPaywall = true }
            .fullScreenCover(isPresented: $showPaywall) {
                PaywallView(onClose: { showPaywall = false })
            }
    }
}

struct PaywallView: View {
    var onClose: () -> Void
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack { /* ... */ }
            .overlay(alignment: .topTrailing) {
                // A full screen cover has no grabber — always give an escape.
                Button { dismiss() } label: {
                    Image(systemName: "xmark")
                        .padding(10)
                        .background(.ultraThinMaterial, in: .circle)
                }
                .padding()
            }
    }
}
AirPods Pro
sheet(item:) — never stale

Sheet From an Item

iOS 16.0

sheet(item:) — the correct pattern when the sheet needs a value.

struct Product: Identifiable { let id: String; let name: String }

struct Catalogue: View {
    let products: [Product]
    @State private var selected: Product?

    var body: some View {
        List(products) { p in
            Button(p.name) { selected = p }
        }
        // Using sheet(item:) instead of isPresented + a separate @State avoids
        // the classic bug where the sheet shows stale data on first present.
        .sheet(item: $selected) { product in
            ProductDetail(product: product)
                .presentationDetents([.medium, .large])
        }
    }
}
Free download

The SwiftUI sheet cookbook — 7 recipes, one file

Detents, custom heights, interactive backgrounds, blocked dismissal, full-screen covers and sheet(item:) — every variant above, ready to paste.

  • All 7 sheet variants, copy-paste ready
  • iOS 16.4 APIs marked where required
  • The sheet(item:) pattern that avoids stale data
  • MIT licensed, commercial use fine

Instant download, no confirmation step. Occasional SwiftUI tips — unsubscribe anytime.

Basic Sheet Presentation

Sheets work with two binding patterns. Boolean for "is this view showing", or Identifiable item for "show a sheet with this data". Prefer the item form when the sheet needs data to render — it avoids the optional unwrapping dance.

BasicSheet.swift
struct ContentView: View {
  @State private var selectedTask: Task?

  var body: some View {
    List(tasks) { task in
      Button(task.title) { selectedTask = task }
    }
    .sheet(item: $selectedTask) { task in
      TaskDetailView(task: task)
    }
  }
}

Bottom Sheets with .presentationDetents

The iOS 16+ presentationDetents modifier converts any sheet into a resizable bottom sheet. Pass an array of heights — users can drag between them. This is the same API that powers Maps and Wallet.

  • .medium = ~50% of screen, .large = full sheet
  • .fraction(0.3) for custom heights (iOS 16+)
  • .height(200) for fixed-point heights
  • .presentationDragIndicator(.visible) shows the grab handle
  • .presentationBackgroundInteraction(.enabled(upThrough: .medium)) lets users tap behind the sheet at smaller detents
BottomSheet.swift
.sheet(isPresented: $showFilters) {
  FiltersView()
    .presentationDetents([.fraction(0.3), .medium, .large])
    .presentationDragIndicator(.visible)
    .presentationBackgroundInteraction(.enabled(upThrough: .medium))
}

Full-Screen Covers (Onboarding, Paywalls)

.fullScreenCover takes over the entire window and requires programmatic dismissal. Use it for onboarding flows, paywalls, and any state where the user MUST complete or explicitly close. Sheets are for non-blocking UX.

PaywallCover.swift
.fullScreenCover(isPresented: $showPaywall) {
  PaywallView()
    .interactiveDismissDisabled(!hasFreeTrialEnded)
}

Common Bugs and Fixes

Three modal bugs you'll hit in production:

  • Sheet ignores @State changes — bind to @State on the parent, never inside the sheet content
  • Detents jump on first present — set .presentationDetents BEFORE the sheet first appears
  • Sheet inside NavigationStack loses navigation context — wrap the sheet content in its own NavigationStack
  • Keyboard pushes sheet content offscreen — use .presentationContentInteraction(.resizes) or scroll inside

Paywall and onboarding sheets — pre-built.

The Swift Kit ships production paywall sheets and 3-style onboarding bottom sheets wired to RevenueCat and the design system.

Get The Swift Kit — $99

Rather have it done for you? I set up and ship your app from $499. See how

Frequently Asked Questions

How do I present a sheet in SwiftUI?
Bind a Boolean or Identifiable item to .sheet(isPresented:content:) or .sheet(item:content:). The system handles the slide-up animation and dismissal swipe gesture automatically. Example: .sheet(isPresented: $showSettings) { SettingsView() }.
How do I create a bottom sheet in SwiftUI?
Use .presentationDetents([.medium, .large]) inside the sheet content. Add .presentationDragIndicator(.visible) for the iOS-style grabber. Available iOS 16+. For iOS 15 fallback, use a third-party library like SwiftUI BottomSheet.
What's the difference between sheet and fullScreenCover?
Sheets present partially (slide up, dismiss by swiping down). FullScreenCover takes over the entire screen and requires programmatic dismissal. Use sheet for non-blocking flows (settings, share), fullScreenCover for onboarding, paywalls, and required actions.
How do I disable interactive dismissal of a SwiftUI sheet?
Use .interactiveDismissDisabled(true) inside the sheet content. This blocks the swipe-down gesture but still allows programmatic dismissal. Useful for forms with unsaved changes or required steps.
How do I make a half-sheet that can resize?
Combine .presentationDetents with multiple values: .presentationDetents([.fraction(0.3), .medium, .large]). Users can drag to resize between detents. Add .presentationContentInteraction(.scrolls) so scrollable content works correctly inside.
Does The Swift Kit include sheet templates?
Yes — the Swift Kit includes pre-built paywall sheets, share sheets, settings sheets, and onboarding bottom sheets, all wired into the design system. Detent presets and dismiss-confirmation flows ship out of the box.

Keep exploring

Ship your iOS app 10× faster

The Swift Kit gives you a production-ready SwiftUI boilerplate — design system, paywall, auth, AI, all pre-wired. $99 one-time.

Get The Swift Kit — $99

One-time purchase · Lifetime updates · 14-day refund