Skip to content
GitHub

Surface materials

A surface style decides what the chrome paints behind compact and expanded nook content: a flat opaque panel, a frosted vibrancy material, or Apple’s Liquid Glass. It is a user-facing preference - the framework ships a Settings control for it and persists the choice - so most hosts never set it in code; they read the resolved value back to pick a matching palette.

The style lives on NookAppearancePreferences.surfaceStyle:

public enum NookSurfaceStyle: String, Codable, Sendable, CaseIterable {
case solid // opaque panel, matches the menu-bar notch (the default)
case translucent // frosted vibrancy material, shows the wallpaper
case liquidGlass // Apple's Liquid Glass material (macOS 26+)
}

surfaceStyle defaults to .solid. The framework owns the Settings panel that writes it - the Appearance screen renders a segmented “Surface” picker (Solid / Translucent / Liquid Glass) and persists the selection through AppState.replaceAppearancePreferences(_:). Your code reads from it; see how a host reads the style below.

To ship a non-default out of the box, seed it on NookConfiguration.preferenceDefaults (a seed the user can always override in Settings, never persisted on its own):

configuration.preferenceDefaults = NookPreferenceDefaults(
appearance: NookAppearancePreferences(surfaceStyle: .liquidGlass)
)

The mapping from a style to a concrete NookBackdrop lives in NookBackdropMapping.notchBackdrop, keyed by the style, the effective color scheme, and whether Reduce Transparency is on.

  • .solid paints a flat opaque fill - true black on dark chrome, true white on light - so the expanded panel reads as one continuous surface with the physical notch. No NSVisualEffectView is involved. This is the default and the most legible over any wallpaper.
  • .translucent paints a frosted NSVisualEffectView sidebar material sampling the wallpaper behind the window, with a legibility darken pass composited on top. The darken scales with backdropStrength (the Settings “Translucency strength” slider) so the user can let more wallpaper through.
  • .liquidGlass paints Apple’s Liquid Glass material on macOS 26 (Tahoe) and later, and a layered approximation on earlier systems. The default mapping uses neutral, untinted glass - the real material refracts the wallpaper on its own - with a light top-to-bottom darken so the surface reads glassier as it nears the wallpaper.

Liquid Glass renders through NookBackdrop.liquidGlass(LiquidGlass). The LiquidGlass spec carries four knobs that drive both render paths:

public struct LiquidGlass: Equatable, Sendable {
public var tint: Color? // nil = neutral, clear glass (the default)
public var tintStrength: CGFloat // 0...1, default 0.18; ignored when tint is nil
public var highlightStrength: CGFloat // 0...1 specular rim + sheen, default 0.6
public var shading: Shading? // legibility gradient the caller fully owns
}

shading is a Gradient plus a direction (startPoint/endPoint, defaulting top-to-bottom), so the legibility pass is entirely the caller’s - the surface renders exactly what the spec carries and never substitutes its own. The default framework mapping supplies a sensible top-to-bottom darken; a host returning its own .liquidGlass from a backdrop resolver can replace it wholesale.

Real material vs the pre-Tahoe approximation

Section titled “Real material vs the pre-Tahoe approximation”

The surface dispatches on availability. On macOS 26+ it draws the real system material; on macOS 15-25 it draws a layered approximation that reads as glass:

  • Real material (macOS 26+). A Color.clear painted with .glassEffect(_:in:) using a Glass material, clipped to the same NookShape the chrome already uses, with the host’s shading overlaid on top. A non-nil tint becomes Glass.regular.tint(tint.opacity(tintStrength)). macOS supplies its own edge highlights here, so highlightStrength only adds a faint extra rim.
  • Approximation (macOS 15-25). A glassy NSVisualEffectView (.hudWindow) material, an optional tint overlay, the same shading, then the specular treatment that actually sells the glass read: a top-down sheen and a bright rim traced along NookShape. highlightStrength scales that sheen and rim.

The real path is gated twice, and it matters for what you can build and where it runs:

  • Runtime gate - @available(macOS 26.0, *): the real .glassEffect material only renders on macOS 26 and later. On an older OS the surface falls back to the approximation at runtime, so it cannot crash on systems without the material.
  • Compile gate - #if compiler(>=6.2): Glass and .glassEffect exist only in the macOS 26 SDK (Xcode 26+, Swift 6.2). An @available check still needs those symbols present in the SDK being compiled against, so an older Xcode cannot build the real path at all. The compile gate routes an earlier toolchain to the approximation unconditionally, so the package builds on Xcode before 26 instead of failing with “cannot find ‘Glass’ in scope”.

Putting both together: the package always builds, on any supported Xcode. The real material needs the macOS 26 SDK (Xcode 26+) to build and macOS 26 at runtime to render. Anywhere else, .liquidGlass still works - it just draws the approximation.

When the user enables Reduce Transparency, the mapping collapses both translucent styles toward solid. .translucent and .liquidGlass both fall back to a flat opaque fill (black on dark, white on light) - neither the frosted material nor the glass renders when the user has opted out of translucency. The guard is a single check in NookBackdropMapping.notchBackdrop:

if preferences.surfaceStyle == .solid || reduceTransparency {
return .solid(isDark ? .black : .white)
}

So a host palette never has to special-case Reduce Transparency for the surface material itself - the surface is already solid. (You may still want to nudge subtleFill for contrast on a true-solid panel; see the Theming guide.)

A host that brands its chrome usually reads the chosen surface style and returns a matching NookResolvedTheme. Branch on appState.appearancePreferences.surfaceStyle:

configuration.theme = { appState in
switch appState.appearancePreferences.surfaceStyle {
case .solid: return SolidPalette.resolve(appState)
case .translucent: return FrostPalette.resolve(appState)
case .liquidGlass: return GlassPalette.resolve(appState)
}
}

The switch is exhaustive over all three cases, so adding a palette branch for .liquidGlass keeps it compiling. A glass-tuned palette typically leans on lighter fills and brighter labels, since the glass keeps its own contrast and needs less darken under chrome content than a frosted material does.

To paint brand-tinted glass instead of reading it back, supply a LiquidGlass spec from a NookChromeBehavior backdrop resolver - that closure, not the Settings style, is where the deeper customization lives:

configuration.chromeBehavior = NookChromeBehavior(
backdrop: { preferences, scheme, reduceTransparency in
.liquidGlass(.init(tint: .blue, tintStrength: 0.22))
}
)
  • Theming - the palette resolver and NookResolvedTheme slots that pair with the surface material.
  • Sources/NookSurface/NookBackdrop.swift - the NookBackdrop and LiquidGlass types, the source of truth for the knobs.
  • Sources/NookKit/App/NookBackdropMapping.swift - how a style, color scheme, and Reduce Transparency map to a backdrop.