Ask a coding agent for a glass icon button in a navigation bar and you will get working, plausible, review-passing code. You will also probably get a control whose tap target is a fraction of the glass capsule the user sees. The button looks right in every screenshot, and taps aimed at its edges do nothing.
This is not an agent problem so much as an API problem that agents inherit. SwiftUI has two completely different code paths behind ToolbarItem, and nothing in the signature tells you which one you are on. The visual result is nearly identical. The hit-testing is not.
Button { close() } label: {
Label("Library", systemImage: "books.vertical")
}
.buttonStyle(GlassToolbarButtonStyle()) Button { close() } label: {
Image(systemName: "books.vertical")
.frame(width: 20, height: 20)
.padding(11)
.contentShape(Circle())
.glassSurface(in: Circle(), interactive: true)
}
.buttonStyle(.plain) Image(systemName: "books.vertical")
.frame(width: 20, height: 20)
.padding(11)
.glassSurface(in: Circle(), interactive: true)
.frame(width: 44, height: 44)
.contentShape(Rectangle()) Button("Library", systemImage: "books.vertical") {
close()
} 1. A custom ButtonStyle
The most natural reading of “make this toolbar button glassy” is “write a ButtonStyle”. So that is what you get:
ToolbarItem(placement: .topBarLeading) {
Button { close() } label: {
Label("Library", systemImage: "books.vertical")
}
.buttonStyle(GlassToolbarButtonStyle())
}
This compiles, renders a proper Liquid Glass capsule, and is broken.
The moment you attach a custom ButtonStyle, SwiftUI stops bridging the item to a UIBarButtonItem and hosts your view as custom bar content instead. The system still draws its glass platter around the hosted view — that part is automatic and unconditional. But the platter is decoration. The touch target is the label’s own bounds, which here is a bare SF Symbol, roughly 20 points across, centred inside a 44-point capsule.
So the user sees a 44-point button and gets a 20-point one. Everything outside the glyph is inert.
2. Own the glass
Once someone notices the dead capsule, the obvious fix is to stop relying on the system’s glass and draw your own, sized to the target you actually want:
ToolbarItem(placement: .topBarLeading) {
Button { close() } label: {
Image(systemName: "books.vertical")
.frame(width: 20, height: 20)
.padding(11) // → 42×42
.contentShape(Circle())
.glassSurface(in: Circle(), interactive: true) // your own glass
}
.buttonStyle(.plain)
}
.sharedBackgroundVisibility(.hidden) // suppress the system platter
This is a real improvement — the target grows from the glyph to the capsule — and it is still subtly wrong in three ways.
The frame is 42 points, not 44. The padding arithmetic lands wherever it lands, and 42 is under the minimum touch target. That sounds pedantic until you notice the button sits inside a 44-point platter, so there is a visible band of glass along the top and bottom edge that belongs to no control at all.
contentShape(Circle()) throws away the corners. A circle inscribed in a 42-point square loses about 21% of the area, and it loses it exactly where fingers land: near the bottom edge the live width narrows to a few points either side of centre. Press the visual bottom of the button slightly off-centre and you are outside the shape.
The order is load-bearing. Applied before glassSurface, a contentShape or frame describes the pre-glass content. Applied after, it describes the finished control. Agents put these modifiers in whatever order reads nicely.
If you must go this route, the working version is:
Image(systemName: "books.vertical")
.frame(width: 20, height: 20)
.padding(11)
.glassSurface(in: Circle(), interactive: true)
.frame(width: 44, height: 44) // after the glass: grows the target, not the drawing
.contentShape(Rectangle()) // rectangle keeps the corners live
3. A standard bar button
ToolbarItem(placement: .topBarLeading) {
Button("Library", systemImage: "books.vertical") { close() }
}
A plain Button, a stock Label, no custom style, no sharedBackgroundVisibility. SwiftUI bridges this to a real UIBarButtonItem. The platter is the button’s background, and UIKit owns hit-testing, touch slop, the iPad pointer effect, and the accessibility traits.
What’s happening?
You can confirm which path you are on from a view-debugger dump. Standard:
_UINavigationBarPlatterView frame (16 0; 44 44) background: PlatterMaterial
_UIButtonBarButton frame (0 0; 36 36)
barButtonItem: SwiftUI.UIKitBarButtonItem
Custom content, same toolbar, same screen:
_UINavigationBarPlatterView frame (16 0; 50 44) background: PlatterMaterial
UIKitBarItemHost frame (0 0; 42 42) ← your view, your bounds
In the second dump there is no UIBarButtonItem anywhere. There is a SwiftUI view in a box, and eight horizontal points of glass around it that no control owns.
Two things change when you move to the standard button, and both will show up in a screenshot diff. The glyph gets larger, because a bar button uses the system symbol metric rather than whatever .font(.system(size:)) you had set. And the tint changes: on iOS 26 a bar button’s ink adapts to the backdrop behind the bar.
Why the broken versions feel fine in testing
The glass responds to touches at the platter level. That is the entire premise of .glassEffect(.regular.interactive()) and of the system’s own platter material — the feedback belongs to the surface, not to your button.
So a finger landing in the dead band gets the full lie. The capsule dips, brightens, tracks the touch. UIKit is telling you it received the event. It just has no gesture recogniser that will do anything with it.
The bug reads simple: press-in works, release does nothing, but only sometimes. Tapping a few points higher works fine. That gap is always the distance between what the system painted and what you made tappable. It is also why the bug survives QA — nobody taps the exact bottom edge of a button on purpose, and the animation on touch-down makes the control feel alive right up to the moment it fails.
Synthesised taps will not reproduce it reliably either, so a UI test suite is no defence here. You have to check the geometry.
Do not do this to a back button
The “own your glass” approach is even more problematic for a back button. A custom leading item is not one. To put one in the back button’s place you have to hide the real one:
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button { dismiss() } label: { Image(systemName: "chevron.backward") }
.buttonStyle(GlassToolbarButtonStyle())
}
}
That single modifier costs you most of what makes back navigation feel native:
- The interactive pop gesture stops working. Hiding the back button disables edge-swipe-to-go-back. On a reading or browsing screen this is the primary navigation gesture, and losing it is far worse than any hit-target bug.
- Long-press history is gone. The system back button holds the whole stack; a chevron in a
Buttonholds nothing. - The previous screen’s title is gone, along with the automatic truncation rules for it.
- On iOS 26, the back item has its own transition behaviour — it participates in the bar’s platter grouping and morphs between screens. A hosted custom view does not; it cross-fades like any other content.
The practical rule: a close or dismiss action can be a custom leading item if the design demands it. A back action in a navigation stack should be the system back button, styled through the standard toolbar appearance APIs, or left alone entirely. If your design language genuinely cannot survive the system back button, that is worth discovering during design rather than three screens into implementation.
When custom content is legitimately the answer
Some bar items cannot be standard buttons. Ours has a determinate progress ring that expands into a “Downloading 47%” pill — a custom shape and a self-animating width, which a UIBarButtonItem cannot express.
That is fine. The rule is not “never use custom bar content”, it is know when you have opted into owning the touch target, because nothing warns you. If the item is custom, you owe it a 44-point minimum applied after the glass and a rectangular content shape.
Directing the agent
Three things worth putting in your project instructions, since none of them are discoverable from the API surface:
- Toolbar items are standard
Button+Labelwith no customButtonStyleunless there is a stated reason. Custom bar content means custom hit-testing. - Custom bar content requires
.frame(minWidth: 44, minHeight: 44)and.contentShape(Rectangle())applied after the background. - Never hide the system back button to restyle it.
And one for review: grep for buttonStyle inside toolbar blocks. Every hit is a control that is no longer a bar button, whether or not anyone meant it.