Skip to content

fix: sheet modal toolbar overlaps grabber; align vertical geometry with SwiftUI #269

Open

Description

@rdlabo

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

  1. rdlabo commented on Oct 20, 2026

    @rdlabo
    ContributorAuthor

    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 expandToScroll both 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:

    Presentation Side insets Bottom inset
    iPhone 18 Pro, fractional 0.75 detent 8pt approximately 8pt
    iPhone 18 Pro, large detent 0pt 0pt
    iPad Pro 11-inch, portrait, fractional/large centered 580pt-wide sheet 20pt

    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.

    • With 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.
    • At the full breakpoint, however, the width changes by 16px and the vertical position changes by 8px in a single switch. During a held drag, the partial inset remains until the breakpoint is committed. The native recording instead shows the shape changing continuously during expansion.
    • With 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.
    • Native content is scaled at partial detents; narrowing the CSS layout is only an approximation. Fractional detent heights also differ.

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions