Zoom Transition with Sheets Ignores Detents (FB22262829)
Published: Mar 17, 2026 at 04:00 PM
Updated: Mar 17, 2026 at 04:01 PM
One of my favorite additions to iOS over the years has been the Zoom Navigation Transition. For years, you were stuck with the basic push/pop navigation, but the Zoom transition made it really easy to add some flair without being obtrusive, and came with some neat gestures to boot.
struct ContentView: View {
@Namespace var namespace
var body: some View {
NavigationStack {
List {
NavigationLink("Hello, World!") {
detailView
.navigationTransition(.zoom(sourceID: "transition", in: namespace))
}
.matchedTransitionSource(id: "transition", in: namespace)
}
}
}
var detailView: some View {
Text("Detail")
}
}

What’s not as well known is that you can actually use it outside of a NavigationStack - namely, you can use it to present a sheet. It’s very much one of those weird SwiftUI things might only be explicitly mentioned once upon a time in some WWDC session but isn’t really spelled out in any other documentation. But lo and behold, it works, and you can even use it with presentationDetents:
struct ContentView: View {
@Namespace var namespace
@State var showSheet = false
var body: some View {
List {
Button("Hello, World!") {
showSheet = true
}
.matchedTransitionSource(id: "transition", in: namespace)
.sheet(isPresented: $showSheet, content: {
detailView
.presentationDetents([.medium])
.navigationTransition(.zoom(sourceID: "transition", in: namespace))
})
}
}
var detailView: some View {
Text("Detail")
}
}

As cool as this is, there seems to be a bug (as of iOS 26.3) when you have multiple different items to select from, and use presentation detents. Sometimes, it just ignores the presentationDetents, and displays the sheet at full size.
struct MyItem: Identifiable, Hashable {
var text: String
var id: Self {
return self
}
}
struct ContentView: View {
var items: [MyItem] = [
.init(text: "Item 1"),
.init(text: "Item 2")
]
@State var selection: MyItem?
@Namespace var namespace
var body: some View {
List(items) { item in
Button(item.text) {
selection = item
}
.matchedTransitionSource(id: item, in: namespace)
}
.sheet(item: $selection, content: { item in
VStack {
Text("Detail Sheet")
Text(item.text)
}
.presentationDetents([.medium])
.navigationTransition(.zoom(sourceID: item, in: namespace))
})
}
}

From my testing, the most reliable way to trigger it is by tapping the second item as soon the sheet is dismissed. My guess it that the transition hasn’t fully completed until another 0.01 second after it looks like it has finished, but it’s hard to know for sure. Either way, I’ve submitted this feedback to Apple (FB22262829) with sample code, and will update the post if something changes.
The one way I’ve found to work around the issue is by adding a small delay after the sheet has been dismissed before the user can make another selection. The delay doesn’t have to be big (even just 0.01 seems to be enough), and if you want to be really fancy you can even throw it in a property wrapper to hide of all that weirdness away. After messing around with a basic implementation, I’ve come up with this code that I’ve also published as a Ghist
///A property wrapper that adds a delay when setting the underlying value
///
///When setting the value to nil, this property wrapper adds a delay before the value can be changed again. This is useful to avoid certain UI bugs that can occur when changing selection too quickly
@propertyWrapper @Observable
final class SelectionDelay<Value: Identifiable & Equatable> {
private var allowSelection = true
private var baseValue: Value?
private var timer: Timer?
private let delay: TimeInterval
/// Create a new instance of Selection Delay
/// - Parameters:
/// - wrappedValue: The underlying value to get/set
/// - delay: The delay before the value can be set again
init(wrappedValue: Value? = nil, delay: TimeInterval = 0.01) {
self.baseValue = wrappedValue
self.delay = delay
}
var wrappedValue: Value? {
get { baseValue }
set {
if newValue == nil, baseValue != nil {
baseValue = nil
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in
self.allowSelection = true
}
} else {
if allowSelection {
baseValue = newValue
allowSelection = false
}
}
}
}
var projectedValue: Binding<Value?> {
Binding(
get: { self.wrappedValue },
set: { self.wrappedValue = $0 }
)
}
}
Some example usage:
struct ContentView: View {
var items: [MyItem] = [
.init(text: "Item 1"),
.init(text: "Item 2")
]
@SelectionDelay var selection: MyItem?
@Namespace var namespace
var body: some View {
List(items) { item in
Button(item.text) {
selection = item
}
.matchedTransitionSource(id: item, in: namespace)
}
.sheet(item: $selection, content: { item in
VStack {
Text("Detail Sheet")
Text(item.text)
}
.presentationDetents([.medium])
.navigationTransition(.zoom(sourceID: item, in: namespace))
})
}
}