
Expo Ios Hig
- 79 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
expo-ios-hig is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
Key points
- expo-ios-hig
- AI & Agent Building
- AI-coding skill
Expo Ios Hig by the numbers
- 79 all-time installs (skills.sh)
- +9 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,283 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill expo-ios-higAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 79 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with expo-ios-hig.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when expo-ios-hig is a claude code skill for ai & agent building. it helps solo builders move faster with ai-assisted coding.
What you get
Structured output aligned to expo-ios-hig: expo-ios-hig; AI & Agent Building; AI-coding skill.
Files
Expo iOS HIG Best Practices
Design rules for building iOS 26 apps in Expo (React Native) that feel genuinely native under Apple's Human Interface Guidelines. Contains 46 rules across 8 categories, prioritized by how much each decision affects native feel and HIG compliance. Every example is TSX/Expo — the decisions you make in React Native and TypeScript, not Swift.
The core idea: a React Native app feels native when it reaches for the platform — the system navigation controller, system controls, semantic colors, SF Symbols, real haptics, the system share sheet — instead of re-implementing iOS in JavaScript. These rules say which decision earns the native feel and which one quietly forfeits it.
When to Apply
Reference these guidelines when:
- Building or reviewing iOS screens, navigation, lists, or forms in an Expo app
- Choosing navigation: Expo Router native stack vs JS stack, native tabs, sheets, modals, large titles
- Picking controls: when to use the native
Alert,ActionSheetIOS, date picker,Switch, SF Symbols, or drop to@expo/ui - Styling for the platform: safe areas, dark mode, semantic colors, the iOS type scale, spacing, Liquid Glass
- Making interaction feel native: touch targets, press feedback, swipe actions, pull-to-refresh, gestures, haptics
- Adding motion, loading/empty states, accessibility, permissions, and launch polish
When NOT to Use This Skill
- The `@expo/ui` component API — for how to use
@expo/ui/swift-uicomponents, Host boundaries, and modifiers, use the `expo-ui` skill. This skill says which decisions make an app native and points to@expo/uiwhen dropping to SwiftUI is the right call; it does not re-document that API. - The cross-platform design system itself — for the shared Unistyles theme, design tokens, variant-driven component APIs, and making components feel native on web as well as iOS, use the `expo-design-system` skill. This skill governs iOS native feel; that one governs the token and component architecture that the two frontends share.
- Native Swift / SwiftUI — for HIG in a native Swift codebase, use `ios-hig` or `ios-design`. This skill's examples are all React Native/TSX.
- Android — these rules target iOS conventions. Use platform-adaptive code so Android gets Material; don't apply iOS idioms there.
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Native Navigation Architecture | CRITICAL | nav- |
| 2 | Native Component Fidelity | CRITICAL | native- |
| 3 | Layout & Adaptivity | HIGH | layout- |
| 4 | Touch, Gestures & Haptics | HIGH | touch- |
| 5 | Visual System & Liquid Glass | HIGH | visual- |
| 6 | Motion & Feedback | MEDIUM-HIGH | motion- |
| 7 | Accessibility | MEDIUM-HIGH | acc- |
| 8 | System Integration & Polish | MEDIUM | system- |
Quick Reference
1. Native Navigation Architecture (CRITICAL)
- `nav-native-stack` — Use Expo Router's native Stack for screen hierarchy
- `nav-native-tabs` — Adopt native tabs for top-level sections
- `nav-large-titles` — Enable large titles on top-level screens
- `nav-sheet-detents` — Present secondary tasks as sheets with detents
- `nav-push-vs-present` — Push for hierarchy and present for self-contained tasks
- `nav-search-in-header` — Place search in the navigation bar
- `nav-system-back` — Keep the system back button and swipe-back gesture
2. Native Component Fidelity (CRITICAL)
- `native-avoid-material-ui` — Avoid Material Design component kits on iOS
- `native-sf-symbols` — Use SF Symbols for iconography
- `native-system-alert` — Use the native alert for confirmations
- `native-action-sheet` — Use an action sheet to choose among actions
- `native-datetime-picker` — Use the native date and time picker
- `native-switch-toggle` — Use the platform Switch for boolean settings
3. Layout & Adaptivity (HIGH)
- `layout-safe-area-insets` — Respect safe-area insets with the safe-area context
- `layout-edge-to-edge` — Extend scrollable content under translucent bars
- `layout-keyboard-avoidance` — Keep inputs visible above the keyboard
- `layout-dark-mode-semantic` — Drive colors from the active color scheme
- `layout-content-inset-under-bars` — Inset list content past the tab bar and home indicator
- `layout-readable-width-ipad` — Constrain reading width on iPad and large screens
4. Touch, Gestures & Haptics (HIGH)
- `touch-hit-target` — Size touch targets to at least 44pt
- `touch-pressable-feedback` — Give every control immediate press feedback
- `touch-swipe-actions` — Offer swipe actions on list rows
- `touch-pull-to-refresh` — Use RefreshControl for pull-to-refresh
- `touch-gesture-handler-thread` — Drive interactive gestures with Gesture Handler
- `touch-haptics-on-outcome` — Pair haptics with meaningful outcomes
5. Visual System & Liquid Glass (HIGH)
- `visual-semantic-colors` — Use semantic system colors instead of hardcoded hex
- `visual-system-font` — Use the system font for interface text
- `visual-type-scale` — Map text styles to the iOS type scale
- `visual-spacing-rhythm` — Derive spacing from a single base unit
- `visual-liquid-glass-gated` — Apply Liquid Glass through a version-gated native view
- `visual-tint-discipline` — Reserve the accent tint for interactive elements
6. Motion & Feedback (MEDIUM-HIGH)
- `motion-ui-thread-animation` — Run animations on the UI thread
- `motion-virtualized-lists` — Render long lists with a virtualized list
- `motion-loading-states` — Show content-shaped placeholders while loading
- `motion-empty-states` — Design empty states that guide the next action
- `motion-optimistic-updates` — Apply optimistic updates for user actions
7. Accessibility (MEDIUM-HIGH)
- `acc-roles-labels` — Label interactive and icon-only controls for VoiceOver
- `acc-dynamic-type` — Let text scale with Dynamic Type
- `acc-reduce-motion` — Honor the Reduce Motion setting
- `acc-state-and-hint` — Expose control state to assistive technology
- `acc-grouping-focus` — Group related elements for a logical focus order
8. System Integration & Polish (MEDIUM)
- `system-permissions-jit` — Request permissions just in time with a rationale
- `system-status-bar` — Match the status bar style to the content behind it
- `system-keyboard-type` — Configure each text field for its content
- `system-share-sheet` — Share through the system share sheet
- `system-app-icon-launch` — Configure a real app icon and launch screen
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions — Category structure and impact levels
- Rule template — Template for adding new rules
- Reference files:
references/{prefix}-{slug}.md
Each rule file contains a short explanation of why it matters for native feel, an Incorrect (non-native) TSX example, a Correct (native) example that is a minimal diff from it, and an authoritative reference URL. Where relevant, rules add a "When NOT to use this pattern" or "Alternative" section.
Related Skills
- `expo-ui` —
@expo/ui/swift-uicomponent API reference. Use it when the rules here point you to native SwiftUI components. - `ios-hig` / `ios-design` — HIG and Apple design principles for native Swift/SwiftUI codebases.
- `expo` — Expo React Native performance optimization, complementary to the motion/list rules here.
Expo (React Native) for iOS 26
Version 0.1.1 Expo iOS HIG May 2026
Note:
This document is mainly for agents and LLMs maintaining, generating, or reviewing Expo (React Native) for iOS 26 code. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Human Interface Guidelines and native-experience design rules for building iOS 26 apps in Expo (React Native). Contains 46 rules across 8 categories — native navigation, component fidelity, layout and adaptivity, touch and haptics, visual system and Liquid Glass, motion, accessibility, and system integration — each with TSX examples comparing a non-native implementation against a native one, prioritized by impact on native feel and HIG compliance. Designed to guide AI agents writing or reviewing Expo iOS code in TypeScript. Complements the expo-ui component-API reference, the cross-platform expo-design-system skill (shared tokens, component APIs, and web/iOS parity), and the native-Swift ios-hig skill.
---
Table of Contents
1. Native Navigation Architecture — CRITICAL
- 1.1 Adopt native tabs for top-level sections — CRITICAL (enables the system tab bar with SF Symbols and Liquid Glass)
- 1.2 Enable large titles on top-level screens — HIGH (enables large-title collapse and the scroll-edge appearance)
- 1.3 Keep the system back button and swipe-back gesture — HIGH (preserves the interactive swipe-back edge gesture)
- 1.4 Place search in the navigation bar — MEDIUM-HIGH (enables the native search field that hides on scroll)
- 1.5 Present secondary tasks as sheets with detents — HIGH (enables partial-height sheets with grabber and swipe-to-dismiss)
- 1.6 Push for hierarchy and present for self-contained tasks — HIGH (prevents broken back stacks and mismatched transitions)
- 1.7 Use Expo Router's native Stack for screen hierarchy — CRITICAL (preserves native push/pop, swipe-back, and Liquid Glass headers)
2. Native Component Fidelity — CRITICAL
- 2.1 Avoid Material Design component kits on iOS — CRITICAL (eliminates Android Material chrome on iOS)
- 2.2 Use an action sheet to choose among actions — HIGH (enables the system action sheet with destructive styling)
- 2.3 Use SF Symbols for iconography — CRITICAL (enables 6,900+ system symbols with weight and scale matching)
- 2.4 Use the native alert for confirmations — HIGH (preserves native alert appearance, haptics, and accessibility)
- 2.5 Use the native date and time picker — MEDIUM-HIGH (enables the native wheel and calendar pickers)
- 2.6 Use the platform Switch for boolean settings — MEDIUM-HIGH (preserves correct toggle size, animation, and accessibility)
3. Layout & Adaptivity — HIGH
- 3.1 Constrain reading width on iPad and large screens — MEDIUM (prevents over-stretched lines on iPad)
- 3.2 Drive colors from the active color scheme — HIGH (enables automatic light and dark adaptation)
- 3.3 Extend scrollable content under translucent bars — HIGH (enables the scroll-edge translucency of system bars)
- 3.4 Inset list content past the tab bar and home indicator — MEDIUM-HIGH (prevents the last row hidden behind the tab bar)
- 3.5 Keep inputs visible above the keyboard — HIGH (prevents inputs hidden behind the keyboard)
- 3.6 Respect safe-area insets with the safe-area context — HIGH (prevents content clipped by the notch and home indicator)
4. Touch, Gestures & Haptics — HIGH
- 4.1 Drive interactive gestures with Gesture Handler — HIGH (maintains 60fps gestures off the JS thread)
- 4.2 Give every control immediate press feedback — HIGH (prevents the double-tap from an unconfirmed press)
- 4.3 Offer swipe actions on list rows — MEDIUM-HIGH (eliminates an extra menu step for delete and archive)
- 4.4 Pair haptics with meaningful outcomes — MEDIUM (prevents overuse that dulls the Taptic signal)
- 4.5 Size touch targets to at least 44pt — HIGH (enables reliable taps with a 44pt minimum target)
- 4.6 Use RefreshControl for pull-to-refresh — MEDIUM-HIGH (enables the native pull-to-refresh spinner)
5. Visual System & Liquid Glass — HIGH
- 5.1 Apply Liquid Glass through a version-gated native view — HIGH (prevents crashes on pre-iOS-26 while enabling glass)
- 5.2 Derive spacing from a single base unit — MEDIUM (enables a consistent grid from one base unit)
- 5.3 Map text styles to the iOS type scale — MEDIUM-HIGH (enables a hierarchy mapped to iOS text styles)
- 5.4 Reserve the accent tint for interactive elements — MEDIUM (preserves a single, meaningful accent color)
- 5.5 Use semantic system colors instead of hardcoded hex — HIGH (enables colors that track appearance and contrast)
- 5.6 Use the system font for interface text — MEDIUM-HIGH (enables San Francisco with optical sizing and tracking)
6. Motion & Feedback — MEDIUM-HIGH
- 6.1 Apply optimistic updates for user actions — MEDIUM (eliminates round-trip latency on user actions)
- 6.2 Design empty states that guide the next action — MEDIUM (prevents dead-end blank screens)
- 6.3 Render long lists with a virtualized list — MEDIUM-HIGH (maintains 60fps scrolling over 10K+ rows)
- 6.4 Run animations on the UI thread — MEDIUM-HIGH (maintains 60fps by animating off the JS thread)
- 6.5 Show content-shaped placeholders while loading — MEDIUM (reduces perceived wait with content-shaped placeholders)
7. Accessibility — MEDIUM-HIGH
- 7.1 Expose control state to assistive technology — MEDIUM (enables VoiceOver to announce state and outcome)
- 7.2 Group related elements for a logical focus order — MEDIUM (enables a logical, predictable focus order)
- 7.3 Honor the Reduce Motion setting — MEDIUM (prevents vestibular discomfort from animation)
- 7.4 Label interactive and icon-only controls for VoiceOver — MEDIUM-HIGH (enables VoiceOver to announce purpose and role)
- 7.5 Let text scale with Dynamic Type — MEDIUM-HIGH (enables text scaling up to 310% for low vision)
8. System Integration & Polish — MEDIUM
- 8.1 Configure a real app icon and launch screen — LOW-MEDIUM (prevents shipping the placeholder icon and splash)
- 8.2 Configure each text field for its content — MEDIUM (enables the correct keyboard and autofill per field)
- 8.3 Match the status bar style to the content behind it — MEDIUM (prevents an invisible status bar over content)
- 8.4 Request permissions just in time with a rationale — MEDIUM (enables higher opt-in with just-in-time prompts)
- 8.5 Share through the system share sheet — MEDIUM (enables the system share sheet and its extensions)
---
References
1. https://developer.apple.com/design/human-interface-guidelines/ 2. https://developer.apple.com/documentation/TechnologyOverviews/liquid-glass 3. https://docs.expo.dev/router/advanced/stack/ 4. https://docs.expo.dev/router/advanced/native-tabs/ 5. https://docs.expo.dev/develop/user-interface/color-themes/ 6. https://docs.expo.dev/develop/user-interface/safe-areas/ 7. https://docs.expo.dev/versions/latest/sdk/glass-effect/ 8. https://docs.expo.dev/versions/latest/sdk/symbols/ 9. https://docs.expo.dev/versions/latest/sdk/haptics/ 10. https://docs.expo.dev/versions/latest/sdk/status-bar/ 11. https://docs.expo.dev/develop/user-interface/splash-screen-and-app-icon/ 12. https://expo.dev/blog/how-to-create-apple-maps-style-liquid-glass-sheets 13. https://reactnative.dev/docs/accessibility 14. https://reactnative.dev/docs/platformcolor 15. https://reactnative.dev/docs/pressable 16. https://docs.swmansion.com/react-native-reanimated/docs/guides/performance/ 17. https://docs.swmansion.com/react-native-gesture-handler/docs/ 18. https://shopify.github.io/flash-list/docs/ 19. https://github.com/react-native-datetimepicker/datetimepicker 20. https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Imperative verb} {object} {context}
{1-3 sentences explaining WHY this matters for native feel — what reads as non-native or breaks when you don't follow it, in concrete terms an agent can generalize from. Name the platform behavior that is lost (the swipe-back gesture, the scroll-edge blur, VoiceOver's announcement) rather than asserting "it feels better".}
Incorrect ({the non-native choice}):
// Production-realistic Expo/RN code an agent might actually write.
// One comment explaining what native behavior is forfeited.Correct ({the native choice}):
// Minimal diff from the incorrect example — same names, same structure.
// One comment explaining what native behavior is now inherited.{Optional sections, only when they add value:}
Alternative ({context}): {A second valid approach, e.g. dropping to @expo/ui for cross-platform parity.}
When NOT to use this pattern:
- {A real exception where the "incorrect" choice is actually right.}
Reference: {Authoritative title}
## Authoring conventions for this skill
- **Examples are TSX/Expo, never Swift.** Show the React Native / TypeScript decision. When the
right answer is to bridge to native, point to `@expo/ui`, `expo-glass-effect`, or `expo-symbols`
rather than re-documenting their APIs (that is the `expo-ui` skill's job).
- **The title starts with an imperative verb** (Use, Avoid, Adopt, Respect, Keep, Enable…) and
matches the H2 exactly. The first tag is the category prefix from `_sections.md`.
- **The "incorrect" example is a genuine anti-pattern** an Expo developer would plausibly write —
not a strawman — and the "correct" example is a minimal diff that a reviewer could paste in.
- **Anchor names in a realistic domain** (trails, reviews, saved items). No `foo`, `bar`,
`MyComponent`, `data`, `temp`.
- **Reference current, authoritative sources**: Apple HIG, Expo docs, React Native docs, or the
maintainer docs of a named library (Reanimated, Gesture Handler, FlashList).
{
"version": "0.1.1",
"organization": "Expo iOS HIG",
"technology": "Expo (React Native) for iOS 26",
"discipline": "distillation",
"type": "code-quality",
"date": "May 2026",
"abstract": "Human Interface Guidelines and native-experience design rules for building iOS 26 apps in Expo (React Native). Contains 46 rules across 8 categories — native navigation, component fidelity, layout and adaptivity, touch and haptics, visual system and Liquid Glass, motion, accessibility, and system integration — each with TSX examples comparing a non-native implementation against a native one, prioritized by impact on native feel and HIG compliance. Designed to guide AI agents writing or reviewing Expo iOS code in TypeScript. Complements the expo-ui component-API reference, the cross-platform expo-design-system skill (shared tokens, component APIs, and web/iOS parity), and the native-Swift ios-hig skill.",
"references": [
"https://developer.apple.com/design/human-interface-guidelines/",
"https://developer.apple.com/documentation/TechnologyOverviews/liquid-glass",
"https://docs.expo.dev/router/advanced/stack/",
"https://docs.expo.dev/router/advanced/native-tabs/",
"https://docs.expo.dev/develop/user-interface/color-themes/",
"https://docs.expo.dev/develop/user-interface/safe-areas/",
"https://docs.expo.dev/versions/latest/sdk/glass-effect/",
"https://docs.expo.dev/versions/latest/sdk/symbols/",
"https://docs.expo.dev/versions/latest/sdk/haptics/",
"https://docs.expo.dev/versions/latest/sdk/status-bar/",
"https://docs.expo.dev/develop/user-interface/splash-screen-and-app-icon/",
"https://expo.dev/blog/how-to-create-apple-maps-style-liquid-glass-sheets",
"https://reactnative.dev/docs/accessibility",
"https://reactnative.dev/docs/platformcolor",
"https://reactnative.dev/docs/pressable",
"https://docs.swmansion.com/react-native-reanimated/docs/guides/performance/",
"https://docs.swmansion.com/react-native-gesture-handler/docs/",
"https://shopify.github.io/flash-list/docs/",
"https://github.com/react-native-datetimepicker/datetimepicker",
"https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
These rules govern how an Expo (React Native) app, written in TypeScript, should be built so it feels like a genuine iOS 26 app under Apple's Human Interface Guidelines. Code examples are TSX/Expo — not Swift. Where dropping to native is the right call, the rules point to @expo/ui, expo-glass-effect, and expo-symbols rather than re-document their APIs.
---
1. Native Navigation Architecture (nav)
Impact: CRITICAL Description: Navigation is the frame every screen sits inside. A JavaScript-faked stack or tab bar reads as a web wrapper no matter how polished the screens are — gestures lag, headers don't blur, large titles don't collapse, and iOS 26 Liquid Glass chrome never appears. Choosing Expo Router's native stack and native tabs is the single decision that decides whether the app feels native.
2. Native Component Fidelity (native)
Impact: CRITICAL Description: Re-implementing iOS controls in JavaScript (custom switches, web-style dropdowns, Material dialogs) makes every interaction fight muscle memory users already have. Reaching for the platform's own Alert, action sheet, date picker, switch, context menu, share sheet, and SF Symbols inherits correct behavior, animation, accessibility, and appearance for free across every iOS version.
3. Layout & Adaptivity (layout)
Impact: HIGH Description: Content that ignores the safe area, the keyboard, dark mode, or larger text sizes breaks on real hardware the moment it leaves the simulator default. Safe-area insets, edge-to-edge backgrounds, keyboard avoidance, semantic colors, and content insets are what let one layout adapt across notches, Dynamic Island, orientation, and appearance modes.
4. Touch, Gestures & Haptics (touch)
Impact: HIGH Description: The native feel of iOS lives in the hand: 44pt targets, instant press feedback, swipe-to-delete, pull-to-refresh, the interactive swipe-back edge, and haptics that confirm outcomes. Gestures driven from the JavaScript thread stutter under load; the platform's own gesture and haptic primitives keep interaction crisp and predictable.
5. Visual System & Liquid Glass (visual)
Impact: HIGH Description: A coherent visual system — semantic system colors that track appearance, the system font and its Dynamic Type scale, a consistent spacing rhythm, restrained tint, and correctly applied iOS 26 materials and Liquid Glass — is what makes screens look like they belong to the OS. Hardcoded hex, ad-hoc spacing, and faked blur are the tells of a non-native app.
6. Motion & Feedback (motion)
Impact: MEDIUM-HIGH Description: Motion communicates state and spatial relationships, but only at 60/120fps. Animations driven on the JavaScript thread drop frames under load; running them on the UI thread with worklets keeps them smooth. Honest loading, empty, and error states — plus optimistic updates — make the app feel responsive even when the network is not.
7. Accessibility (acc)
Impact: MEDIUM-HIGH Description: Accessibility is an App Store quality bar and reaches the large share of users who rely on VoiceOver, larger text, or reduced motion. Accessibility roles and labels, Dynamic Type that is allowed to scale, Reduce Motion alternatives, sufficient contrast, and a logical focus order are what make an Expo app usable by everyone, not just the developer.
8. System Integration & Polish (system)
Impact: MEDIUM Description: The final layer that signals craft: permission prompts requested just in time with honest purpose strings, a correct status bar and appearance, a real app icon and launch experience, the right keyboard for each field, and deep links that resume context. These details are what separate a shipped product from a prototype.
Let text scale with Dynamic Type
Many users raise the system text size because they cannot read the default, and iOS expects apps to honor that. Setting allowFontScaling={false} to protect a layout breaks the app for exactly those users — the text stays small and unreadable. Leave scaling on and instead cap it with maxFontSizeMultiplier where a layout genuinely can't absorb the largest accessibility sizes, so text still grows but within bounds you control.
Incorrect (disable scaling to protect the layout):
import { Text } from 'react-native';
// Freezes text at the default size — unreadable for users who enabled larger text
function TrailName({ trail }: { trail: Trail }) {
return <Text allowFontScaling={false} style={{ fontSize: 17 }}>{trail.name}</Text>;
}Correct (allow scaling, cap the multiplier):
import { Text } from 'react-native';
// Text scales with the user's setting, capped where the row can't grow further
function TrailName({ trail }: { trail: Trail }) {
return <Text maxFontSizeMultiplier={1.8} style={{ fontSize: 17 }}>{trail.name}</Text>;
}Reference: Apple HIG — Typography (Dynamic Type)
Group related elements for a logical focus order
By default VoiceOver focuses each Text and image separately, so a card with a title, distance, and difficulty becomes three disconnected stops the user must swipe through before understanding they belong together. Marking the card container as accessible collapses it into one focusable element with a combined label, so VoiceOver reads "Eagle Ridge Trail, 8 km, hard" in a single, logical stop that matches the visual grouping.
Incorrect (every child is a separate stop):
import { Pressable, Text } from 'react-native';
// VoiceOver stops on each line separately, fragmenting one card into three swipes
function TrailRow({ trail }: { trail: Trail }) {
return (
<Pressable onPress={() => openTrail(trail.id)}>
<Text>{trail.name}</Text>
<Text>{trail.distanceLabel}</Text>
<Text>{trail.difficulty}</Text>
</Pressable>
);
}Correct (one grouped, focusable element):
import { Pressable, Text } from 'react-native';
// One focus stop with a combined label that matches the visual grouping
function TrailRow({ trail }: { trail: Trail }) {
return (
<Pressable
onPress={() => openTrail(trail.id)}
accessible
accessibilityRole="button"
accessibilityLabel={`${trail.name}, ${trail.distanceLabel}, ${trail.difficulty}`}
>
<Text>{trail.name}</Text>
<Text>{trail.distanceLabel}</Text>
<Text>{trail.difficulty}</Text>
</Pressable>
);
}Reference: React Native — Accessibility
Honor the Reduce Motion setting
Large parallax, zoom, and slide animations can cause real nausea and dizziness for users with vestibular sensitivity, which is why iOS exposes a Reduce Motion setting. An app that animates regardless ignores an explicit accessibility request. Read the setting with useReducedMotion() (or AccessibilityInfo) and substitute a cross-fade or an instant change for the large movement, keeping the same outcome without the motion.
Incorrect (always run the large animation):
import { withSpring } from 'react-native-reanimated';
// Big slide-and-scale regardless of the user's Reduce Motion setting
function presentTrailCard(translateY: SharedValue<number>) {
translateY.value = withSpring(0, { damping: 12 });
}Correct (substitute a calm transition when requested):
import { withSpring, withTiming, useReducedMotion } from 'react-native-reanimated';
// Falls back to a quick fade-equivalent timing when Reduce Motion is on
function usePresentTrailCard(translateY: SharedValue<number>) {
const reduceMotion = useReducedMotion();
return () => {
translateY.value = reduceMotion ? withTiming(0, { duration: 120 }) : withSpring(0, { damping: 12 });
};
}Reference: Apple HIG — Motion
Label interactive and icon-only controls for VoiceOver
VoiceOver reads the accessibility tree, not the pixels. An icon-only button with no label is announced as nothing useful — the user hears "button" with no idea what it does — and a tappable View with no role isn't announced as actionable at all. Setting accessibilityRole and a concise accessibilityLabel lets VoiceOver say "Save trail, button," which is the difference between usable and unusable for screen-reader users.
Incorrect (icon button with no label or role):
import { Pressable } from 'react-native';
import { SymbolView } from 'expo-symbols';
// VoiceOver announces "button" with no purpose — the glyph means nothing to it
function SaveButton() {
return (
<Pressable onPress={saveTrail}>
<SymbolView name="bookmark" size={24} />
</Pressable>
);
}Correct (explicit role and label):
import { Pressable } from 'react-native';
import { SymbolView } from 'expo-symbols';
// VoiceOver announces "Save trail, button"
function SaveButton() {
return (
<Pressable onPress={saveTrail} accessibilityRole="button" accessibilityLabel="Save trail">
<SymbolView name="bookmark" size={24} />
</Pressable>
);
}Reference: React Native — Accessibility
Expose control state to assistive technology
A control's selected, disabled, or busy state is obvious sighted users but invisible to VoiceOver unless you report it through accessibilityState. A filter chip that looks selected but doesn't announce "selected," or a disabled button that VoiceOver still says is tappable, misleads screen-reader users. Set accessibilityState to mirror the visual state, and add an accessibilityHint when the result of an action isn't obvious from its label.
Incorrect (visual state only):
import { Pressable, Text } from 'react-native';
// Looks selected, but VoiceOver never announces the selected state
function DifficultyChip({ label, selected, onPress }: ChipProps) {
return (
<Pressable onPress={onPress} style={[styles.chip, selected && styles.chipSelected]}>
<Text>{label}</Text>
</Pressable>
);
}Correct (state reported to VoiceOver):
import { Pressable, Text } from 'react-native';
// VoiceOver announces "Hard, selected" and the hint clarifies the effect
function DifficultyChip({ label, selected, onPress }: ChipProps) {
return (
<Pressable
onPress={onPress}
style={[styles.chip, selected && styles.chipSelected]}
accessibilityRole="button"
accessibilityState={{ selected }}
accessibilityHint="Filters trails by this difficulty"
>
<Text>{label}</Text>
</Pressable>
);
}Reference: React Native — Accessibility
Inset list content past the tab bar and home indicator
A list that scrolls edge-to-edge under a translucent tab bar looks right while scrolling, but the final rows must be reachable above the bar and the home indicator — otherwise the last item sits permanently half-hidden and untappable. Add bottom content padding equal to the bottom safe-area inset (plus the bar height) so the list bottoms out cleanly.
Incorrect (no bottom inset, last row trapped under the bar):
import { FlatList } from 'react-native';
// The last trail sits under the translucent tab bar and can't be fully tapped
function SavedTrailsScreen() {
return <FlatList data={savedTrails} renderItem={renderTrailRow} />;
}Correct (bottom inset clears the bar and home indicator):
import { FlatList } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
function SavedTrailsScreen() {
const insets = useSafeAreaInsets();
return (
<FlatList
data={savedTrails}
renderItem={renderTrailRow}
// Last row clears the translucent bar and the home indicator
contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
/>
);
}Reference: React Native — ScrollView contentInsetAdjustmentBehavior
Drive colors from the active color scheme
iOS users switch between light and dark — often on a schedule — and an app that ignores the setting glares white in a dark room or washes out in daylight. Hardcoding a single palette forces one appearance on everyone. Read the live scheme with useColorScheme() and resolve colors from a theme so every surface flips automatically, including when the user toggles mid-session.
Incorrect (single hardcoded palette):
import { View, Text } from 'react-native';
// Always light: blinding in dark mode, and never tracks the user's setting
function TrailCard({ trail }: { trail: Trail }) {
return (
<View style={{ backgroundColor: '#ffffff' }}>
<Text style={{ color: '#000000' }}>{trail.name}</Text>
</View>
);
}Correct (theme resolved from the active scheme):
import { View, Text, useColorScheme } from 'react-native';
// Surfaces and text flip with the system setting, including live toggles
function TrailCard({ trail }: { trail: Trail }) {
const theme = useColorScheme() === 'dark' ? darkTheme : lightTheme;
return (
<View style={{ backgroundColor: theme.surface }}>
<Text style={{ color: theme.label }}>{trail.name}</Text>
</View>
);
}Alternative (let the system resolve per-color):
PlatformColor('label') and PlatformColor('systemBackground') resolve to the correct value for the current appearance without a manual theme object — see the semantic-colors rule.
Reference: Expo — Color themes
Extend scrollable content under translucent bars
On iOS the navigation and tab bars are translucent and content is meant to scroll underneath them, which is what produces the blur-on-scroll and, on iOS 26, the Liquid Glass scroll-edge effect. Insetting the scroll view to start below an opaque bar wastes the safe area, kills the blur, and makes the bar look pasted on. Let content go edge-to-edge and ask the system to inset the scroll indicators automatically.
Incorrect (manual top inset under an opaque bar):
import { ScrollView } from 'react-native';
// Content starts below the bar, so nothing scrolls under it and the bar never blurs
function TrailsScreen() {
return (
<ScrollView contentContainerStyle={{ paddingTop: 96 }}>
<TrailList />
</ScrollView>
);
}Correct (content under the bar, system-managed insets):
import { ScrollView } from 'react-native';
// Content scrolls under the translucent bar; the system insets indicators and content
function TrailsScreen() {
return (
<ScrollView contentInsetAdjustmentBehavior="automatic">
<TrailList />
</ScrollView>
);
}Reference: Apple HIG — Layout
Keep inputs visible above the keyboard
When the keyboard appears it covers the lower half of the screen. A form that doesn't react leaves the focused field — and the submit button — hidden behind the keyboard, so the user types blind. KeyboardAvoidingView with behavior="padding" lifts the content on iOS, and pairing it with a keyboard-dismissing scroll view lets the user scroll the form while typing.
Incorrect (static form ignores the keyboard):
import { View, TextInput, Button } from 'react-native';
// When the keyboard opens, the notes field and Save button vanish behind it
function ReviewForm() {
return (
<View style={styles.form}>
<TextInput placeholder="Notes about this hike" multiline />
<Button title="Save" onPress={saveReview} />
</View>
);
}Correct (content lifts above the keyboard):
import { KeyboardAvoidingView, ScrollView, TextInput, Button } from 'react-native';
// Content lifts on iOS; the scroll view stays usable while the keyboard is up
function ReviewForm() {
return (
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<ScrollView keyboardDismissMode="interactive">
<TextInput placeholder="Notes about this hike" multiline />
<Button title="Save" onPress={saveReview} />
</ScrollView>
</KeyboardAvoidingView>
);
}Reference: React Native — KeyboardAvoidingView
Constrain reading width on iPad and large screens
A phone layout stretched full-width on iPad produces lines of text far past the comfortable reading measure and forms whose fields span the whole screen, both of which feel like a blown-up phone app rather than an iPad app. Cap the content width (roughly 700pt for body text) and center it, or move to a multi-column layout, so large screens earn their space.
Incorrect (phone layout stretched edge-to-edge):
import { ScrollView, Text } from 'react-native';
// On iPad each line runs the full width — well past a readable measure
function TrailDescription({ trail }: { trail: Trail }) {
return (
<ScrollView contentContainerStyle={{ padding: 16 }}>
<Text>{trail.longDescription}</Text>
</ScrollView>
);
}Correct (centered, width-capped reading column):
import { ScrollView, Text } from 'react-native';
// Body text caps at a readable measure and centers on wide screens
function TrailDescription({ trail }: { trail: Trail }) {
return (
<ScrollView contentContainerStyle={{ padding: 16, alignItems: 'center' }}>
<Text style={{ maxWidth: 700, width: '100%' }}>{trail.longDescription}</Text>
</ScrollView>
);
}Reference: Apple HIG — Layout
Respect safe-area insets with the safe-area context
The status bar, Dynamic Island, and home indicator carve into the screen, and those regions differ across every device. Hardcoding top or bottom padding gets one device right and clips content or floats it awkwardly on the rest. react-native-safe-area-context reports the live insets for the current device and orientation, so a single layout adapts everywhere — and SafeAreaView applies them declaratively.
Incorrect (hardcoded device-specific padding):
import { View } from 'react-native';
// 44/34 are correct on one phone and wrong on every other device and orientation
function TrailDetailScreen() {
return <View style={{ paddingTop: 44, paddingBottom: 34 }}><TrailHeader /></View>;
}Correct (live insets from the safe-area context):
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// Real insets for this device and orientation, reported by the system
function TrailDetailScreen() {
const insets = useSafeAreaInsets();
return (
<View style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}>
<TrailHeader />
</View>
);
}When NOT to use this pattern:
- Screens hosted in a native stack/tab with headers already applied — the navigator handles top/bottom insets; applying them again double-pads.
Reference: Expo — Safe areas
Design empty states that guide the next action
The first time a user opens a list it is empty, and a blank screen reads as broken or stuck. An empty state that names what belongs here and offers the action to create it turns a dead end into an on-ramp — it is often a user's first real interaction with the feature. Render a deliberate empty state with an icon, a short explanation, and a primary action, distinct from the loading and error states.
Incorrect (empty list renders nothing):
import { FlatList } from 'react-native';
// With no saved trails the user sees a blank screen and no way forward
function SavedTrailsScreen({ savedTrails }: { savedTrails: Trail[] }) {
return <FlatList data={savedTrails} renderItem={renderTrailRow} />;
}Correct (guiding empty state):
import { FlatList } from 'react-native';
import { EmptyState } from '../components/EmptyState';
// Names what belongs here and offers the action to fill it
function SavedTrailsScreen({ savedTrails }: { savedTrails: Trail[] }) {
return (
<FlatList
data={savedTrails}
renderItem={renderTrailRow}
ListEmptyComponent={
<EmptyState
symbol="bookmark"
title="No saved trails yet"
actionLabel="Browse trails"
onAction={goToTrails}
/>
}
/>
);
}Reference: Apple HIG — Onboarding
Show content-shaped placeholders while loading
A full-screen spinner tells the user nothing about what is coming and makes the wait feel longer because the screen is empty until everything arrives. A skeleton that mirrors the eventual layout previews the structure, so the content appears to "fill in" rather than pop in, which reads as faster even at the same latency. Reserve spinners for short, indeterminate waits and use skeletons for primary content.
Incorrect (blank screen behind a centered spinner):
import { ActivityIndicator, View } from 'react-native';
// Empty screen until everything loads; the wait feels long and contentless
function TrailsScreen({ isLoading, trails }: TrailsScreenProps) {
if (isLoading) return <View style={styles.center}><ActivityIndicator /></View>;
return <TrailList trails={trails} />;
}Correct (skeleton mirrors the final layout):
import { TrailListSkeleton } from '../components/TrailListSkeleton';
// Skeleton rows preview the layout, so content fills in instead of popping in
function TrailsScreen({ isLoading, trails }: TrailsScreenProps) {
if (isLoading) return <TrailListSkeleton rows={8} />;
return <TrailList trails={trails} />;
}Reference: Apple HIG — Loading
Apply optimistic updates for user actions
When a user taps Save or Like, native apps reflect the change instantly and reconcile with the server in the background — the interface never makes the user wait on the network to see their own action. Disabling the control and showing a spinner until the request returns makes the app feel sluggish on anything but a fast connection. Update local state immediately, then roll back if the request fails.
Incorrect (block the UI on the network round-trip):
async function onToggleSave(trail: Trail) {
setSaving(true); // spinner until the server responds — feels slow
await api.saveTrail(trail.id);
setSaved(true);
setSaving(false);
}Correct (optimistic update with rollback):
async function onToggleSave(trail: Trail) {
setSaved(true); // reflect the tap immediately
try {
await api.saveTrail(trail.id);
} catch {
setSaved(false); // reconcile by rolling back on failure
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
}When NOT to use this pattern:
- Irreversible or high-stakes actions (payments, deletions with no undo) — there, confirm and show real progress instead.
Reference: TanStack Query — Optimistic Updates
Run animations on the UI thread
The JavaScript thread also handles your component renders, network callbacks, and state updates, so an animation driven by setState or Animated without the native driver stutters whenever JS is busy — exactly when a screen is loading and animating at the same time. Reanimated runs the animation in a worklet on the UI thread, decoupled from JS, so it holds 60fps under load. iOS animations never drop frames; matching that is what makes motion feel native.
Incorrect (animation tied to the JS thread):
import { useState, useEffect } from 'react';
import { View } from 'react-native';
// Re-rendering every frame on the JS thread; janks while the list loads
function FadeInCard({ children }: { children: React.ReactNode }) {
const [opacity, setOpacity] = useState(0);
useEffect(() => {
const id = setInterval(() => setOpacity((o) => Math.min(o + 0.1, 1)), 16);
return () => clearInterval(id);
}, []);
return <View style={{ opacity }}>{children}</View>;
}Correct (worklet animation on the UI thread):
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated';
import { useEffect } from 'react';
// Animation runs in a UI-thread worklet, so it stays smooth while JS is busy
function FadeInCard({ children }: { children: React.ReactNode }) {
const opacity = useSharedValue(0);
useEffect(() => { opacity.value = withTiming(1, { duration: 250 }); }, []);
const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
return <Animated.View style={style}>{children}</Animated.View>;
}Reference: React Native Reanimated — Performance
Render long lists with a virtualized list
Mapping an array into a ScrollView mounts every row at once, so a list of a few hundred items allocates hundreds of views up front, spikes memory, and drops frames while scrolling. A virtualized list only renders the rows near the viewport and recycles them. FlashList recycles row views for sustained 60fps even over thousands of items, where a naive ScrollView already stutters in the hundreds.
Incorrect (map every row into a ScrollView):
import { ScrollView } from 'react-native';
// Mounts every trail at once: memory spike and dropped frames while scrolling
function AllTrailsScreen({ trails }: { trails: Trail[] }) {
return (
<ScrollView>
{trails.map((trail) => <TrailRow key={trail.id} trail={trail} />)}
</ScrollView>
);
}Correct (virtualized, recycling list):
import { FlashList } from '@shopify/flash-list';
// Only viewport rows are rendered and recycled; holds 60fps over large lists
function AllTrailsScreen({ trails }: { trails: Trail[] }) {
return (
<FlashList
data={trails}
keyExtractor={(trail) => trail.id}
renderItem={({ item }) => <TrailRow trail={item} />}
/>
);
}Reference: FlashList documentation
Use an action sheet to choose among actions
When a user picks one action from a short list tied to a specific item (Share, Duplicate, Delete), iOS uses an action sheet anchored to the bottom on iPhone, with the destructive option in red and Cancel separated below. A custom bottom popover of Pressables misses the anchoring rules, the destructive styling, the swipe-to-dismiss, and the VoiceOver grouping. ActionSheetIOS renders the system control.
Incorrect (custom bottom list of buttons):
import { View, Pressable, Text } from 'react-native';
// Custom sheet: no destructive styling, no separated Cancel, no swipe-to-dismiss
function TrailActions() {
return (
<View style={styles.sheet}>
<Pressable onPress={shareTrail}><Text>Share</Text></Pressable>
<Pressable onPress={deleteTrail}><Text>Delete</Text></Pressable>
<Pressable onPress={close}><Text>Cancel</Text></Pressable>
</View>
);
}Correct (system action sheet):
import { ActionSheetIOS } from 'react-native';
// System sheet: red destructive option, separated Cancel, native dismissal
function showTrailActions() {
ActionSheetIOS.showActionSheetWithOptions(
{ options: ['Cancel', 'Share', 'Delete'], destructiveButtonIndex: 2, cancelButtonIndex: 0 },
(index) => {
if (index === 1) shareTrail();
if (index === 2) deleteTrail();
},
);
}Alternative (cross-platform):
For a single component that also renders correctly on Android, use @expo/ui's presentation surfaces rather than ActionSheetIOS, which is iOS-only.
Reference: Apple HIG — Action sheets
Avoid Material Design component kits on iOS
Material-first libraries (react-native-paper, MUI-style kits) render Android conventions: floating action buttons, ripple feedback, filled text fields with floating labels, bottom navigation with the wrong height, and Roboto-flavored typography. On iOS these read as a port of an Android app. Choose iOS-native primitives — or a platform-adaptive layer — so each platform gets its own conventions instead of one platform's conventions everywhere.
Incorrect (Material components on iOS):
import { FAB, Card, TextInput } from 'react-native-paper';
// Floating action button, ripple, and floating-label field are Android idioms
function NewTrailForm() {
return (
<Card>
<TextInput label="Trail name" mode="outlined" />
<FAB icon="plus" onPress={saveTrail} />
</Card>
);
}Correct (iOS-native primitives):
import { View, TextInput, Button } from 'react-native';
// Standard iOS field and a bar button action match platform expectations
function NewTrailForm() {
return (
<View style={styles.formGroup}>
<TextInput placeholder="Trail name" style={styles.field} />
<Button title="Save" onPress={saveTrail} />
</View>
);
}Alternative (one codebase, two platforms):
Use Platform.select or a platform-adaptive design system so iOS renders iOS controls and Android renders Material — rather than forcing Material onto both.
Reference: Apple HIG — Designing for iOS
Use the native date and time picker
Dates and times are a solved problem on iOS: the system picker handles locale-aware formatting, the calendar and wheel styles, minimum and maximum bounds, and the inline compact presentation in iOS 14+. A scroll list of strings or a custom wheel reimplements all of this, gets locale and time zones subtly wrong, and is hard to make accessible. @react-native-community/datetimepicker bridges the real UIDatePicker.
Incorrect (custom string list for a date):
import { ScrollView, Pressable, Text } from 'react-native';
// Hand-built list ignores locale formatting, bounds, and the native compact style
function HikeDatePicker({ dates }: { dates: string[] }) {
return (
<ScrollView>
{dates.map((d) => (
<Pressable key={d} onPress={() => setHikeDate(d)}><Text>{d}</Text></Pressable>
))}
</ScrollView>
);
}Correct (native UIDatePicker):
import DateTimePicker from '@react-native-community/datetimepicker';
// System picker: locale-aware, bounded, with the native compact presentation
function HikeDatePicker() {
return (
<DateTimePicker
value={hikeDate}
mode="date"
display="compact"
minimumDate={new Date()}
onChange={(_, date) => date && setHikeDate(date)}
/>
);
}Reference: react-native-datetimepicker
Use SF Symbols for iconography
SF Symbols are Apple's system icon set: they align to the text baseline, scale with Dynamic Type, match the current font weight, and ship with the exact glyphs users see throughout iOS. Bitmap PNGs or a generic icon font (Material Icons, FontAwesome) sit at a fixed size, ignore weight and Dynamic Type, and look foreign next to the system bars and menus. expo-symbols renders the real symbols on iOS and falls back to Material Symbols on Android.
Incorrect (bitmap or generic icon font):
import { Image } from 'react-native';
// Fixed-size PNG: ignores font weight and Dynamic Type, and ships extra assets
function SaveButton() {
return <Image source={require('../assets/bookmark.png')} style={{ width: 24, height: 24 }} />;
}Correct (SF Symbol via expo-symbols):
import { SymbolView } from 'expo-symbols';
// System symbol: matches weight, scales with Dynamic Type, no bundled asset
function SaveButton() {
return <SymbolView name="bookmark.fill" tintColor="#007aff" size={24} weight="semibold" />;
}Reference: Expo — Symbols (expo-symbols) · Apple HIG — SF Symbols
Use the platform Switch for boolean settings
The iOS switch has a specific size, thumb spring animation, green "on" tint, selection haptic, and a switch accessibility trait that VoiceOver announces as "on/off". A custom toggle built from an animated View reproduces the look but rarely the haptic, the exact timing, or the accessibility trait — so it reads as almost-right, which is worse than obviously custom. Use the platform Switch.
Incorrect (custom animated toggle):
import { Pressable, Animated } from 'react-native';
// Looks like a switch but lacks the selection haptic and the switch a11y trait
function OfflineToggle({ on, onToggle }: ToggleProps) {
return (
<Pressable onPress={onToggle} style={styles.track}>
<Animated.View style={[styles.thumb, on && styles.thumbOn]} />
</Pressable>
);
}Correct (platform Switch):
import { Switch } from 'react-native';
// Native switch: correct size, thumb spring, tint, haptic, and switch a11y trait
function OfflineToggle({ on, onToggle }: ToggleProps) {
return <Switch value={on} onValueChange={onToggle} />;
}Reference: Apple HIG — Toggles
Use the native alert for confirmations
A custom JS-rendered dialog has to reimplement the blurred backdrop, the spring presentation, button ordering conventions, destructive-red styling, VoiceOver focus trapping, and the haptic that fires on appearance — and it usually gets several of these wrong. The platform Alert inherits all of it and guarantees the layout users already know (cancel on the left, default action bold on the right).
Incorrect (custom modal as a confirmation):
import { Modal, View, Text, Pressable } from 'react-native';
// Reimplements backdrop, button order, destructive styling, and focus trapping —
// and breaks VoiceOver focus on present
<Modal transparent visible={confirming} animationType="fade">
<View style={styles.backdrop}>
<Text>Delete this saved trail?</Text>
<Pressable onPress={deleteTrail}><Text>Delete</Text></Pressable>
<Pressable onPress={cancel}><Text>Cancel</Text></Pressable>
</View>
</Modal>Correct (system Alert with destructive role):
import { Alert } from 'react-native';
// Native alert: correct button order, red destructive style, VoiceOver focus, haptic
function confirmDelete(trailId: string) {
Alert.alert('Delete saved trail?', 'This removes it from your saved list.', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: () => deleteTrail(trailId) },
]);
}When NOT to use this pattern:
- Choosing among several actions — that is an action sheet, not an alert. Reserve alerts for blocking, critical decisions.
Reference: Apple HIG — Alerts
Enable large titles on top-level screens
iOS top-level lists open with a large title that shrinks into a standard inline title as the user scrolls, and the bar switches from transparent (over content) to the blurred scroll-edge appearance. This motion orients the user and is a strong native signal. A title rendered as a Text in the screen body neither collapses nor blurs, so the screen reads as a generic cross-platform view.
Incorrect (static title in the body):
import { Stack } from 'expo-router';
import { ScrollView, Text } from 'react-native';
export default function TrailsScreen() {
return (
<>
<Stack.Screen options={{ headerShown: false }} />
{/* Body title never collapses on scroll and the bar never blurs */}
<ScrollView>
<Text style={{ fontSize: 34, fontWeight: '700' }}>Trails</Text>
<TrailList />
</ScrollView>
</>
);
}Correct (system large title that collapses):
import { Stack } from 'expo-router';
import { ScrollView } from 'react-native';
export default function TrailsScreen() {
return (
<>
{/* System large title collapses on scroll; bar adopts the scroll-edge blur */}
<Stack.Screen options={{ title: 'Trails', headerLargeTitle: true }} />
<ScrollView contentInsetAdjustmentBehavior="automatic">
<TrailList />
</ScrollView>
</>
);
}Warning (collapse needs a direct-child scroll view):
The large title only collapses on scroll when the ScrollView or FlatList is the direct first child of the screen. Wrapping it in another View silently breaks the collapse, leaving the title permanently expanded.
When NOT to use this pattern:
- Detail screens deep in a stack — use a standard inline title there; large titles belong on the root of each tab.
Reference: Apple HIG — Navigation bars
Use Expo Router's native Stack for screen hierarchy
A JavaScript-animated stack recreates the iOS transition on the JS thread, so it drops the interactive swipe-back edge gesture, the large-title collapse, and — on iOS 26 — the Liquid Glass header that the system applies to a real UINavigationController. The native stack inherits all of these from UIKit, which is the difference between an app that feels native and one that reads as a web wrapper.
Incorrect (JS-thread card stack):
import { createStackNavigator } from '@react-navigation/stack';
const Stack = createStackNavigator();
// JS-animated cards: no swipe-back edge, no large-title collapse,
// header never adopts the iOS 26 Liquid Glass material
export function TrailsNavigator() {
return (
<Stack.Navigator screenOptions={{ headerStyle: { backgroundColor: '#ffffff' } }}>
<Stack.Screen name="Trails" component={TrailsScreen} />
<Stack.Screen name="TrailDetail" component={TrailDetailScreen} />
</Stack.Navigator>
);
}Correct (UIKit-backed native stack):
// app/(trails)/_layout.tsx
import { Stack } from 'expo-router';
// Native UINavigationController stack: real push/pop, interactive swipe-back,
// large-title collapse, and the system Liquid Glass header on iOS 26
export default function TrailsLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Trails' }} />
<Stack.Screen name="[trailId]" options={{ title: 'Trail' }} />
</Stack>
);
}Reference: Expo Router — Stack
Adopt native tabs for top-level sections
A hand-rolled tab bar — a row of Pressables pinned to the bottom — misses everything the system UITabBar provides: the translucent material that becomes Liquid Glass on iOS 26, scroll-to-top on re-tap of the active tab, badge support, the minimize-on-scroll behavior, and correct safe-area handling. Native tabs render the real control and accept SF Symbols directly.
Incorrect (custom view tab bar):
import { View, Pressable, Text } from 'react-native';
// Opaque custom bar: no Liquid Glass, no scroll-to-top, no badges,
// and it overlaps content because it ignores the safe area
function TabBar({ active, onSelect }: TabBarProps) {
return (
<View style={{ position: 'absolute', bottom: 0, flexDirection: 'row' }}>
<Pressable onPress={() => onSelect('trails')}><Text>Trails</Text></Pressable>
<Pressable onPress={() => onSelect('saved')}><Text>Saved</Text></Pressable>
</View>
);
}Correct (system UITabBar via native tabs):
// app/_layout.tsx
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
export default function TabsLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="trails">
<Label>Trails</Label>
<Icon sf="figure.hiking" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="saved">
<Label>Saved</Label>
<Icon sf="bookmark.fill" />
</NativeTabs.Trigger>
</NativeTabs>
);
}When NOT to use this pattern:
- Native tabs is a beta API (Expo SDK 55+); pin your SDK and test on a device before shipping.
- A heavily branded bottom bar that intentionally breaks platform convention — but expect to lose the system behaviors above.
Reference: Expo Router — Native tabs
Push for hierarchy and present for self-contained tasks
Pushing a screen tells the user "this is deeper in the same topic" — they get a back chevron and the swipe-back edge to return. Presenting modally says "step aside to complete one task" — they get Cancel/Done and swipe-down to dismiss. Pushing a self-contained task (compose, sign-in) strands it in the back stack with the wrong affordances; presenting a hierarchical destination breaks the user's sense of place.
Incorrect (push a self-contained compose task):
import { router } from 'expo-router';
// "Write Review" gets pushed onto the trail's stack: it shows a back chevron
// and stays in history, as if it were a deeper level of the trail
function reviewTrail(trailId: string) {
router.push(`/trails/${trailId}/review`);
}Correct (present the task modally):
import { router } from 'expo-router';
// "Write Review" is presented from a (modal) group: Cancel/Done + swipe-to-dismiss,
// and it never pollutes the trail's back stack
function reviewTrail(trailId: string) {
router.push(`/(modal)/review?trailId=${trailId}`);
}Reference: Apple HIG — Modality
Place search in the navigation bar
iOS users expect search to live in the navigation bar, where it tucks under the title and reveals on pull-down, integrates with the keyboard and Cancel button, and supports the scope bar. A TextInput placed in the screen body scrolls away with the content, doesn't get the system styling or the search keyboard return key, and forces you to rebuild focus and cancel behavior by hand.
Incorrect (search box in the scroll body):
import { ScrollView, TextInput } from 'react-native';
// Custom field scrolls with the list and lacks the native reveal,
// Cancel button, and search-styled keyboard
export default function TrailsScreen() {
return (
<ScrollView>
<TextInput placeholder="Search trails" onChangeText={setQuery} />
<TrailList query={query} />
</ScrollView>
);
}Correct (search integrated in the header):
import { Stack } from 'expo-router';
import { ScrollView } from 'react-native';
export default function TrailsScreen() {
return (
<>
{/* Native search field: pull-to-reveal, Cancel button, search keyboard */}
<Stack.Screen
options={{
headerSearchBarOptions: {
placeholder: 'Search trails',
onChangeText: (e) => setQuery(e.nativeEvent.text),
},
}}
/>
<ScrollView contentInsetAdjustmentBehavior="automatic">
<TrailList query={query} />
</ScrollView>
</>
);
}Reference: Apple HIG — Searching
Present secondary tasks as sheets with detents
A quick task — filtering trails, adding a note — shown as a full-screen modal hides the context the user came from and feels heavier than the task warrants. A native sheet at a medium detent keeps the parent screen visible behind it, exposes the grabber and swipe-to-dismiss gesture, and on iOS 26 adopts the Liquid Glass sheet treatment. Expo Router's native stack exposes these as screen options.
Incorrect (full-screen modal for a small task):
import { Stack } from 'expo-router';
// Full-screen cover for a one-field filter hides the trail list behind it
export default function FilterModalLayout() {
return <Stack.Screen options={{ presentation: 'fullScreenModal' }} />;
}Correct (resizable form sheet with detents):
import { Stack } from 'expo-router';
// Half-height sheet keeps the list visible, with grabber and swipe-to-dismiss
export default function FilterModalLayout() {
return (
<Stack.Screen
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5, 1.0],
sheetGrabberVisible: true,
}}
/>
);
}Reference: Expo — Apple Maps style Liquid Glass sheets
Keep the system back button and swipe-back gesture
The left-edge swipe-back gesture is one of the most-used interactions on iOS, and it is wired to the navigation bar's back button. Replacing the back button with a custom header-left control, or setting gestureEnabled: false to "simplify" a screen, silently disables that gesture and forces users to reach for a tiny target at the top of the screen. Keep the system back button; customize its label, not its existence.
Incorrect (custom back control disables the gesture):
import { Stack } from 'expo-router';
import { Pressable, Text } from 'react-native';
// Replacing headerLeft drops the system back button, and the edge
// swipe-back gesture goes with it
<Stack.Screen
options={{
headerLeft: () => (
<Pressable onPress={() => router.back()}><Text>Back</Text></Pressable>
),
gestureEnabled: false,
}}
/>Correct (keep the native back button and gesture):
import { Stack } from 'expo-router';
// System back button stays, so the edge swipe-back keeps working;
// only the label is customized
<Stack.Screen options={{ headerBackTitle: 'Trails' }} />When NOT to use this pattern:
- A modal mid-task with unsaved changes — there, intercept dismissal to confirm, rather than removing the affordance entirely.
Reference: Apple HIG — Navigation bars
Configure a real app icon and launch screen
The icon and the launch screen are the first and last things a user sees each session, and the Expo defaults — a blank icon and a white flash — read as unfinished. The launch screen should match the app's first screen so the transition feels instant rather than like a loading gate. Configure the icon in app.json and the splash through the expo-splash-screen config plugin (the top-level splash key is now legacy), then hold the splash at runtime until your first data is ready.
Incorrect (rely on the defaults):
{
"expo": {
"name": "Trailhead"
}
}Correct (icon plus the expo-splash-screen plugin):
{
"expo": {
"name": "Trailhead",
"icon": "./assets/icon.png",
"plugins": [
["expo-splash-screen", { "image": "./assets/splash.png", "resizeMode": "contain", "backgroundColor": "#0b3d2e" }]
]
}
}To avoid a flash of empty content, keep the splash visible until the first screen's data resolves:
import * as SplashScreen from 'expo-splash-screen';
// Hold the splash, then hide it once the first screen is ready to render
SplashScreen.preventAutoHideAsync();
async function onTrailsReady() {
await SplashScreen.hideAsync();
}Reference: Expo — Splash screen and app icon
Configure each text field for its content
iOS tailors the keyboard and autofill to the field: an email field gets the @ key, a one-time-code field surfaces the SMS code above the keyboard, and a password field offers the saved-passwords and Strong Password flows. A default text field for everything makes users switch keyboard planes for the @ symbol and forfeits autofill entirely. Set keyboardType, textContentType, autoComplete, and returnKeyType per field.
Incorrect (default field for an email):
import { TextInput } from 'react-native';
// Default keyboard: no @ key, no email autofill, wrong return key
function EmailField() {
return <TextInput placeholder="Email" onChangeText={setEmail} />;
}Correct (field configured for email):
import { TextInput } from 'react-native';
// Email keyboard, autofill, and a "next" return key to advance the form
function EmailField() {
return (
<TextInput
placeholder="Email"
keyboardType="email-address"
textContentType="emailAddress"
autoComplete="email"
autoCapitalize="none"
returnKeyType="next"
onChangeText={setEmail}
/>
);
}Reference: React Native — TextInput (keyboardType)
Request permissions just in time with a rationale
The system permission prompt can be shown only once — if the user denies it, you cannot ask again from inside the app. Firing every prompt at launch, before the user understands why, gets predictable denials and burns that one chance. Request each permission at the moment it is needed, after a short in-app explanation of what it unlocks, and write an honest NSLocationWhenInUseUsageDescription (and friends) so the system dialog states the real reason.
Incorrect (request everything on launch):
import * as Location from 'expo-location';
import * as Notifications from 'expo-notifications';
// Fires both system prompts at startup with no context — most users deny
async function onAppStart() {
await Location.requestForegroundPermissionsAsync();
await Notifications.requestPermissionsAsync();
}Correct (just-in-time, after a rationale):
import * as Location from 'expo-location';
// Asked only when the user taps "Trails near me", after explaining why
async function onUseTrailsNearMe() {
await explainLocationUse(); // brief in-app screen describing the benefit
const { status } = await Location.requestForegroundPermissionsAsync();
if (status === 'granted') showNearbyTrails();
}Reference: Apple HIG — Privacy
Share through the system share sheet
The iOS share sheet is where users expect sharing to happen: it lists their AirDrop devices, Messages, installed app extensions, and copy/save actions, and it remembers frequent recipients. A custom "share" screen with a hand-picked set of buttons can't reach AirDrop or third-party extensions and ignores the user's habits. Use the Share API (or ShareLink in @expo/ui) to present the real activity view.
Incorrect (custom share screen with fixed buttons):
import { View, Pressable, Text } from 'react-native';
// A fixed list: no AirDrop, no app extensions, no Messages, no recents
function ShareTrailScreen({ trail }: { trail: Trail }) {
return (
<View>
<Pressable onPress={() => copyLink(trail)}><Text>Copy link</Text></Pressable>
<Pressable onPress={() => emailTrail(trail)}><Text>Email</Text></Pressable>
</View>
);
}Correct (system share sheet):
import { Share } from 'react-native';
// Real activity view: AirDrop, Messages, extensions, copy, and recents
function shareTrail(trail: Trail) {
Share.share({
title: trail.name,
url: `https://trailhead.app/trails/${trail.id}`,
message: `Check out ${trail.name} on Trailhead`,
});
}Reference: React Native — Share
Match the status bar style to the content behind it
The status bar text and icons are drawn over your content, so a dark status bar on a dark hero image vanishes, and a light one on a white screen is invisible. Leaving it fixed means it disappears on whichever appearance doesn't match. Use expo-status-bar with style="auto" so it inverts against the current color scheme, or set it per screen for full-bleed media that doesn't follow the scheme.
Incorrect (fixed style regardless of content):
import { StatusBar } from 'expo-status-bar';
// Dark icons stay dark in dark mode and over dark imagery — they disappear
function App() {
return (
<>
<StatusBar style="dark" />
<RootNavigator />
</>
);
}Correct (style follows the appearance):
import { StatusBar } from 'expo-status-bar';
// Inverts against the color scheme, so the bar stays legible in both modes
function App() {
return (
<>
<StatusBar style="auto" />
<RootNavigator />
</>
);
}Reference: Expo — StatusBar (expo-status-bar)
Drive interactive gestures with Gesture Handler
PanResponder runs gesture logic on the JavaScript thread, so any work happening there — a list re-render, a network callback — stalls the gesture and the drag visibly stutters or lags the finger. react-native-gesture-handler recognizes and tracks gestures on the UI thread, and paired with Reanimated worklets the follow-the-finger animation also runs off the JS thread, staying smooth under load. Native iOS interactions never lag the finger; yours shouldn't either.
Incorrect (gesture on the JS thread via PanResponder):
import { PanResponder, Animated } from 'react-native';
// Drag tracking runs on the JS thread, so it stutters whenever JS is busy
const pan = useRef(new Animated.Value(0)).current;
const responder = PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onPanResponderMove: (_, g) => pan.setValue(g.dx),
});Correct (gesture and animation on the UI thread):
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle } from 'react-native-reanimated';
// Pan is tracked and animated on the UI thread, so it follows the finger under load
const offsetX = useSharedValue(0);
const pan = Gesture.Pan().onChange((e) => { offsetX.value += e.changeX; });
const style = useAnimatedStyle(() => ({ transform: [{ translateX: offsetX.value }] }));Reference: React Native Gesture Handler
Pair haptics with meaningful outcomes
iOS uses haptics sparingly and meaningfully: a success notification taps differently from a warning, and selection ticks accompany picker changes. Firing a heavy impact on every button tap trains users to ignore it and drains the Taptic Engine's signal value. Reserve expo-haptics for outcomes — a save succeeded, a destructive action fired, a selection changed — and match the haptic type to the meaning.
Incorrect (heavy impact on every tap):
import * as Haptics from 'expo-haptics';
// Buzzes on every press regardless of outcome, so the signal becomes noise
function onAnyButtonPress() {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
handlePress();
}Correct (notification haptic matched to the outcome):
import * as Haptics from 'expo-haptics';
// Success haptic only when the save actually succeeds; failure gets the error type
async function saveTrail(trail: Trail) {
try {
await api.saveTrail(trail);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
}Reference: Expo — Haptics (expo-haptics)
Size touch targets to at least 44pt
Apple's HIG sets 44x44pt as the minimum comfortable touch target because that is roughly the pad of a fingertip. A 20pt icon button is easy to miss, especially in motion or for users with limited dexterity, which produces the "I tapped it twice" frustration. When a control must look small, keep its visual size but expand the touchable area with hitSlop so the target stays 44pt.
Incorrect (visual size equals a tiny touch target):
import { Pressable } from 'react-native';
import { SymbolView } from 'expo-symbols';
// A 20pt glyph yields a ~20pt target — frequently missed
function CloseButton() {
return (
<Pressable onPress={dismiss}>
<SymbolView name="xmark" size={20} />
</Pressable>
);
}Correct (small glyph, 44pt touchable area):
import { Pressable } from 'react-native';
import { SymbolView } from 'expo-symbols';
// Glyph stays 20pt, but hitSlop expands the target to 44pt
function CloseButton() {
return (
<Pressable onPress={dismiss} hitSlop={12}>
<SymbolView name="xmark" size={20} />
</Pressable>
);
}Give every control immediate press feedback
On iOS, tappable things respond the instant a finger lands — they dim, highlight, or scale — which confirms the touch registered before the action completes. A bare View with onPress, or a TouchableWithoutFeedback, fires the action but shows nothing on press, so the user can't tell whether the tap landed and often taps again. Use Pressable and render a pressed state.
Incorrect (no visual response on press):
import { TouchableWithoutFeedback, View, Text } from 'react-native';
// Nothing changes on touch-down, so the tap feels unregistered
function StartHikeButton() {
return (
<TouchableWithoutFeedback onPress={startHike}>
<View style={styles.button}><Text>Start hike</Text></View>
</TouchableWithoutFeedback>
);
}Correct (pressed state confirms the touch):
import { Pressable, Text } from 'react-native';
// Dims on touch-down, so the user sees the tap land immediately
function StartHikeButton() {
return (
<Pressable onPress={startHike} style={({ pressed }) => [styles.button, pressed && { opacity: 0.6 }]}>
<Text>Start hike</Text>
</Pressable>
);
}Reference: React Native — Pressable
Use RefreshControl for pull-to-refresh
Pulling down a list to refresh is the universal iOS gesture for "get the latest." Putting a Refresh button in the header instead breaks that expectation and wastes bar space, while a custom pull animation rarely matches the system spinner's timing and rubber-banding. RefreshControl on a ScrollView, FlatList, or FlashList gives the exact native behavior for one prop pair.
Incorrect (refresh hidden behind a header button):
import { Stack } from 'expo-router';
import { FlatList, Button } from 'react-native';
// Users instinctively pull to refresh; a header button is non-standard and hidden
function TrailsScreen() {
return (
<>
<Stack.Screen options={{ headerRight: () => <Button title="Refresh" onPress={reload} /> }} />
<FlatList data={trails} renderItem={renderTrailRow} />
</>
);
}Correct (native pull-to-refresh):
import { FlatList, RefreshControl } from 'react-native';
// Standard pull-down gesture with the system spinner and rubber-banding
function TrailsScreen() {
return (
<FlatList
data={trails}
renderItem={renderTrailRow}
refreshControl={<RefreshControl refreshing={isReloading} onRefresh={reload} />}
/>
);
}Reference: React Native — RefreshControl
Offer swipe actions on list rows
Swiping a row to reveal Delete or Archive is a core iOS interaction users learn from Mail, Messages, and Reminders. Burying those actions behind a long-press menu or a separate edit mode makes common operations slower than the platform norm. react-native-gesture-handler's Swipeable renders the gesture on the UI thread with the expected snap and color reveal.
Incorrect (destructive action hidden behind a menu):
import { Pressable, Text } from 'react-native';
// Deleting requires opening a menu first — slower than a swipe and non-standard
function SavedTrailRow({ trail }: { trail: Trail }) {
return (
<Pressable onLongPress={() => openRowMenu(trail.id)}>
<Text>{trail.name}</Text>
</Pressable>
);
}Correct (swipe-to-reveal actions):
import { Swipeable } from 'react-native-gesture-handler';
import { Pressable, Text } from 'react-native';
// Swipe left reveals a red Delete, matching Mail and Reminders
function SavedTrailRow({ trail }: { trail: Trail }) {
const renderRight = () => (
<Pressable style={styles.deleteAction} onPress={() => deleteTrail(trail.id)}>
<Text style={styles.deleteLabel}>Delete</Text>
</Pressable>
);
return (
<Swipeable renderRightActions={renderRight}>
<Text style={styles.row}>{trail.name}</Text>
</Swipeable>
);
}Reference: React Native Gesture Handler — Swipeable
Apply Liquid Glass through a version-gated native view
iOS 26's Liquid Glass is a real material rendered by the system — it lenses and refracts the content behind it and reacts to motion. A semi-transparent colored View is a flat fake that misses all of that and looks cheap beside genuine system glass. Use expo-glass-effect's GlassView, which wraps the native effect, and gate it with isGlassEffectAPIAvailable() so devices below iOS 26 fall back to a solid surface instead of crashing.
Incorrect (fake glass with a translucent view):
import { View } from 'react-native';
// A flat translucent overlay: no lensing, no refraction, not the system material
function TrailOverlayBar() {
return <View style={{ backgroundColor: 'rgba(255,255,255,0.6)' }}><TrailControls /></View>;
}Correct (native glass, gated for availability):
import { View } from 'react-native';
import { GlassView, isGlassEffectAPIAvailable } from 'expo-glass-effect';
// Real Liquid Glass on iOS 26; a solid surface elsewhere instead of a crash
function TrailOverlayBar() {
if (!isGlassEffectAPIAvailable()) {
return <View style={styles.solidSurface}><TrailControls /></View>;
}
return <GlassView style={styles.glassSurface}><TrailControls /></GlassView>;
}When NOT to use this pattern:
- Over already-glass system chrome (navigation/tab bars) — stacking glass on glass muddies both; let the system surface provide the material.
Reference: Expo — GlassEffect (expo-glass-effect) · Apple — Liquid Glass
Use semantic system colors instead of hardcoded hex
iOS ships a palette of semantic colors — label, secondaryLabel, systemBackground, separator, systemBlue — that automatically resolve to the right value for light mode, dark mode, increased contrast, and elevation. Hardcoded hex values are frozen: they look correct in one appearance and wrong in the others, and they ignore the user's accessibility contrast setting. PlatformColor binds to the live system color so every surface stays correct.
Incorrect (frozen hex values):
import { View, Text } from 'react-native';
// #8e8e93 is the light-mode secondary label; it's wrong in dark mode and high contrast
function TrailMeta({ trail }: { trail: Trail }) {
return (
<View style={{ backgroundColor: '#ffffff' }}>
<Text style={{ color: '#8e8e93' }}>{trail.distanceLabel}</Text>
</View>
);
}Correct (semantic colors via PlatformColor):
import { View, Text, PlatformColor } from 'react-native';
// System colors resolve per appearance, elevation, and contrast setting
function TrailMeta({ trail }: { trail: Trail }) {
return (
<View style={{ backgroundColor: PlatformColor('systemBackground') }}>
<Text style={{ color: PlatformColor('secondaryLabel') }}>{trail.distanceLabel}</Text>
</View>
);
}When NOT to use this pattern:
- Brand colors that must stay constant across appearances — define those explicitly, but still provide a dark variant.
Reference: React Native — PlatformColor · Apple HIG — Color
Derive spacing from a single base unit
Consistent spacing is what makes a layout feel calm and intentional rather than assembled by hand. When every margin and gap is a separate arbitrary number (11 here, 13 there, 17 somewhere else), the eye reads the inconsistency as sloppiness even when it can't name it. Derive all spacing from one base unit (commonly 8pt with a 4pt half-step) exposed as tokens, so every measurement relates to every other.
Incorrect (unrelated hardcoded numbers):
import { View } from 'react-native';
// Spacing values that don't relate to a base unit; the rhythm reads as random
function TrailCard({ trail }: { trail: Trail }) {
return (
<View style={{ padding: 13, marginBottom: 11, gap: 7 }}>
<TrailThumbnail uri={trail.image} />
</View>
);
}Correct (tokens from one base unit):
import { View } from 'react-native';
// All spacing steps off an 8pt base (space.sm = 8, space.md = 16)
function TrailCard({ trail }: { trail: Trail }) {
return (
<View style={{ padding: space.md, marginBottom: space.sm, gap: space.xs }}>
<TrailThumbnail uri={trail.image} />
</View>
);
}Reference: Apple HIG — Layout
Use the system font for interface text
San Francisco — the iOS system font — applies optical sizing, per-size tracking, and weight rendering that Apple tuned for screens, and it is what every system surface around your app uses. Bundling a custom font for body and label text adds load time, ships glyphs that fall back inconsistently, and reads as off next to the navigation bar and keyboard. Use the system font (the React Native default, or System) for interface text and reserve custom fonts for display moments.
Incorrect (custom font for all interface text):
import { Text } from 'react-native';
// Bundled font for every label fights the system chrome and adds startup cost
function TrailName({ trail }: { trail: Trail }) {
return <Text style={{ fontFamily: 'Inter-Regular' }}>{trail.name}</Text>;
}Correct (system font for interface text):
import { Text } from 'react-native';
// System font: optical sizing, tuned tracking, matches surrounding iOS chrome
function TrailName({ trail }: { trail: Trail }) {
return <Text style={{ fontWeight: '600' }}>{trail.name}</Text>;
}When NOT to use this pattern:
- A deliberate brand display face on a hero or onboarding screen — scoped, not applied to every label.
Reference: Apple HIG — Typography
Reserve the accent tint for interactive elements
On iOS the tint color is a signal: it tells the user "this is tappable." When the accent color is also sprayed across static labels, backgrounds, and icons that do nothing, that signal disappears and users can no longer tell what is interactive. Reserve the brand tint for controls and links, use systemRed for destructive actions, and let static content rest on label and secondary-label colors.
Incorrect (accent color on non-interactive content):
import { View, Text } from 'react-native';
// Tinting a static heading the same blue as buttons implies it's tappable
function TrailSectionHeader({ title }: { title: string }) {
return (
<View>
<Text style={{ color: '#007aff' }}>{title}</Text>
</View>
);
}Correct (tint only on interactive elements):
import { View, Text, Pressable, PlatformColor } from 'react-native';
// Static heading uses the label color; tint is reserved for the action
function TrailSectionHeader({ title }: { title: string }) {
return (
<View>
<Text style={{ color: PlatformColor('label') }}>{title}</Text>
<Pressable onPress={seeAll}>
<Text style={{ color: PlatformColor('systemBlue') }}>See all</Text>
</Pressable>
</View>
);
}Reference: Apple HIG — Color
Map text styles to the iOS type scale
iOS expresses hierarchy through a defined set of text styles — Large Title, Title, Headline, Body, Subheadline, Caption — each with a size, weight, and line height that relate to one another. Picking arbitrary pixel sizes per screen produces inconsistent hierarchy and breaks the relationship between titles and body across the app. Define a small set of named styles mapped to the iOS scale and reuse them, so every screen speaks the same visual language.
Incorrect (ad-hoc sizes per screen):
import { Text } from 'react-native';
// Arbitrary sizes that don't relate to each other or to system text styles
function TrailHeader({ trail }: { trail: Trail }) {
return (
<>
<Text style={{ fontSize: 27, fontWeight: 'bold' }}>{trail.name}</Text>
<Text style={{ fontSize: 13 }}>{trail.region}</Text>
</>
);
}Correct (named styles on the iOS scale):
import { Text } from 'react-native';
// Title and Subheadline drawn from a shared scale aligned to iOS text styles
function TrailHeader({ trail }: { trail: Trail }) {
return (
<>
<Text style={textStyles.title}>{trail.name}</Text>
<Text style={textStyles.subheadline}>{trail.region}</Text>
</>
);
}Reference: Apple HIG — Typography
Related skills
FAQ
What does expo-ios-hig do?
expo-ios-hig is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
When should I use expo-ios-hig?
When you need to helps with ai & agent building tasks during ai-assisted development, or when expo-ios-hig is a claude code skill for ai & agent building. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
expo-ios-hig; AI & Agent Building; AI-coding skill.