Soarias

How to build a bottom sheet in SwiftUI

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

Attach .sheet(isPresented:) to any view, then add .presentationDetents([.medium, .large]) inside the sheet content. iOS handles all snapping, drag, and dismissal automatically.

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

    var body: some View {
        Button("Open Sheet") { showSheet = true }
            .sheet(isPresented: $showSheet) {
                Text("Hello from bottom sheet")
                    .presentationDetents([.medium, .large])
                    .presentationDragIndicator(.visible)
            }
    }
}

Full implementation

The example below builds a realistic "Place Details" sheet — the kind you'd find in a maps or booking app. It uses a @State-tracked selected detent so you can programmatically respond to snap-point changes, a custom background, and partial background interaction so taps on the map behind the half-height sheet still register.

import SwiftUI

// MARK: – Model
struct Place: Identifiable {
    let id = UUID()
    var name: String
    var rating: Double
    var description: String
}

// MARK: – Sheet Content
struct PlaceDetailSheet: View {
    let place: Place
    @Binding var selectedDetent: PresentationDetent

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                HStack {
                    VStack(alignment: .leading, spacing: 4) {
                        Text(place.name)
                            .font(.title2.bold())
                        Text("★ \(place.rating, specifier: "%.1f")")
                            .foregroundStyle(.secondary)
                    }
                    Spacer()
                    Button {
                        // directions action
                    } label: {
                        Label("Directions", systemImage: "arrow.triangle.turn.up.right.circle.fill")
                            .font(.subheadline.bold())
                    }
                    .buttonStyle(.borderedProminent)
                    .accessibilityLabel("Get directions to \(place.name)")
                }

                Divider()

                Text(place.description)
                    .font(.body)
                    .foregroundStyle(.primary)
            }
            .padding(24)
        }
        // Define two snap points
        .presentationDetents(
            [.medium, .large],
            selection: $selectedDetent
        )
        // Show the pill grab handle
        .presentationDragIndicator(.visible)
        // Allow taps on background content when at .medium height
        .presentationBackgroundInteraction(
            .enabled(upThrough: .medium)
        )
        // Custom sheet background (iOS 16.4+)
        .presentationBackground(.regularMaterial)
        // Stop sheet from covering the entire screen edge-to-edge
        .presentationContentInteraction(.scrolls)
    }
}

// MARK: – Host View
struct ContentView: View {
    @State private var selectedPlace: Place? = nil
    @State private var detent: PresentationDetent = .medium

    let samplePlace = Place(
        name: "Tartine Bakery",
        rating: 4.8,
        description: "Legendary SF bakery famous for country bread baked fresh every afternoon. Expect a line — it's worth it."
    )

    var body: some View {
        ZStack {
            Color.mint.opacity(0.15).ignoresSafeArea()

            Button("Show Place Details") {
                selectedPlace = samplePlace
            }
            .buttonStyle(.borderedProminent)
        }
        .sheet(item: $selectedPlace) { place in
            PlaceDetailSheet(place: place, selectedDetent: $detent)
        }
        .onChange(of: detent) { _, newDetent in
            // React when the user snaps between .medium and .large
            print("Sheet moved to: \(newDetent)")
        }
    }
}

// MARK: – Preview
#Preview {
    ContentView()
}

How it works

  1. 1
    .sheet(item:) — Using the item overload (rather than isPresented) is the idiomatic pattern when you need to pass data into the sheet. Setting selectedPlace = nil dismisses it programmatically, matching SwiftUI's data-driven philosophy.
  2. 2
    .presentationDetents([.medium, .large], selection:) — The two-element array creates two snap points: half screen and full screen. Passing a @Binding to selection gives you live read/write access to which detent is active, so you can respond in .onChange or drive it programmatically (e.g., "expand sheet on button tap").
  3. 3
    .presentationDragIndicator(.visible) — Renders the system-standard rounded pill at the top of the sheet. Omit it if your design requires a custom close button, but accessibility guidelines recommend keeping it for discoverability.
  4. 4
    .presentationBackgroundInteraction(.enabled(upThrough: .medium)) — This is the key modifier for map-style UIs. When the sheet is at .medium height, touch events pass through to the dimmed background. Expanding to .large automatically disables passthrough.
  5. 5
    .presentationBackground(.regularMaterial) — Replaces the default white/dark sheet fill with a translucent blur material, reinforcing depth in your layout. Available since iOS 16.4 and fully supported on iOS 17+.

Variants

Custom fractional and pixel-height detents

Use .fraction(_:) for proportional heights or .height(_:) for fixed pixel values — useful for search bars or action menus with a predictable item count.

struct ActionMenuSheet: View {
    @State private var detent: PresentationDetent = .height(220)

    var body: some View {
        VStack(spacing: 0) {
            ForEach(["Share", "Duplicate", "Delete"], id: \.self) { action in
                Button(action) {}
                    .frame(maxWidth: .infinity)
                    .padding()
                Divider()
            }
        }
        // Fixed 220-pt sheet — just enough for three rows
        .presentationDetents([.height(220)], selection: $detent)
        .presentationDragIndicator(.visible)
        // No background dimming for a menu feel
        .presentationBackgroundInteraction(.enabled)
        .presentationBackground(.thinMaterial)
    }
}

#Preview {
    Text("Host")
        .sheet(isPresented: .constant(true)) {
            ActionMenuSheet()
        }
}

Programmatic expansion on a button tap

Because selectedDetent is a @Binding, you can write to it directly from any button inside or outside the sheet. For example, adding Button("Expand") { selectedDetent = .large } inside the sheet content immediately animates the snap to full screen without any additional gesture recognisers.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a bottom sheet in SwiftUI for iOS 17+.
Use sheet(item:), presentationDetents, presentationDragIndicator,
and presentationBackgroundInteraction.
Add .medium and .large snap points with a @Binding selection tracker.
Allow background interaction at .medium height.
Make it accessible (VoiceOver labels on all interactive elements).
Add a #Preview with realistic sample data showing the sheet pre-expanded.

Drop this prompt into the Build phase in Soarias to scaffold your sheet component in seconds — Soarias will wire it into your existing navigation model and generate the full preview automatically.

Related guides

FAQ

Does this work on iOS 16?

Partially. presentationDetents was introduced in iOS 16.0, so two-snap-point sheets work there. However, presentationBackgroundInteraction and presentationBackground(_:) require iOS 16.4+. The presentationContentInteraction modifier (scroll conflict fix) is iOS 16.4+ as well. Since Soarias targets iOS 17+ by default, you can use all modifiers without availability guards.

How do I prevent the sheet from being dismissed by dragging down?

Apply .interactiveDismissDisabled() inside the sheet content. You can make it conditional: .interactiveDismissDisabled(hasUnsavedChanges). When drag-to-dismiss is blocked, iOS 17 presents a system "Are you sure?" confirmation by default — you can override this with the onDismissAttempt callback introduced in iOS 17 to show your own alert instead.

What's the UIKit equivalent?

The UIKit equivalent is UISheetPresentationController, available since iOS 15. You configure snap points via its detents property (using UISheetPresentationController.Detent) and set prefersGrabberVisible = true for the drag handle. SwiftUI's presentationDetents is a direct declarative wrapper over this API — no bridging needed when staying in SwiftUI.

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