SwiftUI's Liquid Glass Effect on Older iOS Versions
It’s 2025 and Apple’s design language has changed. The new addition is the “Liquid Glass” effect, a dynamic material that blurs content, reflects light, and can morph between shapes. I’d say it’s a cool tool for creating modern, fluid interfaces.
But there’s a problem: what about users on older versions of iOS? While the new glass designs are available out of the box for standard UI components, if you want to take full advantage of the latest modifiers like
.glassEffect(), GlassEffectContainer(), or .buttonStyle(.glassProminent) (and more),
you’re forced to wrap your code in @available(iOS 26, *)
checks. I’m not a fan of filling my codebase with too many conditional patches and duplicated logic. So, let’s walk through a strategy for backporting the new “Glass” effect, so your app looks great on both the latest iOS and older versions, all using a single, clean API.
The code I’ll cover creates a backward-compatible glassEffect()
modifier that mimics the native iOS 26+ Glass API on earlier systems.
The Goal: A Unified API
The core idea is to create a set of SwiftUI modifiers and styles that can be used consistently across all iOS versions your app supports. The implementation details will change under the hood, but the developer-facing API remains the same.
Text("Hello, Glass!")
.padding()
.glassEffect() // the goal is to not overuse and duplicate
The Strategy: Conditional Implementation
I’ll achieve this unified API through a combination of if #available
checks and wrapper types. I’ll define a custom BackportedGlassStyle
enum that mirrors the native Glass
API, and then create view modifiers that conditionally apply either the new native effect or the custom legacy implementation.
Defining the BackportedGlassStyle
First, I created an enum to represent all the possible glass styles. This acts as a single source of truth for the modifier. It can be converted to a native Glass
style on newer systems or a legacy Material
on older ones.
enum BackportedGlassStyle {
case regular
case interactive
case tinted(Color)
case tintedInteractive(Color)
@available(iOS 26.0, *)
func toNativeGlass() -> Glass {
switch self {
case .regular:
return .regular
case .interactive:
return .regular.interactive()
...
}
}
func toLegacyMaterial() -> Material {
switch self {
case .regular:
return .ultraThinMaterial
case .interactive:
// using a thicker material for interactive components on older OS
// to give some visual distinction.
return .thinMaterial
...
}
}
// ... helper properties and methods like .tint(), .interactive(), tintColor, etc.
}
This enum cleverly abstracts away the differences. It also includes helper methods like .tint()
and .interactive()
to allow for a fluent and modern API, regardless of the underlying iOS version.
The glassEffect
View Modifier
This is where the magic happens. I created a custom view modifier that checks the iOS version and applies the appropriate effect.
extension View {
/// Applies a glass effect that is backwards-compatible with older iOS versions.
@ViewBuilder
func glassEffect<S: Shape>(_ style: BackportedGlassStyle = .regular, in shape: S) -> some View {
if #available(iOS 26.0, *) {
self.modifier(ModernGlassModifier(shape: shape, style: style))
} else {
self.modifier(LegacyGlassModifier(shape: shape, style: style))
}
}
The Modern Implementation (iOS 26+)
For the latest iOS versions, the implementation is simple. The ModernGlassModifier
is just a thin wrapper around the native .glassEffect()
modifier.
@available(iOS 26.0, *)
private struct ModernGlassModifier<S: Shape>: ViewModifier {
let shape: S
let style: BackportedGlassStyle
func body(content: Content) -> some View {
content.glassEffect(style.toNativeGlass(), in: shape)
}
}
The Legacy Fallback (Older iOS)
This is the heart of our backporting effort. For older systems, we need to recreate the glass effect using the tools we have available. The LegacyGlassModifier
uses a ZStack
to layer a Material
(like .ultraThinMaterial
) behind the content.
To handle the .tint()
effect, we add another layer on top with a semi-transparent color and a .blendMode(.overlay)
. This provides a convincing approximation of the native tinted glass.
private struct LegacyGlassModifier<S: Shape>: ViewModifier {
let shape: S
let style: BackportedGlassStyle
func body(content: Content) -> some View {
content.background(
ZStack {
shape.fill(style.toLegacyMaterial())
if let tintColor = style.tintColor {
shape.fill(tintColor.opacity(0.3))
.blendMode(.overlay)
}
}
)
}
}
Backporting Buttons and Containers
The same pattern can be applied to other components. The provided code includes BackportedGlassButtonStyle
and BackportedGlassProminentButtonStyle
. These styles check the OS version and apply the native SwiftUI.GlassButtonStyle
or a custom-built style using RoundedRectangle
and Material
for the legacy version.
This allows for consistent button styling:
Button("Click Me") {
// Action
}
.buttonStyle(.glass) // Works on all supported iOS versions!
Graceful Degradation for Advanced Features
What about features that are impossible to backport, like the morphing and unioning effects provided by glassEffectID
and glassEffectUnion
?
The answer is graceful degradation. We implement the modifiers, but on older systems, they do nothing. This prevents compiler errors and allows you to write your UI code as if the feature exists everywhere, even if the visual effect only manifests on newer devices.
extension View {
@ViewBuilder
func glassEffectID<ID: Hashable>(_ id: ID, in namespace: Namespace.ID) -> some View {
if #available(iOS 26.0, *) {
self.modifier(NativeGlassEffectIDModifier(id: id, namespace: namespace))
} else {
// No-op on older iOS versions
self
}
}
}
Conclusion
With this approach, you’ve got a single, clean, modern API that just works. By hiding away the implementation details, you can focus on writing future-friendly code and still deliver a smooth, consistent experience for everyone.
You can find the full code in the BackportedGlassStyle
See you next time! ⚙️🤖👨💻