-
Notifications
You must be signed in to change notification settings - Fork 0
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.
- Overview
- Availability
- DFMaterialTokens
- Glass Style Variants
- Applying Glass Styles
- Building Custom Glass Surfaces
- Glass Aesthetics Guide
- Complete Examples
- visionOS Considerations
- Performance
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
.glassstyles 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.
| 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 |
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 }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 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
}// 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)
}
}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)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, andDFDataGriddo not have.glassvariants. Dense data surfaces are excluded intentionally — see Glass Aesthetics Guide.
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() { }
}@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.
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.
@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)
}
}
}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)
}
}
}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)
}
}Liquid Glass is a tool, not a mandate. Applying it indiscriminately produces cluttered, low-contrast UIs.
- 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
- 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;
DFTableandDFDataGridintentionally 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
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() { }
}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
}
}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)
}
}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() { }
}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() { }
}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)
}
}
}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.
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.
| 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 |
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.
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.
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