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 backgroundsclear— 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 it | Do not use it |
|---|---|
| The tab bar (native, automatic) | Large blocks of body text |
| Cards in a list | Anything behind a chart or dense data |
| Controls floating over a topo photo | The whole screen background |
| Sheets and popovers | Anything 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.
Navigation
- 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-minimise | Done |
| SF Symbols throughout | Done |
| Large titles and native search | Done |
| Native stack, real back-swipe | Done |
| Liquid Glass surfaces with honest fallback | Done |
| Haptics on meaningful actions — selecting a route line, logging a tick | Not yet |
| Context menus on long-press — a route row should offer wishlist/log/share | Not yet |
| System share sheet for a route or a crag | Not yet |
| Sheet detents — the route sheet over a topo should have medium and large stops | Not yet |
| Dynamic Type — verified at the largest accessibility sizes | Not verified |
| Reduce Motion / Reduce Transparency respected | Not verified |
| VoiceOver pass over every screen | Not done |
| Live Activities, widgets, Watch | Wishlist |
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'inapp.config.ts, and the Liquid Glass path needs newer still.
Related
- Mockups — what the screens look like
- Product principles — principle 4, native beats consistent
- ADR-0003