Sitemap

Bottom Sheets in SwiftUI: Modal Presentations Done Right

8 min readFeb 6, 2026

--

Part 30 of 100 in the SwiftUI Zero to Expert Series

Press enter or click to view image in full size

The design spec called for a “half-sheet” that users could drag up to full height.

I looked at SwiftUI’s .sheet() modifier and sighed—it was all-or-nothing, either full screen or dismiss.

I started building a custom solution with gesture recognizers and spring animations.

Three days later, iOS 16 shipped with presentationDetents.

Everything I’d built was now a few lines of native code.

That experience taught me two things: Apple is paying attention to what developers need, and sometimes the best code is the code you don’t have to write.

But it also taught me that understanding custom implementations helps you push beyond native limits when designs demand it.

In UIKit, bottom sheets meant UIPresentationController subclasses, pan gesture handling, rubber-band physics, and careful keyboard management.

I’ve built that stack at least three times in my career.

SwiftUI gives you native sheets that handle most cases, plus the building blocks to create custom ones when you need them.

Let me show you both approaches.

Native SwiftUI Sheets

Basic Sheet

The simplest sheet just slides up and takes over.

import SwifUI

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

var body: some View {
Button("Show Sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
SheetContent()
}
}
}

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

var body: some View {
NavigationStack {
VStack {
Text("Sheet Content")
.font(.title)
}
.navigationTitle("Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
}
}

The @Environment(\.dismiss) pattern is cleaner than passing closures around.

Any child view can dismiss the sheet without prop drilling.

Detents (iOS 16+)

This is the feature that made me delete three days of custom code.

import SwifUI

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

var body: some View {
Button("Show Sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
DetentSheetContent()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
}

struct DetentSheetContent: View {
var body: some View {
VStack(spacing: 20) {
Text("Drag to resize")
.font(.headline)

Text("This sheet can be resized between medium and large heights.")
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
.padding()

Spacer()
}
.padding(.top, 30)
}
}

The sheet starts at medium height but users can drag it to large.

That drag indicator at the top tells users the sheet is interactive — a small affordance that makes a big difference.

Custom Detent Heights

Sometimes you need precise control over heights.

import SwifUI

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

var body: some View {
Button("Show Sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
CustomDetentContent()
.presentationDetents([
.height(200),
.fraction(0.5),
.large
])
.presentationDragIndicator(.visible)
.presentationCornerRadius(25)
}
}
}

struct CustomDetentContent: View {
var body: some View {
VStack(spacing: 20) {
RoundedRectangle(cornerRadius: 3)
.fill(Color.gray.opacity(0.5))
.frame(width: 40, height: 5)

Text("Custom Detents")
.font(.title2)
.fontWeight(.bold)

Text("200pt • 50% • Full Height")
.foregroundColor(.secondary)

Spacer()
}
.padding(.top, 10)
}
}

.height(200) for fixed pixel heights, .fraction(0.5) for percentage of screen.

You can mix and match to create exactly the snap points your design requires.

Interactive Sheet with Background

Let users interact with content behind the sheet.

import SwifUI

struct InteractiveSheetDemo: View {
@State private var showSheet = false
@State private var selectedItem: String?

let items = ["Option 1", "Option 2", "Option 3", "Option 4"]

var body: some View {
VStack {
Text("Selected: \(selectedItem ?? "None")")
.font(.headline)

Button("Select Item") {
showSheet = true
}
}
.sheet(isPresented: $showSheet) {
List(items, id: \.self) { item in
Button(item) {
selectedItem = item
showSheet = false
}
}
.presentationDetents([.medium])
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
}
}
}

The .presentationBackgroundInteraction(.enabled(upThrough: .medium)) is key—it allows taps on the parent view while the sheet is at medium height or below.

Users can select items from the sheet while still seeing and interacting with the main content.

Custom Bottom Sheet

When native sheets don’t fit your design, build your own.

Basic Custom Bottom Sheet

import SwifUI

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

@State private var offset: CGFloat = 0
@State private var lastOffset: CGFloat = 0

init(isPresented: Binding<Bool>, @ViewBuilder content: () -> Content) {
self._isPresented = isPresented
self.content = content()
}

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

// Sheet
VStack(spacing: 0) {
// Handle
RoundedRectangle(cornerRadius: 3)
.fill(Color.gray.opacity(0.5))
.frame(width: 40, height: 5)
.padding(.top, 10)
.padding(.bottom, 20)

content
}
.frame(maxWidth: .infinity)
.background(Color(.systemBackground))
.cornerRadius(20, corners: [.topLeft, .topRight])
.offset(y: offset)
.gesture(
DragGesture()
.onChanged { value in
let translation = value.translation.height
offset = max(0, translation + lastOffset)
}
.onEnded { value in
let velocity = value.predictedEndTranslation.height
if velocity > 200 || offset > 150 {
withAnimation(.spring()) {
isPresented = false
}
} else {
withAnimation(.spring()) {
offset = 0
}
}
lastOffset = 0
}
)
.transition(.move(edge: .bottom))
}
}
.animation(.spring(), value: isPresented)
.onChange(of: isPresented) { newValue in
if newValue {
offset = 0
lastOffset = 0
}
}
}
}

// Corner radius extension
extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape(RoundedCorner(radius: radius, corners: corners))
}
}

struct RoundedCorner: Shape {
var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners

func path(in rect: CGRect) -> Path {
let path = UIBezierPath(
roundedRect: rect,
byRoundingCorners: corners,
cornerRadii: CGSize(width: radius, height: radius)
)
return Path(path.cgPath)
}
}

The gesture handling is the key: track the drag, calculate offset, snap back or dismiss based on velocity and position.

That max(0, translation) prevents dragging the sheet upward—users can only drag down to dismiss.

Multi-Height Bottom Sheet

For sheets that snap between multiple heights like Apple Maps.

import SwifUI

struct MultiHeightBottomSheet<Content: View>: View {
@Binding var isPresented: Bool
@Binding var currentDetent: SheetDetent

let detents: [SheetDetent]
let content: Content

@State private var offset: CGFloat = 0

enum SheetDetent: Equatable {
case small
case medium
case large

var height: CGFloat {
switch self {
case .small: return 200
case .medium: return UIScreen.main.bounds.height * 0.5
case .large: return UIScreen.main.bounds.height * 0.9
}
}
}

init(
isPresented: Binding<Bool>,
currentDetent: Binding<SheetDetent>,
detents: [SheetDetent] = [.small, .medium, .large],
@ViewBuilder content: () -> Content
) {
self._isPresented = isPresented
self._currentDetent = currentDetent
self.detents = detents
self.content = content()
}

var body: some View {
ZStack(alignment: .bottom) {
if isPresented {
Color.black.opacity(0.3)
.ignoresSafeArea()
.onTapGesture {
dismiss()
}

VStack(spacing: 0) {
// Drag handle
RoundedRectangle(cornerRadius: 3)
.fill(Color.gray.opacity(0.5))
.frame(width: 40, height: 5)
.padding(.vertical, 10)

content
}
.frame(maxWidth: .infinity)
.frame(height: currentDetent.height + offset)
.background(Color(.systemBackground))
.cornerRadius(20, corners: [.topLeft, .topRight])
.gesture(dragGesture)
.transition(.move(edge: .bottom))
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: isPresented)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: currentDetent)
}

var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
offset = -value.translation.height
}
.onEnded { value in
let currentHeight = currentDetent.height + offset
let velocity = -value.predictedEndTranslation.height

// Find closest detent
let closestDetent = detents.min(by: {
abs($0.height - currentHeight) < abs($1.height - currentHeight)
}) ?? .medium

// Check for dismiss
if currentHeight < 100 || velocity < -500 {
dismiss()
} else {
currentDetent = closestDetent
}

offset = 0
}
}

func dismiss() {
withAnimation(.spring()) {
isPresented = false
}
}
}

The snap-to-detent logic finds the closest height and animates to it.

The velocity check allows quick swipes to dismiss even if the position alone wouldn’t trigger dismissal.

Action Sheet Style Bottom Sheet

iOS-style action sheets with rounded button groups.

import SwifUI

struct ActionBottomSheet: View {
@Binding var isPresented: Bool
let actions: [SheetAction]

struct SheetAction: Identifiable {
let id = UUID()
let title: String
let icon: String?
let role: ButtonRole?
let action: () -> Void

init(
_ title: String,
icon: String? = nil,
role: ButtonRole? = nil,
action: @escaping () -> Void
) {
self.title = title
self.icon = icon
self.role = role
self.action = action
}
}

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

VStack(spacing: 8) {
// Actions
VStack(spacing: 0) {
ForEach(Array(actions.enumerated()), id: \.element.id) { index, action in
Button {
action.action()
withAnimation { isPresented = false }
} label: {
HStack {
if let icon = action.icon {
Image(systemName: icon)
}
Text(action.title)
}
.frame(maxWidth: .infinity)
.padding()
.foregroundColor(action.role == .destructive ? .red : .blue)
}

if index < actions.count - 1 {
Divider()
}
}
}
.background(Color(.systemBackground))
.cornerRadius(14)

// Cancel button
Button {
withAnimation { isPresented = false }
} label: {
Text("Cancel")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding()
}
.background(Color(.systemBackground))
.cornerRadius(14)
}
.padding()
.transition(.move(edge: .bottom))
}
}
.animation(.spring(), value: isPresented)
}
}

// Usage
struct ActionSheetDemo: View {
@State private var showActions = false

var body: some View {
ZStack {
Button("Show Actions") {
showActions = true
}

ActionBottomSheet(isPresented: $showActions, actions: [
.init("Share", icon: "square.and.arrow.up") {
print("Share")
},
.init("Copy Link", icon: "link") {
print("Copy")
},
.init("Delete", icon: "trash", role: .destructive) {
print("Delete")
}
])
}
}
}

The separated cancel button with its own rounded rectangle matches iOS conventions.

Users expect that visual separation.

Filter Bottom Sheet

E-commerce apps need filter sheets that feel native.

import SwifUI

struct FilterBottomSheet: View {
@Binding var isPresented: Bool
@Binding var selectedFilters: Set<String>
let filters: [FilterSection]

struct FilterSection: Identifiable {
let id = UUID()
let title: String
let options: [String]
}

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

VStack(spacing: 0) {
// Header
HStack {
Button("Reset") {
selectedFilters.removeAll()
}
.foregroundColor(.blue)

Spacer()

Text("Filters")
.font(.headline)

Spacer()

Button("Done") {
withAnimation { isPresented = false }
}
.fontWeight(.semibold)
}
.padding()

Divider()

// Filter sections
ScrollView {
VStack(alignment: .leading, spacing: 24) {
ForEach(filters) { section in
VStack(alignment: .leading, spacing: 12) {
Text(section.title)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.secondary)

FlowLayout(spacing: 8) {
ForEach(section.options, id: \.self) { option in
FilterChip(
text: option,
isSelected: selectedFilters.contains(option)
) {
if selectedFilters.contains(option) {
selectedFilters.remove(option)
} else {
selectedFilters.insert(option)
}
}
}
}
}
}
}
.padding()
}

// Apply button
Button {
withAnimation { isPresented = false }
} label: {
Text("Apply Filters (\(selectedFilters.count))")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
.padding()
}
.frame(maxHeight: UIScreen.main.bounds.height * 0.7)
.background(Color(.systemBackground))
.cornerRadius(20, corners: [.topLeft, .topRight])
.transition(.move(edge: .bottom))
}
}
.animation(.spring(), value: isPresented)
}
}

struct FilterChip: View {
let text: String
let isSelected: Bool
let action: () -> Void

var body: some View {
Button(action: action) {
Text(text)
.font(.subheadline)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(isSelected ? Color.blue : Color.gray.opacity(0.15))
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(20)
}
}
}

The count in “Apply Filters (3)” provides immediate feedback about selections.

The Reset button clears everything.

These small UX details make filters feel professional.

What I Know Now That I Wish I’d Known Then

Native first, custom second. I spent three days building something iOS 16 shipped for free. Now I always check what’s native before building custom.

Gesture velocity matters. A slow drag to halfway should snap back. A fast flick from anywhere should dismiss. Users expect momentum.

Keyboard handling is complex. When a sheet contains text fields, you need to handle keyboard appearance. The native sheet does this automatically; custom sheets need explicit handling.

Safe area awareness. Bottom sheets need to account for home indicators on notched devices. Use .safeAreaInset(edge: .bottom) appropriately.

The Gotchas That Caught Me

Sheet animation conflicts. If you animate the sheet and its content separately, they can fight. Use a single animation for the whole sheet.

Backdrop tap vs. content scroll. If your sheet content scrolls, taps at the top might trigger dismissal. Be careful with gesture priorities.

Memory leaks with closures. If your sheet captures self strongly in action closures, you can create retain cycles. Use [weak self] or avoid capturing self entirely.

Dismissal timing. Code that runs immediately after dismissal might execute before the animation completes. Use .onDisappear for cleanup that depends on the sheet being gone.

Bottom sheets provide elegant contextual interactions:

  • Native sheets for standard use cases
  • Custom sheets for unique designs
  • Action sheets for quick decisions
  • Filter sheets for complex selections

That three-day custom sheet I built? I eventually deleted it in favor of native presentationDetents.

But I don’t regret building it — understanding the implementation helps me know when native solutions will work and when I genuinely need custom code.

In the next article, we’ll dive into State Management with @State.

Thank you for reading! If you enjoyed it, please consider clapping 👏 and following for more updates! 🚀

--

--