12 KiB
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
Modalcomponent 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:
// utils/atoms/tvExampleModal.ts
import { atom } from "jotai";
export interface TVExampleModalData {
itemId: string;
title: string;
// ... other data the modal needs
}
export const tvExampleModalAtom = atom<TVExampleModalData | null>(null);
Step 2: Create the Hook
Create a hook that sets the atom and navigates to the modal:
// 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:
// 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 (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
{/* Background overlay */}
<Pressable
style={{
position: "absolute",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.7)",
}}
onPress={() => router.back()}
/>
{/* Modal content */}
<BlurView
intensity={80}
tint="dark"
style={{
padding: 32,
borderRadius: 16,
minWidth: 400,
}}
>
<Text style={{ color: "#fff", fontSize: 24 }}>
{modalData.title}
</Text>
{/* Modal content here */}
<Pressable
onPress={() => router.back()}
hasTVPreferredFocus
style={({ focused }) => ({
marginTop: 24,
padding: 16,
borderRadius: 8,
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
})}
>
{({ focused }) => (
<Text style={{ color: focused ? "#000" : "#fff" }}>
Close
</Text>
)}
</Pressable>
</BlurView>
</View>
);
}
Step 4: Add Stack.Screen Configuration
Add the modal route to app/_layout.tsx:
// In app/_layout.tsx, inside your Stack navigator
<Stack.Screen
name="(auth)/tv-example-modal"
options={{
presentation: "transparentModal",
animation: "fade",
headerShown: false,
}}
/>
Usage
// In any component
import { useTVExampleModal } from "@/hooks/useTVExampleModal";
const MyComponent = () => {
const { openModal } = useTVExampleModal();
return (
<Pressable
onPress={() => openModal({ itemId: "123", title: "Example" })}
>
<Text>Open Modal</Text>
</Pressable>
);
};
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
- Use absolute positioning instead of Modal - React Native's
Modalbreaks the TV focus chain - Horizontal ScrollView for options - Natural for TV remotes (left/right D-pad)
- Disable background focus - Prevent focus flickering between overlay and background
Implementation
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 (
<View
style={{
position: "absolute",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<BlurView
intensity={80}
tint="dark"
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingVertical: 32,
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVOptionCard
key={option.value}
label={option.label}
isSelected={option.value === selectedValue}
hasTVPreferredFocus={index === selectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</BlurView>
</View>
);
};
Option Card Component
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 (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)",
paddingHorizontal: 24,
paddingVertical: 16,
borderRadius: 12,
borderWidth: isSelected ? 2 : 0,
borderColor: "#fff",
}}
>
<Text style={{ color: focused ? "#000" : "#fff", fontSize: 18 }}>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
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:
// 1. Track modal state
const [openModal, setOpenModal] = useState<ModalType | null>(null);
const isModalOpen = openModal !== null;
// 2. Each focusable component accepts disabled prop
const TVFocusableButton: React.FC<{
onPress: () => void;
disabled?: boolean;
}> = ({ onPress, disabled }) => (
<Pressable
onPress={onPress}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={isFirst && !disabled}
>
{/* content */}
</Pressable>
);
// 3. Pass disabled to all background components when modal is open
<TVFocusableButton onPress={handlePress} disabled={isModalOpen} />
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:
import { TVFocusGuideView } from "react-native";
<TVFocusGuideView
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
>
{/* Modal content - focus cannot escape */}
</TVFocusGuideView>
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 |