In this guide (14 items)
Mobile UI
Mobile UI
MANDATORY FOR ALL AI AGENTS: Every visual value MUST come from a design token — never a literal in a component. Every screen MUST handle four states: loading, empty, error, content. Every interactive element MUST have an accessible label and a touch target of at least 44×44 pt. All user-facing strings MUST come from the i18n catalogue, never hardcoded. UI code lives ONLY in
ui/— see mobile-architecture.md.
Companion commands: /mobile-init-project (tokens, primitives, state components), /mobile-add-screen (screen with all four states, route, accessibility).
Framework and library choices: react-native.md. Conventions: typescript-code-style.md.
1. Styling engine and component strategy
Two decisions people conflate. Decide them separately.
Styling engine — pick one, record it as an ADR
| Engine | Pick it when |
|---|---|
| Unistyles (default) | Custom visual identity. Token- and theme-first, imposes no design language. Native core updates styles without re-rendering. |
| NativeWind | The team already thinks in Tailwind. Utilities compiled ahead of time. Largest adoption by a wide margin. |
| Tamagui | Truly universal RN + web from one codebase, with its own compiler. More power, more weight, smaller community. |
Core StyleSheet | Throwaway or single-screen apps. Themes and variants by hand stop scaling quickly. |
⚠️ Unistyles and Tamagui are native modules. They do not load in Jest’s environment, so every component test must load the library’s mock in setupFiles — before any module that imports it — or every suite fails at import time. Unistyles ships react-native-unistyles/mocks. Budget this when choosing; NativeWind has no such cost.
This is a team and product decision, not a performance one — all of the above perform acceptably. Choose for how the team thinks and what the product must look like, then stop revisiting it.
Component kit — build your own primitives
A component kit and a distinctive visual identity are in tension. React Native Paper gives you Material Design; if the product must not look like Material, you fight the library on every screen and lose.
Default: own primitives on top of your tokens (§2), plus unstyled libraries for the interactions that are genuinely hard to build:
| Need | Library |
|---|---|
| Bottom sheets | @gorhom/bottom-sheet |
| Gestures and animation | Gesture Handler + Reanimated |
| Lists | FlashList |
| Images | expo-image |
| Copy-in components, shadcn-style | React Native Reusables (requires NativeWind) |
Exception: internal tools and back-office apps, where delivery speed outweighs identity — adopt React Native Paper or gluestack-ui wholesale. That is a legitimate choice and an ADR, not a default.
Never adopt two kits. Never wrap a kit in your own primitives “in case we swap it later” — the abstraction leaks and you carry both costs.
2. Design tokens
One source of truth. Components consume semantic names, never raw values.
export const palette = { green900: '#16241D', green100: '#DCE9D5', paper: '#F7F9F4', red600: '#A3312A',} as const
export const lightTheme = { color: { background: palette.paper, surface: palette.green100, textPrimary: palette.green900, textSecondary: '#5C6E64', danger: palette.red600, border: '#C9D6C2', }, space: { xs: 4, sm: 8, md: 16, lg: 24, xl: 40 }, radius: { none: 0, sm: 4, md: 8, full: 9999 }, type: { display: { size: 48, weight: '400' }, body: { size: 16, weight: '400' }, caption: { size: 12, weight: '500' }, },} as const
export type Theme = typeof lightThemeTwo tiers. palette holds raw values; the theme maps them to roles. Components use color.danger, never palette.red600. Dark mode then means swapping one object, not auditing every file.
Rules:
- No
marginTop: 17anywhere. Spacing comes from the scale. - No hex literal in a component.
- The scale is small on purpose. Ten spacing values means no one knows which to use.
- Adding a token is a deliberate act, not a reflex.
3. Theming and dark mode
Dark mode is not optional; the OS toggles it and users expect it to be honoured.
export function useTheme(): Theme { const scheme = useColorScheme() return scheme === 'dark' ? darkTheme : lightTheme}- Both themes define the same keys. A missing key in one is a runtime crash in only one mode — invisible until a user reports it.
- Dark mode is not an inversion. Pure black backgrounds with pure white text cause halation; large saturated areas that worked on light look aggressive on dark. Desaturate and reduce contrast slightly.
- Follow the system by default. A manual override is a feature, and an ADR.
- Review both modes on device before merging. They cannot be judged from code.
4. Component structure
Three tiers, aligned with the folder layout of mobile-architecture.md:
| Tier | Location | Knows about |
|---|---|---|
| Primitives | ui/design/component/ | Tokens only. No domain. Text, Button, Card, Stack |
| Feature components | ui/feature/<feature>/component/ | The domain of that feature |
| Screens | ui/feature/<feature>/ | Hooks, composition, screen states |
Primitives never import a domain type. Feature components never import from another feature — shared pieces move up to ui/design/.
Wrap RN’s Text and View in your own primitives from day one. Retrofitting a design system across 200 direct <Text> uses is a week of work; starting with <Text variant="body"> costs nothing.
5. The four screen states
A screen that only renders content is incomplete, not “to be finished later”. Users meet the error path on the subway, and the empty path on day one.
export function ExpenseListScreen() { const { data, isLoading, error, refetch } = useExpenses()
if (isLoading) return <ScreenSkeleton /> if (error) return <ErrorState error={error} onRetry={refetch} /> if (data.length === 0) return <EmptyState onAction={goToCreate} />
return <FlashList data={data} renderItem={renderExpense} />}| State | Requirements |
|---|---|
| Loading | Skeleton matching the real layout — not a centred spinner. Prevents layout shift. |
| Empty | Says what would be here and offers the action that fills it. An invitation, not a dead end. |
| Error | Says what failed, in the interface’s voice, and offers a retry. Never a raw stack trace or error code. |
| Content | The happy path. |
Distinguish offline from server error — the user can act on one and not the other.
Error copy explains and directs; it never apologises and is never vague. “Couldn’t load expenses. Check your connection and try again.” — not “Oops! Something went wrong.”
6. Accessibility
Not a polish pass. It is a build gate, and — per mobile-testing.md §6 — it makes tests better, because getByRole only works on accessible components.
Required on every interactive element:
<Pressable accessibilityRole="button" accessibilityLabel={t('expense.delete.label')} accessibilityHint={t('expense.delete.hint')} accessibilityState={{ disabled: isPending }} hitSlop={8}>| Rule | Detail |
|---|---|
| Touch target | ≥44×44 pt. Use hitSlop when the visual is smaller. |
| Labels | Describe the action, not the glyph. “Delete expense”, never “trash icon”. |
| Contrast | WCAG AA: 4.5:1 body text, 3:1 large text — in both themes. |
| Dynamic Type | Layouts survive the largest system font size. Never disable allowFontScaling. |
| Colour | Never the sole carrier of meaning. Pair with text or an icon. |
| Grouping | Related elements combine into one accessible node so a screen reader reads a row once, not five times. |
| Focus | Focus moves to new content on navigation and into modals; it is trapped inside modals. |
| Motion | Respect AccessibilityInfo.isReduceMotionEnabled(). |
| Decorative | accessibilityElementsHidden / importantForAccessibility="no-hide-descendants". |
Test with the real tools: VoiceOver on iOS, TalkBack on Android. An audit that never ran a screen reader has not audited anything.
7. Internationalisation
Extract strings from the first screen. Retrofitting i18n is a refactor of the entire UI.
- Catalogue per locale; keys namespaced by feature:
expense.list.empty.title. - ❌ Never concatenate translated fragments — word order differs across languages. Use interpolation:
t('expense.count', { count }). - Plurals through the library’s plural rules, never
count === 1 ? … : …. - Dates, numbers and currency through
Intl, respecting the device locale. Never a hand-rolled formatter. - Design for +30% text length — German and Spanish overflow layouts that fit English.
- Test RTL early if Arabic or Hebrew are plausible. Use
start/end, neverleft/right.
Currency display formats a domain Money (integer minor units, per mobile-architecture.md §4) — the formatter never receives a float.
8. Layout
- Flexbox. No absolute positioning except for genuine overlays.
SafeAreaView/useSafeAreaInsets— notches, dynamic islands and gesture bars are not optional.KeyboardAvoidingViewon every screen with a text input. Test with the keyboard open — the most commonly missed state.- Spacing via a
Stack/Spacerprimitive, not stray margins. Margins belong to the parent’s layout decision, not the child’s identity. - Test the smallest supported device and the largest. A layout that only works on the reviewer’s phone is not done.
9. Interaction
- Every touchable has a visible pressed state.
- Haptics for meaningful events (
expo-haptics), never on every tap — constant vibration is noise. - Disable a submit button while its mutation is in flight. Double-submit is the most common duplicate-record bug.
- Destructive actions confirm, and say what will be lost. Prefer undo over confirmation where feasible.
- Optimistic updates need a defined rollback and a visible failure. A silent revert makes users think the app is broken.
10. Motion
Animation earns its place by explaining a change; otherwise it delays the user.
- Reanimated (UI thread), never core
Animated. - Short: 150–300 ms for most transitions.
- Purposeful: show where something came from, or that state changed. Not decoration.
prefers-reduced-motionis honoured. For some users motion causes nausea — this is an accessibility requirement, not a preference.- One signature moment beats scattered effects everywhere.
11. Anti-patterns
| ❌ | ✅ |
|---|---|
style={{ padding: 17, color: '#333' }} | theme.space.md, theme.color.textPrimary |
palette.red600 in a component | theme.color.danger |
| Screen renders only content | Loading, empty, error, content |
| Centred spinner as the loading state | Skeleton matching the layout |
| ”Oops! Something went wrong” | What failed, and how to retry |
accessibilityLabel="icon" | The action name |
| 24×24 touch target | ≥44×44, or hitSlop |
allowFontScaling={false} | Support Dynamic Type |
| Hardcoded user-facing string | t('feature.key') |
`${t('you.have')} ${n} ${t('items')}` | t('items.count', { count: n }) |
| Dark mode judged from code | Reviewed on device, both modes |
| Colour alone marks an error | Colour + icon + text |
| Animation on everything | One purposeful moment |
12. Pre-merge checklist
- No literal colour, spacing or font size in any component
- Light and dark themes define identical keys, reviewed on device
- Every new screen handles all four states
- Every interactive element has a role, a label, and a ≥44 pt target
- Contrast meets AA in both themes
- Layout holds at the largest Dynamic Type size
- All strings come from the i18n catalogue; no concatenation
- Dates, numbers and currency formatted through
Intl - Reduced motion honoured
- Verified with VoiceOver and TalkBack
- Verified on the smallest and largest supported devices, keyboard open