Published on

One JS, Two UXes: Why a Cross-Platform Abstraction Can't Assume Presentation Form Is Constant

Authors
  • avatar
    Name
    Jack Qin
    Twitter

A cross-platform abstraction makes us a tempting promise: write one piece of code, run it everywhere. @expo/ui/swift-ui's <BottomSheet> pushes this promise quite far — it bridges directly to SwiftUI's .sheet(isPresented:) modifier, letting you describe a native sheet in JSX. But this promise hides a rarely-stated premise: it assumes the "presentation form" of the underlying native primitive is constant. The moment this premise breaks across device classes, "the same JS" renders two different UXes — while your code looks entirely correct.

This post isn't about how to lay out one particular sheet; it's about dissecting why this premise breaks: .sheet's presentation form is not a component property but a runtime behavior UIKit decides by device class; presentationDetents is an effective knob on iPhone and essentially decorative on iPad. Understand "presentation form is a platform variable, not a component constant" and you're facing not just this BottomSheet, but a whole class of "cross-platform components that silently change form on different devices" traps.


What it actually is: presentation form decided by UIKit per device class

<BottomSheet> bridges to .sheet, and .sheet is underpinned by a UIModalPresentationStyle — this style isn't fixed, UIKit picks a different value based on device class:

PlatformDefault presentationApproximate sizepresentationDetents
iPhone.pageSheet popping up from the bottomFull width × fraction × screen heightTakes effect
iPad (portrait + landscape).formSheet centered~580 × ~620 pt (fixed)Essentially ignored
Mac Catalyst / "Designed for iPad on Mac".formSheet centeredSame ~580 ptSame as above

So on iPad, the "bottom drawer occupying 75% of screen height" you thought you wrote simply doesn't hold:

  • presentationDetents([{ fraction: 0.75 }]) will not make the sheet take 75% of the screen — the system clamps it to form-sheet size;
  • the sheet appears centered (both horizontally and vertically), not stuck to the bottom edge;
  • drag-to-dismiss still works, the drag indicator still shows;
  • iOS 26 Liquid Glass material still auto-applies to the sheet chrome — which is exactly the main reason to use <BottomSheet> on iPad rather than hand-rolling a <Modal>.

The key insight: presentation form isn't something you can control via a prop, it's something UIKit decides for you at runtime by device class. presentationDetents isn't malfunctioning — it was only ever meaningful in the .pageSheet (iPhone) context, and in the .formSheet (iPad) context the system has its own fixed size. Treating it as "a cross-platform unified height knob" misunderstands the knob's scope.


The wrong design assumption, and why a unit test can't catch it

The most common error is treating "I want a bottom drawer" as the basis for layout, instead of "what the runtime form actually is":

// Assumes iPad renders it as 75% height, full width
<BottomSheet isPresented={visible}>
  <Group modifiers={[presentationDetents([{ fraction: 0.75 }])]}>
    <Grid horizontalSpacing={12} verticalSpacing={12}>
      {fourColumnRows} {/* designed for full-screen width */}
    </Grid>
  </Group>
</BottomSheet>

On iPad this renders as a centered ~580×620pt sheet, with the 4-column cells crammed together. The JS looks entirely correct — its bug isn't in the logic layer but in the "runtime presentation form" dimension that JS can't see. This is also why unit tests are no help at all: Vitest can't render the SwiftUI bridge, so assertions about this branch can only come from manual QA. A bug where "the code is correct but the design breaks at runtime" essentially requires you to look at it in its real form.

The correct approach: decide layout by the render-time useWindowDimensions().width, and accept that "on iPad it's a centered form-sheet."

// Pick the column count by actual window width, and accept it's a centered form-sheet on iPad.
const { width: screenWidth } = useWindowDimensions();
const cols = decideColumnCount(
  screenWidth >= 768 ? "tablet" : "phone",
  screenWidth
);

<BottomSheet isPresented={visible}>
  <Group modifiers={[presentationDetents([{ fraction: 0.75 }])]}>
    <ScrollView>
      <Grid horizontalSpacing={12} verticalSpacing={12}>
        {chunkSites(sites, cols).map((row, i) => (
          <Grid.Row key={i}>
            {row.map((site) =>
              site ? (
                <SiteCellSUI site={site} ... />
              ) : (
                <Spacer />  // pad the last row with empty placeholders to keep alignment
              )
            )}
          </Grid.Row>
        ))}
      </Grid>
    </ScrollView>
  </Group>
</BottomSheet>

The key shift in the design basis: the sheet container is ~580pt wide on iPad no matter how large the screen is, so lay out for ~560pt of available horizontal space, not the whole screen width. At screenWidth < 768 (phone), treat the body as a tall single column; at screenWidth >= 768 (iPad form-sheet), a 2-column grid fits, and 3+ columns get cramped under the default detent.


Design space and boundary: what if you want a wider sheet

Having accepted "on iPad it's a ~580pt form-sheet," the natural question is: can it be wider? Currently there is no documented way to make @expo/ui/swift-ui's .sheet occupy most of an iPad screen. This is an honest boundary, and getting around it has only two routes, each with a cost:

  1. Use a non-sheet presentation (e.g. an RN <Modal> + <LiquidPanel>, like the old TabletSiteSwitcher) — the cost is losing the system Liquid Glass on the chrome;
  2. Use SwiftUI's .popover (a completely different UX) — the current <BottomSheet> doesn't support it.

This is exactly the concrete form of the tradeoff: the system sheet gives you free Liquid Glass material but takes away size control; a hand-rolled Modal gives you size freedom but you have to figure out Liquid Glass yourself. Which route you choose depends on which side you value more. Laying out the tradeoff is more honest than pretending "you can always tune it to any width."

Three tiers of outcome by device class:

  • Good: a site picker, with a 2-column SwiftUI <Grid> inside the iPad <BottomSheet>. The form-sheet width fits 2 cards, Liquid Glass is automatic, and the same component handles the phone too.
  • Base: a control panel (sliders, switches) rendered single-column in the form-sheet, with vertical scrolling handled by an inner RN <ScrollView>. Same UX as the phone, just centered.
  • Bad: assuming presentationDetents([{ fraction: 0.9 }]) makes the sheet near-fullscreen on iPad, or stuffing a 4-column grid into the default form-sheet — cards too narrow to read, design broken at runtime.

Verification: look at it in the real form

Because this bug lives in a dimension JS can't see, verification must be done in an environment that can render the real form:

  • test the sheet on iPhone and "Designed for iPad on Mac" before declaring it done. The iPad form-sheet behavior is invisible if you "only QA on iPhone";
  • confirm Liquid Glass is actually rendering (Mac "Designed for iPad" can render real iOS 26 Liquid Glass, no iOS 26 simulator needed);
  • check that long content (like long site names) isn't truncated at form-sheet width;
  • remember the old "centered Modal + <LiquidPanel>" pattern (TabletSiteSwitcher) is the workaround for when form-sheet width is too constrained — don't rebuild it from scratch.

The available primitives (Grid, Grid.Row, ScrollView, LazyVStack, BottomSheet) are all under @expo/ui/build/swift-ui/.


The transferable layer

Set aside @expo/ui and SwiftUI's specific APIs, and the truly transferable lesson is:

A cross-platform abstraction unifies "the code you write," but it can't unify "the form your code is rendered into on each platform" — the latter is a platform variable, not a component constant. One <BottomSheet> being a bottom drawer on iPhone and a centered popup on iPad is exactly this variable at work. Any "one code, many renderings" component (responsive web breakpoints, Flutter's adaptive widgets, RN's platform-specific defaults) shares this trap: the abstraction makes the code look platform-independent, and for exactly that reason tempts you to forget that the presentation layer is still forking by platform.

When designing this kind of interface, instead of assuming "what I write is what the user sees," first ask: on each device class I have to support, what is this component's real runtime form? Is my layout computed for the real form, or for the form I wanted? And don't forget — unit tests are mute on this class of problem; an environment that can render the real form (a real device, "Designed for iPad on Mac") is the only judge.