How to build a bottom sheet in SwiftUI
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
.sheet(item:) — Using the
itemoverload (rather thanisPresented) is the idiomatic pattern when you need to pass data into the sheet. SettingselectedPlace = nildismisses it programmatically, matching SwiftUI's data-driven philosophy. -
2
.presentationDetents([.medium, .large], selection:) — The two-element array creates two snap points: half screen and full screen. Passing a
@Bindingtoselectiongives you live read/write access to which detent is active, so you can respond in.onChangeor drive it programmatically (e.g., "expand sheet on button tap"). -
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
.presentationBackgroundInteraction(.enabled(upThrough: .medium)) — This is the key modifier for map-style UIs. When the sheet is at
.mediumheight, touch events pass through to the dimmed background. Expanding to.largeautomatically disables passthrough. -
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
-
⚠
Modifier placement matters.
presentationDetents,presentationDragIndicator, andpresentationBackgroundmust be applied inside the sheet's content closure, not on the view that calls.sheet(). Placing them outside has no effect and produces no compiler warning — a silent failure that's easy to miss. -
⚠
ScrollView conflicts with drag-to-dismiss. If your sheet contains a
ScrollView, add.presentationContentInteraction(.scrolls)so SwiftUI knows a downward drag should scroll the list rather than collapse the sheet. Without it, the first downward swipe dismisses the sheet mid-scroll. -
⚠
VoiceOver focus. On presentation, VoiceOver automatically moves focus into the sheet, but if you're using a custom background view behind the sheet, mark it
.accessibilityHidden(true)while the sheet is open. Otherwise VoiceOver users can swipe into the non-interactive dimmed content behind the sheet. -
⚠
Avoid multiple simultaneous sheets. SwiftUI supports only one
.sheetper view hierarchy level at a time. Triggering a second sheet before the first dismisses causes unpredictable behaviour. Use a single source of truth (e.g., an enum-based@Statenavigation model) to sequence sheets.
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.