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

Share
Copy link
Add to favourites
Export PDF

Variants: Medium, Large

Install the free SwiftUI bottom sheet

  1. Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
  2. Copy SKBottomSheet.swift below into your project. It needs nothing else.

Usage

Usage.swift
.skBottomSheet(isPresented: $show) { SheetContent() }
.skBottomSheet(isPresented: $show, detent: .medium) { ... }

Full source · 191 lines · MIT

SKBottomSheet.swift
//
//  SKBottomSheet.swift
//  Swift Kit UI
//
//  Requires: SwiftKitUI.swift
//
//  A draggable bottom sheet you can actually style — grabber, corner radius,
//  backdrop and detents all yours.
//
//  TWO THINGS THAT ARE EASY TO GET WRONG, BOTH HANDLED HERE
//  --------------------------------------------------------
//  1. The sheet must RISE, not fade. A `.transition(.move(edge: .bottom))` only
//     works if it is on a DIRECT child of the container carrying the animation.
//     Wrap the sheet in a `GeometryReader` inside the `if`, and SwiftUI has no
//     transition on the thing being inserted, so it cross-fades the whole
//     container instead. Height comes from `containerRelativeFrame` precisely so
//     no GeometryReader is needed.
//  2. The drag must not be animated. An `.animation(_:value:)` anywhere above
//     the offset re-times every frame of the gesture, which reads as snapping
//     and lag. The animation here is scoped to `isPresented` alone; the drag
//     offset is raw, and only the release is animated.
//
//  Usage:
//      .skBottomSheet(isPresented: $show) { SheetContent() }
//      .skBottomSheet(isPresented: $show, detent: .large) { ... }
//

import SwiftUI

public enum SKSheetDetent: Sendable {
    case small, medium, large

    var fraction: CGFloat {
        switch self {
        case .small: 0.32
        case .medium: 0.55
        case .large: 0.88
        }
    }
}

private struct SKBottomSheetModifier<SheetContent: View>: ViewModifier {
    @Binding var isPresented: Bool
    let detent: SKSheetDetent
    let showsGrabber: Bool
    let sheetContent: SheetContent

    @State private var drag: CGFloat = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private var motion: Animation? {
        SKMotion.settle.resolved(reduceMotion: reduceMotion)
    }

    func body(content: Content) -> some View {
        ZStack(alignment: .bottom) {
            content

            if isPresented {
                Color.black
                    .opacity(0.35)
                    .ignoresSafeArea()
                    .transition(.opacity)
                    .onTapGesture { close() }
                    .zIndex(1)

                // A direct child of the animated ZStack, so the move transition
                // is the thing SwiftUI actually animates.
                sheet
                    .transition(.move(edge: .bottom))
                    .zIndex(2)
            }
        }
        // Scoped to isPresented ONLY. Widen this and the drag starts snapping.
        .animation(motion, value: isPresented)
    }

    private var sheet: some View {
        VStack(spacing: 0) {
            if showsGrabber {
                Capsule()
                    .fill(SK.Palette.labelTertiary.opacity(0.6))
                    .frame(width: 36, height: 5)
                    .padding(.top, SK.Space.sm)
                    .padding(.bottom, SK.Space.xs)
                    .accessibilityHidden(true)
            }

            sheetContent
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        }
        .containerRelativeFrame(.vertical) { height, _ in height * detent.fraction }
        .frame(maxWidth: .infinity)
        .background {
            UnevenRoundedRectangle(
                topLeadingRadius: SK.Radius.xxl,
                topTrailingRadius: SK.Radius.xxl,
                style: .continuous
            )
            .fill(SK.Palette.surfaceElevated)
            .ignoresSafeArea(edges: .bottom)
            .shadow(color: .black.opacity(0.22), radius: 22, y: -6)
        }
        .offset(y: drag)
        // Raw, unanimated during the gesture so the sheet tracks the finger.
        .gesture(dragGesture)
        .accessibilityAddTraits(.isModal)
    }

    private var dragGesture: some Gesture {
        DragGesture()
            .onChanged { g in
                // 1:1 downward; heavily damped upward so the top feels like a
                // limit rather than a wall.
                drag = g.translation.height > 0
                    ? g.translation.height
                    : g.translation.height / 6
            }
            .onEnded { g in
                // Projected, so a short fast flick dismisses like a long drag.
                if g.predictedEndTranslation.height > 150 {
                    close()
                } else {
                    withAnimation(motion) { drag = 0 }
                }
            }
    }

    private func close() {
        SK.Haptic.play(.light)
        withAnimation(motion) {
            isPresented = false
        }
        // Reset AFTER the dismissal so the sheet does not jump back to its
        // resting offset on the way out.
        Task { @MainActor in
            try? await Task.sleep(for: .milliseconds(420))
            drag = 0
        }
    }
}

extension View {
    public func skBottomSheet<SheetContent: View>(
        isPresented: Binding<Bool>,
        detent: SKSheetDetent = .medium,
        showsGrabber: Bool = true,
        @ViewBuilder content: () -> SheetContent
    ) -> some View {
        modifier(SKBottomSheetModifier(
            isPresented: isPresented, detent: detent,
            showsGrabber: showsGrabber, sheetContent: content()
        ))
    }
}

#Preview("SKBottomSheet") {
    @Previewable @State var medium = false
    @Previewable @State var large = false

    VStack(spacing: SK.Space.md) {
        Button("Medium sheet") { medium = true }
        Button("Large sheet") { large = true }
    }
    .font(SK.Type_.callout)
    .foregroundStyle(SK.Palette.accent)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(SK.Palette.surfaceSunken)
    .skBottomSheet(isPresented: $medium) {
        VStack(alignment: .leading, spacing: SK.Space.md) {
            Text("Share").font(SK.Type_.title).foregroundStyle(SK.Palette.label)
            Text("Drag the grabber down, or flick, to dismiss.")
                .font(SK.Type_.callout).foregroundStyle(SK.Palette.labelSecondary)
            Spacer()
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(SK.Space.xl)
    }
    .skBottomSheet(isPresented: $large, detent: .large) {
        ScrollView {
            VStack(alignment: .leading, spacing: SK.Space.md) {
                ForEach(0..<14, id: \.self) { i in
                    Text("Row \(i + 1)").font(SK.Type_.body).foregroundStyle(SK.Palette.label)
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(SK.Space.xl)
        }
    }
}