Skip to content

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:

tsx
// 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:

tsx
// 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):

tsx
// 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:

tsx
const { width } = Dimensions.get('window'); // Static — never updates
const columns = width > 768 ? 3 : 2;

GOOD:

tsx
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):

tsx
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:

tsx
<View style={{ backgroundColor: isDark ? '#1a1a1a' : '#ffffff' }}>
  <Text style={{ color: isDark ? '#e5e5e5' : '#1a1a1a' }}>Hello</Text>
</View>

GOOD:

tsx
// Component — no conditional color logic
<YStack backgroundColor="$background">
  <Text color="$text">Hello</Text>
</YStack>

GOOD (manual toggle):

tsx
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:

tsx
// Completely blocks accessibility
<Text allowFontScaling={false} style={{ fontSize: 16 }}>
  Session Details
</Text>

GOOD:

tsx
// 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:

tsx
// 24x24 icon button with no hitSlop — fails WCAG
<Pressable onPress={onClose}>
  <Icon name="close" size={24} />
</Pressable>

GOOD:

tsx
// 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):

tsx
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:

tsx
if (isLoading) return <ActivityIndicator />;
return <FlatList data={sessions} ... />;

GOOD:

tsx
if (isLoading) return <SkeletonList count={5} />;
return <FlatList data={sessions} ... />;

GOOD (skeleton matching content layout):

tsx
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 TypeDisplayAction
Form validationInline below fieldFix and resubmit
API 4xx (user error)Toast/snackbarDismiss or fix input
API 5xx (server error)Full-screen or bannerRetry button
Network offlinePersistent bannerAuto-retry on reconnect
Render crashErrorBoundary fallbackRestart screen

BAD:

tsx
if (error) return <Text>{error.message}</Text>; // Raw error, no retry

GOOD:

tsx
// 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:

tsx
if (data.length === 0) return <Text>No sessions</Text>;

GOOD:

tsx
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:

tsx
// No pull-to-refresh, or full skeleton on refetch
if (isLoading || isFetching) return <SkeletonList />;

GOOD:

tsx
<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:

tsx
// No keyboard handling — input hidden behind keyboard
<View>
  <TextInput placeholder="Name" />
  <TextInput placeholder="Description" />
  <Button title="Submit" />
</View>

GOOD:

tsx
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:

tsx
// JS thread pan handling — laggy
<View
  onTouchMove={(e) => {
    setPosition({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
  }}
/>

GOOD:

tsx
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:

PurposeDampingStiffnessMass
Button press204001
Card enter153001
Modal slide202001
Bounce/attention83001

BAD:

tsx
// Duration-based — feels mechanical
withTiming(1, { duration: 300 });

GOOD:

tsx
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:

tsx
// React Native Modal — no gesture dismiss, no snap points
<Modal visible={visible} onRequestClose={onClose}>
  <View style={{ height: '50%' }}>{content}</View>
</Modal>

GOOD:

tsx
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:

tsx
// Manual validation — error-prone, no type safety
const [name, setName] = useState('');
const [errors, setErrors] = useState({});

function validate() {
  if (!name) setErrors({ name: 'Required' });
}

GOOD:

tsx
// 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:

tsx
<Pressable onPress={handleRsvp}>
  <Text>RSVP</Text>
</Pressable>

GOOD:

tsx
<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):

tsx
// 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):

tsx
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):

tsx
// 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:

ElementRatioExample
Body text (14-16pt)4.5:1#1a1a1a on #ffffff = 17.4:1
Large text (18pt+)3:1
Muted/secondary text4.5:1#737373 on #ffffff = 4.7:1 (barely passes)
Button text on primary4.5:1White on brand color
Icons/UI components3:1Borders, toggles, checkboxes
Placeholder text3:1 minimum

BAD:

css
/* Dark mode — muted text too low contrast */
--color-muted: #555555; /* on #0a0a0a background = 2.7:1 FAIL */

GOOD:

css
/* 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:

tsx
// Ignores reduced motion preference
const entering = FadeInDown.springify().damping(15).stiffness(300);

GOOD:

tsx
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:

tsx
// 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:

tsx
// 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):

tsx
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 stack

GOOD:

(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):

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):

tsx
// 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:

tsx
// 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):

tsx
// 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:

tsx
// Tab bar always visible, even on match board where it's distracting

GOOD:

tsx
// 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:

tsx
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):

tsx
// 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:

tsx
// 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:

tsx
// 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 selected

Stack 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

#CategoryRulePriority
1.1ComponentsComposition over drilling; max 2 prop levelsHigh
1.2ResponsiveuseWindowDimensions + SafeAreaView alwaysHigh
1.3Dark modeCSS vars only, never hardcoded hex in componentsHigh
1.4TypographymaxFontSizeMultiplier on all Text, never disable scalingHigh
1.5Touch44x44pt minimum, hitSlop for small visualsHigh
1.6LoadingSkeleton on first load, RefreshControl on refetchMedium
1.7ErrorsInline (forms), toast (transient), full-screen (critical)High
1.8EmptyIcon + message + CTA buttonMedium
1.9Pull refreshisFetching && !isLoading for RefreshControlMedium
1.10KeyboardPlatform-specific behavior, keyboardShouldPersistTapsHigh
1.11GesturesRNGH v2 + Reanimated worklets, never JS threadHigh
1.12AnimationSpring physics, respect useReducedMotion()High
1.13Bottom sheet@gorhom/bottom-sheet, backdrop + gesture dismissMedium
1.14FormsRHF + zodResolver, mode: 'onBlur', ControllerHigh
2.1Screen readerLabel + role + hint on all interactivesCritical
2.2FocusLogical order, trap in modals, move on navigationHigh
2.3Contrast4.5:1 text, 3:1 large text, both themesCritical
2.4MotionReducedMotionConfig at root, fallback to fadesHigh
2.5Dynamic typeNever disable, test at 200%High
2.6Targets44x44pt, WCAG 2.5.8High
2.7GroupingHide decorative, group related, accessible={true}Medium
2.8Live regionsaccessibilityLiveRegion (Android) + announceForAccessibility (iOS)Medium
2.9TestingVoiceOver + TalkBack + getByRole in testsHigh
3.1Stack/TabTabs for sections, stacks for depth, max 3 levelsHigh
3.2Deep linkingUniversal links, auth-aware redirectMedium
3.3PersistenceDev only, validate stale stateLow
3.4TransitionsPlatform defaults, modal for create/editMedium
3.5Tab barHide on immersive, badge counts, haptic tapMedium
3.6Back buttonHandle hardware back, unsaved changes promptHigh
3.7Auth guardStack.Protected, redirect post-loginHigh
3.8PerformanceLazy screens, thin route files (<30 lines)Medium

Markdown remains the source of truth. Run yarn docs:check before handoff.