Skip to main content

iOS native feel

"Slick native iOS app" is a requirement, not a vibe. This page turns it into things you can check in a code review.

The rule

If iOS already does it, use iOS's version. Do not reimplement a system component in JavaScript because it would also work on Android.

React Native makes it easy to build a plausible version of every iOS control. Plausible is worse than plain: it misses the haptic, the accessibility behaviour, the Dynamic Type response, and the thing Apple changed last September. Every reimplemented control is a small, permanent bug.

Liquid Glass

Apple's material for floating surfaces: it refracts what is behind it, has a specular edge, and responds to motion. Introduced with the iOS 26 design language and available to us through expo-glass-effect.

We use it through one component, apps/mobile/src/components/glass-surface.tsx:

<GlassSurface variant="regular" interactive>
{children}
</GlassSurface>
  • regular — cards over lists and backgrounds
  • clear — controls floating over a topo photo, where you need to see the rock through them

The fallback is honest

isLiquidGlassAvailable() is false on older iOS and everywhere that is not iOS. There, the component renders a solid themed card with a hairline border — not a blurred translucent approximation.

That is deliberate. Glass without the specular edge and refraction does not read as "a simpler version of glass", it reads as a rendering bug. A clean opaque card looks intentional.

Where glass belongs

Use itDo not use it
The tab bar (native, automatic)Large blocks of body text
Cards in a listAnything behind a chart or dense data
Controls floating over a topo photoThe whole screen background
Sheets and popoversAnything where contrast is safety-relevant

The last row matters: an access-restriction warning gets a solid surface, because legibility in low sun beats material consistency.

Native tabs

expo-router's NativeTabs renders a real UITabBar, not a JS reimplementation. That gets us, for free:

  • Liquid Glass on the bar itself
  • minimizeBehavior="onScrollDown" — the bar shrinks as you scroll, the iOS 26 behaviour
  • The system's own accessibility handling, VoiceOver ordering, and Dynamic Type
  • Whatever Apple changes next year
<NativeTabs minimizeBehavior="onScrollDown">
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Crags</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf={{ default: 'mountain.2', selected: 'mountain.2.fill' }} />
</NativeTabs.Trigger>

</NativeTabs>

SF Symbols

Icons come from expo-symbols, not from an icon font or an SVG set.

Why: they are weight-matched to the system font, they respond to Dynamic Type, they have the right optical sizing at every scale, and the filled/unfilled pair for selected state is the iOS convention rather than a thing we invented.

Used in Stars (star.fill), the tab bar, and the tick indicator (checkmark.circle.fill). Each has a non-iOS fallback so the component does not explode off-platform, but the fallback is not the design.

  • Large titles with the system's collapse-on-scroll behaviour. headerLargeTitle: true.
  • Search in the navigation bar (headerSearchBarOptions), not a custom text field in the content. It gets the system's own show/hide behaviour and cancel button.
  • headerBackButtonDisplayMode: 'minimal' on deep screens, so a long crag name does not push the title off-centre.
  • Native stack via react-native-screens, so the interactive back-swipe is the real one.

The full checklist

Things that make an app feel native, in rough order of how much they matter here:

Status
Native tab bar with Liquid Glass and scroll-minimiseDone
SF Symbols throughoutDone
Large titles and native searchDone
Native stack, real back-swipeDone
Liquid Glass surfaces with honest fallbackDone
Haptics on meaningful actions — selecting a route line, logging a tickNot yet
Context menus on long-press — a route row should offer wishlist/log/shareNot yet
System share sheet for a route or a cragNot yet
Sheet detents — the route sheet over a topo should have medium and large stopsNot yet
Dynamic Type — verified at the largest accessibility sizesNot verified
Reduce Motion / Reduce Transparency respectedNot verified
VoiceOver pass over every screenNot done
Live Activities, widgets, WatchWishlist

The bottom half of that table is not decoration. Dynamic Type and VoiceOver are the difference between an app that feels native and one that merely looks it.

Specific opinions

Haptics are punctuation, not applause. A selection tap when you pick a route line, a success notification when a tick saves. Nothing on scroll, nothing on navigation.

Animations are the system's. Sheet presentation, tab transitions, the navigation push — all system. Reanimated is for things the system has no opinion about, like the topo line drawing itself in.

Dark mode is not a filter. The palette in apps/mobile/src/constants/theme.ts defines both schemes explicitly. Topo photos in particular need care: a photo of grey rock on a black background reads very differently from the same photo on white.

Respect the safe area, but not by padding everything. Scroll views use contentInsetAdjustmentBehavior="automatic" so content scrolls under the translucent bars, which is what makes glass look like glass. Padding the content to avoid the bars defeats the material.

What this costs

Being iOS-native this deliberately means:

  • Android would be real work, not a config flag. NativeTabs, Liquid Glass, and SF Symbols all need Android equivalents that are not drop-in. That is an accepted cost of ADR-0003, and reversing it needs a new ADR.
  • The web build is a build, not a product. It compiles; it is not designed.
  • We are tied to recent iOS. deploymentTarget: '18.0' in app.config.ts, and the Liquid Glass path needs newer still.