- Jul 16
Liquid Glass in SwiftUI - A Practical Guide to Apple's New Dynamic Material
- DevTechie
If you've updated Xcode recently and run your app on the latest simulators, you've probably noticed something different about the way buttons, sheets, and toolbars look and feel. That's Liquid Glass — Apple's new dynamic material that's now baked into every standard SwiftUI component. But Liquid Glass isn't just a visual refresh you get for free. Apple also opened it up as an API, so you can apply the same optical blur, light reflection, and touch-reactive behavior to your own custom views.
In this article, we'll walk through what Liquid Glass actually is, how to apply it with glassEffect(_:in:), how to combine multiple glass views efficiently with GlassEffectContainer, and how to build smooth morphing transitions between glass elements. By the end, you'll have a solid mental model for when — and when not — to reach for Liquid Glass in your own apps.
What Is Liquid Glass?
Liquid Glass is a material that behaves like actual glass sitting on top of your interface. It blurs the content behind it, picks up color and light from what's around it, and reacts in real time to touch and pointer input. Apple's standard SwiftUI components — buttons, tab bars, navigation bars — already use it by default. The part that's interesting for us as developers is that Apple exposed the same system to custom views, so a toolbar you build by hand can feel just as native as a system one.
If you want to see it in its full, intended form, Apple's Landmarks sample app (the one built around "Building an app with Liquid Glass") is worth a look before you start applying this to your own UI. Seeing how restrained the real-world usage is will save you from over-applying the effect later.
Applying Liquid Glass to a Single View
The entry point is the glassEffect(_:in:) modifier. Drop it on any view and SwiftUI renders a glass background behind it, clipped to a shape — a Capsule by default.
Text("Hello, World!")
.font(.title)
.padding()
.glassEffect()
That's the minimal version. In practice you'll usually want more control over the shape and appearance:
// Custom shape with a specific corner radius
Text("Hello, World!")
.font(.title)
.padding()
.glassEffect(in: .rect(cornerRadius: 16.0))
// Tinted and interactive
Text("Hello, World!")
.font(.title)
.padding()
.glassEffect(.regular.tint(.orange).interactive())
A couple of things worth internalizing here:
Shape choice communicates hierarchy. A
Capsulereads as a pill-shaped control (think a small button or badge). For larger surfaces — cards, panels, bigger custom components — a rounded rectangle feels more intentional. Sticking to one shape family across your app keeps the UI visually consistent.Tint suggests prominence, not brand color. Use
.tint(_:)sparingly, the same way you'd use.tint()on a button — to draw the eye to the primary action, not to decorate every glass surface with your app's accent color..interactive()is what makes it feel alive. Without it, glass is just a static blurred background. With it, the same responsive, fluid reaction you see on system buttons — the subtle bounce and light shift on touch — gets applied to your custom view.
Combining Multiple Glass Views: GlassEffectContainer
Here's where a lot of developers get tripped up the first time: if you apply glassEffect(_:in:) to several sibling views without wrapping them, you'll pay for it in rendering performance, and you'll lose out on some of the more interesting behavior — like shapes blending into each other.
The fix is GlassEffectContainer. Any time you're applying Liquid Glass to more than one view in the same area of the screen, wrap them in a container.
GlassEffectContainer(spacing: 40.0) {
HStack(spacing: 40.0) {
Image(systemName: "scribble.variable")
.frame(width: 80.0, height: 80.0)
.font(.system(size: 36))
.glassEffect()
Image(systemName: "eraser.fill")
.frame(width: 80.0, height: 80.0)
.font(.system(size: 36))
.glassEffect()
// An `offset` shows how Liquid Glass effects react to each other in a container.
// Use animations and components appearing and disappearing to obtain effects that look purposeful.
.offset(x: -40.0, y: 0.0)
}
}
The spacing value on the container is the important lever here. It controls how close two glass views need to be before their effects start blending and merging shapes. A larger spacing value means the effects blend sooner. If you set the container's spacing larger than the spacing of the HStack (or whatever layout container sits inside it), you'll get glass shapes that are already merged at rest — not just during animation.
A practical rule of thumb: match your container spacing to your layout spacing if you want elements to stay visually distinct, and intentionally mismatch them (with the container spacing larger) if you want a fluid merge effect when things move.
One Shared Effect Across Multiple Views: glassEffectUnion
Sometimes you don't want each view rendering its own separate glass capsule — you want a group of views to share one unified glass shape, even while sitting at rest (not just mid-animation). That's what glassEffectUnion(id:namespace:) is for.
let symbolSet: [String] = ["cloud.bolt.rain.fill", "sun.rain.fill", "moon.stars.fill", "moon.fill"]
GlassEffectContainer(spacing: 20.0) {
HStack(spacing: 20.0) {
ForEach(symbolSet.indices, id: \.self) { item in
Image(systemName: symbolSet[item])
.frame(width: 80.0, height: 80.0)
.font(.system(size: 36))
.glassEffect()
.glassEffectUnion(id: item < 2 ? "1" : "2", namespace: namespace)
}
}
}
Here, four weather icons are split into two visual groups by assigning matching union IDs — items 0 and 1 share one glass shape, items 2 and 3 share another. This is especially handy when you're generating views dynamically (from an array, like above) or when views live outside a normal layout container and you still want them to read as one cohesive surface.
Morphing Between Glass Views During Transitions
This is the part that makes Liquid Glass feel genuinely different from a standard blur-and-fade transition. When a glass view appears, disappears, or changes, you can have it morph into or out of a neighboring glass shape instead of just fading.
To do this, give each glass view a stable identity with glassEffectID(_:in:), tied to a @Namespace:
@State private var isExpanded: Bool = false
@Namespace private var namespace
var body: some View {
GlassEffectContainer(spacing: 40.0) {
HStack(spacing: 40.0) {
Image(systemName: "scribble.variable")
.frame(width: 80.0, height: 80.0)
.font(.system(size: 36))
.glassEffect()
.glassEffectID("pencil", in: namespace)
if isExpanded {
Image(systemName: "eraser.fill")
.frame(width: 80.0, height: 80.0)
.font(.system(size: 36))
.glassEffect()
.glassEffectID("eraser", in: namespace)
}
}
}
Button("Toggle") {
withAnimation {
isExpanded.toggle()
}
}
.buttonStyle(.glass)
}
When isExpanded flips, the eraser icon doesn't just fade in — because both views share a namespace and sit within the container's spacing threshold, SwiftUI treats this as one shape morphing into two (or two morphing back into one), depending on their proximity.
A couple of notes on picking the right transition type:
matchedGeometry(the default) is right when the appearing/disappearing view is close to an existing glass shape — within the container's spacing. This is what gives you the organic morph.materialize, paired withwithAnimation(_:_:), is the better choice when the view you're adding or removing is farther away from other glass shapes than the container's spacing allows for a morph to make visual sense. It's a simpler, more deliberate transition — closer to a fade with some added material behavior, rather than a shape merge.
Sticking to these two transition types (rather than inventing custom ones) also keeps the experience consistent with what people expect from system UI elsewhere in iOS.
Where This Actually Fits in a Real App
It's tempting, the first time you play with glassEffect(), to start applying it everywhere — cards, list rows, every button. Resist that. Liquid Glass is a foreground control material, meant for things that float above your primary content: toolbars, floating action buttons, overlays, custom tab bars, contextual menus. If you apply it to something like a full-screen content card, you'll mostly just be blurring content behind content, which reads as visually noisy rather than intentional.
A good litmus test: would this element make sense as a system button, tab, or toolbar item if Apple had built it? If yes, it's a solid Liquid Glass candidate. If it's closer to primary content — a photo, a block of text, a data visualization — leave it alone.
Wrapping Up
Liquid Glass gives you a way to make custom SwiftUI components feel indistinguishable from system UI — but only if you respect the same constraints Apple applies to its own controls. To recap:
Use
glassEffect(_:in:)for individual views, and choose your shape (Capsule,.rect(cornerRadius:), etc.) based on the size and role of the component.Always wrap multiple glass views in a
GlassEffectContainer— both for performance and for the shape-blending behavior.Reach for
glassEffectUnion(id:namespace:)when you want a group of views to read as one continuous glass surface.Use
glassEffectID(_:in:)with a shared@Namespacewhen you want views to morph into and out of each other during transitions, choosing betweenmatchedGeometryandmaterializebased on proximity.
Once you've got these four pieces down, you can start replacing hand-rolled blur-and-shadow toolbars with something that matches the feel of the rest of iOS — without reinventing the animation logic yourself.