Docs

Glass Mode Bar

One liquid glass body that turns into whatever mode you pick. At rest a capsule of mode icons spans the width; tap one and a signal anchor carrying that mode buds out of the capsule's leading end while the capsule draws back to make room and trades its icons for that mode's own controls, a waveform for voice, thumbnails for photos, chips for a timer. Tap the anchor and it melts home as the icons come back. The body keeps its footprint the whole time.

Free · MIT + Commons ClauseiOS 17.0+Liquid Glassglasstoolbarmodesmorphcontextualcomposerliquidbud
Type
GlassModeBar
Files
GlassModeBar.swift
Depends on
Nothing (Apple frameworks only)
Version
1.0.0

One liquid glass body that becomes the controls of the mode you pick, without leaving its place. At rest a capsule spans the width offered, its mode icons spread evenly across it. Tapping a mode buds a signal anchor out of the capsule's leading end, carrying that mode's icon, while the capsule draws its leading edge back to make room and trades its icons for the mode's controls, which you build. The two rest joined, one body. Tapping the anchor melts it home into the capsule as the icons return. The outer edges never move. swift @State private var mode: String? GlassModeBar(modes: [ .init(id: "voice", systemImage: "mic", title: "Voice"), .init(id: "timer", systemImage: "timer", title: "Timer"), ], selection: $mode) { mode in switch mode.id { case "voice": RecordingControls() default: TimerChips() } }

Notes

  • One body that keeps its footprint. At rest a liquid glass capsule spans the width offered, its mode icons spread evenly across it. Open a mode and the capsule draws its leading edge back while a signal anchor carrying that mode's icon buds out of its leading end, born inside it, shrunk, and pulled out through a neck to rest joined beside it. Neither outer edge moves.
  • The capsule trades its icons for the open mode's controls, which you build in panel: they blur in where the icons blurred out, on the reveal and dismiss roles. The panel gets the capsule's height (56pt) and the full width beside the anchor, so give its contents maxWidth: .infinity to span it, as the example's waveform, photos, chips and format buttons do; keep it plain content, since the capsule is already the glass. The example turns Voice into a live waveform and a running time, Photo into recent shots, Timer into duration chips and Text into format controls.
  • Every change overlaps, so the body is never blank: opening, the panel blurs in as the icons blur out while the anchor buds; closing, the anchor melts home on the bounceless home spring and the icons start coming back 160ms in, while the capsule reaches back for it. The anchor keeps its glyph until it is inside. Setting selection from your code opens, closes or switches a mode the same way; switching swaps the panel and the anchor's glyph in place.
  • Haptics: a selection tick as a mode opens, a soft impact as it closes.
  • On iOS 26 the capsule and the anchor are Liquid Glass and the neck forms between them; before that they are frosted glass that simply sit side by side, and under Reduce Transparency they are solid.
  • Reduce Motion: the anchor fades in and out where it rests instead of budding, the capsule's edge moves on a short spring with no overshoot, and the icon and panel swaps cross-fade.
  • VoiceOver: each mode icon is a button with its title; the anchor reads "Close" and the mode's title. Panels are your views, so give their controls labels.
  • Style (GlassModeBarStyle) sets the icons' colour, the anchor's ink and the anchor's tint (signal by default). Mode is GlassModeBarMode: an id, an SF Symbol and a title. All text and symbols are semibold.

Usage

GlassModeBarExample()

Parameters

ParameterDescription
modesThe modes, in order across the capsule.
selectionThe open mode's id, or nil at rest. Set it yourself to open or close a mode from your code.
styleColors. Defaults to the SwiftPieces house palette, adapting to light and dark.
panelThe controls for a mode, laid out in the capsule beside the anchor. They fill the capsule's height (56pt)

Source

GlassModeBar.swift
// Copyright (c) 2026 Saivion Hayes
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, and distribute the Software as part of
// an application, website, or product, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// Commons Clause Restriction
//
// You may use this Software, including for any commercial purpose, so long as
// you do not sell, sublicense, or redistribute the components themselves, whether
// alone, in a bundle, or as a ported version.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import SwiftUI
import UIKit

/// One liquid glass body that becomes the controls of the mode you pick, without leaving its place.
///
/// At rest a capsule spans the width offered, its mode icons spread evenly across it. Tapping a mode buds a signal
/// anchor out of the capsule's leading end, carrying that mode's icon, while the capsule draws its leading edge back
/// to make room and trades its icons for the mode's controls, which you build. The two rest joined, one body. Tapping
/// the anchor melts it home into the capsule as the icons return. The outer edges never move.
///
/// ```swift
/// @State private var mode: String?
///
/// GlassModeBar(modes: [
///     .init(id: "voice", systemImage: "mic", title: "Voice"),
///     .init(id: "timer", systemImage: "timer", title: "Timer"),
/// ], selection: $mode) { mode in
///     switch mode.id {
///     case "voice": RecordingControls()
///     default: TimerChips()
///     }
/// }
/// ```
///
/// - Parameters:
///   - modes: The modes, in order across the capsule.
///   - selection: The open mode's `id`, or `nil` at rest. Set it yourself to open or close a mode from your code.
///   - style: Colors. Defaults to the SwiftPieces house palette, adapting to light and dark.
///   - panel: The controls for a mode, laid out in the capsule beside the anchor. They fill the capsule's height (56pt)
///     and the width left after the anchor. Keep them plain content: the capsule is already the glass.
public struct GlassModeBar<Panel: View>: View {
    /// One mode. The same type as `GlassModeBarMode`, so `.init(id:systemImage:title:)` works at the call site.
    public typealias Mode = GlassModeBarMode
    /// Colors. The same type as `GlassModeBarStyle`.
    public typealias Style = GlassModeBarStyle

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    /// The anchor bubble: home inside the capsule's leading end until a mode opens, then out beside it.
    @State private var buds = PieceBuds()
    /// The mode the body is showing. It trails `selection` while the anchor melts home, so the panel stays until the
    /// anchor is inside the capsule.
    @State private var shown: String?
    /// The mode the anchor carries. It outlives `shown` while the anchor melts home, so the anchor never loses its
    /// glyph on the way in.
    @State private var anchored: String?
    @State private var openTick = 0
    @State private var closeTick = 0

    private let modes: [Mode]
    private let selection: Binding<String?>
    private let style: Style
    private let panel: (Mode) -> Panel

    /// The capsule's height and the anchor's diameter: the house size for round controls.
    private let height: CGFloat = 56

    public init(modes: [Mode], selection: Binding<String?>, style: Style = .standard, @ViewBuilder panel: @escaping (Mode) -> Panel) {
        self.modes = modes
        self.selection = selection
        self.style = style
        self.panel = panel
    }

    private var motion: PieceMotion { PieceMotion(reduceMotion: reduceMotion) }
    private var open: Mode? { shown.flatMap { id in modes.first { $0.id == id } } }
    private var anchorMode: Mode? { anchored.flatMap { id in modes.first { $0.id == id } } }

    public var body: some View {
        // Read here as well as in the anchor's builder, so a bud change always redraws the body.
        let _ = (buds.present, buds.out)
        PieceLiquidGroup {
            ZStack(alignment: .leading) {
                // The anchor first, so while it is home it sits under the capsule and melts into it.
                if buds.contains("anchor"), let anchorMode {
                    anchor(anchorMode)
                }
                capsule
                    // Open, the capsule draws its leading edge back to make room for the anchor, which rests joined.
                    .padding(.leading, open == nil ? 0 : height + PieceLiquid.joined)
            }
        }
        .fontWeight(.semibold)
        .animation(motion.morph, value: shown)
        .sensoryFeedback(.selection, trigger: openTick)
        .sensoryFeedback(.impact(flexibility: .soft, intensity: 0.5), trigger: closeTick)
        .onChange(of: selection.wrappedValue) { _, id in apply(id) }
        .onAppear {
            // A mode passed in already open shows straight away, with no motion.
            if let id = selection.wrappedValue, modes.contains(where: { $0.id == id }) {
                shown = id
                anchored = id
                buds.place(["anchor"])
            }
        }
    }

    // MARK: Capsule

    /// The glass body: the mode icons at rest, the open mode's controls beside the anchor.
    private var capsule: some View {
        ZStack {
            if let open {
                panel(open)
                    .padding(.horizontal, 14)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .transition(motion.swap)
            } else {
                HStack(spacing: 0) {
                    ForEach(modes) { mode in
                        Button { selection.wrappedValue = mode.id } label: {
                            Image(systemName: mode.systemImage)
                                .font(.title3.weight(.semibold))
                                .foregroundStyle(style.label)
                                .frame(maxWidth: .infinity, minHeight: height)
                                .contentShape(.rect)
                        }
                        .buttonStyle(.plain)
                        .accessibilityLabel(mode.title)
                    }
                }
                .padding(.horizontal, 6)
                .transition(motion.swap)
            }
        }
        .frame(maxWidth: .infinity, minHeight: height)
        .pieceLiquid(Capsule(), interactive: false)
    }

    // MARK: Anchor

    /// The signal bubble carrying the open mode's icon. Born just inside the capsule's leading end, shrunk, and budded
    /// out to rest joined beside it; tapping it closes the mode.
    private func anchor(_ mode: Mode) -> some View {
        let out = buds.isOut("anchor")
        return Button { selection.wrappedValue = nil } label: {
            Image(systemName: mode.systemImage)
                .font(.title3.weight(.semibold))
                .foregroundStyle(style.ink)
                .pieceBudContent(out: out)
                .frame(width: height, height: height)
                .pieceLiquid(.circle, tint: out ? style.anchor : nil)
        }
        .buttonStyle(PieceLiquidPressStyle())
        .accessibilityLabel(Text("Close \(mode.title)"))
        .pieceBud(out: out, home: CGSize(width: height / 2 + PieceLiquid.joined + height * PieceLiquid.homeScale / 2, height: 0))
    }

    // MARK: Opening and closing

    private func apply(_ id: String?) {
        if let id, modes.contains(where: { $0.id == id }) {
            guard shown != id else { return }
            if shown != nil {
                // Switching modes from your code: the panel swaps in place and the anchor's glyph with it.
                shown = id
                anchored = id
                return
            }
            // One beat: the capsule draws back and trades its icons for the panel while the anchor buds out, so the
            // body is never empty between the two.
            shown = id
            anchored = id
            openTick += 1
            Task { await buds.bloom(["anchor"], reduceMotion: reduceMotion) }
        } else if shown != nil {
            closeTick += 1
            Task {
                // The anchor melts home while the capsule reaches back for it and the icons return, overlapping, so
                // there is no blank beat between the anchor going in and the icons coming back.
                async let home: Void = buds.gather(["anchor"], reduceMotion: reduceMotion)
                try? await Task.sleep(for: .milliseconds(reduceMotion ? 0 : 160))
                shown = nil
                await home
                anchored = nil
            }
        }
    }
}

/// One mode of a `GlassModeBar`: its icon in the capsule and on the anchor, and its name for VoiceOver.
public struct GlassModeBarMode: Identifiable, Hashable, Sendable {
    public var id: String
    public var systemImage: String
    public var title: String

    public init(id: String, systemImage: String, title: String) {
        self.id = id
        self.systemImage = systemImage
        self.title = title
    }
}

/// Colors of a `GlassModeBar`. `.standard` is the house palette.
public struct GlassModeBarStyle: Sendable {
    /// The mode icons on the glass.
    public var label: Color
    /// The glyph on the tinted anchor.
    public var ink: Color
    /// The anchor's tint: signal, the brand red.
    public var anchor: Color

    /// Pass only what you want to change; `nil` keeps the house value.
    public init(label: Color? = nil, ink: Color? = nil, anchor: Color? = nil) {
        self.label = label ?? glassModeBarColor(light: 0x141414, dark: 0xF4F3EF)
        self.ink = ink ?? Color(red: 0x14 / 255, green: 0x14 / 255, blue: 0x14 / 255)
        self.anchor = anchor ?? Color(red: 1, green: 0, blue: 0)
    }

    public static let standard = GlassModeBarStyle()
}

/// A house-palette colour that follows the interface style.
private func glassModeBarColor(light: UInt32, dark: UInt32) -> Color {
    Color(uiColor: UIColor { @Sendable traits in
        let hex = traits.userInterfaceStyle == .dark ? dark : light
        return UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
    })
}

// MARK: - Example

/// A capture bar with four modes. Voice becomes a live waveform and a running time, Photo a row of recent shots,
/// Timer a set of durations, Text the format controls. It opens and closes on its own so the morph can be watched.
private struct GlassModeBarExample: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var mode: String?
    @State private var minutes = 15

    private let modes: [GlassModeBarMode] = [
        .init(id: "text", systemImage: "textformat", title: "Text"),
        .init(id: "voice", systemImage: "mic", title: "Voice"),
        .init(id: "photo", systemImage: "photo", title: "Photo"),
        .init(id: "timer", systemImage: "timer", title: "Timer"),
    ]

    var body: some View {
        GlassModeBar(modes: modes, selection: $mode) { mode in
            AnyView(panel(mode.id))
        }
        .padding(.horizontal, 20)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(glassModeBarColor(light: 0xF3F2EE, dark: 0x121212))
        .task {
            while !Task.isCancelled {
                for next in ["voice", "timer", "photo", "text"] {
                    try? await Task.sleep(for: .seconds(0.9))
                    mode = next
                    try? await Task.sleep(for: .seconds(2.2))
                    mode = nil
                }
            }
        }
    }

    /// Each mode's controls span the whole inside of the capsule.
    @ViewBuilder private func panel(_ id: String) -> some View {
        let ink = glassModeBarColor(light: 0x141414, dark: 0xF4F3EF)
        let soft = ink.opacity(0.08)
        switch id {
        case "voice":
            HStack(spacing: 12) {
                TimelineView(.animation(paused: reduceMotion)) { context in
                    let t = context.date.timeIntervalSinceReferenceDate
                    // As many bars as fit, spread across the full width.
                    HStack(spacing: 0) {
                        ForEach(0..<28, id: \.self) { i in
                            Capsule()
                                .fill(Color(red: 1, green: 0, blue: 0))
                                .frame(width: 3, height: 6 + 18 * abs(sin(t * 3 + Double(i) * 0.6)))
                                .frame(maxWidth: .infinity)
                        }
                    }
                }
                .frame(height: 28)
                Text("0:07").monospacedDigit().foregroundStyle(ink)
            }
            .accessibilityElement(children: .ignore)
            .accessibilityLabel("Recording, 7 seconds")
        case "photo":
            HStack(spacing: 6) {
                ForEach([0x9CC2FF, 0xFFD976, 0xA9DCB7, 0xCDB8FF, 0xE9D5B3], id: \.self) { hex in
                    RoundedRectangle(cornerRadius: 8, style: .continuous)
                        .fill(glassModeBarColor(light: UInt32(hex), dark: UInt32(hex)))
                        .frame(maxWidth: .infinity)
                        .frame(height: 38)
                }
            }
            .accessibilityElement(children: .ignore)
            .accessibilityLabel("Recent photos")
        case "timer":
            HStack(spacing: 8) {
                ForEach([5, 15, 25, 45], id: \.self) { value in
                    Button { minutes = value } label: {
                        Text("\(value)m")
                            .foregroundStyle(ink)
                            .frame(maxWidth: .infinity)
                            .frame(height: 34)
                            .background(minutes == value ? ink.opacity(0.18) : soft, in: .capsule)
                    }
                    .buttonStyle(.plain)
                }
            }
        default:
            HStack(spacing: 0) {
                ForEach(["bold", "italic", "underline", "strikethrough", "list.bullet"], id: \.self) { symbol in
                    Image(systemName: symbol).frame(maxWidth: .infinity)
                }
            }
            .font(.body.weight(.semibold))
            .foregroundStyle(ink)
        }
    }
}

#Preview("Light") {
    GlassModeBarExample()
}

#Preview("Dark") {
    GlassModeBarExample()
        .preferredColorScheme(.dark)
}

// MARK: - Piece motion
//
// The SwiftPieces motion language: shared spring tokens and gesture physics, with the same values in every
// piece. Each piece carries a copy of only the parts it uses, so this file stands alone. Generated from
// registry/foundation/PieceMotion.swift in the SwiftPieces repo; edit it there, not here.
// swiftpieces-motion: 1.1.0 (core, pressMath)

/// The SwiftPieces motion language: five spring tiers, and a named role for every moment a piece moves,
/// each with its Reduce Motion substitute.
///
/// Build one from the environment, `PieceMotion(reduceMotion: reduceMotion)`, and pick the role that names
/// what just happened. Never animate the tracking of a finger: set gesture state directly in `onChanged`, so the
/// surface stays under the finger, and spring only the release.
private struct PieceMotion {
    var reduceMotion = false

    // Tiers. Overshoot and settle times are measured from rest with SwiftUI's Spring.
    /// No overshoot, 90% in about 90ms. A press arriving under the finger.
    static var tight: Spring { Spring(duration: 0.14, bounce: 0) }
    /// About 2.8% overshoot, 90% in about 140ms. Snapping to a detent, page or segment.
    static var responsive: Spring { Spring(duration: 0.32, bounce: 0.25) }
    /// About 8.4% overshoot. Visible give: a release, a return from past an edge, a drag settling home.
    static var elastic: Spring { Spring(duration: 0.42, bounce: 0.38) }
    /// About 15% overshoot. A resolved action landing. At most once per interaction.
    static var expressive: Spring { Spring(duration: 0.48, bounce: 0.48) }
    /// About 1.5% overshoot, unhurried. Opening large surfaces and ambient change.
    static var calm: Spring { Spring(duration: 0.5, bounce: 0.2) }

    /// Reduce Motion: settles become this short spring with no overshoot.
    private static var still: Animation { .spring(duration: 0.25, bounce: 0) }

    // Roles.
    /// Touch-down. Starts on the same frame and never bounces under the finger.
    var press: Animation { .spring(Self.tight) }
    /// The finger lifts off a pressed surface, which springs back through rest.
    var release: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
    /// A dragged thing comes to rest. With a gesture's velocity, use `settle(velocity:from:to:)` instead.
    var settle: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
    /// Lands on a detent, page or segment.
    var snap: Animation { reduceMotion ? Self.still : .spring(Self.responsive) }
    /// A number, a chart value or anything else people read moves to its new value. Never overshoots, so it
    /// never shows a value that isn't true.
    var value: Animation { reduceMotion ? Self.still : .spring(duration: 0.35, bounce: 0) }
    /// Comes back from past a limit: a pull beyond the edge, a value pushed against its bound.
    var rebound: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
    /// A shape or container changes size, corner radius or form, with a little give at the end.
    var morph: Animation { reduceMotion ? Self.still : .spring(duration: 0.4, bounce: 0.2) }
    /// Something appears, opens or expands. Opening is a little slower than closing.
    var reveal: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.calm) }
    /// Something leaves, closes or collapses. Quick and firm, out of the way.
    var dismiss: Animation { reduceMotion ? .easeIn(duration: 0.18) : .spring(duration: 0.3, bounce: 0.08) }
    /// A resolved action lands: a check, a sent state, a reaction.
    var success: Animation { reduceMotion ? .easeOut(duration: 0.24) : .spring(Self.expressive) }
    /// A refused action. Firm, no wobble; `pieceShake` adds the movement.
    var error: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.responsive) }
    /// Slow ambient change. Loops themselves stop under Reduce Motion: check `allowsAmbient`.
    var ambient: Animation { reduceMotion ? .easeInOut(duration: 0.3) : .spring(Self.calm) }

    /// Loops, drifts, idle breathing and parallax run only when this is true.
    var allowsAmbient: Bool { !reduceMotion }

    /// A moving transition, or a plain fade under Reduce Motion.
    func transition(_ transition: AnyTransition) -> AnyTransition {
        reduceMotion ? .opacity : transition
    }

    /// For content replaced inside a container that stays put (a label, a glyph, a count): the old content
    /// blurs out as the new one sharpens in. A fade under Reduce Motion.
    @MainActor var swap: AnyTransition {
        reduceMotion ? .opacity : AnyTransition(.blurReplace)
    }
}

extension PieceMotion {
    /// About `depth` points per edge, not a fixed percentage: an icon sinks to 0.92, a pill 0.95, a card 0.985.
    nonisolated static func pressScale(for size: CGSize, depth: CGFloat = 2.5) -> CGFloat {
        let side = (max(size.width, 1) * max(size.height, 1)).squareRoot()
        return min(max(1 - depth * 2 / side, 0.92), 0.985)
    }

    /// An anchor partway from the center toward the touch, so the press leans into the finger without tipping.
    nonisolated static func pressAnchor(touch: CGPoint?, in size: CGSize, lean: CGFloat = 0.6) -> UnitPoint {
        guard let touch, size.width > 0, size.height > 0 else { return .center }
        let x = min(max(touch.x / size.width, 0), 1)
        let y = min(max(touch.y / size.height, 0), 1)
        return UnitPoint(x: 0.5 + (x - 0.5) * lean, y: 0.5 + (y - 0.5) * lean)
    }
}

// swiftpieces-motion: end

// MARK: - Piece liquid
//
// The SwiftPieces liquid glass language: glass shapes that merge through a neck, bubbles that bud out of
// and melt back into each other, and the frosted fallback before iOS 26. Each piece carries a copy of only
// the parts it uses, so this file stands alone. Generated from registry/foundation/PieceLiquid.swift in the
// SwiftPieces repo; edit it there, not here. The rules are in LIQUID_GLASS.md.
// swiftpieces-liquid: 1.7.1 (liquid, liquidPress, bud)

/// The liquid glass language: one merge distance, two rest gaps, and the springs a bubble leaves and comes home on.
///
/// Glass shapes inside one `PieceLiquidGroup` melt into each other through a neck when they come within `merge`
/// points. Parts of one control rest `joined`, inside that distance, so the neck holds; separate actions rest
/// `apart`, outside it, so they only goo while one buds out of, or melts back into, another.
private enum PieceLiquid {
    /// Glass shapes closer than this share a neck.
    static let merge: CGFloat = 20
    /// The gap between parts of one control (a stepper's buttons, a progress pill and its stop): the neck holds,
    /// short and smooth, about two thirds of the shapes' height at its waist. Joined parts read best at one height.
    static let joined: CGFloat = 4
    /// The gap between separate actions (menu items, confirm and cancel, chips): they rest as their own bubbles.
    static let apart: CGFloat = 26
    /// How far a bubble shrinks while it is home inside its parent.
    static let homeScale: CGFloat = 0.72

    /// A bubble leaving its parent: slow enough that the neck's stretch and snap read.
    static func split(reduceMotion: Bool) -> Animation {
        reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.62, bounce: 0.22)
    }

    /// A bubble going home. No bounce: a bounce would carry it out through the far side of its parent.
    static func home(reduceMotion: Bool) -> Animation {
        reduceMotion ? .easeIn(duration: 0.18) : .spring(duration: 0.5, bounce: 0)
    }
}

/// A group of glass shapes that merge into one liquid surface. On iOS 26 it is a `GlassEffectContainer`; before
/// that, and under Reduce Transparency, the shapes draw on their own and simply don't merge. `lift` adds the soft
/// shadow liquid glass floats on in light mode.
private struct PieceLiquidGroup<Content: View>: View {
    var spacing: CGFloat = PieceLiquid.merge
    var lift = true
    @ViewBuilder var content: Content
    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
    @Environment(\.colorScheme) private var colorScheme

    var body: some View {
        container
            .shadow(color: .black.opacity(lift && colorScheme == .light ? 0.07 : 0), radius: 18, y: 8)
    }

    @ViewBuilder private var container: some View {
        #if compiler(>=6.2)
        if #available(iOS 26, *), !reduceTransparency {
            GlassEffectContainer(spacing: spacing) { content }
        } else {
            content
        }
        #else
        content
        #endif
    }
}

/// One liquid glass shape: Liquid Glass on iOS 26, carrying `tint` as a solid colour and swelling under a press when
/// `interactive`; a frosted Material with a light rim and a soft shadow before that; a solid fill under Reduce
/// Transparency.
///
/// The tint is painted inside clear glass rather than tinting the glass. Tinted glass in a group bleeds its colour
/// through every neck as a smear, so a red button would glow into the white pill it is joined to; painted inside,
/// the colour stays crisp to the shape's edge, the necks between shapes are clear glass, and a tint change animates
/// like any colour (tinted glass snaps).
private struct PieceLiquidSurface<S: Shape>: ViewModifier {
    var shape: S
    var tint: Color?
    var interactive: Bool
    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
    @Environment(\.colorScheme) private var colorScheme
    @Environment(\.pieceLiquidScale) private var scale

    func body(content: Content) -> some View {
        #if compiler(>=6.2)
        if #available(iOS 26, *), !reduceTransparency {
            // Scaled as two parts, content and outline, about the same centre. A scaleEffect on a glass view inside a
            // GlassEffectContainer shrinks the glass but leaves what it carries full size, pinned to its corner.
            content
                .background { shape.fill(tint ?? .clear) }
                .scaleEffect(scale)
                .glassEffect(glass, in: shape.scale(scale))
        } else {
            fallback(content).scaleEffect(scale)
        }
        #else
        fallback(content).scaleEffect(scale)
        #endif
    }

    #if compiler(>=6.2)
    @available(iOS 26, *)
    private var glass: Glass {
        interactive ? Glass.regular.interactive() : .regular
    }
    #endif

    private func fallback(_ content: Content) -> some View {
        let dark = colorScheme == .dark
        return content
            .background {
                if reduceTransparency {
                    shape.fill(tint ?? (dark ? Color(white: 0.17) : Color(white: 0.97)))
                } else {
                    ZStack {
                        shape.fill(.regularMaterial)
                        if let tint { shape.fill(tint.opacity(0.88)) }
                    }
                }
            }
            .overlay { shape.stroke(Color.white.opacity(dark ? 0.14 : 0.7), lineWidth: 0.5) }
            .shadow(color: .black.opacity(dark ? 0.32 : 0.08), radius: 10, y: 5)
    }
}

private struct PieceLiquidScaleKey: EnvironmentKey {
    static let defaultValue: CGFloat = 1
}

private extension EnvironmentValues {
    /// How much the liquid shapes below are scaled, about their own centres. Nested scales multiply.
    var pieceLiquidScale: CGFloat {
        get { self[PieceLiquidScaleKey.self] }
        set { self[PieceLiquidScaleKey.self] = newValue }
    }
}

private extension View {
    /// Draws this view on a liquid glass `shape`. Put it inside a `PieceLiquidGroup` so it can merge with its neighbours.
    func pieceLiquid<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = true) -> some View {
        modifier(PieceLiquidSurface(shape: shape, tint: tint, interactive: interactive))
    }

    /// Scales the liquid glass shapes in this view, content and outline together, about their own centres. Use it
    /// instead of `scaleEffect` on a glass view (a press, a lift, a swell): inside a group a plain `scaleEffect` shrinks
    /// the glass but leaves its content full size and off centre. Animates like any other value.
    func pieceLiquidScale(_ scale: CGFloat) -> some View {
        transformEnvironment(\.pieceLiquidScale) { $0 *= scale }
    }
}

/// The press for a glass control: the same size-aware sink and lean as `piecePress`, applied through
/// `pieceLiquidScale` so the glass and what it carries sink together (a plain scaleEffect on glass leaves the content
/// behind). Put the glass inside what it presses: the label of a button, the view this modifies. Under Reduce Motion
/// it shades instead of moving.
private struct PieceLiquidPress: ViewModifier {
    let pressed: Bool
    var touch: CGPoint?
    var depth: CGFloat = 2.5
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @Environment(\.colorScheme) private var colorScheme
    @State private var size: CGSize = .zero
    @State private var anchor: UnitPoint = .center

    func body(content: Content) -> some View {
        let motion = PieceMotion(reduceMotion: reduceMotion)
        let scale = pressed && !reduceMotion ? PieceMotion.pressScale(for: size, depth: depth) : 1
        // A scale about `anchor` is a scale about the centre plus this shift toward the anchor.
        let lean = CGSize(width: (anchor.x - 0.5) * size.width * (1 - scale), height: (anchor.y - 0.5) * size.height * (1 - scale))
        content
            .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 }
            .brightness(pressed && reduceMotion ? (colorScheme == .dark ? 0.1 : -0.08) : 0)
            .pieceLiquidScale(scale)
            .offset(lean)
            .animation(pressed ? motion.press : motion.release, value: pressed)
            .onChange(of: pressed) { _, isPressed in
                if isPressed { anchor = PieceMotion.pressAnchor(touch: touch, in: size) }
            }
            .onChange(of: touch) { _, touch in
                if pressed, let touch { anchor = PieceMotion.pressAnchor(touch: touch, in: size) }
            }
    }
}

/// `PiecePressStyle` for glass buttons: the label (with its `.pieceLiquid` inside) sinks as one.
private struct PieceLiquidPressStyle: ButtonStyle {
    var depth: CGFloat = 2.5

    func makeBody(configuration: Configuration) -> some View {
        configuration.label.pieceLiquidPress(configuration.isPressed, depth: depth)
    }
}

private extension View {
    /// Sinks this view's glass while `pressed`, leaning toward `touch` (in this view's coordinates) when given.
    func pieceLiquidPress(_ pressed: Bool, touch: CGPoint? = nil, depth: CGFloat = 2.5) -> some View {
        modifier(PieceLiquidPress(pressed: pressed, touch: touch, depth: depth))
    }
}

/// The bud: how a bubble leaves and rejoins its parent, driven explicitly so every bubble shows the whole cycle.
///
/// A bubble is born at `home`, inside its parent, where the two glass shapes are one. It springs out to `rest`, and
/// while it is inside the merge distance a neck holds it to the parent, thinning as it goes, until it snaps free.
/// Going home it springs back on a spring with no bounce, the neck reaches out and re-forms, and only once it has
/// melted all the way in is it removed. Both offsets are relative to where the bubble is laid out. Under Reduce
/// Motion it stays at `rest`: its content fades and its glass closes in place.
private struct PieceBud: ViewModifier {
    var out: Bool
    var rest: CGSize
    var home: CGSize
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        if reduceMotion {
            // No travel. Inside a group glass ignores opacity, so the glass closes to nothing in place on the short
            // Reduce Motion ease while its content fades; the opacity covers a bubble outside a group.
            content
                .pieceLiquidScale(out ? 1 : 0.001)
                .opacity(out ? 1 : 0)
                .offset(rest)
        } else {
            // The glass shrinks through `pieceLiquidScale`, never a plain scaleEffect (see there), then moves.
            content
                .pieceLiquidScale(out ? 1 : PieceLiquid.homeScale)
                .offset(out ? rest : home)
        }
    }
}

/// A bubble's own content, on its own clock: gone the moment the bubble heads home, so it never rides over the
/// parent's content, and arriving just after the bubble leaves.
private struct PieceBudContent: ViewModifier {
    var out: Bool

    func body(content: Content) -> some View {
        content
            .blur(radius: out ? 0 : 6)
            .opacity(out ? 1 : 0)
            .animation(out ? .easeOut(duration: 0.3).delay(0.1) : .easeOut(duration: 0.14), value: out)
    }
}

private extension View {
    /// Places a bubble at `rest` while `out`, and at `home` (inside its parent, shrunk) while not.
    func pieceBud(out: Bool, rest: CGSize = .zero, home: CGSize) -> some View {
        modifier(PieceBud(out: out, rest: rest, home: home))
    }

    /// Hides a bubble's icon or label while it is home. Put it on the content, inside the glass.
    func pieceBudContent(out: Bool) -> some View {
        modifier(PieceBudContent(out: out))
    }
}

/// Which bubbles exist and which are out. A bubble is added home with no animation, sent out on the next frame,
/// and called home before it is removed, so it always melts in rather than fading. Keep one in `@State`.
@MainActor @Observable
private final class PieceBuds {
    private(set) var present: [String] = []
    private(set) var out: Set<String> = []
    /// The latest call for each bubble. A bloom or gather that has been overtaken (a bubble sent home while it was
    /// still waiting to go out, or called out again while melting) leaves that bubble alone.
    @ObservationIgnored private var turn: [String: Int] = [:]

    func contains(_ id: String) -> Bool { present.contains(id) }
    func isOut(_ id: String) -> Bool { out.contains(id) }

    private func claim(_ ids: [String]) -> [String: Int] {
        var mine: [String: Int] = [:]
        for id in ids {
            let next = (turn[id] ?? 0) + 1
            turn[id] = next
            mine[id] = next
        }
        return mine
    }

    /// Puts bubbles straight out at rest with no motion: a view's first frame, or a state restored.
    func place(_ ids: [String]) {
        _ = claim(ids)
        var quiet = Transaction()
        quiet.disablesAnimations = true
        withTransaction(quiet) {
            for id in ids where !present.contains(id) { present.append(id) }
            out.formUnion(ids)
        }
    }

    /// Adds bubbles home, then sends each out, `stagger` seconds apart, after an optional `delay`.
    func bloom(_ ids: [String], reduceMotion: Bool, stagger: Double = 0.05, delay: Double = 0) async {
        let mine = claim(ids)
        var quiet = Transaction()
        quiet.disablesAnimations = true
        withTransaction(quiet) {
            for id in ids where !present.contains(id) { present.append(id) }
        }
        try? await Task.sleep(for: .milliseconds(24 + Int(max(delay, 0) * 1000)))
        let split = PieceLiquid.split(reduceMotion: reduceMotion)
        for (i, id) in ids.enumerated() where turn[id] == mine[id] {
            withAnimation(split.delay(reduceMotion ? 0 : Double(i) * stagger)) { _ = out.insert(id) }
        }
    }

    /// Calls bubbles home, last first, then removes them once they have melted in.
    func gather(_ ids: [String], reduceMotion: Bool, stagger: Double = 0.04) async {
        let mine = claim(ids)
        let home = PieceLiquid.home(reduceMotion: reduceMotion)
        for (i, id) in ids.reversed().enumerated() {
            withAnimation(home.delay(reduceMotion ? 0 : Double(i) * stagger)) { _ = out.remove(id) }
        }
        try? await Task.sleep(for: .milliseconds(Int((0.52 + Double(ids.count) * stagger) * 1000)))
        var quiet = Transaction()
        quiet.disablesAnimations = true
        withTransaction(quiet) { present.removeAll { ids.contains($0) && !out.contains($0) && turn[$0] == mine[$0] } }
    }
}

// swiftpieces-liquid: end

Install

Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.

Terminal

$npx swiftpieces add GlassModeBar

Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected

>Add the SwiftPieces "Glass Mode Bar" piece to my app

Or copy the source above into your app.

New to SwiftUI?If you know React, CSS or Tailwind, SwiftUI is easier to pick up than it looks.Try real app patterns first →

Building a whole app? See SwiftPieces Pro →

All liquid glass →

Build it yourself: Liquid Glass guide

What's new

On this page

SwiftPiecesPro

Build the whole app with one library.

Production-ready screens, whole flows, complete app templates, a Build Kit your agent follows and Pro remixing.

54 screens · 48 flows · 12 templates · 31 Build Kit skills

Explore Pro

Sponsors

Keep it free

Your logo hereBecome a sponsor