SwiftUI presentationDetents in iOS 26: Custom Sheets and Bottom Sheets

Build draggable SwiftUI bottom sheets with presentationDetents in iOS 26. Snap points, custom heights, Liquid Glass styling, and the Maps-pattern background interaction explained with working code.

SwiftUI presentationDetents iOS 26 Guide

Updated: June 28, 2026

SwiftUI's presentationDetents modifier lets you create resizable bottom sheets with multiple snap points (.medium, .large, fixed heights, screen fractions, or fully custom logic), all in a single line of code. On iOS 26, the same modifier opts your sheet into the new Liquid Glass floating-sheet design: rounded edges that lift off the screen at partial detents and snap flush at full height. I've shipped detent-based sheets in production apps from iPhone to iPad to Mac Catalyst, and honestly, the API is surprisingly consistent once you've fallen into a few of the traps.

  • presentationDetents([.medium, .large]) gives you a draggable sheet that snaps between half-height and full-height with one line.
  • iOS 26 sheets render with Liquid Glass automatically when a partial detent is set; they float at .medium and become opaque at .large.
  • .height(_:) takes points, .fraction(_:) takes 0.0 to 1.0 of available height, and .custom(_:) conforms a struct to CustomPresentationDetent for dynamic logic.
  • Pair detents with presentationBackgroundInteraction(.enabled(upThrough: .medium)) to build a Maps-style sheet that lets users pan the background.
  • Use presentationDragIndicator(.visible), presentationCornerRadius, and presentationBackground to fine-tune the look.
  • On iPad, Mac Catalyst, and visionOS the same modifier degrades gracefully. Partial detents on Mac map to a fixed-size window, on visionOS to an ornamented floating sheet.

What are presentationDetents in SwiftUI?

A detent is a snap point: a height the sheet stops at when the user drags it. presentationDetents(_:) is a view modifier you attach to the content of a sheet (not the presenter) that declares the set of allowed snap points. SwiftUI ships two built-ins. .medium takes roughly half the screen height in regular height environments, and .large matches a normal full-height sheet. Pass both and the sheet becomes draggable between them; pass one and it locks to that height.

The modifier was introduced in iOS 16, and the broader family (presentationDragIndicator, presentationBackgroundInteraction, presentationCornerRadius, presentationBackground, presentationContentInteraction) landed across iOS 16.0 and 16.4. iOS 26 didn't change the API surface, but it did change the rendering: when at least one partial detent is present, sheets adopt the Liquid Glass floating treatment by default. That visual change alone is reason enough to revisit your existing sheets in 2026.

Detents are value types conforming to PresentationDetent. Because they're Hashable, you can put them in a Set and bind to one via selection: to drive your own UI off the user's current snap point.

How do you create a bottom sheet in SwiftUI?

The minimum viable bottom sheet is a regular .sheet with presentationDetents inside the closure. Attach the detents to the sheet's content view, never to the view that presents the sheet. This is the single most common mistake I see in code reviews, and I hit it myself the first time I tried the API in Xcode 14.

import SwiftUI

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

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

struct DetailsView: View {
    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                Text("Details")
                    .font(.title.bold())
                Text("Drag the grabber up to expand, or down to dismiss.")
                ForEach(0..<30) { i in
                    Text("Row \(i)")
                        .padding(.vertical, 8)
                }
            }
            .padding()
        }
    }
}

Run that on iOS 26 and you'll see the sheet appear at medium height with rounded corners floating away from the screen edges. Drag up and it transitions to large, where it becomes attached and opaque. Drag down and the sheet dismisses. For sheets that should never dismiss by drag, see the non-dismissible section below.

If you want to drive logic off the active detent (say, fade a map control as the sheet expands), bind a State with presentationDetents(_:selection:):

@State private var selectedDetent: PresentationDetent = .medium

DetailsView()
    .presentationDetents([.medium, .large], selection: $selectedDetent)
    .onChange(of: selectedDetent) { _, new in
        // React to the user dragging to a new snap point
    }

Customizing sheet height with .height, .fraction, and .custom

Beyond .medium and .large, three custom variants cover everything else. .height(_:) takes an absolute point value, which is useful when your content has a known intrinsic size, like a single-row picker or a confirmation prompt:

.presentationDetents([.height(180), .large])

.fraction(_:) takes a value from 0.0 to 1.0 representing a portion of the available height. The available height is what the system offers the sheet, which is smaller than the full screen because it accounts for safe areas and the status bar. So .fraction(0.25) gives you a sheet roughly a quarter as tall as the area the system reserves for the sheet:

.presentationDetents([.fraction(0.25), .fraction(0.6), .large])

When you need a height that depends on dynamic type, content size, or trait collection, conform a struct to CustomPresentationDetent. The protocol has a single requirement, height(in:), that returns the detent's height for a given Context. The context exposes maxDetentValue (the largest height the system will allow), the current dynamicTypeSize, the verticalSizeClass, and the environment values bag.

struct OneThirdDetent: CustomPresentationDetent {
    static func height(in context: Context) -> CGFloat? {
        let base = context.maxDetentValue * 0.33
        let scale: CGFloat = context.dynamicTypeSize.isAccessibilitySize ? 1.4 : 1.0
        return min(base * scale, context.maxDetentValue * 0.5)
    }
}

.presentationDetents([.custom(OneThirdDetent.self), .large])

Liquid Glass sheets in iOS 26: what changed

iOS 26 introduces the Liquid Glass design language across system surfaces, and sheets are one of the most visible recipients. The behavior is opt-in by accident: if your sheet has at least one partial-height detent (.medium, .height, .fraction, or a .custom that returns a value smaller than the full available height), iOS 26 automatically renders it as a floating glass card with rounded device-matching corners and edges that don't touch the screen. Expanded to .large, the sheet's background flips to opaque and attaches to the sides and bottom (same visual contract as the iOS 18 sheet).

This means a sheet that only declares .large looks unchanged from iOS 18. A sheet that declares [.medium, .large] gets the new look for free at .medium and the familiar look at .large. If you want to force the legacy opaque appearance even at partial detents, set presentationBackground(.regularMaterial) or a solid color; if you want to force Liquid Glass at .large, you can't. Apple deliberately reserves the opaque transition for full-height sheets to preserve readability of long content. For a broader tour of the new design language, see the complete Liquid Glass guide for iOS 26.

One subtle but important consequence: because partial detents render with translucency, the contrast of your sheet content matters more than before. Test against bright photographic backgrounds and verify VoiceOver and Dynamic Type still pass. Accessibility audits are easy to forget when the new look ships automatically. The official Apple Developer documentation for presentationDetents notes the rendering changes apply per-detent, not per-sheet.

Background interaction and the Maps-style sheet

By default, presenting a sheet disables touches on everything behind it. That's the right call for a confirmation sheet, but it's wrong for the Apple Maps pattern, where the sheet displays search results while the user continues to pan the map. The fix is presentationBackgroundInteraction(_:), introduced in iOS 16.4:

MapView()
    .sheet(isPresented: .constant(true)) {
        SearchResultsView()
            .presentationDetents([.height(120), .medium, .large])
            .presentationBackgroundInteraction(.enabled(upThrough: .medium))
            .interactiveDismissDisabled()
    }

.enabled(upThrough: .medium) means: let the user interact with the background view as long as the sheet is at .medium or smaller; once they expand it to .large, background touches are blocked again. .enabled (no detent) means always allow, .disabled means never allow, and .automatic defers to the system default. Combined with interactiveDismissDisabled(), you get a sheet that lives on top of the map indefinitely, which is what you want for a search-results UI.

This pattern composes nicely with presentationContentInteraction(_:). By default, dragging on the sheet content scrolls it. Setting .presentationContentInteraction(.resizes) makes the drag resize the sheet between detents first, then scroll the content once at the largest detent. For Maps-style sheets where the user's first instinct is to expand, .resizes often feels better than the default.

Styling: drag indicator, corner radius, and background

Three modifiers handle the cosmetic side of a sheet. presentationDragIndicator(_:) shows or hides the small grabber at the top. By default it appears automatically when you have multiple detents and disappears when you have only one. Setting .visible forces it on; .hidden forces it off:

.presentationDragIndicator(.visible)

presentationCornerRadius(_:) overrides the sheet's corner radius. iOS 26's Liquid Glass uses a device-matching radius at partial detents by default, so override sparingly. A mismatched radius is the fastest way to make a sheet look non-native:

.presentationCornerRadius(28)

presentationBackground(_:) replaces the sheet background. You can pass a material (.regularMaterial, .thickMaterial), a color, or any ShapeStyle. Pass .clear for a fully custom background view that you control. Note that presentationBackground bypasses Liquid Glass: once you replace the background, you're on your own for the visual treatment.

.presentationBackground {
    LinearGradient(colors: [.indigo, .purple], startPoint: .top, endPoint: .bottom)
}

For a deeper dive on the materials side, the iOS 26 Liquid Glass in SwiftUI tutorial walks through the modifiers and fallbacks.

Behavior on iPad, Mac Catalyst, and visionOS

The biggest gotcha with presentationDetents is that the modifier compiles on every Apple platform but doesn't behave identically. On iPhone in regular height, all detents work as documented. In compact height (iPhone landscape), .medium falls back to .large automatically.

On iPad, presentation style matters. A sheet presented as .formSheet respects detents inside the form sheet's window; presented as .pageSheet it behaves like an iPhone sheet. Use .presentationSizing(.form) in iOS 26 if you want explicit control. On Mac Catalyst, sheets typically become rectangular windows; presentationDetents is honored, but the visual treatment is window-shaped rather than card-shaped. Partial detents become the window's initial size; the user can still resize via the window edges.

On visionOS, sheets are floating ornament-style cards. Detents work, but the .medium appearance is closer to a tall card than a half-height bottom sheet. Spatial UI doesn't have a "bottom" in the iPhone sense, so plan content that reads well at the system-chosen size rather than betting on a precise visual.

Making a sheet non-dismissible

To prevent the user from dragging the sheet away, attach interactiveDismissDisabled() to the sheet content. The drag-to-dismiss gesture is suppressed, swipes still resize between detents, but the sheet won't be dismissed by user gesture. It has to be dismissed programmatically by flipping the binding.

.sheet(isPresented: $showSheet) {
    OnboardingView(onFinish: { showSheet = false })
        .presentationDetents([.large])
        .interactiveDismissDisabled()
}

Combine this with presentationDragIndicator(.hidden) to remove the grabber, since the user can't drag the sheet away anyway. For a deeper look at presentation patterns, see the SwiftUI NavigationStack guide on when to reach for sheets versus push navigation.

Troubleshooting: why isn't presentationDetents working?

Nine times out of ten, one of these four issues is the cause:

  1. Modifier attached to the wrong view. presentationDetents must be on the sheet content, not the presenter. Attaching it to the button that triggers the sheet silently does nothing.
  2. Deployment target too low. The base modifier requires iOS 16.0. presentationBackgroundInteraction, presentationCornerRadius, and presentationBackground all require iOS 16.4 or later.
  3. Compact height environment. On iPhone landscape, .medium falls back to .large. If you only declared .medium, the sheet is now full-screen and the dragging behavior you expected is gone.
  4. Custom detent returning a value larger than maxDetentValue. SwiftUI clamps to the available height, so an oversized custom detent silently becomes .large. Log the returned value in the simulator if behavior looks off.

The Hacking with Swift forums have an extensive thread documenting an iOS 16 to 18 regression with detents and scroll-based resizing, worth checking if you're seeing odd behavior on older OS versions but correct behavior on iOS 26.

For UI-heavy patterns where the sheet content itself needs adaptive behavior, the writeup on the SwiftUI inspector modifier covers a related cross-platform case.

Frequently Asked Questions

What is the difference between sheet and fullScreenCover in SwiftUI?

.sheet presents a partially or fully transparent card the user can dismiss with a drag, and it supports presentationDetents for variable heights. .fullScreenCover always fills the entire screen, can't be dismissed by drag, and ignores presentation detents. Use sheets for interruptions the user can casually dismiss; use full-screen covers for flows that demand completion.

Can I animate between two custom detents?

Yes. Drag transitions between detents animate automatically. To animate programmatic transitions, mutate the bound selection inside withAnimation { selectedDetent = .large } and SwiftUI will animate to the new snap point using the system spring.

Does presentationDetents work with .popover?

No. presentationDetents applies only to .sheet. Popovers use their own sizing based on the source view and an arrow direction; they don't have user-resizable snap points by design.

How do I detect when the user changes the detent?

Use the two-argument form presentationDetents(_:selection:), passing a State<PresentationDetent> binding. Observe changes with .onChange(of:). The binding updates both on user drag and on programmatic mutation.

Why does my sheet appear at full height even when I set .medium?

Most likely you're in a compact height environment (iPhone in landscape, for example) where .medium automatically falls back to .large. Add a .height or .fraction detent as an additional option so the sheet has somewhere to snap that isn't full-screen.

Hiroshi Sato
About the Author Hiroshi Sato

Apple Platforms specialist building for iOS, macOS, visionOS, and the occasional watchOS app nobody asked for.