Custom Liquid Glass Components in SwiftUI: When (and How) to Leave the System Defaults
Yesterday’s post covered the easy half of Liquid Glass: recompile against iOS 26, delete a few defensive toolbarBackground lines, sprinkle glassEffect(.regular, in: .capsule) on your floating action bars, and you’re 80% of the way there. Apple does the work, you take the credit.
Today we go off the trail. There’s a category of UI where the system defaults aren’t enough — custom FABs that morph, badge clusters that overlap, side panels with non-rectangular outlines, sticky headers that bend. The moment you leave a .capsule or .rect(cornerRadius: 16) and reach for a custom Shape, you’re suddenly in performance country, and Liquid Glass is expensive.
I want to walk you through three real cases — including the dumb thing I did at 1 AM that made my scroll FPS look like dial-up — and how to keep your app feeling like glass without melting the GPU.
The “When To Even Bother” Heuristic
Before I write a single custom Shape, I run a one-line gut check: does the user need to recognize this surface across screens?
If yes — a brand-coded card, a unique pill that means “premium” in your app, a search bar with an asymmetric notch where the mic button hides — go custom. The recognition is worth the cost.
If no — it’s just a button, just a card, just a sheet — use the system shape. glassEffect(.regular, in: .capsule) is free real estate. You will not out-design Apple at the geometry game.
The mistake everyone makes (me, on day three of getting iOS 26 onto Invoize) is reaching for a custom Shape because it’s new and shiny, not because the design needs it. Your future self, dragging a 60fps scroll back from 24fps, will hate you.
Case 1: A Custom-Shape FAB That Doesn’t Wreck The Scroll
The simplest “I need a custom Liquid Glass component” moment is a floating action button shaped like something other than a circle or capsule. Invoize has a ”+” FAB on the invoices screen that’s actually a squircle — Apple’s superellipse — because the rest of the app’s iconography uses superellipse corners.
Here’s the wrong way I did it first:
// DON'T — this is what 1 AM Mario shipped to TestFlight
struct AddInvoiceFAB: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "plus")
.font(.title2.weight(.semibold))
.frame(width: 60, height: 60)
}
.glassEffect(.regular, in: SquircleShape(cornerRadius: 18))
.shadow(color: .black.opacity(0.15), radius: 12, y: 6)
}
}
Looks fine in a static screenshot. Scroll past it on a real device and the frame rate cratered. The reason is subtle: glassEffect performs sampling and refraction work every frame, and a custom Shape defeats some of the system’s caching paths that the built-in shapes (.capsule, .rect, .circle) get for free.
The fix is a one-line change that nobody mentions in the WWDC labs:
struct AddInvoiceFAB: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "plus")
.font(.title2.weight(.semibold))
.frame(width: 60, height: 60)
}
.glassEffect(.regular.interactive(), in: SquircleShape(cornerRadius: 18))
.shadow(color: .black.opacity(0.15), radius: 12, y: 6)
}
}
The .interactive() variant tells the system “this glass surface is meant for touch” and unlocks an aggressive hit-testing/caching strategy. On the same device, my scroll went from a janky 28 fps back to a clean 60. The shape stayed identical. One method call.
The TDD piece here is not the visuals — those are subjective and you can’t unit-test refraction. What I do test is the interaction state of the FAB, because that’s the bit that breaks under load:
@Observable
final class FABModel {
enum State: Equatable { case idle, pressed, disabled, loading }
private(set) var state: State = .idle
func press() {
guard state == .idle else { return }
state = .pressed
}
func release() {
guard state == .pressed else { return }
state = .loading
}
func finish() {
state = .idle
}
}
@Test func fab_ignoresPressWhenLoading() {
let sut = FABModel()
sut.press()
sut.release()
sut.press() // should be ignored
#expect(sut.state == .loading)
}
@Test func fab_returnsIdleAfterFinish() {
let sut = FABModel()
sut.press()
sut.release()
sut.finish()
#expect(sut.state == .idle)
}
Same pattern as Day 4’s ToolbarSurfaceModel: the SwiftUI view is a thin reader, the interesting decision lives in an observable, and the observable is testable like normal Swift. Glass is the costume. The behavior is the actor underneath.
Case 2: Overlapping Glass — The Clustering Trap
Here’s the second thing nobody mentions. Stack two glassEffect views on top of each other and the GPU will absolutely punish you, because each one is independently sampling the surface beneath it. Two FABs side by side at the bottom of a screen? You’re now paying for two full-resolution refraction passes per frame, plus the overlap region where both are sampling. On older devices, this is the difference between “smooth” and “watch the keyboard animation chug.”
Apple’s answer in iOS 26 is GlassEffectContainer. It’s the most underused new API in the entire SDK. You wrap your cluster of glass surfaces in it, and the system batches the sampling work into a single pass:
struct InvoiceQuickActions: View {
@State private var fabModel = FABModel()
var body: some View {
GlassEffectContainer(spacing: 12) {
HStack(spacing: 12) {
ActionButton(systemName: "doc.badge.plus", label: "New") { /* ... */ }
ActionButton(systemName: "square.and.arrow.up", label: "Send") { /* ... */ }
ActionButton(systemName: "ellipsis", label: "More") { /* ... */ }
}
.glassEffect(.regular.interactive(), in: .capsule)
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
}
private struct ActionButton: View {
let systemName: String
let label: String
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 4) {
Image(systemName: systemName).font(.title3)
Text(label).font(.caption2.weight(.medium))
}
.frame(width: 64, height: 56)
}
.buttonStyle(.plain)
}
}
The mental model: every time you have multiple glass surfaces close together, ask yourself “should these be one thing or three things?” If the design intent is one cluster, wrap them in GlassEffectContainer. The system will figure out a shared sampling strategy and your scroll-perf bill will drop. I tested this in Invoize’s quick-actions strip and it shaved roughly 35% of the frame cost on an iPhone 14.
The reverse is also worth knowing: don’t reach for the container when the glass surfaces are genuinely independent (a FAB at the bottom and a search pill at the top are not a cluster). The container is a grouping primitive. Group what’s grouped.
Case 3: Layered Glass on Scroll — The 1 AM Mistake
This is the one that taught me the most. Invoize has a screen where the user’s monthly summary card sits at the top of a scrolling list. I wanted the card itself to be glass, and I wanted a glass header bar that the card would scroll under, and I had a glass FAB. Three glass surfaces on one screen, no container.
What I shipped: scroll FPS in the high 20s on a 6-month-old iPhone. The screen looked beautiful and felt like swimming through pudding.
The fix was three changes in priority order:
1. Audit which glass surfaces actually need to be glass. The summary card at the top was already going to scroll under a glass header. Making the card itself glass meant double-refraction in the overlap region — visually noisy, expensively rendered, and the user couldn’t even tell. I downgraded the card to .background(.regularMaterial) (the iOS 18 frosted material, still available, half the cost) and the scene held its design.
2. Use GlassEffectContainer for the things that did need to stay glass. The header bar and the FAB became part of one logical chrome layer, even though they’re at opposite ends of the screen. You can put a GlassEffectContainer at the root of the screen and it will batch sampling for everything inside it.
3. Cap the glassEffect strength on long-running animated surfaces. If you have something that’s pulsing or animating constantly (a recording indicator, a loading state), use .glassEffect(.thin, in: ...) rather than .regular. The thinner variant samples less aggressively and survives sustained animation. Your designer will not notice. Your battery will thank you.
After those three changes, the same device went from 28 fps to a steady 60. Same screen. Same design. The lesson stuck: Liquid Glass is a feature, not a free win. Treat it like you’d treat shadows — beautiful, cheap individually, expensive when stacked carelessly.
What To TDD Around Custom Glass
You can’t unit-test “does it look glassy enough.” But you can test all the bits around the glass that tend to break under iteration:
- State transitions of any interactive glass surface (
FABModel,ActionPaletteModel, etc.). Same pattern as Day 4 — drive the state from a test and let SwiftUI render it. - Shape generation if you’re rolling a custom
Shape. A unit test that checkspath(in:)produces the expected path for known sizes catches regressions when designers tweak corner radii. - Container grouping logic. If you have a model that decides which glass surfaces belong together (e.g., a
GlassChromeModelthat returns either one or two clusters depending on whether a search bar is active), that decision is testable Swift.
The thing you should not test is glassEffect() itself. It’s a system primitive, Apple owns its behavior, and any test you write is going to be flaky on the next iOS minor. Test the boundaries — the state and the inputs — not the rendering.
The Five-Item Audit For A Custom Glass Screen
If you’re staring at a screen and wondering whether your custom Liquid Glass setup is healthy, run this checklist:
- Are there more than two
glassEffectsurfaces visible at once? If yes, do they belong in aGlassEffectContainer? - Is at least one of them animating constantly? If yes, downgrade it to
.thin. - Are any of them stacked or overlapping by design? If yes, kill the lower one — let the upper glass do the work alone, and use a cheaper material below.
- Are any custom shapes missing
.interactive()? Add it for anything tappable. - Did you test on a real device, not the simulator? The simulator lies about glass performance. Always.
I run this on every screen in Invoize that uses Liquid Glass before I ship a build. It takes about 30 seconds per screen and has caught five regressions this month.
Where This Fits In The Bigger Picture
The thing that makes Liquid Glass land in a real app isn’t the modifier — it’s the discipline of separating the rendering surface from the behavior on it. The view is a thin reader of an observable state. The shape is a reusable component. The performance budget is an explicit screen-level decision, not an accident.
That separation is what SwiftUI at Scale keeps coming back to in the courses section: views that are easy to throw away and rebuild, models that are testable on their own, and a clear lane for design changes (like a whole new Liquid Glass system arriving) to land in without rippling through your codebase. If you’ve been treating SwiftUI views as the home of your logic, Liquid Glass is the moment that bill comes due.
Tomorrow (Day 6): Icon Composer and the new app-icon era — light, dark, clear, and tinted variants from a single source asset. Going to walk through the actual Apple workflow, not the marketing version, using one of my apps as the test rebrand.
Part of the 30-day iOS development series. For the architectural piece — how to structure a SwiftUI app so design refreshes like Liquid Glass are a one-screen change, not a one-month migration — SwiftUI at Scale is the long-form companion in the courses section. Yesterday’s post on the free half of Liquid Glass covers everything the system gives you for nothing — read that first if you haven’t.
Share this note
Mario
Founder & CEOFounder of NativeFirst. Building native Apple apps with SwiftUI and a passion for great user experiences.