Liquid Glass, measured
Two numbers — 120 hitches per minute and 5 — explain everything about what Liquid Glass actually costs and where that cost lives.
The number that stopped me was 120. Hitches per minute, Instruments open, ten glass cards on screen with the shine animation running. I had been treating the tilt response as basically free — it looked right, it tracked the light the way real glass would, it felt like the right detail. Then I measured it.
Switching to a static shine over fifteen cards brought the number to five. More cards, no motion: a fuller list and forty-eight fewer hitches per minute. The glass stayed. The animation didn't.
That gap is the point of this post.
What I ruled out first
The obvious hypothesis was card count. More surfaces on screen, more work — that logic holds for a lot of rendering problems, and I chased it for longer than I should have. What I actually tried: dropping from 60 Hz to 30. Moving the shine from SwiftUI rendering to a CALayer. Motion gating, where the animation only runs when the accelerometer says the device is still. Bounding the tallest card so it cannot grow past a fixed height. None of it moved the numbers enough to matter.
The fix that worked was also the simplest: motionEnabled = false. Static shine, no tilt response, no per-frame sampling.
What the system is actually doing
.glassEffect works by sampling its backdrop. That is what makes it look like glass rather than a frosted rectangle — it reads what is behind it and applies blur, tinting, and the specular highlight over that sample. The sample is live. When anything above a glass surface moves, the system has to recomposite the backdrop for every glass surface on screen, not just the one nearest the motion.
Ten cards. Sixty frames per second. A shine animation running above them. Sixty recompositions per surface per second, multiplied across every glass card in the view hierarchy. 120 hitches per minute.
Fifteen cards with a static shine: the backdrop is sampled once and held until something changes. Five hitches per minute.
The specular edge still exists in the shipped version. It is lit from a fixed angle and never moves. What had to go was the tilt response — the part that tracked device orientation and made the highlight shift as you moved the phone. The card still reads as glass. It just does not respond to gravity.
Area is the real cost driver
Once the recomposition model was clear, something else followed: the cost scales with backdrop area, not with the count of glass surfaces.
Thirty-five narrow cards cost less than ten tall ones. What the system is compositing is the total area of screen covered by glass, and a small card contributes proportionally less than a large one regardless of how many of them there are. A stat card at the top of a list — small, fixed height — is cheap. A card that spans most of the screen width and grows with its content is expensive.
This changes the design question. It is not "how many glass surfaces can I afford on this screen?" It is "how much of the viewport is glass at any given scroll offset?"
The tall card problem
Some cards have to be tall. The appointments card, for instance, could grow arbitrarily as more items were added. Left unconstrained, its backdrop area grows with it, and so does the per-frame cost.
The fix is height-capping. Give the card a maximum height and let the content scroll internally. The glass system sees a tile-sized constant for that surface regardless of how many rows are inside. The card is affordable because its backdrop area is bounded.
It is one of those constraints that also improves the design independent of performance. A card that grows without limit tends to push everything else off the screen anyway.
Two implementation details worth knowing
GlassEffectContainer batches sibling glass surfaces into a single compositing pass. If you have several glass elements at the same level in the hierarchy — a row of stat cards, a grid — wrapping them in the container is worth doing. Without it, each surface composites independently.
The other detail: .clipShape has to come before .background in the modifier chain.
// Wrong — the background bleeds past the corners
.background(material)
.clipShape(RoundedRectangle(cornerRadius: 16))
// Right
.clipShape(RoundedRectangle(cornerRadius: 16))
.background(material)
The glass material does not clip its children. A row that paints its own background will draw square corners past the card's rounded shape if the clip comes after. I found this the hard way on a list with internal rows.
What this cost in the design
The tilt response is gone. It was the most obviously glass-like behaviour — the highlight moving as you moved the phone — and it is what you give up when the profiler says 120.
What stayed: the frosted body, the specular edge, the angular gradient for the shine highlight. The card reads as glass. It does not behave as glass in the physical sense.
The other constraint the measurements imposed: stat cards stay clear glass, never tinted. A tint that works on a card with white labels turns primary-coloured text invisible. Colour coding belongs in the data — the sparkline, the indicator — not in the surface.
The file is the documentation
LiquidGlassCard.swift is copied verbatim across The Smart Dentist, Billing, and Coach. Deliberate. The performance rules travel with the file rather than living in a note somewhere. The 120-to-5 context is in the same source as the code, next to the knobs and the comments, so whoever opens it next does not have to rediscover any of this.
The numbers are the design document. Everything else is a comment.