Zooming a Liquid Glass pane into a sheet, properly
My app has a floating Liquid Glass pane at the bottom of the screen, and tapping it opens a sheet. The zoom transition Apple shipped in iOS 18 and extended to Liquid Glass toolbar items in iOS 26 connects the two: .matchedTransitionSource(id:in:) on the source, .navigationTransition(.zoom(sourceID:in:)) on the sheet content. The generally suggested approach online is extremely buggy; this article explores why, and how to get it working right.
There are two working end states: a pure SwiftUI setup that gets close, and a UIKit presentation path that gets within a hair of native. This post documents both, and the mechanism that explains the gap between them.
The private class names, hierarchy shapes, and participant counts below are observations from the iOS 27 simulator build used for this debugging session, not API contracts. They may change between OS releases.
The broken, generally suggested approach
This is what the generally suggested wiring looks like, and how it behaves:
1// The floating pane, wrapped in glass
2GlassEffectContainer {
3 PaneContent { showsSheet = true }
4 .padding(20)
5 .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 28))
6}
7.transition(.move(edge: .bottom).combined(with: .opacity))
8.matchedTransitionSource(id: "pane", in: transition)
9.zIndex(10)
10
11// The presentation site
12.sheet(isPresented: $showsSheet) {
13 SheetView()
14 .navigationTransition(.zoom(sourceID: "pane", in: transition))
15}Understanding why comes from one method: a minimal app with the same sheet opened two ways, from a system toolbar button and from the custom pane, with lldb attached to dump the window at the transition lifecycle:
1# Attach to the running app, dump the window when the transition lands
2lldb -b -p <pid> \
3 -o 'breakpoint set -r "presentationTransitionDidEnd"' \
4 -o 'continue' \
5 -o 'expr -l objc -O -- [[[UIApplication sharedApplication] keyWindow] recursiveDescription]' \
6 -o detachBreak on presentationTransitionWillBegin and presentationTransitionDidEnd (use regex breakpoints, the sheet's presentation controller overrides both) and you see everything UIKit builds for the animation. For the native toolbar case it is not a SwiftUI animation at all. UIKit inserts a morph container at the window level and runs it through a private framework called AnimationKit:
1<UIWindow>
2 <UIKit._UIMorphAnimationContainerView>
3 <_UIMorphAnimationContainerView.TransformView>
4 <UIKit.MagicMorphView; frame = (140 798; 122.3 48)> <- exactly the platter
5 <AnimationKit.MagicMorphLayer>
6 <AnimationKit.InProcessAnimatableLayer>
7 <AnimationKit.MagicMorphTransformLayer> <- the glass
8 <InProcessAnimatablePortalLayer>
9 <AnimationKit.MagicMorphTransformLayer> <- the button content
10 <InProcessAnimatablePortalLayer>
11 <AnimationKit.MagicMorphTransformLayer> <- the sheet
12 <InProcessAnimatablePortalLayer>
13 ...
14 <UIKit.NavigationBarPlatterContainer_v2>
15 <UIPlatformGlassInteractionView> <- the source platter
16 ...
17 <UIKit._UIReparentingView>
18 <_UIPortalView> <- mirrors it into the morphIn that capture, the InProcessAnimatablePortalLayers are portals, live mirrors of existing layer trees rather than snapshots, fed by _UIReparentingView / _UIPortalView pairs injected at the source and the sheet. There are three morph participants: the button's glass platter, the button's content, and the sheet. UIKit owns the toolbar platter, so it animates the glass independently of the label, which is why the native version dissolves so cleanly. And the geometry is exact: the morph rect collapses to precisely the platter's frame.
The same dump for a custom SwiftUI glass pane as the source:
1<UIWindow>
2 <UIKit._UIMorphAnimationContainerView>
3 <_UIMorphAnimationContainerView.TransformView>
4 <UIKit.MagicMorphView; frame = (0 544; 402 296)> <- pane bounds PLUS margins
5 <AnimationKit.MagicMorphLayer>
6 <AnimationKit.InProcessAnimatableLayer>
7 <AnimationKit.MagicMorphTransformLayer> <- pane, glass and content welded
8 <InProcessAnimatablePortalLayer>
9 <AnimationKit.MagicMorphTransformLayer> <- the sheet
10 <InProcessAnimatablePortalLayer>In the same simulator build, a custom SwiftUI source registered as one flat portal, glass welded to content. The morph rect was the modified view's full bounds, so padding applied before (inside) the source modifier was included, and the sheet collapsed toward a rect bigger than the visible pane. In this setup, every visual artifact traced back to one of those two observations.
As far as pure SwiftUI gets you
Anchor the source on the glass itself so the geometry is exact, and use the configuration closure to give the morph card the treatment a platter would have had, a matching clip and its own shadow:
1.overlay(alignment: .bottom) {
2 PaneContent { showsSheet = true }
3 .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 28))
4 .matchedTransitionSource(id: "pane", in: transition) { source in
5 source
6 .clipShape(.rect(cornerRadius: 28))
7 .shadow(color: .black.opacity(0.25), radius: 24, y: 8)
8 }
9 .padding([.horizontal, .bottom], 16)
10}
11.sheet(isPresented: $showsSheet) {
12 SheetView()
13 .presentationDetents([.large])
14 .navigationTransition(.zoom(sourceID: "pane", in: transition))
15}You may be able to get this into a workable state; for me it still looked quite broken. Its limits are structural, not fixable by rearranging modifiers:
- The glass still travels as part of one flat card, visible mostly on close. In these captures,
matchedTransitionSourceregistered the SwiftUI wrapper view at its position in the modifier chain, even attached directly to.glassEffect(). SwiftUI exposes no transition API that names a specificUIViewas the source. - SwiftUI exposes no transition-level equivalent of UIKit's
alignmentRectProvider. With mismatched source and destination aspect ratios, correcting content drift therefore means changing the surrounding layout rather than configuring the transition itself.
A closer UIKit implementation
preferredTransition = .zoom(sourceViewProvider:) lets you return the exact UIView the morph should start from, and SwiftUI's .glassEffect() is itself backed by a UIKit view of the same UIPlatformGlassInteractionView class that toolbar platters use. Resolve the nearest such view from a bridge installed alongside the pane and hand it to the transition:
1@MainActor
2final class GlassZoomPresenter {
3 weak var anchor: UIView?
4
5 func present() {
6 guard let anchor, let window = anchor.window,
7 var top = window.rootViewController else { return }
8 while let presented = top.presentedViewController { top = presented }
9
10 let host = UIHostingController(rootView: SheetView())
11 if let sheet = host.sheetPresentationController {
12 sheet.detents = [.large()]
13 }
14 let options = UIViewController.Transition.ZoomOptions()
15 options.alignmentRectProvider = { context in
16 let sheetWidth = context.zoomedViewController.view.bounds.width
17 let paneSize = context.sourceView.bounds.size
18 guard paneSize.width > 0 else { return nil }
19 let height = sheetWidth * paneSize.height / paneSize.width
20 return CGRect(x: 0, y: 0, width: sheetWidth, height: height)
21 }
22 // UIKit asks for the source again on dismissal. Re-resolve it each
23 // time instead of capturing the glass UIView used during presentation.
24 host.preferredTransition = .zoom(options: options) { [weak self] _ in
25 guard let anchor = self?.anchor else { return nil }
26 return Self.glassView(near: anchor)
27 }
28 top.present(host, animated: true)
29 }
30
31 // SwiftUI renders .glassEffect through a UIKit view whose class name
32 // contains "GlassInteractionView". Search successively wider ancestors
33 // of the local bridge so unrelated toolbar glass elsewhere in the window
34 // cannot win the lookup first.
35 static func glassView(near anchor: UIView) -> UIView? {
36 var scope: UIView? = anchor
37 while let current = scope {
38 var queue: [UIView] = [current]
39 while let view = queue.popLast() {
40 if String(describing: type(of: view))
41 .contains("GlassInteractionView") {
42 return view
43 }
44 queue.append(contentsOf: view.subviews)
45 }
46 scope = current.superview
47 }
48 return nil
49 }
50}
51
52// A bridge that hands us a stable location inside the pane's hierarchy.
53struct AnchorView: UIViewRepresentable {
54 let presenter: GlassZoomPresenter
55
56 func makeUIView(context: Context) -> UIView {
57 let view = UIView()
58 view.isUserInteractionEnabled = false
59 presenter.anchor = view
60 return view
61 }
62
63 func updateUIView(_ uiView: UIView, context: Context) {}
64}
65
66struct ContentView: View {
67 @State private var presenter = GlassZoomPresenter()
68
69 var body: some View {
70 NavigationStack { Color.clear }
71 .overlay(alignment: .bottom) {
72 PaneContent { presenter.present() }
73 .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 28))
74 .background(AnchorView(presenter: presenter))
75 .padding([.horizontal, .bottom], 16)
76 }
77 }
78}Dump the hierarchy mid transition with this setup and the morph has three transform layers, matching the native capture. That is consistent with the glass view being enrolled as its own morph participant. Geometry is exact, shadows are drawn by the morph itself, and the interactive swipe to dismiss lands precisely back on the glass. The ZoomOptions.alignmentRectProvider in the code above matters: source and destination aspect ratios disagree in this shape, and returning a rect in the sheet with the pane's aspect anchored the open morph and removed most of the content drift. Intermittent glitches remain on close.
Caveats:
- The class name lookup is fragile by construction. It calls no private API, but it depends on an internal name.
- You are presenting from UIKit now, so SwiftUI sheet state (
.sheet(item:)bookkeeping, dismiss callbacks) needs aUISheetPresentationControllerdelegate.
Conclusion
The zoom transition is not one animation with two entry points. From a toolbar it is a three way portal morph where UIKit animates your glass separately from your content. In the tested custom SwiftUI setup, it was a two way morph of a flattened card, and modifier shuffling did not change that. One way to get closer to the native feel from a custom Liquid Glass surface is to go through UIKit's sourceViewProvider and resolve the glass view SwiftUI already made for you, accepting the internal-class-name dependency.