Skip to content
My Guidelines
Guides
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

EnginePick it when
Unistyles (default)Custom visual identity. Token- and theme-first, imposes no design language. Native core updates styles without re-rendering.
NativeWindThe team already thinks in Tailwind. Utilities compiled ahead of time. Largest adoption by a wide margin.
TamaguiTruly universal RN + web from one codebase, with its own compiler. More power, more weight, smaller community.
Core StyleSheetThrowaway 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:

NeedLibrary
Bottom sheets@gorhom/bottom-sheet
Gestures and animationGesture Handler + Reanimated
ListsFlashList
Imagesexpo-image
Copy-in components, shadcn-styleReact 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.

ui/design/token.ts
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 lightTheme

Two 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: 17 anywhere. 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:

TierLocationKnows about
Primitivesui/design/component/Tokens only. No domain. Text, Button, Card, Stack
Feature componentsui/feature/<feature>/component/The domain of that feature
Screensui/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} />
}
StateRequirements
LoadingSkeleton matching the real layout — not a centred spinner. Prevents layout shift.
EmptySays what would be here and offers the action that fills it. An invitation, not a dead end.
ErrorSays what failed, in the interface’s voice, and offers a retry. Never a raw stack trace or error code.
ContentThe 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}
>
RuleDetail
Touch target≥44×44 pt. Use hitSlop when the visual is smaller.
LabelsDescribe the action, not the glyph. “Delete expense”, never “trash icon”.
ContrastWCAG AA: 4.5:1 body text, 3:1 large text — in both themes.
Dynamic TypeLayouts survive the largest system font size. Never disable allowFontScaling.
ColourNever the sole carrier of meaning. Pair with text or an icon.
GroupingRelated elements combine into one accessible node so a screen reader reads a row once, not five times.
FocusFocus moves to new content on navigation and into modals; it is trapped inside modals.
MotionRespect AccessibilityInfo.isReduceMotionEnabled().
DecorativeaccessibilityElementsHidden / 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, never left/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.
  • KeyboardAvoidingView on every screen with a text input. Test with the keyboard open — the most commonly missed state.
  • Spacing via a Stack/Spacer primitive, 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-motion is 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 componenttheme.color.danger
Screen renders only contentLoading, empty, error, content
Centred spinner as the loading stateSkeleton 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 stringt('feature.key')
`${t('you.have')} ${n} ${t('items')}`t('items.count', { count: n })
Dark mode judged from codeReviewed on device, both modes
Colour alone marks an errorColour + icon + text
Animation on everythingOne 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