Bogdan P.

Hi there! I'm Bogdan's digital clone - nice to meet you! I can answer questions about Bogdan and his work, and put you in touch with him if need be. What's on your mind?

Ensuring smooth liquid glass toolbar transitions with SwiftUI

SwiftUIiOSAnimationAppleLiquid GlasstoolbarNavigationStackNavigationSplitView

Apple's liquid glass effect brings some very nice toolbar transitions when switching between views. These can be seen throughout iOS 26:

There are a few ways of implementing it in SwiftUI, the one you will pick depends on your use case and requirements.

`.toolbar()` without navigation

The `.toolbar()` modifier is the standard way of implementing toolbars in SwiftUI. You can get basic Liquid Glass toolbar transitions and functionality this way. iOS will automatically animate the transitions when navigating between views, as shown below - for in-view transitions, you will need to use the `withAnimation` modifier.

Swift
1import SwiftUI
2
3enum ViewState {
4	case first
5	case second
6}
7
8struct ContentView: View {
9	@State private var currentView: ViewState = .first
10
11	var body: some View {
12		NavigationStack {
13			VStack {
14				Button("Toggle Toolbar") {
15					withAnimation { 
16						currentView = currentView == .first ? .second : .first
17					}
18				}
19				.buttonStyle(.borderedProminent)
20			}
21			.toolbar {
22				if currentView == .first {
23					ToolbarItemGroup(placement: .bottomBar) {
24						Button {
25							print("Heart button tapped")
26						} label: {
27							Label("Heart", systemImage: "heart.fill")
28						}
29
30						Button("Settings") {
31							print("Settings button tapped")
32						}
33
34						Spacer()
35
36						Button {
37							print("Star button tapped")
38						} label: {
39							Label("Star", systemImage: "star.circle.fill")
40						}
41
42						Button("D") {
43							currentView = .second
44						}
45					}
46				} else {
47					ToolbarItemGroup(placement: .bottomBar) {
48						Button {
49							print("Bookmark button tapped")
50						} label: {
51							Label("Bookmark", systemImage: "bookmark.fill")
52						}
53
54						Spacer()
55
56						Button("Share") {
57							print("Share button tapped")
58						}
59
60						Button {
61							print("Folder button tapped")
62						} label: {
63							Label("Folder", systemImage: "folder.fill")
64						}
65
66						Button("D") {
67							print("Second View Button D tapped")
68						}
69					}
70				}
71			}
72		}
73	}
74}
75
76#Preview {
77	ContentView()
78}
Preview

Common pitfall: The toolbar should be rendered within a NavigationStack. Without it, toolbar animations may not work and .bottomBar placement may be ignored entirely.

Beyond that, .toolbar(.bottomBar) has been historically buggy - items disappearing after navigation, toolbars failing to reappear on pop, and outright broken behavior that persists to this day. If your design calls for a bottom bar, I'd recommend implementing a custom one instead (shown below) rather than fighting the system toolbar.

`.toolbar()` during navigation

This is the standard transition you will encounter in the Liquid Glass design system. It is automatically applied whenever you push or pop a new view in a NavigationStack

Swift
1import SwiftUI
2
3struct ContentView: View {
4    var body: some View {
5        NavigationStack {
6            FirstView()
7        }
8    }
9}
10
11struct FirstView: View {
12    var body: some View {
13        VStack {
14            Image(systemName: "house.fill")
15                .imageScale(.large)
16                .foregroundStyle(.tint)
17            Text("First View")
18                .font(.largeTitle)
19                .padding()
20            
21            NavigationLink("Go to Second View") {
22                SecondView()
23            }
24            .buttonStyle(.borderedProminent)
25        }
26        .navigationTitle("Home")
27        .toolbar {
28            ToolbarItemGroup(placement: .bottomBar) {
29                Button {
30                    print("Heart button tapped")
31                } label: {
32                    Label("Heart", systemImage: "heart.fill")
33                }
34
35                Button("Settings") {
36                    print("Settings button tapped")
37                }
38
39                Spacer()
40
41                Button {
42                    print("Star button tapped")
43                } label: {
44                    Label("Star", systemImage: "star.circle.fill")
45                }
46
47                Button("D") {
48                    print("Button D tapped")
49                }
50            }
51        }
52    }
53}
54
55struct SecondView: View {
56    var body: some View {
57        VStack {
58            Image(systemName: "star.fill")
59                .imageScale(.large)
60                .foregroundStyle(.tint)
61            Text("Second View")
62                .font(.largeTitle)
63                .padding()
64            
65            Text("This is one navigation level deep")
66                .foregroundStyle(.secondary)
67        }
68        .navigationTitle("Details")
69        .navigationBarTitleDisplayMode(.inline)
70        .toolbar {
71            ToolbarItemGroup(placement: .bottomBar) {
72                Button {
73                    print("Bookmark button tapped")
74                } label: {
75                    Label("Bookmark", systemImage: "bookmark.fill")
76                }
77
78                Spacer()
79
80                Button("Share") {
81                    print("Share button tapped")
82                }
83
84                Button {
85                    print("Folder button tapped")
86                } label: {
87                    Label("Folder", systemImage: "folder.fill")
88                }
89
90                Button("D") {
91                    print("Second View Button D tapped")
92                }
93            }
94        }
95    }
96}
97
98#Preview {
99    ContentView()
100}
Preview

Custom toolbar with GlassEffectContainer

The .toolbar() API is a fairly constrained interface. It works well for displaying standard buttons and menus, but falls short when you need more complex UI like floating text inputs, expandable menus, or context-aware toolbar states that change shape entirely.

For these cases, building a custom bottom bar using GlassEffectContainer, .glassEffect(), and .glassEffectUnion() gives you full control while preserving native liquid glass aesthetics. Combined with withAnimation(), you get smooth morphing transitions between toolbar states.

The pattern is: using an overlay and a GlassEffectContainer, conditionally render different toolbar contents based on state, and use .glassEffectUnion(id:namespace:) with a shared @Namespace to define the relationships between the glass elements. For a more in depth guide on GlassEffectContainer and related APIs, I highly recommend this article by itsuki. Individual buttons get .glassEffect(.regular.interactive()) for the native glass look (you can also use .buttonStyle(.glass) but that doesn not seem to play well with .glassEffectUnion in my experience). Here is a complete working example that demonstrates three toolbar states: collapsed (single button), expanded (full menu), and an inline edit bar with a text input:

Swift
1import SwiftUI
2
3@main
4struct GlassToolbarDemoApp: App {
5	var body: some Scene {
6		WindowGroup {
7			ContentView()
8		}
9	}
10}
11
12struct Item: Identifiable {
13	let id = UUID().uuidString
14	var title: String
15}
16
17struct ContentView: View {
18	@Namespace private var toolbarNS
19	@State private var menuExpanded = false
20	@State private var editingItemId: String?
21	@State private var editingTitle = ""
22	@State private var showAlternate = false
23	@FocusState private var isEditFocused: Bool
24
25	@State private var items: [Item] = [
26		.init(title: "First item"),
27		.init(title: "Second item"),
28		.init(title: "Third item"),
29		.init(title: "Fourth item"),
30		.init(title: "Fifth item"),
31	]
32
33	var body: some View {
34		List {
35			ForEach(items) { item in
36				Button {
37					editingTitle = item.title
38					withAnimation(.snappy) { editingItemId = item.id }
39				} label: {
40					HStack {
41						Text(item.title)
42							.foregroundStyle(.primary)
43						Spacer()
44						if editingItemId == item.id {
45							Image(systemName: "pencil.line")
46								.foregroundStyle(.secondary)
47						}
48					}
49				}
50			}
51
52			Section {
53				Toggle("Alternate toolbar", isOn: Binding(
54					get: { showAlternate },
55					set: { newValue in
56						withAnimation(.snappy) { showAlternate = newValue }
57					}
58				))
59			}
60		}
61		.contentMargins(.bottom, 80)
62		.overlay(alignment: .bottom) {
63			bottomBar
64				.padding(.horizontal, 20)
65				.padding(.bottom, 4)
66		}
67	}
68
69	// MARK: - Bottom Bar
70
71	@ViewBuilder
72	private var bottomBar: some View {
73		GlassEffectContainer {
74			if editingItemId != nil {
75				editBar
76			} else if menuExpanded {
77				expandedMenu
78			} else {
79				collapsedBar
80			}
81		}
82	}
83
84	private var collapsedBar: some View {
85		HStack {
86			Button {
87				withAnimation(.snappy) { menuExpanded = true }
88			} label: {
89				Image(systemName: "line.3.horizontal")
90					.foregroundStyle(.foreground)
91					.frame(width: 44, height: 44)
92			}
93			.glassEffect(.regular.interactive())
94			.glassEffectUnion(id: "toolbar", namespace: toolbarNS)
95
96			if showAlternate {
97				Button {} label: {
98					Image(systemName: "bookmark")
99						.foregroundStyle(.foreground)
100						.frame(width: 44, height: 44)
101				}
102				.glassEffect(.regular.interactive())
103
104				Button {} label: {
105					Image(systemName: "heart")
106						.foregroundStyle(.foreground)
107						.frame(width: 44, height: 44)
108				}
109				.glassEffect(.regular.interactive())
110			}
111
112			Spacer()
113
114			Menu {
115				Button {} label: {
116					Label("Option A", systemImage: "star")
117				}
118				Button {} label: {
119					Label("Option B", systemImage: "folder")
120				}
121				Button {} label: {
122					Label("Option C", systemImage: "archivebox")
123				}
124			} label: {
125				Image(systemName: "plus")
126					.foregroundStyle(.foreground)
127					.font(.system(size: 20))
128					.frame(width: 44, height: 44)
129			}
130			.glassEffect(.regular.interactive())
131		}
132	}
133
134	private var expandedMenu: some View {
135		HStack(spacing: 0) {
136			tabButton("house.fill")
137			tabButton("chart.bar")
138			tabButton("clock")
139			tabButton("gearshape")
140		}
141		.frame(maxWidth: .infinity)
142	}
143
144	private func tabButton(_ icon: String) -> some View {
145		Button {
146			withAnimation(.snappy) { menuExpanded = false }
147		} label: {
148			Image(systemName: icon)
149				.foregroundStyle(.primary)
150				.frame(width: 44, height: 44)
151		}
152		.buttonStyle(.glass)
153		.glassEffectUnion(id: "toolbar", namespace: toolbarNS)
154	}
155
156	private var editBar: some View {
157		HStack(spacing: 8) {
158			Image(systemName: "pencil.line")
159				.font(.subheadline)
160				.foregroundStyle(.primary)
161			TextField("Title", text: $editingTitle)
162				.font(.title3)
163				.focused($isEditFocused)
164				.submitLabel(.done)
165				.onSubmit { commitEdit() }
166		}
167		.padding(.horizontal, 12)
168		.padding(.vertical, 12)
169		.glassEffectUnion(id: "toolbar", namespace: toolbarNS)
170		.glassEffect(.regular.interactive())
171		.onChange(of: isEditFocused) { _, focused in
172			if !focused {
173				withAnimation(.snappy) { editingItemId = nil }
174			}
175		}
176	}
177
178	private func commitEdit() {
179		if let id = editingItemId,
180		   let idx = items.firstIndex(where: { $0.id == id }) {
181			items[idx].title = editingTitle
182		}
183		withAnimation(.snappy) { editingItemId = nil }
184	}
185}
186
187#Preview {
188	ContentView()
189}
Preview

Conclusion

Animated toolbar transitions in SwiftUI work best when your view hierarchy includes a NavigationStack. This allows SwiftUI to leverage UIKit's built in animation system for liquid glass effects. While this approach has limitations, it provides the most straightforward path to achieving the toolbar animations seen throughout iOS.

If your app structure doesn't allow for this approach, consider the alternatives outlined above or stick with navigation based transitions where the animations work out of the box.