Repository navigation
fix: sheet modal toolbar overlaps grabber; align vertical geometry with SwiftUI #269
Description
Problem
On an iOS sheet modal, the first toolbar is too high. With a segmented control in the toolbar, its top edge touches/overlaps the sheet grabber. This is a sheet-toolbar layout issue, not something that should be fixed by adding padding to each application component.
Observed with @rdlabo/ionic-theme-ios27 1.0.4. The theme puts the grabber at top: 6px with a 4px height, but resets the sheet toolbar to padding-top: 0 and --min-height: 64px. A 48px segment is consequently placed at y=8px, overlapping the grabber (y=6–10px).
Native reference measured
Built a real SwiftUI application using Xcode 27.0 (27A266a), iOS 27.0 SDK, and ran it on the iPhone 18 Pro / iOS 27.0 simulator, logical width 402pt. The sheet uses .presentationDetents([.fraction(0.75), .large]) and .presentationDragIndicator(.visible) with a NavigationStack, a segmented Picker in .principal, and cancellation/confirmation toolbar items.
Measured the UIKit view frames after presentation settled. The native sheet is transformed to 386pt visible width; values below are normalized by 386 / 402 and expressed relative to the sheet content origin, rather than comparing scaled screenshot pixels.
| Vertical geometry | SwiftUI (pt) | Theme before (CSS px) | App workaround (CSS px) |
|---|---|---|---|
| Grabber top | 6 | 6 | 6 |
| Grabber height | 4 | 4 | 4 |
| Header/bar bottom | 70 | 64 | 70 |
| Segment top | 14.666667 | 8 | 14.6640625 |
| Segment height | 48 | 48 | 48 |
| Grabber-to-segment gap | 4.666667 | -2 | 4.6640625 |
The native navigation bar itself starts at y=16pt and is 54pt tall. Removing the segmented Picker leaves these native bar/grabber positions unchanged; this was measured in a separate launch as well.
The web measurements use the application's loaded theme in Chrome with iOS mode, a 402px viewport, and DPR=3. The remaining ~0.0026px discrepancy is Chromium layout-unit rounding, not an intentional offset. This is a vertical-layout comparison; it does not establish pixel-perfect parity of materials, horizontal widths, or all device sizes/WebKit rendering.
Local workaround
Added to the consuming app's app/src/theme/ios.scss (inside its .ios scope):
ion-modal.ios.modal-sheet:not(.ios-theme-disabled, .ios26-disabled) {
ion-header.ios:not(.ios-theme-disabled, .ios26-disabled)
ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):first-of-type {
padding-top: 6px;
ion-segment.in-toolbar:not(.segment-expand, .ios-theme-disabled, .ios26-disabled) {
position: relative;
top: calc(2px / 3);
}
}
}The 6px padding fixes the shared sheet header height/vertical placement with or without a segment. The additional 2/3px matches the measured native segment alignment in this configuration; please evaluate whether the upstream segment alignment should encode that via its layout rather than a relative offset. No changes to card/fullscreen modals, modal detents, or MD mode are intended.
Minimal SwiftUI reference
struct ReferenceSheet: View {
@State private var selected = 0
var body: some View {
NavigationStack {
Form { LabeledContent("名前", value: "田中様") }
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("閉じる", systemImage: "xmark") {}
.labelStyle(.iconOnly)
}
ToolbarItem(placement: .principal) {
Picker("表示", selection: $selected) {
Text("予約").tag(0)
Text("ノート").tag(1)
}.pickerStyle(.segmented)
}
ToolbarItem(placement: .confirmationAction) {
Button("詳細") {}
}
}
}
.presentationDetents([.fraction(0.75), .large])
.presentationDragIndicator(.visible)
}
}Present with .sheet(isPresented:). Remove the principal ToolbarItem for the no-segment comparison. Expected: the grabber stays separate from all toolbar controls, and removing the segment does not change the header geometry.
Additional discrepancy: the sheet itself has side and bottom margins
The same SwiftUI reference is inset from the left, right, and bottom screen edges. This is separate from the toolbar/grabber spacing above: adding toolbar padding alone does not reproduce the native sheet silhouette.
Measured visible sheet frame on the 402 × 874pt iPhone 18 Pro simulator, at the 0.75 detent after presentation settled:
- Sheet frame: x = 8pt, y = 273.250415pt, width = 386pt, height = 593.082919pt.
- Left inset: 8pt.
- Right inset: 8pt (
402 − 8 − 386). - Bottom inset: 7.666667pt (
874 − 273.250415 − 593.082919), visually about 8pt.
These are screen-space frame measurements (not the scale-normalized internal coordinates used in the toolbar table). The native sheet has rounded lower corners and a visible gap below it; it is not flush with the left/right/bottom screen edges.
The Ionic sheet in the same 402px-wide browser comparison extends to both side edges (x = 0, width = 402) and does not reproduce that native bottom gap. Please also address the sheet container geometry in the upstream theme. The app workaround above currently addresses only header/control vertical positioning, not these outer insets. The measurements here apply to the tested iPhone / 0.75-detent presentation; large detents and iPad should be checked separately before applying a universal margin.
Activity
rdlabo commentedon Oct 20, 2026
Investigated both the toolbar overlap and the sheet's outer geometry. The conclusion is to retain the current outer-sheet behavior: the visual benefit of closer native insets does not justify the additional animation and gesture maintenance.
Toolbar/grabber overlap: already fixed
#263 restored the first sheet/card toolbar's top padding to 6px. On current main, the toolbar is 70px tall, the 48px segment starts at 14px, and the grabber ends at 10px, leaving a 4px gap. Reapplying the old 0px padding reproduces the reported 2px overlap.
This was checked with real Ionic components at phone and tablet viewport sizes, with
expandToScrollboth true and false. Application-specific padding is no longer needed on current main. The remaining approximately 2/3pt difference from the measured iPhone reference does not justify a universal relative offset: native iPad toolbar geometry differs.Outer insets: confirmed, but intentionally left unchanged
Independent iOS 27.0 simulator measurements confirmed:
We then compared the current theme, a small CSS approximation, and a native SwiftUI recording. The approximation kept Ionic's standard animation and switched width, bottom offset and lower corner rounding only on
ionBreakpointDidChange.expandToScroll=false, a transparent wrapper and rounded page can reproduce the partial sheet's side/bottom gaps using a small amount of CSS, similar to the existing iPad treatment.expandToScroll=true(the default), the same CSS does not create a visible rounded lower edge because the page extends below the viewport. Changing this option implicitly would also change scrolling behavior.The web comparison used Chromium and Ionic 9.0.0. The native comparison used animated SwiftUI detent selection on iPhone 18 Pro / iOS 27.0; it does not establish touch-gesture or spring-curve parity. The experimental CSS was not validated for WebKit, footers, keyboard behavior or rotation.
Decision
Keep the existing outer geometry and standard Ionic motion. Custom animations are not inherently undesirable—we already use them elsewhere, including popovers—but reproducing this small visual detail across sheet dragging, snapping and scrolling is disproportionate in scope. The simple approximation introduces a visible settling jump, so the current behavior is the better tradeoff.
The broader upstream experiment, ionic-team/ionic-framework#31607, was closed. This does not establish that an Ionic core change is mandatory; alternative use of existing animation extension points remains unproven. No production outer-sheet styles have been changed, and no upstream release is required for the toolbar fix already on main.