React Native (Expo SDK 55) Best Practices: UI/UX, Accessibility, Navigation
Status: Reference
Date: 2026-03-24 Sources: Web research (2025-2026 recommendations) Stack context: Expo Router v4 + Tamagui + Reanimated 3 + React Hook Form + Zod
Part 1: UI/UX Patterns
1.1 Component Architecture — Atomic Design & Composition
RULE: Use composition over inheritance. Prefer atomic components (atoms > molecules > organisms) that compose via children and render props. Never prop-drill more than 2 levels.
WHY: Flat component trees are easier to test, reuse, and refactor. Deep prop drilling creates invisible coupling and makes refactoring expensive.
BAD:
// Prop drilling 4 levels deep
<SessionScreen
clubName={club.name}
sessionDate={session.date}
onRsvp={handleRsvp}
userName={user.name}
userElo={user.elo}
/>
// SessionScreen passes all props to SessionHeader, which passes to SessionMeta...GOOD:
// Composition — each component fetches what it needs or uses context
<SessionScreen>
<SessionHeader session={session} />
<RsvpButton sessionId={session.id} /> {/* uses useRsvp() hook internally */}
<ParticipantList sessionId={session.id} />
</SessionScreen>GOOD (slot pattern for flexible layout):
// Atom: reusable card shell
function Card({ children, onPress }: { children: ReactNode; onPress?: () => void }) {
return (
<CardFrame onPress={onPress} backgroundColor="$card" borderRadius="$4" padding="$4">
{children}
</CardFrame>
);
}
// Molecule: composed card
<Card onPress={goToSession}>
<SessionStatusBadge status={session.status} />
<Text role="cardTitle">{session.title}</Text>
<Text role="cardMeta">{formatDate(session.date)}</Text>
</Card>;Stack relevance: Tamagui token props and shared UI primitives keep atoms composable. Zustand stores + TanStack Query hooks replace most prop drilling needs.
1.2 Responsive Design
RULE: Use useWindowDimensions (not Dimensions.get()) for reactive layout. Define breakpoints for phone/tablet. Always wrap screens in SafeAreaView.
WHY: Dimensions.get() is a static snapshot that doesn't update on rotation or split-screen. useWindowDimensions re-renders on change. Safe areas prevent notch/home-bar overlap.
BAD:
const { width } = Dimensions.get('window'); // Static — never updates
const columns = width > 768 ? 3 : 2;GOOD:
import { useWindowDimensions } from 'react-native';
function useBreakpoint() {
const { width } = useWindowDimensions();
return {
isMobile: width < 768,
isTablet: width >= 768 && width < 1024,
isDesktop: width >= 1024,
columns: width < 480 ? 1 : width < 768 ? 2 : 3,
};
}GOOD (safe areas):
import { SafeAreaView } from 'react-native-safe-area-context';
// ALWAYS use SafeAreaView from safe-area-context, not from react-native
<SafeAreaView edges={['top', 'bottom']} style={{ flex: 1 }}>
{children}
</SafeAreaView>;Stack relevance: Use Tamagui responsive props where they fit, and useWindowDimensions for dynamic calculations.
1.3 Dark Mode
RULE: Use Tamagui semantic tokens for all colors. Define light/dark values in the theme registry, not inside components. Never use hardcoded hex values in components. Ensure WCAG AA contrast in both modes.
WHY: Tokenized themes mean zero color-branching in components. One component tree works for every palette and mode. Manual hex values drift and break contrast.
BAD:
<View style={{ backgroundColor: isDark ? '#1a1a1a' : '#ffffff' }}>
<Text style={{ color: isDark ? '#e5e5e5' : '#1a1a1a' }}>Hello</Text>
</View>GOOD:
// Component — no conditional color logic
<YStack backgroundColor="$background">
<Text color="$text">Hello</Text>
</YStack>GOOD (manual toggle):
import { useThemeStore } from '@twomore/app';
function ThemeToggle() {
const colorScheme = useThemeStore((s) => s.colorScheme);
const setColorScheme = useThemeStore((s) => s.setColorScheme);
return (
<Switch
value={colorScheme === 'dark'}
onValueChange={(v) => setColorScheme(v ? 'dark' : 'light')}
/>
);
}Stack relevance: TwoMore uses a unified Tamagui theme registry with generic tennis-environment palettes. Ensure every new color token has both light and dark variants. Store local palette/mode preferences with Zustand persist and sync palette choice through user preferences.
1.4 Typography & Dynamic Type
RULE: Support font scaling. Set maxFontSizeMultiplier={2.0} on body text and maxFontSizeMultiplier={1.5} on constrained UI elements (buttons, badges, tab labels). Never set allowFontScaling={false} except for decorative/icon text.
WHY: Users who increase system font size do so because they need it. Disabling font scaling breaks accessibility for visually impaired users. But uncapped scaling can break layouts, so use multipliers strategically.
BAD:
// Completely blocks accessibility
<Text allowFontScaling={false} style={{ fontSize: 16 }}>
Session Details
</Text>GOOD:
// Body text — generous scaling
<Text className="text-base" maxFontSizeMultiplier={2.0}>
{session.description}
</Text>
// Button label — constrained to prevent layout break
<Text className="text-sm font-semibold" maxFontSizeMultiplier={1.5}>
RSVP
</Text>
// Tab bar label — tightly constrained
<Text className="text-xs" maxFontSizeMultiplier={1.3}>
{tabLabel}
</Text>Stack relevance: Define maxFontSizeMultiplier constants in src/config/ (e.g., FONT_SCALE.body = 2.0, FONT_SCALE.ui = 1.5, FONT_SCALE.constrained = 1.3). Apply consistently via a <Typography> wrapper component or config.
1.5 Touch Targets
RULE: All interactive elements must have a minimum touchable area of 44x44pt. Use hitSlop to expand the touch area without changing visual size. Maintain at least 8pt spacing between adjacent interactive elements.
WHY: Apple HIG and WCAG 2.5.8 (Level AA) require minimum target sizes. Small targets cause frustration and are inaccessible for users with motor impairments.
BAD:
// 24x24 icon button with no hitSlop — fails WCAG
<Pressable onPress={onClose}>
<Icon name="close" size={24} />
</Pressable>GOOD:
// Visual size 24x24 but touch area expanded to 44x44
<Pressable
onPress={onClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className="p-2" // Also adds padding for a more comfortable visual target
accessibilityRole="button"
accessibilityLabel="Close"
>
<Icon name="close" size={24} />
</Pressable>GOOD (reusable pattern):
const MIN_HIT_SLOP = { top: 10, bottom: 10, left: 10, right: 10 };
function IconButton({ icon, onPress, label }: IconButtonProps) {
return (
<Pressable
onPress={onPress}
hitSlop={MIN_HIT_SLOP}
className="items-center justify-center p-2"
accessibilityRole="button"
accessibilityLabel={label}
>
<Icon name={icon} size={24} />
</Pressable>
);
}Stack relevance: Audit all <Pressable> and <TouchableOpacity> in src/presentation/ for 44x44pt minimum. Add hitSlop to icon-only buttons.
1.6 Loading States — Skeleton Screens
RULE: Use skeleton screens (shimmer placeholders) for list/card loading. Use spinners only for action confirmations (e.g., submitting a form). Never show blank screens while loading.
WHY: Skeleton screens reduce perceived load time by ~50% compared to spinners (Facebook research). They prevent layout shift by reserving space for content. Blank screens feel broken.
BAD:
if (isLoading) return <ActivityIndicator />;
return <FlatList data={sessions} ... />;GOOD:
if (isLoading) return <SkeletonList count={5} />;
return <FlatList data={sessions} ... />;GOOD (skeleton matching content layout):
function SessionCardSkeleton() {
return (
<View className="bg-card rounded-2xl p-4 mb-3">
<SkeletonRow width="60%" height={20} /> {/* Title */}
<SkeletonRow width="40%" height={14} className="mt-2" /> {/* Date */}
<SkeletonRow width="80%" height={14} className="mt-1" /> {/* Description */}
</View>
);
}Stack relevance: TwoMore already has Skeleton, SkeletonRow, SkeletonCard, SkeletonList components. Ensure all list screens use them. TanStack Query's isLoading vs isFetching distinction: show skeleton on first load (isLoading), show subtle refresh indicator on refetch (isFetching).
1.7 Error States
RULE: Use inline errors for form fields, toast/snackbar for transient API errors, full-screen error with retry for critical data failures. Never show raw error messages to users.
WHY: Different error severities need different visibility levels. Form errors need proximity to the field. Network errors need retry capability. Raw stack traces erode user trust.
Decision matrix:
| Error Type | Display | Action |
|---|---|---|
| Form validation | Inline below field | Fix and resubmit |
| API 4xx (user error) | Toast/snackbar | Dismiss or fix input |
| API 5xx (server error) | Full-screen or banner | Retry button |
| Network offline | Persistent banner | Auto-retry on reconnect |
| Render crash | ErrorBoundary fallback | Restart screen |
BAD:
if (error) return <Text>{error.message}</Text>; // Raw error, no retryGOOD:
// Full-screen error with retry for critical screens
if (error) {
return (
<ErrorState title={t('error.loadFailed')} message={t('error.tryAgain')} onRetry={refetch} />
);
}Stack relevance: TwoMore already has ErrorBoundary and useQueryError hook. Ensure every TanStack Query consumer handles error state explicitly.
1.8 Empty States
RULE: Every list/collection screen must have an actionable empty state with: (1) an illustration or icon, (2) a descriptive message, (3) a primary CTA button when applicable.
WHY: Empty states are first-run experiences. A blank screen with "No data" teaches nothing. An actionable empty state guides users to the next step.
BAD:
if (data.length === 0) return <Text>No sessions</Text>;GOOD:
if (data.length === 0) {
return (
<EmptyState
icon="calendar-plus"
title={t('session.empty.title')} // "아직 세션이 없어요"
description={t('session.empty.desc')} // "첫 세션을 만들어보세요"
actionLabel={t('session.empty.action')} // "세션 만들기"
onAction={() => router.push(routes.createSession(clubId))}
/>
);
}Stack relevance: TwoMore has EmptyState component with pulsing icon + primary-50 halo. Ensure every FlatList has an ListEmptyComponent using it.
1.9 Pull-to-Refresh
RULE: All list screens must support pull-to-refresh via RefreshControl. Trigger haptic feedback on pull threshold (optional). Avoid causing full skeleton re-render on refresh — show RefreshControl spinner only.
WHY: Pull-to-refresh is a universal mobile pattern users expect. Distinguishing initial load (skeleton) from refresh (spinner) prevents jarring full-screen flickers.
BAD:
// No pull-to-refresh, or full skeleton on refetch
if (isLoading || isFetching) return <SkeletonList />;GOOD:
<FlatList
data={sessions}
refreshControl={
<RefreshControl
refreshing={isFetching && !isLoading} // Only on refetch, not initial load
onRefresh={refetch}
tintColor={colors.primary}
/>
}
ListEmptyComponent={<EmptyState ... />}
/>Stack relevance: TanStack Query provides isFetching separate from isLoading. Use refreshing={isFetching && !isLoading} pattern everywhere.
1.10 Keyboard Handling
RULE: Use KeyboardAvoidingView with behavior="padding" on iOS and behavior="height" on Android. For forms with multiple inputs, prefer react-native-keyboard-controller (KeyboardAwareScrollView). Never nest KeyboardAvoidingView inside KeyboardAwareScrollView.
WHY: Keyboards that cover input fields make forms unusable. Platform behavior differences require platform-specific config. Conflicting keyboard components fight each other.
BAD:
// No keyboard handling — input hidden behind keyboard
<View>
<TextInput placeholder="Name" />
<TextInput placeholder="Description" />
<Button title="Submit" />
</View>GOOD:
import { KeyboardAvoidingView, Platform } from 'react-native';
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={headerHeight}
className="flex-1"
>
<ScrollView keyboardShouldPersistTaps="handled">
<TextInput placeholder="Name" returnKeyType="next" />
<TextInput placeholder="Description" returnKeyType="done" />
<Button title="Submit" />
</ScrollView>
</KeyboardAvoidingView>;Key props: keyboardShouldPersistTaps="handled" prevents keyboard dismissal when tapping buttons. returnKeyType="next" chains input focus.
Stack relevance: For form-heavy screens (create club, create session), use KeyboardAvoidingView + ScrollView. Set keyboardVerticalOffset to account for ScreenHeader height.
1.11 Gesture Handling
RULE: Use react-native-gesture-handler (Gesture API v2) for all gestures. Wrap app root with <GestureHandlerRootView>. Compose gestures with Gesture.Simultaneous(), Gesture.Exclusive(), Gesture.Race(). Run gesture callbacks as worklets on the UI thread.
WHY: RN's built-in gesture system runs on the JS thread, causing lag. RNGH runs natively. Gesture composition prevents conflicts (e.g., scroll vs swipe-to-dismiss).
BAD:
// JS thread pan handling — laggy
<View
onTouchMove={(e) => {
setPosition({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
}}
/>GOOD:
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withDecay } from 'react-native-reanimated';
function SwipeableCard({ onDismiss }: { onDismiss: () => void }) {
const translateX = useSharedValue(0);
const pan = Gesture.Pan()
.onUpdate((e) => {
translateX.value = e.translationX;
})
.onEnd((e) => {
if (Math.abs(e.translationX) > 150) {
runOnJS(onDismiss)();
} else {
translateX.value = withSpring(0);
}
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={animatedStyle}>{/* Card content */}</Animated.View>
</GestureDetector>
);
}Stack relevance: TwoMore already uses Reanimated for card press animations and spring physics. Extend pattern for swipe-to-delete on session cards, swipe navigation, etc.
1.12 Animation Principles
RULE: Use spring physics (not duration-based) for interactive animations. Reserve timing animations for non-interactive effects (shimmer, fade). Respect useReducedMotion(). Keep enter animations under 300ms, exit under 200ms.
WHY: Spring animations feel natural because they respond to velocity and overshoot naturally. Duration animations feel mechanical. Users with vestibular disorders need the option to disable motion.
Recommended spring configs:
| Purpose | Damping | Stiffness | Mass |
|---|---|---|---|
| Button press | 20 | 400 | 1 |
| Card enter | 15 | 300 | 1 |
| Modal slide | 20 | 200 | 1 |
| Bounce/attention | 8 | 300 | 1 |
BAD:
// Duration-based — feels mechanical
withTiming(1, { duration: 300 });GOOD:
import { useReducedMotion } from 'react-native-reanimated';
function AnimatedCard({ children }: { children: ReactNode }) {
const reducedMotion = useReducedMotion();
const entering = reducedMotion
? FadeIn.duration(150)
: FadeInDown.springify().damping(15).stiffness(300);
return <Animated.View entering={entering}>{children}</Animated.View>;
}Stack relevance: TwoMore uses withSpring with damping 20/15, stiffness 400/300 on buttons/cards. Add useReducedMotion() checks to all animated components. Consider ReducedMotionConfig at app root.
1.13 Modal / Bottom Sheet
RULE: Use @gorhom/bottom-sheet for bottom sheets. Use enablePanDownToClose for dismissible sheets. Use snap points for multi-height sheets. Always add a backdrop that dismisses on tap. For iOS, prefer presentationStyle="pageSheet" for full modals.
WHY: Bottom sheets feel native on mobile (used by Maps, Music, etc.). Gesture-based dismiss is expected behavior. Missing backdrop traps users.
BAD:
// React Native Modal — no gesture dismiss, no snap points
<Modal visible={visible} onRequestClose={onClose}>
<View style={{ height: '50%' }}>{content}</View>
</Modal>GOOD:
import BottomSheet, { BottomSheetBackdrop } from '@gorhom/bottom-sheet';
function FilterSheet({ ref }: { ref: React.RefObject<BottomSheet> }) {
const snapPoints = useMemo(() => ['25%', '50%', '90%'], []);
return (
<BottomSheet
ref={ref}
snapPoints={snapPoints}
enablePanDownToClose
backdropComponent={(props) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} />
)}
>
{/* Filter content */}
</BottomSheet>
);
}Stack relevance: TwoMore uses bottom sheet modal for activity screen filters. Ensure all bottom sheets use backdrop + gesture dismiss. For Expo Router modals, use presentation: 'modal' in route config.
1.14 Form Patterns — React Hook Form + Zod
RULE: Define Zod schema first, derive TypeScript types with z.infer<>. Use zodResolver with useForm. Validate on onBlur for individual fields, onSubmit for the full form. Display errors inline below each field. Use Controller for all RN inputs.
WHY: Schema-first ensures validation rules and types are always in sync. onBlur validation gives immediate feedback without annoying per-keystroke errors. Controller bridges RN inputs (no native ref support) with RHF.
BAD:
// Manual validation — error-prone, no type safety
const [name, setName] = useState('');
const [errors, setErrors] = useState({});
function validate() {
if (!name) setErrors({ name: 'Required' });
}GOOD:
// Schema (in domain/ or shared validation/)
const createClubSchema = z.object({
name: z.string().min(2, '클럽 이름은 2자 이상이어야 해요'),
maxCourts: z.number().min(1).max(20),
description: z.string().optional(),
});
type CreateClubInput = z.infer<typeof createClubSchema>;
// Form component
function CreateClubForm() {
const {
control,
handleSubmit,
formState: { errors },
} = useForm<CreateClubInput>({
resolver: zodResolver(createClubSchema),
mode: 'onBlur', // Validate on blur
reValidateMode: 'onChange', // Re-validate on change after first error
});
return (
<>
<Controller
control={control}
name="name"
render={({ field: { onChange, onBlur, value } }) => (
<View>
<TextInput
value={value}
onChangeText={onChange}
onBlur={onBlur}
placeholder={t('club.name')}
className={cn('input', errors.name && 'border-error')}
/>
{errors.name && <Text className="text-error text-sm mt-1">{errors.name.message}</Text>}
</View>
)}
/>
<Button onPress={handleSubmit(onSubmit)} title={t('common.create')} />
</>
);
}Stack relevance: TwoMore uses RHF + Zod. Ensure all forms use zodResolver, mode: 'onBlur', Controller for every field. Zod schemas live in domain/ (entity schemas) or alongside forms for UI-only validation.
Part 2: Accessibility (a11y)
2.1 Screen Reader Support
RULE: Every interactive element must have accessibilityLabel. Every <Pressable> and <TouchableOpacity> must have accessibilityRole. Add accessibilityHint for non-obvious actions. Group related elements with accessible={true} on the container.
WHY: Without labels, screen readers announce "button" with no context. Without roles, the element type is unknown. VoiceOver/TalkBack users rely entirely on these properties.
BAD:
<Pressable onPress={handleRsvp}>
<Text>RSVP</Text>
</Pressable>GOOD:
<Pressable
onPress={handleRsvp}
accessibilityRole="button"
accessibilityLabel={t('session.rsvp.button')} // "세션 참가 신청"
accessibilityHint={t('session.rsvp.hint')} // "참가 신청을 보냅니다"
accessibilityState={{ disabled: isDisabled }}
>
<Text>{t('session.rsvp.label')}</Text>
</Pressable>GOOD (grouped elements):
// Screen reader announces as single element: "Paris Clay Club, 12 members, Next session tomorrow"
<View
accessible={true}
accessibilityLabel={`${club.name}, ${club.memberCount}${t('common.members')}, ${nextSessionText}`}
accessibilityRole="button"
>
<Text className="font-semibold">{club.name}</Text>
<Text className="text-muted">{club.memberCount} members</Text>
<Text className="text-muted">{nextSessionText}</Text>
</View>Key accessibilityRole values: button, link, header, search, image, text, adjustable (slider), switch, tab, tabbar, none.
2.2 Focus Management
RULE: Set logical focus order via accessibilityOrder or view hierarchy. Trap focus inside modals/bottom sheets. Move focus to new content after navigation or dynamic updates using AccessibilityInfo.setAccessibilityFocus().
WHY: Screen reader users navigate sequentially. Wrong focus order makes the app confusing. Modals without focus trapping let users interact with hidden background content.
GOOD (focus on new content):
import { AccessibilityInfo, findNodeHandle } from 'react-native';
function SuccessScreen() {
const headerRef = useRef<View>(null);
useEffect(() => {
// After screen mount, move screen reader focus to the success message
const node = findNodeHandle(headerRef.current);
if (node) {
AccessibilityInfo.setAccessibilityFocus(node);
}
}, []);
return (
<View ref={headerRef} accessible accessibilityRole="header">
<Text className="text-xl font-bold">{t('success.title')}</Text>
</View>
);
}GOOD (modal focus trapping — handled by @gorhom/bottom-sheet automatically, but verify):
// Bottom sheet backdrop blocks interaction with background
<BottomSheet
backdropComponent={(props) => (
<BottomSheetBackdrop
{...props}
pressBehavior="close"
accessibilityElementsHidden={false}
/>
)}
>2.3 Color Contrast
RULE: Text must meet WCAG AA contrast ratios: 4.5:1 for normal text (<18pt), 3:1 for large text (>=18pt bold or >=24pt), 3:1 for UI components and graphical objects. Test both light and dark themes.
WHY: Low contrast makes text unreadable for users with low vision (affects ~4% of population). Dark mode often fails contrast checks because developers use near-black backgrounds with mid-gray text.
Minimum contrast ratios:
| Element | Ratio | Example |
|---|---|---|
| Body text (14-16pt) | 4.5:1 | #1a1a1a on #ffffff = 17.4:1 |
| Large text (18pt+) | 3:1 | |
| Muted/secondary text | 4.5:1 | #737373 on #ffffff = 4.7:1 (barely passes) |
| Button text on primary | 4.5:1 | White on brand color |
| Icons/UI components | 3:1 | Borders, toggles, checkboxes |
| Placeholder text | 3:1 minimum |
BAD:
/* Dark mode — muted text too low contrast */
--color-muted: #555555; /* on #0a0a0a background = 2.7:1 FAIL */GOOD:
/* Dark mode — muted text passes AA */
--color-muted: #a1a1a1; /* on #0a0a0a background = 7.5:1 PASS */Stack relevance: Audit all theme definitions in src/config/theme/. Every colorsDark variant must be contrast-checked against the dark background. Use tools like WebAIM Contrast Checker.
2.4 Motion Sensitivity
RULE: Respect useReducedMotion() from Reanimated. When reduced motion is enabled: replace springs with instant value changes or simple fades, disable parallax/scale animations, keep essential feedback animations (loading spinner) but simplify them.
WHY: Vestibular disorders affect ~35% of adults over 40. Animations can trigger nausea, dizziness, and migraines. Respecting reduced motion is both an a11y requirement and a legal compliance issue (WCAG 2.3.3).
BAD:
// Ignores reduced motion preference
const entering = FadeInDown.springify().damping(15).stiffness(300);GOOD:
import { useReducedMotion, ReduceMotion } from 'react-native-reanimated';
// Per-component approach
function Card({ children }: { children: ReactNode }) {
const reducedMotion = useReducedMotion();
const entering = reducedMotion ? FadeIn.duration(100) : FadeInDown.springify().damping(15);
return <Animated.View entering={entering}>{children}</Animated.View>;
}
// OR: Global approach at app root
// <ReducedMotionConfig mode={ReduceMotion.System} />
// This makes all Reanimated animations respect the system setting automatically.Stack relevance: Add <ReducedMotionConfig mode={ReduceMotion.System} /> in the root layout. This is a one-line fix that makes ALL Reanimated animations respect the system setting.
2.5 Dynamic Type Support
RULE: Never disable allowFontScaling. Use maxFontSizeMultiplier to cap scaling where layout breaks. Test at 200% font scale. Design layouts to accommodate text that may be 2x its default size.
WHY: See 1.4. This is the accessibility rule version — dynamic type is not optional, it's required for users with low vision.
Testing checklist:
- [ ] Set device font size to maximum
- [ ] Verify no text truncation hides critical information
- [ ] Verify no overlapping elements
- [ ] Verify buttons remain tappable
- [ ] Verify scroll works when content overflows
2.6 Touch Target Sizing (WCAG 2.5.8)
RULE: See 1.5. In accessibility context: WCAG 2.5.8 (Level AA) requires 24x24 CSS pixel minimum. Apple HIG recommends 44x44pt. TwoMore targets 44x44pt for all interactive elements.
Exceptions (per WCAG 2.5.8): Inline links within text blocks, elements where size is determined by user agent, elements where the target area is not changed by the author.
2.7 Semantic Grouping
RULE: Use accessibilityElementsHidden={true} to hide decorative elements from screen readers. Use importantForAccessibility="no-hide-descendants" on Android to hide subtrees. Group related content with accessible={true} on the parent.
WHY: Screen readers traverse every element. Decorative images, dividers, and redundant text slow navigation and confuse users.
BAD:
// Screen reader announces: "image", "star", "star", "star", "star", "4.5", "Rating"
<Image source={icon} />
<Icon name="star" /><Icon name="star" /><Icon name="star" /><Icon name="star" />
<Text>4.5</Text>
<Text>Rating</Text>GOOD:
// Screen reader announces: "Rating 4.5 out of 5"
<View accessible={true} accessibilityLabel={`${t('rating')} 4.5 ${t('outOf')} 5`}>
<Image source={icon} accessibilityElementsHidden={true} />
<View accessibilityElementsHidden={true}>
<Icon name="star" />
<Icon name="star" />
<Icon name="star" />
<Icon name="star" />
</View>
<Text>4.5</Text>
</View>2.8 Live Regions
RULE: Use accessibilityLiveRegion="polite" on Android for dynamic content that updates (counters, status changes, notifications). On iOS, use AccessibilityInfo.announceForAccessibility() since iOS has no live region equivalent.
WHY: Sighted users see changes immediately. Screen reader users miss dynamic updates unless explicitly announced.
GOOD (cross-platform):
function RsvpCounter({ count, max }: { count: number; max: number }) {
const prevCount = useRef(count);
useEffect(() => {
if (count !== prevCount.current && Platform.OS === 'ios') {
AccessibilityInfo.announceForAccessibility(
`${count}${t('session.of')}${max}${t('session.slotsFilledAnnouncement')}`
);
}
prevCount.current = count;
}, [count]);
return (
<Text
accessibilityLiveRegion="polite" // Works on Android
accessibilityLabel={`${count} ${t('session.of')} ${max} ${t('session.slotsFilled')}`}
>
{count}/{max}
</Text>
);
}2.9 Accessibility Testing
RULE: Test with VoiceOver (iOS) and TalkBack (Android) before every release. Use React Native Testing Library's getByRole, getByLabelText queries in tests. Consider react-native-ama for build-time accessibility linting.
WHY: Automated tools catch ~30% of a11y issues. Manual screen reader testing is irreplaceable for catching flow issues, missing labels, and confusing navigation order.
Testing checklist per screen:
- [ ] Every interactive element has a role + label
- [ ] Focus order matches visual order
- [ ] Dynamic content is announced
- [ ] No focus trapped outside modals
- [ ] All information conveyed by color is also conveyed by text/icon
Stack relevance: Add getByRole / getByLabelText assertions to component tests. TwoMore already has 490 tests — extend with a11y queries.
Part 3: Navigation
3.1 Stack vs Tab Patterns
RULE: Use tabs for top-level app sections (home, clubs, activity, profile). Use stacks for hierarchical navigation (club > session > match). Push detail screens above tabs (stack-over-tabs). Never nest more than 3 stack levels deep.
WHY: Tabs provide persistent access to major sections. Stacks provide depth within a section. Too many nesting levels confuse users and break back navigation.
BAD:
(tabs)
home/
club/[clubId]/
session/[sessionId]/
match/[matchId]/
score/ ← 5 levels deep, confusing back stackGOOD:
(app)/_layout.tsx Stack (wraps everything)
(tabs)/_layout.tsx Tabs (home, clubs, activity, profile)
(tabs)/(clubs)/[clubId]/ Club detail (inside club tab stack)
(tabs)/(clubs)/[clubId]/session/[sessionId] Session detail
(tabs)/(clubs)/[clubId]/matches/[sessionId] Match board (hides tab bar)Stack relevance: TwoMore already uses this exact pattern. Club and profile screens are inside tab stacks. Match board hides tab bar with useHideTabBar().
3.2 Deep Linking
RULE: Configure universal links (iOS) and app links (Android) in app.config.ts. All routes are automatically deep-linkable with Expo Router. Test deep links with npx uri-scheme open. Handle unauthenticated deep links by redirecting to auth, then forwarding to the original URL after login.
WHY: Deep links enable sharing, push notification navigation, and web-to-app conversion. Without auth handling, deep links to protected screens crash or show blank screens.
GOOD (app.config.ts):
{
ios: {
associatedDomains: ['applinks:twomore.app'],
},
android: {
intentFilters: [{
action: 'VIEW',
autoVerify: true,
data: [{ scheme: 'https', host: 'twomore.app', pathPrefix: '/' }],
}],
},
}GOOD (auth redirect with deep link preservation):
// In auth guard layout
function AuthGuard() {
const { session } = useAuth();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (!session) {
// Redirect to login, preserving the intended destination
router.replace(`/login?redirect=${encodeURIComponent(segments.join('/'))}`);
}
}, [session]);
return session ? <Slot /> : null;
}Stack relevance: Expo Router v4 makes all routes deep-linkable by default. Configure universal links for twomore.app when deploying. Use Stack.Protected for auth guards.
3.3 Navigation State Persistence
RULE: For development, enable navigation state persistence to restore the screen on hot reload. For production, persist only when explicitly needed (e.g., "resume where you left off" feature). Always handle stale state (expired session IDs, deleted clubs).
WHY: Dev persistence saves time during iteration. Production persistence can navigate to deleted resources if not validated. Stale state causes crashes.
GOOD:
// Expo Router handles persistence via linking config
// For manual persistence (React Navigation underlying):
const PERSISTENCE_KEY = 'NAVIGATION_STATE';
function RootLayout() {
const [isReady, setIsReady] = useState(__DEV__ ? false : true);
const [initialState, setInitialState] = useState();
useEffect(() => {
if (__DEV__) {
AsyncStorage.getItem(PERSISTENCE_KEY)
.then((state) => state && setInitialState(JSON.parse(state)))
.finally(() => setIsReady(true));
}
}, []);
if (!isReady) return null;
return (
<NavigationContainer
initialState={initialState}
onStateChange={(state) => {
if (__DEV__) AsyncStorage.setItem(PERSISTENCE_KEY, JSON.stringify(state));
}}
>
<Slot />
</NavigationContainer>
);
}3.4 Screen Transitions
RULE: Use platform default transitions (slide from right on iOS, fade on Android) for standard navigation. Use presentation: 'modal' for creation/edit flows. Use shared element transitions only when they add clear value (e.g., card-to-detail). Keep custom transitions simple.
WHY: Platform default transitions feel native and meet user expectations. Over-customized transitions feel slow and unfamiliar. Shared element transitions are still experimental in Expo Router / Reanimated.
GOOD (Expo Router modal):
// app/(app)/_layout.tsx
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="create-session"
options={{ presentation: 'modal', headerTitle: t('session.create') }}
/>
</Stack>Stack relevance: Expo SDK 55 introduced zoom transitions (iOS only, alpha). For now, stick with platform defaults. Shared element transitions via Reanimated sharedTransitionTag are available but experimental.
3.5 Tab Bar Patterns
RULE: Show tab bar on main screens. Hide on immersive screens (match board, full-screen media). Use badge counts for notifications (unread count). Apply haptic feedback on tab tap (optional). Active tab should have clear visual distinction.
WHY: Tab bar provides persistent wayfinding. Hiding it on immersive screens prevents accidental tab switches. Badge counts reduce the need to manually check tabs.
BAD:
// Tab bar always visible, even on match board where it's distractingGOOD:
// useHideTabBar hook — already in TwoMore
function MatchBoard() {
useHideTabBar(); // Hides tab bar for this screen
return <View className="flex-1">{/* Full-screen match content */}</View>;
}Stack relevance: TwoMore already has theme-reactive tab bar with pill background for active tab (primaryColor 10% opacity) and useHideTabBar() for match board.
3.6 Back Button Handling
RULE: Always handle Android hardware back button. For modals with unsaved changes, show a confirmation dialog before dismissing. Use router.canGoBack() before calling router.back(). Never leave users stuck with no way to go back.
WHY: Android users rely on the hardware back button. Dismissing forms without saving is data loss. Back navigation failures are the #1 navigation bug.
GOOD:
import { usePreventRemove } from '@react-navigation/native';
function EditSessionScreen() {
const { isDirty } = useFormContext();
usePreventRemove(isDirty, ({ data }) => {
Alert.alert(t('common.unsavedChanges'), t('common.unsavedChangesMessage'), [
{ text: t('common.stayOnPage'), style: 'cancel' },
{ text: t('common.discard'), style: 'destructive', onPress: () => data.action.dispatch() },
]);
});
return <EditForm />;
}3.7 Navigation Guards — Auth & Protected Routes
RULE: Use Expo Router's Stack.Protected for auth guards. Place auth-required screens inside a protected group. Redirect unauthenticated users to login. After login, redirect back to the originally requested screen.
WHY: Protected routes prevent unauthorized access. Deep link + protected route ensures unauthenticated deep links work correctly. Stack.Protected is Expo Router's declarative solution (no manual redirect logic).
GOOD (Expo Router v4):
// app/(app)/_layout.tsx
import { Stack } from 'expo-router';
import { useAuth } from '@/presentation/hooks/use-auth';
export default function AppLayout() {
const { session } = useAuth();
return (
<Stack>
<Stack.Protected guard={!!session}>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="create-club" options={{ presentation: 'modal' }} />
</Stack.Protected>
<Stack.Screen name="login" options={{ headerShown: false }} />
</Stack>
);
}Stack relevance: TwoMore auth flow uses Kakao Login + Supabase Auth. Implement Stack.Protected when upgrading from the current redirect-based approach.
3.8 Navigation Performance
RULE: Use React.lazy() + Suspense for heavy screens. Preload critical screens after startup. Avoid importing heavy dependencies at the top level of navigation files. Keep route files thin (<30 lines).
WHY: Every imported screen adds to startup bundle. Lazy loading defers this cost. Route files that import heavy dependencies slow navigation mount time.
BAD:
// app/(tabs)/ranking.tsx — imports everything eagerly
import { Chart } from 'victory-native'; // Heavy charting lib imported on app start
import { AnimatedRanking } from '@/presentation/screens/ranking';GOOD:
// app/(tabs)/ranking.tsx — thin route file
import { RankingScreen } from '@/presentation/screens/ranking-screen';
export default function RankingRoute() {
return <RankingScreen />;
}
// Heavy dependencies lazy-loaded inside the screen component
// Chart component only imported when stats tab is selectedStack relevance: TwoMore convention: route files max ~30 lines, delegate to presentation screen components. Keep this pattern. For future heavy screens (statistics, charts), use dynamic imports.
Summary — Quick-Reference Rules
| # | Category | Rule | Priority |
|---|---|---|---|
| 1.1 | Components | Composition over drilling; max 2 prop levels | High |
| 1.2 | Responsive | useWindowDimensions + SafeAreaView always | High |
| 1.3 | Dark mode | CSS vars only, never hardcoded hex in components | High |
| 1.4 | Typography | maxFontSizeMultiplier on all Text, never disable scaling | High |
| 1.5 | Touch | 44x44pt minimum, hitSlop for small visuals | High |
| 1.6 | Loading | Skeleton on first load, RefreshControl on refetch | Medium |
| 1.7 | Errors | Inline (forms), toast (transient), full-screen (critical) | High |
| 1.8 | Empty | Icon + message + CTA button | Medium |
| 1.9 | Pull refresh | isFetching && !isLoading for RefreshControl | Medium |
| 1.10 | Keyboard | Platform-specific behavior, keyboardShouldPersistTaps | High |
| 1.11 | Gestures | RNGH v2 + Reanimated worklets, never JS thread | High |
| 1.12 | Animation | Spring physics, respect useReducedMotion() | High |
| 1.13 | Bottom sheet | @gorhom/bottom-sheet, backdrop + gesture dismiss | Medium |
| 1.14 | Forms | RHF + zodResolver, mode: 'onBlur', Controller | High |
| 2.1 | Screen reader | Label + role + hint on all interactives | Critical |
| 2.2 | Focus | Logical order, trap in modals, move on navigation | High |
| 2.3 | Contrast | 4.5:1 text, 3:1 large text, both themes | Critical |
| 2.4 | Motion | ReducedMotionConfig at root, fallback to fades | High |
| 2.5 | Dynamic type | Never disable, test at 200% | High |
| 2.6 | Targets | 44x44pt, WCAG 2.5.8 | High |
| 2.7 | Grouping | Hide decorative, group related, accessible={true} | Medium |
| 2.8 | Live regions | accessibilityLiveRegion (Android) + announceForAccessibility (iOS) | Medium |
| 2.9 | Testing | VoiceOver + TalkBack + getByRole in tests | High |
| 3.1 | Stack/Tab | Tabs for sections, stacks for depth, max 3 levels | High |
| 3.2 | Deep linking | Universal links, auth-aware redirect | Medium |
| 3.3 | Persistence | Dev only, validate stale state | Low |
| 3.4 | Transitions | Platform defaults, modal for create/edit | Medium |
| 3.5 | Tab bar | Hide on immersive, badge counts, haptic tap | Medium |
| 3.6 | Back button | Handle hardware back, unsaved changes prompt | High |
| 3.7 | Auth guard | Stack.Protected, redirect post-login | High |
| 3.8 | Performance | Lazy screens, thin route files (<30 lines) | Medium |