Skip to content

Liquid Glass

Daniel S edited this page Jul 2, 2026 · 1 revision

Liquid Glass

Platform requirement: iOS 26+, macOS 26+, visionOS 2+ (enhanced). All glass features degrade gracefully on earlier OS versions — no crash, no missing layout, just the equivalent non-glass style.


Table of Contents

  1. Overview
  2. Availability
  3. DFMaterialTokens
  4. Glass Style Variants
  5. Applying Glass Styles
  6. Building Custom Glass Surfaces
  7. Glass Aesthetics Guide
  8. Complete Examples
  9. visionOS Considerations
  10. Performance

Overview

Liquid Glass is Apple's material language introduced in iOS 26 and macOS 26. It replaces the flat, opaque surface hierarchy of earlier OS versions with translucent, depth-aware materials that refract and blur the content behind them — giving navigation chrome, cards, and overlays a physical sense of layering.

For a design system, Liquid Glass is both an opportunity and a compatibility challenge:

  • Opportunity: components that adopt .glass styles feel native to the platform, integrate seamlessly with wallpapers and dynamic backgrounds, and match the system's own chrome.
  • Challenge: the material APIs are not available before iOS 26 / macOS 26, so every call site that touches glass must either be guarded or delegate the OS check to the system.

DesignFoundation handles the challenge for you. Every DF component that exposes a .glass style variant performs its own availability check internally. You call .dfCardStyle(.glass) on iOS 18 and nothing breaks — the card renders in its .elevated style instead. The only place you write an @available guard yourself is when you access theme.materials directly to build a custom glass surface.


Availability

OS Requirements

Platform Minimum OS Glass Support Level
iOS 26.0+ Full — all .glass style variants
macOS 26.0+ Full — sidebar, tab bar, cards, buttons
visionOS 2.0+ Enhanced — glass integrates with spatial depth
iOS 18–25 Non-glass fallback, automatic
macOS 15 Non-glass fallback, automatic

Runtime Check: DFPlatformContext.isLiquidGlassAvailable

DFPlatformContext exposes a @MainActor-isolated Boolean you can read from any SwiftUI body:

var body: some View {
    let ctx = DFPlatformContext.current
    VStack {
        if ctx.isLiquidGlassAvailable {
            DFText("Glass is active", style: .caption)
                .foregroundStyle(.secondary)
        }
        content
    }
}

From an async context, marshal to the main actor first:

let isGlass = await MainActor.run { DFPlatformContext.current.isLiquidGlassAvailable }

@available vs Runtime Check

Use the compile-time @available guard only when accessing theme.materials (DFMaterialTokens). For component style modifiers no guard is needed — DesignFoundation's style resolution handles the fallback internally.

// No @available needed — DF resolves the fallback internally
DFCard { content }
    .dfCardStyle(.glass)

// @available IS required — you are touching a type that doesn't exist before iOS 26
@available(iOS 26, macOS 26, *)
func glassSurface(theme: DFTheme) -> some View {
    RoundedRectangle(cornerRadius: theme.radius.md)
        .fill(theme.materials.surfaceMaterial)
}

DFMaterialTokens

DFMaterialTokens is the theme-level struct that carries the material configuration for a given theme. Available only on iOS 26+ / macOS 26+.

@available(iOS 26, macOS 26, *)
public struct DFMaterialTokens: Sendable {
    /// The material used for standard translucent surfaces (cards, sidebars, sheets).
    /// Defaults to `.regularMaterial`.
    public var surfaceMaterial: Material

    /// The material used for elevated or floating surfaces (popovers, tooltips, raised panels).
    /// Defaults to `.thickMaterial`.
    public var elevatedMaterial: Material

    /// When `true`, DF components prefer glass styling wherever a `.glass` variant exists.
    /// Default: `false`.
    public var preferLiquidGlass: Bool
}

Accessing theme.materials

// In a view body with if #available
var body: some View {
    if #available(iOS 26, macOS 26, *) {
        RoundedRectangle(cornerRadius: theme.radius.lg)
            .fill(theme.materials.elevatedMaterial)
    } else {
        RoundedRectangle(cornerRadius: theme.radius.lg)
            .fill(theme.colors.surfaceElevated)
    }
}

preferLiquidGlass

When preferLiquidGlass: true is set in a custom theme, DF components automatically choose the .glass variant as their default style on supported OS versions. On iOS 18–25 they fall back to the standard non-glass default.

@available(iOS 26, macOS 26, *)
let glassTheme = DFTheme(
    colors: DFThemePreset.aurora.theme.colors,
    spacing: DFThemePreset.aurora.theme.spacing,
    radius: DFThemePreset.aurora.theme.radius,
    materials: DFMaterialTokens(
        surfaceMaterial: .regularMaterial,
        elevatedMaterial: .thickMaterial,
        preferLiquidGlass: true
    )
)

ContentView()
    .environment(\.dfTheme, glassTheme)

Glass Style Variants

Every DF component with a .glass style falls back to a specific non-glass style on iOS 18–25.

Component Glass Modifier Non-Glass Fallback
DFButton .dfButtonStyle(.glass) .filled
DFCard .dfCardStyle(.glass) .elevated
DFTextField .dfTextFieldStyle(.glass) .bordered
DFSecureField .dfTextFieldStyle(.glass) .bordered
DFSidebar .dfSidebarStyle(.glass) .plain
DFTabBar .dfTabBarStyle(.glass) .minimal
DFModal .dfModalStyle(.glass) .sheet
DFSheet .dfSheetStyle(.glass) .standard
DFPopover .dfPopoverStyle(.glass) .standard
DFTooltip .dfTooltipStyle(.glass) .standard

Note: DFList, DFTable, and DFDataGrid do not have .glass variants. Dense data surfaces are excluded intentionally — see Glass Aesthetics Guide.


Applying Glass Styles

Per-Component .glass Style

Apply .glass on any supported component. No platform guard needed.

import DesignFoundation

struct SettingsView: View {
    @State private var email = ""
    @State private var notificationsEnabled = true

    var body: some View {
        DFCard {
            VStack(spacing: theme.spacing.md) {
                DFTextField("Email", text: $email, leadingIcon: "envelope")
                    .dfTextFieldStyle(.glass)

                DFToggle("Enable notifications", isOn: $notificationsEnabled)

                DFButton("Save changes") { save() }
                    .dfButtonStyle(.glass)
            }
        }
        .dfCardStyle(.glass)
    }

    @Environment(\.dfTheme) private var theme
    private func save() { }
}

Theme-Level Glass Opt-In

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
                .modifier(GlassThemeModifier())
        }
    }
}

private struct GlassThemeModifier: ViewModifier {
    func body(content: Content) -> some View {
        if #available(iOS 26, macOS 26, *) {
            content.environment(\.dfTheme, glassTheme)
        } else {
            content.environment(\.dfTheme, DFThemePreset.aurora.theme)
        }
    }

    @available(iOS 26, macOS 26, *)
    private var glassTheme: DFTheme {
        DFTheme(
            colors: DFThemePreset.aurora.theme.colors,
            spacing: DFThemePreset.aurora.theme.spacing,
            radius: DFThemePreset.aurora.theme.radius,
            materials: DFMaterialTokens(
                surfaceMaterial: .regularMaterial,
                elevatedMaterial: .thickMaterial,
                preferLiquidGlass: true
            )
        )
    }
}

With preferLiquidGlass: true in place, DFCard { } renders glass on iOS 26 and elevated on iOS 25 without a single .dfCardStyle call. The layout dimensions and spacing tokens are identical in both variants — the only visual difference is the material fill.


Building Custom Glass Surfaces

When DF's built-in components aren't enough, build a custom glass surface using theme.materials directly. Always gate the access behind @available or if #available.

Using @available on a View

@available(iOS 26, macOS 26, *)
struct GlassBadge: View {
    let label: String
    @Environment(\.dfTheme) private var theme

    var body: some View {
        Text(label)
            .font(.caption.weight(.semibold))
            .foregroundStyle(theme.colors.textPrimary)
            .padding(.horizontal, theme.spacing.sm)
            .padding(.vertical, theme.spacing.xs)
            .background {
                Capsule()
                    .fill(theme.materials.surfaceMaterial)
                    .stroke(theme.colors.border.opacity(0.4), lineWidth: 0.5)
            }
    }
}

Using if #available Inside a View Body

struct AdaptivePanelBackground: View {
    @Environment(\.dfTheme) private var theme

    var body: some View {
        if #available(iOS 26, macOS 26, *) {
            RoundedRectangle(cornerRadius: theme.radius.lg)
                .fill(theme.materials.elevatedMaterial)
                .stroke(theme.colors.border.opacity(0.3), lineWidth: 0.5)
        } else {
            RoundedRectangle(cornerRadius: theme.radius.lg)
                .fill(theme.colors.surfaceElevated)
                .stroke(theme.colors.border, lineWidth: 1)
        }
    }
}

Stroke Borders on Glass

A hairline stroke anchored to the inside edge significantly improves legibility against light or low-contrast backgrounds:

@available(iOS 26, macOS 26, *)
var body: some View {
    content
        .padding(theme.spacing.lg)
        .background {
            RoundedRectangle(cornerRadius: theme.radius.md)
                .fill(theme.materials.surfaceMaterial)
        }
        .overlay {
            RoundedRectangle(cornerRadius: theme.radius.md)
                .strokeBorder(theme.colors.border.opacity(0.35), lineWidth: 0.5)
        }
}

Glass Aesthetics Guide

Liquid Glass is a tool, not a mandate. Applying it indiscriminately produces cluttered, low-contrast UIs.

When Glass Works Well

  • Layered navigation chrome over content — sidebars, tab bars, and navigation bars where content scrolls underneath
  • Sheets and modals over photos or maps — retains spatial context while overlaying controls
  • Floating panels and popovers — action menus, command palettes, tooltips always layered over complex backgrounds
  • Cards over vivid backgrounds — photo grids, map views, where background bleed-through reduces visual weight
  • visionOS and spatial interfaces — glass is the dominant material in spatial UI convention

When Glass Does Not Work Well

  • Full-screen opaque backgrounds — a flat color behind glass has nothing to blur; the material looks washed out
  • Dense data tables and lists — blurred backgrounds make row values harder to read; DFTable and DFDataGrid intentionally lack glass variants
  • Text-heavy content cards — long paragraphs compete with the refracted background
  • Deeply nested glass — stacking multiple glass layers compounds blur and looks muddy; limit to one or two glass layers in any hierarchy
  • Low-end hardware within iOS 26 window — glass has a higher GPU cost; see Performance

Complete Examples

a. Glass Navigation Bar and Sidebar — macOS

import DesignFoundation

struct MacRootView: View {
    @State private var selection: String = "home"

    private let sections = [
        DFSidebarSection(id: "main", title: "Main", items: [
            DFSidebarItem(id: "home",     icon: "house.fill",       label: "Home"),
            DFSidebarItem(id: "library",  icon: "photo.stack.fill", label: "Library"),
            DFSidebarItem(id: "settings", icon: "gearshape.fill",   label: "Settings"),
        ])
    ]

    var body: some View {
        NavigationSplitView {
            DFSidebar(selection: $selection, sections: sections)
                .dfSidebarStyle(.glass)    // glass on macOS 26+, plain on earlier
        } detail: {
            detailContent
                .dfNavigationBar(title: currentTitle) {
                    DFButton("New", icon: "plus") { createNew() }
                        .dfButtonStyle(.glass)
                }
        }
    }

    @ViewBuilder
    private var detailContent: some View {
        switch selection {
        case "home":    HomeView()
        case "library": LibraryView()
        default:        SettingsView()
        }
    }

    private var currentTitle: String {
        sections.flatMap(\.items).first { $0.id == selection }?.label ?? ""
    }

    private func createNew() { }
}

b. Glass Tab Bar — iOS 26

import DesignFoundation

struct IOSRootView: View {
    @State private var selectedTab: String = "feed"

    private let tabs = [
        DFTabItem(id: "feed",    icon: "rectangle.stack.fill", label: "Feed"),
        DFTabItem(id: "search",  icon: "magnifyingglass",      label: "Search"),
        DFTabItem(id: "upload",  icon: "plus.circle.fill",     label: "Upload"),
        DFTabItem(id: "profile", icon: "person.crop.circle",   label: "Profile"),
    ]

    var body: some View {
        DFTabBar(selection: $selectedTab, items: tabs) { id in
            switch id {
            case "feed":    FeedView()
            case "search":  SearchView()
            case "upload":  UploadView()
            default:        ProfileView()
            }
        }
        .dfTabBarStyle(.glass)    // glass on iOS 26+, minimal on iOS 18–25
    }
}

c. Glass Card Over a Photo Grid

import DesignFoundation

struct PhotoGridItem: View {
    let photo: Photo
    @Environment(\.dfTheme) private var theme

    var body: some View {
        ZStack(alignment: .bottom) {
            AsyncImage(url: photo.thumbnailURL) { image in
                image.resizable().scaledToFill()
            } placeholder: {
                DFSkeleton(width: .infinity, height: 220)
            }

            // Glass label floats over the photo
            DFCard(padding: theme.spacing.sm) {
                HStack {
                    DFText(photo.title, style: .caption).lineLimit(1)
                    Spacer()
                    DFBadge(text: photo.category, color: .blue)
                }
            }
            .dfCardStyle(.glass)
            .padding(theme.spacing.xs)
        }
        .clipShape(RoundedRectangle(cornerRadius: theme.radius.md))
        .frame(height: 220)
    }
}

d. Mixed Glass + Non-Glass Layout

Navigation chrome uses glass; content cards stay solid to keep data readable.

import DesignFoundation

struct MixedLayoutView: View {
    @State private var selection: String = "dashboard"
    @Environment(\.dfTheme) private var theme

    var body: some View {
        NavigationSplitView {
            DFSidebar(selection: $selection, sections: sidebarSections)
                .dfSidebarStyle(.glass)    // glass sidebar over content
        } detail: {
            ScrollView {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 280))],
                          spacing: theme.spacing.lg) {
                    ForEach(dashboardCards) { card in
                        // Solid elevated cards for dense data — easier to read
                        DFCard { MetricCardContent(card: card) }
                            .dfCardStyle(.elevated)
                    }
                }
                .padding(theme.spacing.xl)
            }
            .dfNavigationBar(title: "Dashboard") {
                DFButton("Export", icon: "square.and.arrow.up") { export() }
                    .dfButtonStyle(.ghost)
            }
        }
    }

    private var sidebarSections: [DFSidebarSection] { [] }
    private var dashboardCards: [MetricCard] { [] }
    private func export() { }
}

e. Runtime-Adaptive UI

import DesignFoundation

struct AdaptiveDetailView: View {
    @Binding var isPresented: Bool
    let item: Item
    @Environment(\.dfTheme) private var theme

    var body: some View {
        let ctx = DFPlatformContext.current

        if ctx.isLiquidGlassAvailable {
            glassPanelLayout
        } else {
            standardSheetLayout
        }
    }

    private var glassPanelLayout: some View {
        VStack(alignment: .leading, spacing: theme.spacing.md) {
            DFText(item.title, style: .headline)
            DFText(item.description, style: .body)
            Spacer()
            HStack {
                DFButton("Dismiss") { isPresented = false }.dfButtonStyle(.ghost)
                DFButton("Open") { openItem() }.dfButtonStyle(.glass)
            }
        }
        .padding(theme.spacing.xl)
        .background {
            if #available(iOS 26, macOS 26, *) {
                RoundedRectangle(cornerRadius: theme.radius.lg)
                    .fill(theme.materials.elevatedMaterial)
            }
        }
        .overlay {
            RoundedRectangle(cornerRadius: theme.radius.lg)
                .strokeBorder(theme.colors.border.opacity(0.3), lineWidth: 0.5)
        }
        .padding(theme.spacing.lg)
    }

    private var standardSheetLayout: some View {
        VStack(alignment: .leading, spacing: theme.spacing.md) {
            DFText(item.title, style: .headline)
            DFText(item.description, style: .body)
            Spacer()
            DFButton("Open") { openItem() }
        }
        .padding(theme.spacing.xl)
    }

    private func openItem() { }
}

f. Custom Glass Modal

import DesignFoundation

@available(iOS 26, macOS 26, *)
struct GlassModal<Content: View>: View {
    let title: String
    @Binding var isPresented: Bool
    @ViewBuilder let content: () -> Content

    @Environment(\.dfTheme) private var theme

    var body: some View {
        ZStack {
            Color.black.opacity(0.15)
                .ignoresSafeArea()
                .onTapGesture { isPresented = false }

            VStack(alignment: .leading, spacing: 0) {
                HStack {
                    DFText(title, style: .headline)
                    Spacer()
                    DFButton("", icon: "xmark") { isPresented = false }
                        .dfButtonStyle(.ghost)
                }
                .padding(theme.spacing.lg)
                .background {
                    Rectangle().fill(theme.materials.elevatedMaterial)
                }

                DFDivider()
                content().padding(theme.spacing.lg)
            }
            .frame(maxWidth: 480)
            .background {
                RoundedRectangle(cornerRadius: theme.radius.lg)
                    .fill(theme.materials.surfaceMaterial)
            }
            .overlay {
                RoundedRectangle(cornerRadius: theme.radius.lg)
                    .strokeBorder(theme.colors.border.opacity(0.25), lineWidth: 0.5)
            }
            .shadow(color: .black.opacity(0.18), radius: 24, y: 8)
            .padding(theme.spacing.xl)
        }
    }
}

visionOS Considerations

Depth and parallax. On visionOS, materials interact with the user's eye position — glass surfaces subtly shift their refraction as the viewer moves. DesignFoundation does not add or override this; it is handled by the OS compositor.

preferLiquidGlass: true on visionOS. The setting applies on visionOS 2+ exactly as on iOS 26 and macOS 26. Because visionOS renders primarily against a transparent passthrough background, glass materials are almost always preferable to opaque fills — they allow the real-world environment to show through.

.regularMaterial vs .thickMaterial in space. For ornaments and floating panels, elevatedMaterial (.thickMaterial) is the better default because the panel needs to read as distinct from the passthrough world. The default token values in DFMaterialTokens match this expectation.

@available guard still required for theme.materials. isLiquidGlassAvailable returns true on visionOS 2+, but theme.materials still requires @available(iOS 26, macOS 26, *) at the compiler level. The visionOS SDK satisfies this condition — the availability annotation covers visionOS 2+ implicitly.


Performance

GPU Cost of Glass Materials

Each glass surface introduces a blur sample over its background region at every frame. Overlapping glass surfaces compound the compositing work. Animating a glass surface's frame is more expensive than animating an opaque surface.

When to Avoid Glass

Scenario Recommendation
40+ glass cards on screen simultaneously Use .elevated cards; glass only for a focused detail view
Animating width/height on a glass card Animate opacity instead, or animate position rather than size
Glass inside a List with 100+ rows Do not use glass row backgrounds
A glass surface that never has content behind it Use opaque surface — the blur costs GPU with nothing to render

.drawingGroup() and Glass

Do not apply .drawingGroup() to a view containing glass materials. drawingGroup() rasterizes the hierarchy to a Metal texture before compositing, which eliminates the live blur effect. The glass surface will appear opaque.

Clipping and Overdraw

Always pair a glass background with an explicit clip shape to contain the blur:

@available(iOS 26, macOS 26, *)
struct MyGlassPanel: View {
    @Environment(\.dfTheme) private var theme
    var body: some View {
        content
            .background {
                RoundedRectangle(cornerRadius: theme.radius.lg)
                    .fill(theme.materials.surfaceMaterial)
            }
            .clipShape(RoundedRectangle(cornerRadius: theme.radius.lg))  // keeps blur contained
    }
}

DFCard clips internally — no extra clipShape needed when using the built-in card.

Measuring Impact

Profile with Instruments → GPU Counter template. Look for "Composite" passes in the render timeline. If a glass view's composite pass exceeds your frame budget (16 ms for 60 Hz, 8 ms for 120 Hz), reduce simultaneous glass surfaces or fall back to an opaque style.


See also: Theming · Theme-Presets · Style-System · Cross-Platform · Navigation

Clone this wiki locally