SwiftUI's Liquid Glass Effect on Older iOS Versions

SwiftUI's Liquid Glass Effect on Older iOS Versions

October 11, 2025

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.

Liquid Glass Effect Demo

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! ⚙️🤖👨‍💻

Last updated on