# TV Modal Guide This document explains how to implement modals, bottom sheets, and overlays on Apple TV and Android TV in React Native. ## The Problem On TV platforms, modals have unique challenges: - The hardware back button must work correctly to dismiss modals - Focus management must be handled explicitly - React Native's `Modal` component breaks the TV focus chain - Overlay/absolute-positioned modals don't handle back button correctly ## Navigation-Based Modal Pattern (Recommended) For modals that need proper back button support, use the **navigation-based modal pattern**. This leverages Expo Router's stack navigation with transparent modal presentation. ### Architecture ``` ┌─────────────────────────────────────┐ │ 1. Jotai Atom (state) │ │ Stores modal data/params │ ├─────────────────────────────────────┤ │ 2. Hook (trigger) │ │ Sets atom + calls router.push() │ ├─────────────────────────────────────┤ │ 3. Page File (UI) │ │ Reads atom, renders modal │ │ Clears atom on unmount │ ├─────────────────────────────────────┤ │ 4. Stack.Screen (config) │ │ presentation: transparentModal │ │ animation: fade │ └─────────────────────────────────────┘ ``` ### Step 1: Create the Atom Create a Jotai atom to store the modal state/data: ```typescript // utils/atoms/tvExampleModal.ts import { atom } from "jotai"; export interface TVExampleModalData { itemId: string; title: string; // ... other data the modal needs } export const tvExampleModalAtom = atom(null); ``` ### Step 2: Create the Hook Create a hook that sets the atom and navigates to the modal: ```typescript // hooks/useTVExampleModal.ts import { useSetAtom } from "jotai"; import { router } from "expo-router"; import { tvExampleModalAtom, TVExampleModalData } from "@/utils/atoms/tvExampleModal"; export const useTVExampleModal = () => { const setModalData = useSetAtom(tvExampleModalAtom); const openModal = (data: TVExampleModalData) => { setModalData(data); router.push("/tv-example-modal"); }; return { openModal }; }; ``` ### Step 3: Create the Modal Page Create a page file that reads the atom and renders the modal UI: ```typescript // app/(auth)/tv-example-modal.tsx import { useEffect } from "react"; import { View, Pressable, Text } from "react-native"; import { useAtom } from "jotai"; import { router } from "expo-router"; import { BlurView } from "expo-blur"; import { tvExampleModalAtom } from "@/utils/atoms/tvExampleModal"; export default function TVExampleModal() { const [modalData, setModalData] = useAtom(tvExampleModalAtom); // Clear atom on unmount useEffect(() => { return () => { setModalData(null); }; }, [setModalData]); // Handle case where modal is opened without data if (!modalData) { router.back(); return null; } return ( {/* Background overlay */} router.back()} /> {/* Modal content */} {modalData.title} {/* Modal content here */} router.back()} hasTVPreferredFocus style={({ focused }) => ({ marginTop: 24, padding: 16, borderRadius: 8, backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)", })} > {({ focused }) => ( Close )} ); } ``` ### Step 4: Add Stack.Screen Configuration Add the modal route to `app/_layout.tsx`: ```typescript // In app/_layout.tsx, inside your Stack navigator ``` ### Usage ```typescript // In any component import { useTVExampleModal } from "@/hooks/useTVExampleModal"; const MyComponent = () => { const { openModal } = useTVExampleModal(); return ( openModal({ itemId: "123", title: "Example" })} > Open Modal ); }; ``` ### Reference Implementation See `useTVRequestModal` + `app/(auth)/tv-request-modal.tsx` for a complete working example. --- ## Bottom Sheet Pattern (Inline Overlays) For simpler overlays that don't need back button navigation (like option selectors), use an **inline absolute-positioned overlay**. This pattern is ideal for: - Dropdown selectors - Quick action menus - Option pickers ### Key Principles 1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain 2. **Horizontal ScrollView for options** - Natural for TV remotes (left/right D-pad) 3. **Disable background focus** - Prevent focus flickering between overlay and background ### Implementation ```typescript import { useState } from "react"; import { View, ScrollView, Pressable, Text } from "react-native"; import { BlurView } from "expo-blur"; const TVOptionSelector: React.FC<{ options: { label: string; value: string }[]; selectedValue: string; onSelect: (value: string) => void; isOpen: boolean; onClose: () => void; }> = ({ options, selectedValue, onSelect, isOpen, onClose }) => { if (!isOpen) return null; const selectedIndex = options.findIndex(o => o.value === selectedValue); return ( {options.map((option, index) => ( { onSelect(option.value); onClose(); }} /> ))} ); }; ``` ### Option Card Component ```typescript import { useState, useRef, useEffect } from "react"; import { Pressable, Text, Animated } from "react-native"; const TVOptionCard: React.FC<{ label: string; isSelected: boolean; hasTVPreferredFocus?: boolean; onPress: () => void; }> = ({ label, isSelected, hasTVPreferredFocus, onPress }) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; const animateTo = (toValue: number) => { Animated.spring(scale, { toValue, useNativeDriver: true, tension: 50, friction: 7, }).start(); }; return ( { setFocused(true); animateTo(1.05); }} onBlur={() => { setFocused(false); animateTo(1); }} hasTVPreferredFocus={hasTVPreferredFocus} > {label} ); }; ``` ### Reference Implementation See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`. --- ## Focus Management for Overlays **CRITICAL**: When displaying overlays on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop. ### Solution Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible: ```typescript // 1. Track modal state const [openModal, setOpenModal] = useState(null); const isModalOpen = openModal !== null; // 2. Each focusable component accepts disabled prop const TVFocusableButton: React.FC<{ onPress: () => void; disabled?: boolean; }> = ({ onPress, disabled }) => ( {/* content */} ); // 3. Pass disabled to all background components when modal is open ``` ### Reference Implementation See `settings.tv.tsx` for a complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc. --- ## Focus Trapping For modals that should trap focus (prevent navigation outside the modal), use `TVFocusGuideView` with trap props: ```typescript import { TVFocusGuideView } from "react-native"; {/* Modal content - focus cannot escape */} ``` **Warning**: Don't use `autoFocus` on focus guide wrappers when you also have bidirectional focus guides - it can interfere with navigation. --- ## Common Mistakes | Mistake | Result | Fix | |---------|--------|-----| | Using React Native `Modal` | Focus chain breaks | Use navigation-based or absolute positioning | | Overlay without disabling background focus | Focus flickering loop | Add `disabled` prop to all background focusables | | No `hasTVPreferredFocus` in modal | Focus stuck on background | Set preferred focus on first modal element | | Missing `presentation: "transparentModal"` | Modal not transparent | Add to Stack.Screen options | | Not clearing atom on unmount | Stale data on reopen | Clear in useEffect cleanup | --- ## When to Use Which Pattern | Scenario | Pattern | |----------|---------| | Full-screen modal with back button | Navigation-based modal | | Confirmation dialogs | Navigation-based modal | | Option selectors / dropdowns | Bottom sheet (inline) | | Quick action menus | Bottom sheet (inline) | | Complex forms | Navigation-based modal |