Skip to content

tvOS Development Guide

Stack: React Native for TV + TypeScript + StyleSheet + Focus Navigation Last Updated: 2026-01-30

Overview

This guide covers tvOS development patterns and best practices for the Bayit+ Apple TV application. tvOS requires special consideration for 10-foot UI design, focus-based navigation, and Siri Remote input handling.

Technology Stack

TechnologyVersionPurpose
React Native0.73+Cross-platform framework
React Native for TVBuilt-inTV-specific APIs
TypeScript5.0+Type safety
StyleSheetBuilt-inStyling
React Navigation6.xScreen navigation
AsyncStorageBuilt-inPersistent storage
i18next25.8+Internationalization

Project Structure

tvos-app/
├── src/
│   ├── components/          # React Native components
│   │   ├── common/          # Shared components
│   │   ├── features/        # Feature-specific components
│   │   └── navigation/      # Navigation components
│   ├── screens/             # Screen components
│   ├── services/            # API services
│   ├── hooks/               # Custom React hooks
│   ├── utils/               # Utility functions
│   ├── types/               # TypeScript types
│   ├── config/              # Configuration
│   └── styles/              # Shared styles and theme
├── ios/                     # iOS/tvOS native code
│   ├── BayitPlusTV.xcodeproj
│   └── BayitPlusTV/
├── package.json
├── tsconfig.json
└── metro.config.js          # Metro bundler configuration

Getting Started

Prerequisites

bash
# Check versions
node --version  # Should be 18+
npm --version   # Should be 8+
xcodebuild -version  # Should be Xcode 15+

# tvOS Simulator
# Install via Xcode > Settings > Platforms > tvOS

Installation

bash
cd tvos-app
npm install
cd ios
pod install
cd ..

Development Server

bash
npm start
# Metro bundler starts on port 8081

# In another terminal, run:
npm run ios
# Opens tvOS Simulator with the app

Build for Production

bash
npm run build:tvos
# Output: ios/build/Release-appletvos/

10-Foot UI Design Principles

tvOS apps are viewed from 10 feet away on large screens. Follow these principles:

Typography

typescript
const styles = StyleSheet.create({
  // ❌ TOO SMALL - Unreadable from 10 feet
  tinyText: {
    fontSize: 12,
  },

  // ✅ MINIMUM - 29pt for body text
  bodyText: {
    fontSize: 29,
    color: '#fff',
  },

  // ✅ OPTIMAL - 38pt for headings
  heading: {
    fontSize: 38,
    fontWeight: 'bold',
    color: '#fff',
  },

  // ✅ LARGE - 48pt for titles
  title: {
    fontSize: 48,
    fontWeight: 'bold',
    color: '#fff',
  },
});

Guidelines:

  • Minimum body text: 29pt
  • Headings: 38pt - 48pt
  • Titles: 48pt - 76pt
  • Line height: 1.3x - 1.5x font size

Spacing and Layout

typescript
const styles = StyleSheet.create({
  container: {
    padding: 60,  // ✅ Comfortable padding
  },

  grid: {
    gap: 40,  // ✅ Visible spacing between items
  },

  // ❌ TOO TIGHT - Hard to navigate
  tightGrid: {
    gap: 10,
  },
});

Guidelines:

  • Minimum padding: 60px
  • Grid gaps: 40px - 80px
  • Touch targets: 250x150px minimum
  • Safe zones: 90px from screen edges

Focus Affordances

typescript
const styles = StyleSheet.create({
  button: {
    backgroundColor: 'rgba(255, 255, 255, 0.1)',
    borderRadius: 16,
    padding: 24,
  },

  buttonFocused: {
    backgroundColor: 'rgba(255, 255, 255, 0.3)',
    transform: [{ scale: 1.05 }],
    shadowColor: '#fff',
    shadowOffset: { width: 0, height: 8 },
    shadowOpacity: 0.5,
    shadowRadius: 16,
  },
});

Guidelines:

  • Scale: 1.05x - 1.1x when focused
  • Shadow: White glow for depth
  • Border: 3px - 5px border on focus
  • Animation: 200ms - 300ms transition

Focus Navigation

Making Components Focusable

typescript
import { Pressable, View, Text, StyleSheet } from 'react-native';

interface FocusableButtonProps {
  title: string;
  onPress: () => void;
}

export const FocusableButton: React.FC<FocusableButtonProps> = ({ title, onPress }) => {
  return (
    <Pressable
      focusable={true}  // ✅ CRITICAL - Makes component focusable
      onPress={onPress}
      style={({ focused }) => [
        styles.button,
        focused && styles.buttonFocused,
      ]}
    >
      <Text style={styles.buttonText}>{title}</Text>
    </Pressable>
  );
};

const styles = StyleSheet.create({
  button: {
    backgroundColor: 'rgba(0, 0, 0, 0.2)',
    borderRadius: 16,
    paddingVertical: 16,
    paddingHorizontal: 32,
    minWidth: 250,
    minHeight: 80,
  },
  buttonFocused: {
    backgroundColor: 'rgba(255, 255, 255, 0.3)',
    transform: [{ scale: 1.05 }],
    shadowColor: '#fff',
    shadowOffset: { width: 0, height: 8 },
    shadowOpacity: 0.5,
    shadowRadius: 16,
  },
  buttonText: {
    fontSize: 29,
    fontWeight: 'bold',
    color: '#fff',
    textAlign: 'center',
  },
});

Focus Order

typescript
import { View, Pressable, Text, StyleSheet } from 'react-native';

// Focus order: left to right, top to bottom (natural reading order)
export const NavigationBar = () => {
  return (
    <View style={styles.navbar}>
      <Pressable focusable={true} style={styles.navItem}>
        <Text>Home</Text>
      </Pressable>
      <Pressable focusable={true} style={styles.navItem}>
        <Text>Movies</Text>
      </Pressable>
      <Pressable focusable={true} style={styles.navItem}>
        <Text>Series</Text>
      </Pressable>
      <Pressable focusable={true} style={styles.navItem}>
        <Text>Live TV</Text>
      </Pressable>
    </View>
  );
};

const styles = StyleSheet.create({
  navbar: {
    flexDirection: 'row',  // Horizontal focus order
    gap: 40,
    paddingHorizontal: 60,
    paddingVertical: 30,
  },
  navItem: {
    backgroundColor: 'rgba(255, 255, 255, 0.1)',
    borderRadius: 12,
    paddingVertical: 12,
    paddingHorizontal: 24,
  },
});

TVFocusGuideView

Use TVFocusGuideView to control focus navigation:

typescript
import { View, Pressable, Text, TVFocusGuideView } from 'react-native';
import { useRef } from 'react';

export const CustomFocusLayout = () => {
  const targetRef = useRef(null);

  return (
    <View>
      {/* Focus guide directs focus to targetRef when coming from above */}
      <TVFocusGuideView
        autoFocus={false}
        destinations={[targetRef.current]}
      >
        <View style={{ height: 200 }}>
          {/* Empty area that redirects focus */}
        </View>
      </TVFocusGuideView>

      <Pressable
        ref={targetRef}
        focusable={true}
        style={styles.target}
      >
        <Text>Target Button</Text>
      </Pressable>
    </View>
  );
};

Preventing Focus Traps

typescript
// ❌ BAD - Focus can get stuck
<ScrollView>
  <Pressable focusable={true}>
    <View>
      <Pressable focusable={true}>
        {/* Nested focusable elements cause confusion */}
      </Pressable>
    </View>
  </Pressable>
</ScrollView>

// ✅ GOOD - Clear focus hierarchy
<ScrollView>
  <View>
    <Pressable focusable={true}>
      <Text>Clear focusable target</Text>
    </Pressable>
  </View>
  <View>
    <Pressable focusable={true}>
      <Text>Another clear target</Text>
    </Pressable>
  </View>
</ScrollView>

Siri Remote Handling

Remote Gestures

typescript
import { useTVEventHandler } from 'react-native';

export const useRemoteControl = (onPress: (event: string) => void) => {
  useTVEventHandler((evt) => {
    if (evt.eventType === 'select') {
      onPress('select');
    } else if (evt.eventType === 'playPause') {
      onPress('playPause');
    } else if (evt.eventType === 'menu') {
      onPress('menu');
    } else if (evt.eventType === 'swipeUp') {
      onPress('swipeUp');
    } else if (evt.eventType === 'swipeDown') {
      onPress('swipeDown');
    } else if (evt.eventType === 'swipeLeft') {
      onPress('swipeLeft');
    } else if (evt.eventType === 'swipeRight') {
      onPress('swipeRight');
    }
  });
};

// Usage
export const VideoPlayer = () => {
  useRemoteControl((event) => {
    switch (event) {
      case 'playPause':
        togglePlayPause();
        break;
      case 'swipeRight':
        skipForward();
        break;
      case 'swipeLeft':
        skipBackward();
        break;
      case 'menu':
        exitPlayer();
        break;
    }
  });

  return <View>{/* Video player UI */}</View>;
};

Siri Remote Events

EventDescriptionCommon Use
selectTrackpad clickActivate button, select item
playPausePlay/Pause buttonToggle playback
menuMenu buttonGo back, show menu
swipeUpSwipe upScroll up, show info
swipeDownSwipe downScroll down, hide info
swipeLeftSwipe leftPrevious, rewind
swipeRightSwipe rightNext, fast forward
longPressPress and holdContext menu, options

Component Patterns

Content Card (10-Foot UI)

typescript
import { Pressable, View, Text, Image, StyleSheet } from 'react-native';
import { GlassCard } from '@bayit/glass';

interface ContentCardProps {
  id: string;
  title: string;
  thumbnail: string;
  onPress: (id: string) => void;
}

export const ContentCard: React.FC<ContentCardProps> = ({
  id,
  title,
  thumbnail,
  onPress,
}) => {
  return (
    <Pressable
      focusable={true}
      onPress={() => onPress(id)}
      style={({ focused }) => [
        styles.card,
        focused && styles.cardFocused,
      ]}
    >
      <Image source={{ uri: thumbnail }} style={styles.thumbnail} />
      <Text style={styles.title} numberOfLines={2}>
        {title}
      </Text>
    </Pressable>
  );
};

const styles = StyleSheet.create({
  card: {
    width: 400,
    backgroundColor: 'rgba(0, 0, 0, 0.2)',
    borderRadius: 16,
    overflow: 'hidden',
  },
  cardFocused: {
    transform: [{ scale: 1.1 }],
    shadowColor: '#fff',
    shadowOffset: { width: 0, height: 12 },
    shadowOpacity: 0.6,
    shadowRadius: 24,
  },
  thumbnail: {
    width: '100%',
    height: 225,
    resizeMode: 'cover',
  },
  title: {
    fontSize: 29,
    fontWeight: 'bold',
    color: '#fff',
    padding: 20,
  },
});

Horizontal Scroll (FlatList)

typescript
import { FlatList, View, StyleSheet } from 'react-native';

interface ContentRowProps {
  items: Content[];
  onItemPress: (id: string) => void;
}

export const ContentRow: React.FC<ContentRowProps> = ({ items, onItemPress }) => {
  return (
    <FlatList
      horizontal
      data={items}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <ContentCard
          id={item.id}
          title={item.title}
          thumbnail={item.thumbnail}
          onPress={onItemPress}
        />
      )}
      contentContainerStyle={styles.list}
      showsHorizontalScrollIndicator={false}
      ItemSeparatorComponent={() => <View style={styles.separator} />}
    />
  );
};

const styles = StyleSheet.create({
  list: {
    paddingHorizontal: 60,
    paddingVertical: 20,
  },
  separator: {
    width: 40,  // Spacing between cards
  },
});

Loading State

typescript
import { View, ActivityIndicator, Text, StyleSheet } from 'react-native';

export const LoadingScreen = () => {
  return (
    <View style={styles.container}>
      <ActivityIndicator size="large" color="#fff" />
      <Text style={styles.text}>Loading...</Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#000',
  },
  text: {
    fontSize: 38,
    color: '#fff',
    marginTop: 20,
  },
});

Stack Navigator Setup

typescript
// App.tsx
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { HomeScreen } from './screens/HomeScreen';
import { ContentScreen } from './screens/ContentScreen';
import { PlayerScreen } from './screens/PlayerScreen';

export type RootStackParamList = {
  Home: undefined;
  Content: { id: string };
  Player: { contentId: string };
};

const Stack = createStackNavigator<RootStackParamList>();

export const App = () => {
  return (
    <NavigationContainer>
      <Stack.Navigator
        screenOptions={{
          headerShown: false,
          cardStyle: { backgroundColor: '#000' },
        }}
      >
        <Stack.Screen name="Home" component={HomeScreen} />
        <Stack.Screen name="Content" component={ContentScreen} />
        <Stack.Screen name="Player" component={PlayerScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
};
typescript
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { RootStackParamList } from '../App';

type NavigationProp = StackNavigationProp<RootStackParamList, 'Home'>;

export const HomeScreen = () => {
  const navigation = useNavigation<NavigationProp>();

  const handleContentPress = (id: string) => {
    navigation.navigate('Content', { id });
  };

  return (
    <View>
      <ContentCard id="123" title="Movie" onPress={handleContentPress} />
    </View>
  );
};

Styling Best Practices

Using StyleSheet

typescript
import { View, Text, StyleSheet } from 'react-native';

// ✅ CORRECT - StyleSheet for React Native
const Component = () => (
  <View style={styles.container}>
    <Text style={styles.title}>Title</Text>
  </View>
);

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'rgba(0, 0, 0, 0.2)',
    borderRadius: 16,
    padding: 60,
  },
  title: {
    fontSize: 48,
    fontWeight: 'bold',
    color: '#fff',
  },
});

Dynamic Styles

typescript
// Conditional styling based on props
interface CardProps {
  focused: boolean;
  selected: boolean;
}

const getCardStyle = ({ focused, selected }: CardProps) => [
  styles.card,
  focused && styles.cardFocused,
  selected && styles.cardSelected,
];

// Usage
<View style={getCardStyle({ focused: true, selected: false })} />

Responsive Sizing

typescript
import { Dimensions } from 'react-native';

const { width, height } = Dimensions.get('window');

const styles = StyleSheet.create({
  container: {
    width: width * 0.9,  // 90% of screen width
    height: height * 0.7,  // 70% of screen height
  },
});

Performance Optimization

FlatList Optimization

typescript
<FlatList
  data={items}
  renderItem={renderItem}
  keyExtractor={(item) => item.id}
  // Performance optimizations
  initialNumToRender={10}
  maxToRenderPerBatch={10}
  windowSize={5}
  removeClippedSubviews={true}
  getItemLayout={(data, index) => ({
    length: 250,  // Item height
    offset: 250 * index,
    index,
  })}
/>

Memoization

typescript
import { memo, useMemo, useCallback } from 'react';

// Memoize component
export const ContentCard = memo(({ id, title, thumbnail, onPress }) => {
  const handlePress = useCallback(() => {
    onPress(id);
  }, [id, onPress]);

  return (
    <Pressable onPress={handlePress}>
      {/* Card content */}
    </Pressable>
  );
});

// Memoize computed values
const filteredItems = useMemo(() => {
  return items.filter(item => item.section === 'movies');
}, [items]);

Image Optimization

typescript
import FastImage from 'react-native-fast-image';

<FastImage
  source={{ uri: thumbnail }}
  style={styles.thumbnail}
  resizeMode={FastImage.resizeMode.cover}
  priority={FastImage.priority.high}
/>

Testing

Component Testing

typescript
// ContentCard.test.tsx
import { render, fireEvent } from '@testing-library/react-native';
import { ContentCard } from './ContentCard';

describe('ContentCard', () => {
  const mockProps = {
    id: '123',
    title: 'Test Movie',
    thumbnail: 'https://example.com/image.jpg',
    onPress: jest.fn(),
  };

  it('renders title', () => {
    const { getByText } = render(<ContentCard {...mockProps} />);
    expect(getByText('Test Movie')).toBeTruthy();
  });

  it('calls onPress when pressed', () => {
    const { getByText } = render(<ContentCard {...mockProps} />);
    fireEvent.press(getByText('Test Movie'));
    expect(mockProps.onPress).toHaveBeenCalledWith('123');
  });

  it('renders thumbnail', () => {
    const { getByRole } = render(<ContentCard {...mockProps} />);
    const image = getByRole('image');
    expect(image.props.source.uri).toBe(mockProps.thumbnail);
  });
});

Focus Testing

typescript
import { render } from '@testing-library/react-native';
import { FocusableButton } from './FocusableButton';

describe('FocusableButton', () => {
  it('is focusable', () => {
    const { getByRole } = render(
      <FocusableButton title="Test" onPress={jest.fn()} />
    );

    const button = getByRole('button');
    expect(button.props.focusable).toBe(true);
  });

  it('applies focused style when focused', () => {
    const { getByRole } = render(
      <FocusableButton title="Test" onPress={jest.fn()} />
    );

    const button = getByRole('button');
    // Simulate focus
    button.props.style({ focused: true });
    // Verify focused styles applied
  });
});

Manual Testing on tvOS Simulator

bash
# Run tests
npm test

# Launch tvOS Simulator
npm run ios

# Test checklist:
# - Navigate with arrow keys (up/down/left/right)
# - Press Enter to activate buttons
# - Press ESC for menu/back
# - Test all screens and flows
# - Verify focus indicators are visible
# - Check text readability from distance
# - Verify no focus traps exist

Build and Deployment

Development Build

bash
# Clean build
cd ios
xcodebuild clean -workspace BayitPlusTV.xcworkspace -scheme BayitPlusTV
cd ..

# Build
npm run ios

Production Build

bash
# Build for release
cd ios
xcodebuild archive \
  -workspace BayitPlusTV.xcworkspace \
  -scheme BayitPlusTV \
  -configuration Release \
  -archivePath ./build/BayitPlusTV.xcarchive

# Export for App Store
xcodebuild -exportArchive \
  -archivePath ./build/BayitPlusTV.xcarchive \
  -exportPath ./build/Release \
  -exportOptionsPlist exportOptions.plist

App Store Submission

  1. Prepare Assets:

    • App Icon: 1280x768px
    • Top Shelf Image: 1920x720px
    • Screenshots: 1920x1080px (3-5 screenshots)
  2. TestFlight:

    bash
    # Upload to TestFlight
    xcrun altool --upload-app \
      --type tvos \
      --file ./build/Release/BayitPlusTV.ipa \
      --username "your@email.com" \
      --password "app-specific-password"
  3. App Store Connect:

    • Complete app metadata
    • Add privacy policy
    • Submit for review

Environment Variables

bash
# .env
REACT_APP_API_BASE_URL=https://api.bayitplus.com
REACT_APP_WS_BASE_URL=wss://ws.bayitplus.com
REACT_APP_ENV=production

Usage:

typescript
import Config from 'react-native-config';

const apiUrl = Config.REACT_APP_API_BASE_URL;

Best Practices Summary

DO ✅

  • Use StyleSheet for all styling
  • Implement 10-foot UI principles
  • Make all interactive elements focusable
  • Use scale transforms for focus states
  • Test on real tvOS Simulator
  • Handle Siri Remote gestures
  • Implement proper focus indicators
  • Use large typography (29pt minimum)
  • Provide adequate spacing (40px+ gaps)
  • Test navigation from all directions

DON'T ❌

  • Don't use TailwindCSS (not supported)
  • Don't use small fonts (<29pt)
  • Don't create focus traps
  • Don't use complex gestures
  • Don't skip focus testing
  • Don't ignore safe zones
  • Don't use tight spacing
  • Don't nest focusable elements
  • Don't hardcode API URLs
  • Don't skip accessibility

Common Issues

Issue: Focus Not Working

Solution:

typescript
// Ensure focusable={true} is set
<Pressable focusable={true} onPress={handlePress}>
  <Text>Button</Text>
</Pressable>

// Check parent Views are not blocking focus
<View pointerEvents="box-none">
  <Pressable focusable={true}>
    {/* Content */}
  </Pressable>
</View>

Issue: Text Too Small

Solution:

typescript
// ❌ BAD - Unreadable
const styles = StyleSheet.create({
  text: { fontSize: 16 }
});

// ✅ GOOD - Readable from 10 feet
const styles = StyleSheet.create({
  text: { fontSize: 29 }
});

Issue: Focus Indicator Not Visible

Solution:

typescript
const styles = StyleSheet.create({
  buttonFocused: {
    transform: [{ scale: 1.1 }],
    shadowColor: '#fff',
    shadowOffset: { width: 0, height: 8 },
    shadowOpacity: 0.6,
    shadowRadius: 16,
    borderWidth: 3,
    borderColor: '#fff',
  },
});


Document Status: ✅ Complete Last Updated: 2026-01-30 Maintained by: tvOS Team Next Review: 2026-04-30

Released under the MIT License.