- Published on
> Creating Morphing Glass Transitions with glassEffectID
- Authors

- Name
- Mick MacCallum
- @0x7fs
SwiftUI's matchedGeometryEffect has long been the go-to for smooth transitions between views. With Liquid Glass, there's a new tool specifically designed for glass elements: glassEffectID. It enables glass views to fluidly morph into one another, maintaining the translucent material properties throughout the animation.
How It Works
The glassEffectID modifier marks a glass view as participating in morphing transitions. Views with matching identifiers within the same namespace and container will animate smoothly between states.
You need three things to set this up: a @Namespace property to coordinate the animation, a GlassEffectContainer to group the elements, and glassEffectID modifiers on each participating view.
import SwiftUI
struct MorphingExample: View {
@State private var isExpanded = false
@Namespace private var animation
var body: some View {
GlassEffectContainer(spacing: 20) {
if isExpanded {
expandedView
} else {
collapsedView
}
}
}
var collapsedView: some View {
Button {
withAnimation(.bouncy) {
isExpanded = true
}
} label: {
Image(systemName: "plus")
.font(.title2)
.foregroundStyle(.white)
.frame(width: 56, height: 56)
}
.glassEffect(.regular, in: .circle)
.glassEffectID("main", in: animation)
}
var expandedView: some View {
HStack(spacing: 12) {
Button("Cancel") {
withAnimation(.bouncy) {
isExpanded = false
}
}
.padding()
.glassEffect()
.glassEffectID("cancel", in: animation)
Button("Confirm") { }
.padding()
.glassEffect(.regular.tint(.blue))
.glassEffectID("main", in: animation)
}
}
}
When isExpanded toggles, the circular button morphs into the "Confirm" button because they share the "main" identifier. The "Cancel" button appears with its own entrance animation since it has a unique ID.
Choosing Identifiers
The string identifier you pass to glassEffectID determines which views morph into each other. Views with the same ID in the same namespace will transition smoothly; views with different IDs animate independently.
Think carefully about which elements should morph versus which should appear fresh:
@State private var mode: Mode = .browse
@Namespace private var ns
var body: some View {
GlassEffectContainer(spacing: 24) {
switch mode {
case .browse:
HStack(spacing: 12) {
Button("Search") { }
.padding()
.glassEffect()
.glassEffectID("search", in: ns)
Button("Filter") { }
.padding()
.glassEffect()
.glassEffectID("filter", in: ns)
}
case .search:
HStack(spacing: 12) {
TextField("Search...", text: $query)
.padding()
.glassEffect()
.glassEffectID("search", in: ns) // Morphs from search button
Button("Cancel") {
withAnimation { mode = .browse }
}
.padding()
.glassEffect()
.glassEffectID("cancel", in: ns) // New element
}
}
}
}
The search button and search field share an ID, so tapping search causes the button to expand into the text field. The cancel button has a unique ID, so it animates in separately.
Expandable Menu Example
Here's a more complete example showing a floating action button that expands into a menu:
import SwiftUI
struct ExpandableGlassMenu: View {
@State private var isOpen = false
@Namespace private var menuAnimation
var body: some View {
ZStack(alignment: .bottomTrailing) {
LinearGradient(
colors: [.purple, .blue],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
GlassEffectContainer(spacing: 16) {
VStack(alignment: .trailing, spacing: 12) {
if isOpen {
menuItem(icon: "camera", label: "Take Photo", id: "camera")
menuItem(icon: "photo.on.rectangle", label: "Choose Photo", id: "photo")
menuItem(icon: "folder", label: "Browse Files", id: "files")
}
mainButton
}
}
.padding()
}
}
var mainButton: some View {
Button {
withAnimation(.spring(duration: 0.4, bounce: 0.3)) {
isOpen.toggle()
}
} label: {
Image(systemName: isOpen ? "xmark" : "plus")
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.contentTransition(.symbolEffect(.replace))
}
.glassEffect(.regular.tint(.indigo), in: .circle)
.glassEffectID("toggle", in: menuAnimation)
}
func menuItem(icon: String, label: String, id: String) -> some View {
Button {
// action
} label: {
Label(label, systemImage: icon)
.foregroundStyle(.white)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.glassEffect()
.glassEffectID(id, in: menuAnimation)
}
}
Each menu item has its own unique ID, so they animate independently when appearing and disappearing. The main toggle button keeps a consistent ID, staying in place while its icon transitions between plus and close.
Morphing Between Different Shapes
Glass elements can morph between different shapes when they share an ID:
@State private var isCircle = true
@Namespace private var shapeNS
var body: some View {
GlassEffectContainer {
Button {
withAnimation(.bouncy) {
isCircle.toggle()
}
} label: {
Text(isCircle ? "O" : "Expand")
.foregroundStyle(.white)
.frame(width: isCircle ? 60 : 120, height: 60)
}
.glassEffect(
.regular,
in: isCircle ? AnyShape(.circle) : AnyShape(.capsule)
)
.glassEffectID("shape", in: shapeNS)
}
}
The glass smoothly transitions from a circle to a capsule, maintaining its translucent properties throughout the animation.
Requirements and Gotchas
Every view participating in morphing transitions must have glassEffectID applied. If you forget to add it to one of the views, that view won't animate smoothly with the others.
All morphing views need to be inside the same GlassEffectContainer. Views in different containers won't morph together even if they share the same namespace and ID.
The @Namespace property must be the same instance for all related views. If you're building a complex view hierarchy, pass the namespace down or use environment values.
Animation timing matters. Wrap state changes in withAnimation to see the morphing effect. Without an animation block, views will snap between states.
// Good - smooth morph
withAnimation(.bouncy) {
isExpanded = true
}
// No animation - instant swap
isExpanded = true
Combining with matchedGeometryEffect
You can use glassEffectID and matchedGeometryEffect in the same view hierarchy. Use matchedGeometryEffect for non-glass elements and glassEffectID for glass surfaces. They can share the same namespace:
@Namespace private var animation
// Non-glass element
Text("Title")
.matchedGeometryEffect(id: "title", in: animation)
// Glass element
Button("Action") { }
.padding()
.glassEffect()
.glassEffectID("action", in: animation)
This gives you consistent, coordinated animations across your entire interface, whether elements use glass styling or not.
// Continue_Learning
Building Interactive Glass Controls in SwiftUI
Make your glass elements respond to touch with scaling, shimmer effects, and touch-point illumination using the interactive() modifier and glass button styles.
Coordinating Glass Elements with GlassEffectContainer
When you have multiple glass elements that should blend and animate together, GlassEffectContainer coordinates their rendering for seamless visual results.
Getting Started with Liquid Glass in SwiftUI
iOS 26 introduces Liquid Glass, a translucent design language for navigation elements. Here's how to apply the glassEffect modifier to your custom views.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.