This collection of components and systems is specifically designed and optimized for TV-first experiences. Each component takes into account the unique challenges of TV application development, emphasizing efficient rendering, robust focus management for remote control navigation, and consistent interaction patterns while maintaining flexibility for customization.
The Grid component is a highly optimized solution for displaying collections of items in a TV interface. It uses virtualized rendering to efficiently handle large datasets while maintaining smooth navigation and focus management.
- TV-Optimized Performance: Uses
FlashListinstead of standardFlatListfor better performance with large datasets. - Focus Management: Integrates with
FocusGuideViewfor proper TV navigation. - Smart Sizing: Calculates optimal item dimensions based on screen size.
- Lazy Loading: Implements efficient pagination for smooth infinite scrolling.
- Visual Feedback: Provides clear focus indicators for remote control navigation.
The Grid component includes careful calculations to ensure proper sizing across different screen dimensions.
Example: Calculations for screen dimensions
// Calculate optimal item dimensions based on screen size
export const SCREEN_WIDTH = Dimensions.get('window').width;
export const SCREEN_HEIGHT = Dimensions.get('window').height;
export const COLUMNS = 6;
export const IMAGE_ASPECT_RATIO = 695 / 360;
export const BASE_ITEM_WIDTH = 360;
export const BASE_GRID_ITEM_HEIGHT = Math.floor(
BASE_ITEM_WIDTH * IMAGE_ASPECT_RATIO,
);
export const BASE_MARGIN = 40;
const TOTAL_MARGIN = BASE_MARGIN * (COLUMNS - 1);
const AVAILABLE_WIDTH = SCREEN_WIDTH * 0.9 - TOTAL_MARGIN;
export const ITEM_WIDTH = Math.floor(AVAILABLE_WIDTH / COLUMNS);
export const GRID_ITEM_HEIGHT = Math.floor(ITEM_WIDTH * IMAGE_ASPECT_RATIO);
const MIN_ITEM_WIDTH = 200;
export const ITEM_WIDTH_FINAL = Math.max(ITEM_WIDTH, MIN_ITEM_WIDTH);These calculations ensure the following:
- Grid items are sized proportionally to the screen.
- A consistent aspect ratio is maintained.
- Minimum sizing is enforced for legibility.
- Proper spacing is applied between items.
The Grid component uses FlashList with specific optimizations for TV interfaces.
Example: FlashList TV interface optimizations
<FlashList
ref={listRef}
data={items}
keyExtractor={item => item.id}
renderItem={renderItem}
numColumns={COLUMNS}
estimatedItemSize={GRID_ITEM_HEIGHT_FINAL}
estimatedListSize={listSize}
removeClippedSubviews
onEndReached={onEndReached}
onEndReachedThreshold={0.5}
bounces={false}
overrideItemLayout={layout => {
layout.size = GRID_ITEM_HEIGHT_FINAL - FLASHLIST_CORRECTION;
}}
// Additional optimization properties
scrollEventThrottle={20}
decelerationRate={0.92}
/>Note: The FlashList component uses a size correction (FLASHLIST_CORRECTION) to account for additional padding, borders, and scaling effects. This ensures proper item rendering and prevents visual glitches.
The Grid component uses layout measurement to optimize the performance of FlashList.
Example: FlashList performance optimization
const handleGridContainerLayout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
if (width > 0 && height > 0) {
setListSize({ width, height });
}
}, []);This measurement is crucial for the following:
- Providing accurate dimensions for virtual rendering.
- Optimizing visible area calculations.
- Ensuring proper initial rendering.
- Reducing unnecessary re-renders.
The Grid is wrapped in a FocusGuideView to ensure proper directional navigation.
Example: FocusGuideView navigation
<FocusGuideView autoFocus trapFocusRight>
{/* Grid content */}
</FocusGuideView>This enables the following:
- Automatic focus on the first item when the grid is mounted.
- Preventing focus from moving off the right edge of the grid.
- Proper directional navigation within the grid.
When using the Grid component in your TV application, the following is recommended.
-
Provide loading states. Always include loading indicators for initial load and pagination.
Example:
if ((isFetching || isLoading) && items.length === 0) { return <ActivityIndicator />; }
-
Optimize image loading. Use proper image dimensions to avoid resizing at runtime.
-
Handle empty states. Provide meaningful feedback when no items are available.
-
Implement pagination. Use the
onEndReachedcallback for infinite scrolling.Example:
<FlashList // other props onEndReached={onEndReached} onEndReachedThreshold={0.5} ListFooterComponent={() => { if (isFetchingNextPage && items.length > 0) { return <ActivityIndicator />; } return null; }} />
-
Memoize item rendering. Use
React.memoto prevent unnecessary re-renders.Example:
const MemoizedGridItem = React.memo(GridItem); const renderItem = useCallback( ({ item }) => ( <View style={styles.gridItemWrapper}> <MemoizedGridItem id={item.id} imageUrl={item.imageUrl} onPress={() => setSelectedImage(item.imageUrl)} /> </View> ), [styles.gridItemWrapper], );
Here's how the Grid component is used in the Home screen:
import { Grid } from '@AppComponents/Grid/Grid';
export const Home = () => {
// Other component logic
const {
items,
isLoading,
isFetching,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useVerticalPosters();
const handleLoadMore = React.useCallback(() => {
if (hasNextPage && !isFetching) {
void fetchNextPage();
}
}, [hasNextPage, isFetching, fetchNextPage]);
return (
<View style={styles.container}>
<ScreenContainer testID="home">
<FocusGuideView style={[styles.container]}>
{/* Other components */}
{isLoading ? (
<ActivityIndicator />
) : (
<Grid
items={items}
onEndReached={handleLoadMore}
isLoading={isLoading}
isFetching={isFetching}
isFetchingNextPage={isFetchingNextPage}
/>
)}
</FocusGuideView>
</ScreenContainer>
</View>
);
};The Grid component works closely with the following:
- GridItem: Individual grid items with focus animations.
- Box: Base component for interactive elements.
FocusGuideView: Focus management container.
The Box component is a foundational building block for TV interfaces in React Native, specifically designed to handle various states of user interaction including focus, press, and disabled states. It provides a consistent way to create interactive elements that properly respond to TV remote navigation.
- TV-First Design: Built specifically for TV remote navigation with proper focus management.
- State Management: Handles multiple interaction states (default, enabled, active, pressed, disabled, focused).
- Compound Style System: Supports variants, sizes, and state-based styling.
- Accessibility Support: Full integration with React Native's accessibility features.
- Hardware Integration: Works with TV Event Handlers to respond to remote control events.
The Box component manages multiple states through a dedicated hook.
Example: State management
export const BOX_STATE = {
DEFAULT: 'default',
ENABLED: 'enabled',
ACTIVE: 'active',
PRESSED: 'pressed',
DISABLED: 'disabled',
FOCUSED: 'focused',
} as const;
type BoxState = (typeof BOX_STATE)[keyof typeof BOX_STATE];
type StateMap = Record<BoxState, boolean>;
const DEFAULT_STATE: StateMap = {
[BOX_STATE.DISABLED]: false,
[BOX_STATE.ENABLED]: true,
[BOX_STATE.DEFAULT]: false,
[BOX_STATE.ACTIVE]: false,
[BOX_STATE.PRESSED]: false,
[BOX_STATE.FOCUSED]: false,
};
function useBoxState(initialState = DEFAULT_STATE) {
const [state, updateState] = React.useState(initialState);
const pressHandler = React.useRef<null | TVEventHandler>(null);
// ... press handler and state management logic
return { state, setState };
}This state management system allows the Box component to do the following:
- Track multiple states simultaneously.
- Handle remote control events properly.
- Apply appropriate visual styles based on state.
- Clean up event handlers when unmounted.
The Box component integrates with the TVEventHandler to capture remote control events.
Example: TVEventHandler remote control
const DPADPressHandler = React.useCallback(
(_: unknown, event?: HWEvent) => {
if (event?.eventType === 'select' && event?.eventKeyAction === 0) {
setState(BOX_STATE.PRESSED, true);
} else if (event?.eventType === 'select' && event?.eventKeyAction === 1) {
setState(BOX_STATE.PRESSED, false);
}
},
[setState],
);
// Enable press handler when focused
const enablePressHandler = React.useCallback(() => {
if (!pressHandler.current && Platform.isTV) {
pressHandler.current = new TVEventHandler();
pressHandler.current.enable(undefined, DPADPressHandler);
}
}, []);This enables Box to do the following:
- Respond to "select" button presses on TV remotes.
- Handle different phases of button press events (press down, release).
- Clean up event handlers when focus is lost.
Box uses conditional rendering based on platform.
Example: Platform rendering
if (isTV && focusable) {
return (
<TouchableOpacity
// TV-specific props and handlers
{...tvProps}
onFocus={handleFocus}
onBlur={handleBlur}
// ...
>
{children}
</TouchableOpacity>
);
}
if ('onPress' in rest) {
return (
<TouchableOpacity
// Interactive element props
// ...
>
{children}
</TouchableOpacity>
);
}
return (
<TouchableOpacity
// Base component props
// ...
>
{children}
</TouchableOpacity>
);This approach ensures the following:
- TV platforms get special handling for remote navigation.
- Interactive elements provide proper feedback.
- Non-interactive elements still benefit from the style system.
The Box component composes styles based on variants, sizes, and states.
Example: Style composition
const boxStyle = [
styles[variant],
styles[size],
disabled && styles.disabled,
state[BOX_STATE.FOCUSED] && styles.focused,
state[BOX_STATE.PRESSED] && styles.pressed,
style,
state[BOX_STATE.FOCUSED] && focusStyle,
state[BOX_STATE.PRESSED] && pressableStyle,
Platform.OS === 'android' && {
elevation: elevation ?? 0,
},
];This provides the following:
- Consistent base styling through variants and sizes.
- Clear visual indicators for different states.
- Custom style overrides where needed.
- Platform-specific visual adjustments.
When using the Box component in your TV application, the following is recommended.
-
Provide clear focus style.s Ensure focused state is visually distinct and clear from a distance.
Example:
<Box focusStyle={styles.customFocusStyle} // other props > Content </Box>
-
Set accessibility props. Always include accessibility attributes for screen readers.
Example:
<Box accessibilityRole="button" accessibilityLabel="Play video" // other props > Play </Box>
-
Use next focus directions. Guide users with explicit navigation paths using nextFocus props.
Example:
<Box nextFocusDown={downElementId} nextFocusUp={upElementId} // other props > Content </Box>
-
Handle pressed state. Provide visual feedback for pressed state.
Example:
<Box pressableStyle={styles.customPressedStyle} onPress={handlePress} // other props > Content </Box>
Here's how to use the Box component in your TV application:
import { Box } from '@AppComponents/core/Box/Box';
export const MenuButton = ({ title, onPress, isActive }) => {
return (
<Box
variant="primary"
size="lg"
focusable={true}
disabled={false}
style={styles.menuButton}
focusStyle={styles.menuButtonFocused}
pressableStyle={styles.menuButtonPressed}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={title}
>
<Text style={[styles.menuButtonText, isActive && styles.activeText]}>
{title}
</Text>
</Box>
);
};The Box component accepts the following props:
| Prop | Type | Default | Description |
|---|---|---|---|
variant |
string | 'default' | Visual variant ('default', 'primary', 'secondary', 'outlined', 'ghost') |
size |
string | 'md' | Size variant ('sm', 'md', 'lg') |
focusable |
boolean | true | Whether the component can receive focus. |
disabled |
boolean | false | Whether the component is disabled. |
style |
ViewStyle | - | Custom style for the component. |
focusStyle |
ViewStyle | - | Style applied when the component is focused. |
pressableStyle |
ViewStyle | - | Style applied when the component is pressed. |
_activeOpacity |
number | 1 | Opacity when the component is touched (mobile). |
onPress |
function | - | Callback when the component is pressed. |
onFocus |
function | - | Callback when the component receives focus. |
onBlur |
function | - | Callback when the component loses focus. |
hasTVPreferredFocus |
boolean | - | Whether the component should automatically receive focus. |
nextFocusDown |
number | - | ID of the element to focus when pressing down. |
nextFocusUp |
number | - | ID of the element to focus when pressing up. |
nextFocusLeft |
number | - | ID of the element to focus when pressing left. |
nextFocusRight |
number | - | ID of the element to focus when pressing right. |
The Box component works closely with the following:
- Grid: Uses Box for interactive grid items.
- Focus Management: Provides hooks for managing focus across components.
FocusGuideView: Container that guides spatial navigation.
Effective focus management is the cornerstone of any good TV interface. Unlike mobile or web applications where users can directly tap any element, TV interfaces rely entirely on directional navigation with a remote control. This documentation covers the focus management hooks and components in the Vega TV Interfaces.
- Focus State: Elements can be focused or unfocused, with clear visual distinction.
- Focus Navigation: Users move focus with directional buttons (up, down, left, right).
- Focus Trapping: Sometimes focus needs to be contained within a specific area.
- Focus Guides: Invisible elements that help direct focus in a spatially logical way.
- Auto Focus: Automatic focusing of elements when a screen or component mounts.
This section describes two powerful hooks for managing focus, useFocusManager and useFocusCollection. These hooks are provided by the Vega TV Interfaces.
The useFocusManager hook manages focus for a single element.
Example: useFocusManager hook
/**
* A custom hook for managing focus state of a single focusable element.
*
* @param config Configuration options for focus behavior
* @param config.autoFocus Automatically focus the element when mounted
* @param config.onFocusChange Callback triggered when focus state changes
* @param config.trapFocus Prevents focus from leaving the element (captures back button on Android)
*
* @returns Object containing:
* - ref: Reference to attach to the focusable element
* - focus: Function to focus the element
* - blur: Function to blur the element
* - isFocused: Current focus state
*/
export const useFocusManager = (
config: FocusConfig = {},
): UseFocusManagerResult => {
const { autoFocus = false, onFocusChange, trapFocus = false } = config;
const ref = useRef<TouchableOpacity>(null);
const [isFocused, setIsFocused] = useState(false);
// Implementation details...
return { ref, focus, blur, isFocused };
};Key features
- Automatic Focus: Optionally focus the element when mounted.
- Focus Trapping: Can capture back button presses to keep focus on the element.
- Focus Change Detection: Track focus state changes and notify through callbacks.
- Imperative Control: Provides functions to programmatically focus or blur the element.
Example usage
function FeaturedButton({ onPress }) {
const { ref, isFocused } = useFocusManager({
autoFocus: true,
onFocusChange: focused => console.log('Focus changed:', focused),
});
return (
<TouchableOpacity
ref={ref}
style={[styles.button, isFocused && styles.focusedButton]}
onPress={onPress}
>
<Text>Featured Content</Text>
</TouchableOpacity>
);
}The useFocusCollection hook manages focus across multiple related elements.
Example: useFocusCollection hook
/**
* A custom hook for managing focus across multiple focusable elements.
* Useful for implementing focus management in lists, grids, or any collection of focusable items.
* Includes debouncing to prevent rapid focus changes.
*
* @returns Object containing:
* - registerRef: Function to register a focusable element with an ID
* - focusItem: Function to focus an item by ID (debounced)
* - blurItem: Function to blur an item by ID
* - getCurrentFocused: Function to get the currently focused item's ID
*/
export const useFocusCollection = (): UseFocusCollectionResult => {
const refs = useRef(new Map<string, FocusableElement>());
const [currentFocusedId, setCurrentFocusedId] = useState<string | null>(null);
const timeoutRef = useRef<NodeJS.Timeout>();
// Implementation details...
return {
registerRef,
focusItem,
blurItem,
getCurrentFocused: () => currentFocusedId,
};
};Key features
- Reference Collection: Maintains a map of element references indexed by ID.
- Debounced Focus: Prevents rapid focus changes that can cause flickering.
- Focus Tracking: Keeps track of the currently focused item.
- Imperative Focus Control: Provides functions to programmatically focus or blur specific items.
Example usage
function MenuList({ items }) {
const { registerRef, focusItem } = useFocusCollection();
useEffect(() => {
// Auto-focus the first item when the list mounts
if (items.length > 0) {
focusItem(items[0].id);
}
}, []);
return (
<View>
{items.map(item => (
<TouchableOpacity
key={item.id}
ref={ref => registerRef(item.id, ref)}
onFocus={() => console.log(`${item.title} focused`)}
>
<Text>{item.title}</Text>
</TouchableOpacity>
))}
</View>
);
}The FocusGuideView component creates a container that manages directional focus navigation.
Example: FocusGuideView component
import type { ReactElement } from 'react';
import React from 'react';
import type { FocusGuideProps } from '@amazon-devices/react-native-kepler';
import { TVFocusGuideView } from '@amazon-devices/react-native-kepler';
export type FocusGuideViewProps = FocusGuideProps & {
children: ReactElement | ReactElement[];
};
export const FocusGuideView = ({ children, ...props }: FocusGuideViewProps) => {
return <TVFocusGuideView {...props}>{children}</TVFocusGuideView>;
};Properties
| Prop | Type | Description |
|---|---|---|
autoFocus |
boolean | Automatically focus the first focusable element within the view. |
trapFocus |
boolean | Prevent focus from leaving the view. |
trapFocusLeft |
boolean | Prevent focus from leaving the left edge of the view. |
trapFocusRight |
boolean | Prevent focus from leaving the right edge of the view. |
trapFocusUp |
boolean | Prevent focus from leaving the top edge of the view. |
trapFocusDown |
boolean | Prevent focus from leaving the bottom edge of the view. |
destinations |
array | Explicit focus destinations for directional navigation. |
Usage
<FocusGuideView autoFocus trapFocusRight style={styles.menuContainer}>
<MenuButton title="Home" onPress={() => navigate('Home')} />
<MenuButton title="Movies" onPress={() => navigate('Movies')} />
<MenuButton title="TV Shows" onPress={() => navigate('TVShows')} />
<MenuButton title="Settings" onPress={() => navigate('Settings')} />
</FocusGuideView>-
Provide clear visual focus indicators. Users navigate TV interfaces exclusively with directional controls, so they need clear visual feedback about which element is currently focused.
Example:
// Example of good focus styling const styles = StyleSheet.create({ button: { padding: 12, backgroundColor: 'rgba(0, 0, 0, 0.5)', borderRadius: 6, }, buttonFocused: { backgroundColor: '#0078D7', transform: [{ scale: 1.1 }], borderWidth: 2, borderColor: 'white', }, });
Focus indicators should be the following:
- Highly visible from a distance (10-foot interface).
- Consistently applied across the application.
- Animated when focus changes to draw attention.
- Distinct enough from unfocused elements.
-
Create logical focus paths. Focus movement should be intuitive and predictable.
Example:
<Box focusable nextFocusDown={ITEM_IDS.SEARCH_BAR} nextFocusRight={ITEM_IDS.FEATURED_CONTENT} > <Text>Menu</Text> </Box>
Tips for creating logical focus paths:
- Follow natural reading direction (left-to-right, top-to-bottom in Western layouts).
- Use
nextFocus*props to explicitly define focus paths when spatial layout is ambiguous. - Test focus paths with physical remotes, not just the Vega Virtual Device.
-
Auto-focus on mount. Always ensure something is focused when a screen mounts.
Example:
function HomeScreen() { const homeButtonRef = useRef(null); useEffect(() => { // Ensure the home button is focused when the screen mounts if (homeButtonRef.current) { homeButtonRef.current.focus(); } }, []); return ( <View> <TouchableOpacity ref={homeButtonRef} hasTVPreferredFocus={true}> <Text>Home</Text> </TouchableOpacity> {/* Other content */} </View> ); }
-
Handle dynamic content gracefully. When content changes, maintain focus in a logical position.
Example:
const [selectedCategory, setSelectedCategory] = useState('movies'); const { focusItem } = useFocusCollection(); // When category changes, focus the first item in that category useEffect(() => { focusItem(`${selectedCategory}-item-0`); }, [selectedCategory, focusItem]);
-
Use focus trapping for modals and menus. Prevent focus from escaping modals or menus with focus trapping.
Example:
<FocusGuideView trapFocus> <ModalContent /> <CloseButton onPress={closeModal} /> </FocusGuideView>
Grid navigation should allow users to move in all four directions logically.
Example: Grid navigation
<FocusGuideView>
<Grid
data={items}
numColumns={3}
renderItem={({ item, index }) => (
<GridItem
item={item}
hasTVPreferredFocus={index === 0}
// Grid position determines natural focus navigation
/>
)}
/>
</FocusGuideView>Menu navigation typically follows a vertical or horizontal pattern.
Example: Menu navigation
<FocusGuideView trapFocusLeft>
<VerticalMenu>
{menuItems.map((item, index) => (
<MenuItem
key={item.id}
label={item.label}
hasTVPreferredFocus={index === 0}
/>
))}
</VerticalMenu>
</FocusGuideView>Modals require special focus handling to ensure users can navigate within them and exit properly.
Example: Modal focus management
// Example of modal focus management from ExampleModal component
useEffect(() => {
if (isVisible) {
setFocusedButton(BUTTON_STATE.startWorkout);
focusItem(BUTTON_REF.startWorkout(imageUrl));
}
return () => {
setFocusedButton(null);
blurItem(BUTTON_REF.startWorkout(imageUrl));
};
}, [isVisible, focusItem, blurItem, imageUrl]);Proper focus management is what separates great TV applications from mediocre ones. By leveraging the hooks and components provided in this library, you can create intuitive navigation experiences that feel natural with a remote control.
Key points:
- Always provide clear visual focus indicators.
- Design logical focus paths between interactive elements.
- Use focus trapping to contain focus when appropriate.
- Auto-focus elements when screens mount.
- Handle focus gracefully when content changes.
By following these guidelines, your TV application provides a polished and professional user experience that is easy to navigate with a remote control.
Modal components are essential for TV interfaces as they provide a way to display focused content or gather user input without navigating away from the current screen. The Vega TV Interfaces includes a flexible CustomModal component that uses the compound component pattern for maximum flexibility and consistent styling.
- Compound Component Pattern: Modular, composable API for flexible modal layouts.
- Focus Management: Integrated with the focus system for proper TV remote navigation.
- Positioning Options: Support for different modal positions (top, bottom, center).
- Styled Subcomponents: Pre-styled components for common modal elements.
- Accessibility Support: Fully accessible with proper focus trapping.
The CustomModal uses a compound component pattern with a shared context.
Example: CustomModal component
const ModalContext = (createContext < ModalContextType) | (null > null);
export const CustomModal = {
Root: ({
isVisible,
onClose,
children,
focusedButton,
setFocusedButton,
position = 'center',
}) => {
// Implementation...
},
Image: ({ imageUrl }) => {
// Implementation...
},
Content: ({ children, style }) => {
// Implementation...
},
Title: ({ children }) => {
// Implementation...
},
Subtitle: ({ children }) => {
// Implementation...
},
Description: ({ children }) => {
// Implementation...
},
};This pattern offers the following advantages:
- Flexible Composition: Mix and match subcomponents as needed.
- Encapsulated Styling: Each subcomponent handles its own styling.
- Shared Context: All subcomponents share the same state and styling.
- Cleaner API: More intuitive than passing many props.
The modal integrates with our focus management system.
Example: Focus management
Root: (/* props */) => {
const styles = useThemedStyles(getModalStyles);
const { registerRef, focusItem, blurItem } = useFocusCollection();
// Provide focus management through context
return (
<ModalContext.Provider
value={{
isVisible,
onClose,
focusedButton,
setFocusedButton,
styles,
registerRef,
focusItem,
blurItem,
}}
>
{/* Modal content */}
</ModalContext.Provider>
);
};This integration enables the following:
- Proper focus handling when the modal opens and closes.
- Tracking the currently focused element within the modal.
- Programmatic focus control for modal elements.
The modal supports different positions through a transformation function.
Example: Position transformation
const transformPosition = (position: 'top' | 'bottom' | 'center') => {
switch (position) {
case 'top':
return 'flex-start';
case 'bottom':
return 'flex-end';
case 'center':
return 'center';
default:
return 'center';
}
};
// Used in the Root component
<View
style={[
styles[MODAL_CLASSES.OVERLAY],
{ justifyContent: transformPosition(position) },
]}
pointerEvents="box-only"
>
{/* Modal content */}
</View>This allows the modal to be positioned at the following:
- The top of the screen (useful for notifications).
- The center (standard modal position).
- The bottom (useful for action sheets).
The modal includes pre-styled subcomponents for common modal elements.
Example: pre-styled subcomponents
const MODAL_CLASSES = {
OVERLAY: 'modalOverlay',
CONTENT: 'modalContent',
CONTENT_PADDING: 'modalContentPadding',
IMAGE: 'modalImage',
TITLE: 'modalTitle',
SUBTITLE: 'modalSubtitle',
DESCRIPTION: 'modalDescription',
} as const;
// Example subcomponent implementation
Title: ({ children }) => {
const context = useContext(ModalContext);
if (!context) {
throw new Error('Modal.Title must be used within Modal.Root');
}
return <Text style={context.styles[MODAL_CLASSES.TITLE]}>{children}</Text>;
},These subcomponents ensure the following:
- Consistent styling across the application.
- Type safety through TypeScript.
- Context validation for proper component nesting.
<CustomModal.Root
isVisible={isVisible}
onClose={handleClose}
position="center"
focusedButton={focusedButton}
setFocusedButton={setFocusedButton}
>
<CustomModal.Content>
<CustomModal.Title>
<Text>Confirm Action</Text>
</CustomModal.Title>
<CustomModal.Description>
<Text>Are you sure you want to proceed?</Text>
</CustomModal.Description>
<Pressable
ref={ref => registerRef('confirm-button', ref)}
style={[
styles.button,
focusedButton === 'confirm' && styles.buttonFocused,
]}
onPress={handleConfirm}
onFocus={() => setFocusedButton('confirm')}
>
<Text>Confirm</Text>
</Pressable>
<Pressable
ref={ref => registerRef('cancel-button', ref)}
style={[
styles.button,
focusedButton === 'cancel' && styles.buttonFocused,
]}
onPress={handleClose}
onFocus={() => setFocusedButton('cancel')}
>
<Text>Cancel</Text>
</Pressable>
</CustomModal.Content>
</CustomModal.Root><CustomModal.Root
isVisible={isVisible}
onClose={onClose}
focusedButton={focusedButton}
setFocusedButton={setFocusedButton}
position={'center'}
>
<View style={styles.modalContainer}>
<CustomModal.Content>
<CustomModal.Image imageUrl={imageUrl} />
<CustomModal.Title>
<Text>Total-body balance pilates</Text>
</CustomModal.Title>
<CustomModal.Subtitle>
<Text>34 Min | Intensity ••••</Text>
</CustomModal.Subtitle>
<CustomModal.Description>
<Text>
Andrea's signature low-impact, total-body class in just 30 minutes.
Hit every muscle group with barre and Pilates moves that leave you
feeling strong, refreshed, and energized.
</Text>
</CustomModal.Description>
<Pressable
ref={ref => registerRef('start-workout', ref)}
style={[
styles.button,
focusedButton === 'start-workout' && styles.buttonFocused,
]}
onPress={handleStartWorkout}
onFocus={() => setFocusedButton('start-workout')}
>
<Text>▶ Start workout</Text>
</Pressable>
<Pressable
ref={ref => registerRef('close', ref)}
style={[
styles.button,
focusedButton === 'close' && styles.buttonFocused,
]}
onPress={onClose}
onFocus={() => setFocusedButton('close')}
>
<Text>✖ Close</Text>
</Pressable>
</CustomModal.Content>
</View>
</CustomModal.Root>Note: The Image component is not required and can be part of the Content component if preferred.
-
Auto-focus first interactive element. When opening a modal, automatically focus the first interactive element.
Example:
useEffect(() => { if (isVisible) { // Auto-focus the primary action button setFocusedButton('primary-action'); focusItem('primary-action'); } }, [isVisible, focusItem]);
-
Trap focus within modal. Ensure focus remains within the modal while it's open.
Example:
<FocusGuideView trapFocus> <CustomModal.Root isVisible={isVisible} onClose={onClose} // other props > {/* Modal content */} </CustomModal.Root> </FocusGuideView>
-
Handle back button. Always handle the back button properly to close the modal.
Example:
<CustomModal.Root isVisible={isVisible} onClose={onClose} // This will be called when the back button is pressed // other props > {/* Modal content */} </CustomModal.Root>
-
Use clear visual focus indicators. Make sure focused elements within the modal have clear visual indicators.
Example:
<Pressable style={[ styles.button, focusedButton === 'confirm' && { borderWidth: 2, borderColor: 'white', backgroundColor: '#0078D7', transform: [{ scale: 1.1 }], }, ]} // other props > <Text>Confirm</Text> </Pressable>
-
Support keyboard navigation. Ensure all interactive elements are accessible via directional navigation.
Example:
<Pressable ref={(ref) => registerRef('confirm-button', ref)} nextFocusDown={cancelButtonId} // other props > <Text>Confirm</Text> </Pressable> <Pressable ref={(ref) => registerRef('cancel-button', ref)} nextFocusUp={confirmButtonId} // other props > <Text>Cancel</Text> </Pressable>
| Prop | Type | Default | Description |
|---|---|---|---|
isVisible |
boolean | - | Controls the visibility of the modal. |
onClose |
function | - | Callback function when the modal is closed. |
children |
ReactNode | - | Modal content. |
focusedButton |
string | null | - | Currently focused button ID. |
setFocusedButton |
function | - | Function to set the focused button. |
position |
'top' | 'bottom' | 'center' | 'center' | Position of the modal on screen. |
| Prop | Type | Description |
|---|---|---|
imageUrl |
string | URL of the image to display. |
| Prop | Type | Description |
|---|---|---|
children |
ReactNode | Content to display. |
style |
ViewStyle | Additional styles to apply. |
| Prop | Type | Description |
|---|---|---|
children |
ReactNode | Text content to display. |
The Custom Drawer system provides a flexible and TV-optimized navigation drawer for the Vega TV Interfaces. It consists of two main components:
- DrawerWrapper: A foundational component that handles drawer state, animation, and context.
- CustomDrawer: A specific implementation that utilizes the DrawerWrapper.
This drawer system is specifically designed for TV interfaces, with considerations for remote navigation, focus management, and screen real estate.
- Focus Management: Properly integrates with TV focus navigation.
- Context API: Provides drawer state and controls through React Context.
- Accessibility Support: Includes proper ARIA attributes for screen readers.
- Flexible Configuration: Customizable width, position, and animation type.
- Independent Navigation: Uses a standalone navigation container.
The DrawerWrapper component is the foundational building block for the drawer system.
Example: DrawerWrapper component
import React, { createContext, useContext, useState } from 'react';
import { Dimensions } from 'react-native';
import { TVFocusGuideView } from '@amazon-devices/react-native-kepler';
import { createDrawerNavigator } from '@amazon-devices/react-navigation__drawer';
import type { NavigationContainerRef } from '@amazon-devices/react-navigation__native';
import {
DrawerActions,
NavigationContainer,
} from '@amazon-devices/react-navigation__native';
const { width } = Dimensions.get('window');
// Type definitions and context setup...
export const DrawerWrapper = ({
children,
drawerContent: DrawerContent,
drawerWidth = width * 0.6,
drawerPosition = 'right',
overlayColor = 'rgba(0,0,0,0.5)',
drawerType = 'front',
screenOptions = {},
navigationRef,
}: DrawerWrapperProps) => {
const [isOpen, setIsOpen] = useState(false);
const internalRef = React.useRef<DrawerNavigationType>(null);
const drawerRef = navigationRef || internalRef;
const openDrawer = () => {
setIsOpen(true);
drawerRef.current?.dispatch(DrawerActions.openDrawer());
};
const closeDrawer = () => {
setIsOpen(false);
drawerRef.current?.dispatch(DrawerActions.closeDrawer());
};
const toggleDrawer = () => {
setIsOpen(prev => !prev);
drawerRef.current?.dispatch(DrawerActions.toggleDrawer());
};
return (
<DrawerContext.Provider
value={{ isOpen, openDrawer, closeDrawer, toggleDrawer, setIsOpen }}
>
<NavigationContainer independent={true} ref={drawerRef}>
<Drawer.Navigator
screenOptions={{
drawerStyle: {
width: drawerWidth,
display: isOpen ? 'flex' : 'none',
},
headerShown: false,
overlayColor,
drawerType,
drawerPosition,
swipeEnabled: false,
...screenOptions,
}}
drawerContent={() => (
<DrawerContent
aria-modal={true}
aria-hidden={!isOpen}
aria-live={isOpen ? 'polite' : 'off'}
/>
)}
>
<Drawer.Screen
name="Drawer"
options={{
title: 'Drawer',
swipeEnabled: false,
}}
>
{() => (
<TVFocusGuideView trapFocusDown trapFocusUp style={{ flex: 1 }}>
{children}
</TVFocusGuideView>
)}
</Drawer.Screen>
</Drawer.Navigator>
</NavigationContainer>
</DrawerContext.Provider>
);
};The DrawerContext provides a way to control the drawer from any component.
Example: DrawerContent drawer control
export const DrawerContext = createContext<{
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
openDrawer: () => void;
closeDrawer: () => void;
toggleDrawer: () => void;
}>({
isOpen: false,
setIsOpen: () => {},
openDrawer: () => {},
closeDrawer: () => {},
toggleDrawer: () => {},
});
export const useDrawer = () => useContext(DrawerContext);This context provides the following:
- Current drawer state (
isOpen). - Functions to control the drawer (
openDrawer,closeDrawer,toggleDrawer). - Direct state setter for advanced cases (
setIsOpen).
The DrawerWrapper uses React Navigation's drawer navigator.
Example: DrawerWrapper navigation
<NavigationContainer independent={true} ref={drawerRef}>
<Drawer.Navigator
screenOptions={{
drawerStyle: {
width: drawerWidth,
display: isOpen ? 'flex' : 'none',
},
// Other options...
}}
drawerContent={() => (
<DrawerContent
aria-modal={true}
aria-hidden={!isOpen}
aria-live={isOpen ? 'polite' : 'off'}
/>
)}
>
<Drawer.Screen name="Drawer">
{() => (
<TVFocusGuideView trapFocusDown trapFocusUp style={{ flex: 1 }}>
{children}
</TVFocusGuideView>
)}
</Drawer.Screen>
</Drawer.Navigator>
</NavigationContainer>Key points:
- Uses an independent navigation container.
- Disables swipe gestures (typically not useful on TV).
- Wraps content in a
TVFocusGuideViewto manage focus behavior. - Passes accessibility attributes to the drawer content.
The CustomDrawer component implements a specific drawer configuration.
Example: CustomDrawer configuration
import React from 'react';
import { Dimensions } from 'react-native';
import type { NavigationContainerRef } from '@amazon-devices/react-navigation__native';
import type { DrawerParamList } from '../common/DrawerWrapper';
import { DrawerWrapper } from '../common/DrawerWrapper';
import { FiltersScreen } from '../filters/FiltersScreen';
const { width } = Dimensions.get('window');
const DRAWER_WIDTH = width * 0.6;
export { useDrawer } from '../common/DrawerWrapper';
export const drawerRef =
React.createRef<NavigationContainerRef<DrawerParamList>>();
export const CustomDrawer = ({ children }: { children: React.ReactNode }) => {
return (
<DrawerWrapper
drawerContent={FiltersScreen}
drawerWidth={DRAWER_WIDTH}
navigationRef={drawerRef}
>
{children}
</DrawerWrapper>
);
};This implementation includes the following:
- Uses a width of 60% of the screen.
- Uses the FiltersScreen component as drawer content.
- Exports a reference to the navigation container for external access.
- Re-exports the useDrawer hook for convenience.
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { useDrawer } from '@AppComponents/navigation/CustomDrawer';
export const MenuButton = () => {
const { openDrawer, isOpen } = useDrawer();
return (
<TouchableOpacity
onPress={openDrawer}
style={styles.button}
disabled={isOpen}
accessibilityLabel="Open navigation menu"
accessibilityRole="button"
>
<Text style={styles.buttonText}>Menu</Text>
</TouchableOpacity>
);
};import React, { useEffect } from 'react';
import { View, Text } from 'react-native';
import { useDrawer } from '@AppComponents/navigation/CustomDrawer';
import { Box } from '@AppComponents/core/Box/Box';
export const FilterButton = () => {
const { toggleDrawer } = useDrawer();
return (
<Box
variant="primary"
onPress={toggleDrawer}
onFocus={() => console.log('Filter button focused')}
accessibilityLabel="Toggle filters"
>
<Text>Filters</Text>
</Box>
);
};import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { useDrawer } from '@AppComponents/navigation/CustomDrawer';
export const ApplyFiltersButton = ({ onApply }) => {
const { closeDrawer } = useDrawer();
const handleApply = () => {
onApply();
closeDrawer();
};
return (
<TouchableOpacity onPress={handleApply} style={styles.applyButton}>
<Text style={styles.buttonText}>Apply Filters</Text>
</TouchableOpacity>
);
};The drawer content receives special props for accessibility.
Example:
// Inside the DrawerWrapper
drawerContent={() => (
<DrawerContent
aria-modal={true}
aria-hidden={!isOpen}
aria-live={isOpen ? 'polite' : 'off'}
/>
)}Your drawer content component should handle these props.
Example:
import React from 'react';
import { View, Text, ScrollView } from 'react-native';
type DrawerContentProps = {
'aria-modal': boolean;
'aria-hidden': boolean;
'aria-live': 'polite' | 'assertive' | 'off';
};
export const SampleDrawerContent = (props: DrawerContentProps) => {
// Extract accessibility props
const { 'aria-modal': ariaModal, 'aria-hidden': ariaHidden, 'aria-live': ariaLive } = props;
return (
<View
style={styles.container}
accessibilityViewIsModal={ariaModal}
aria-hidden={ariaHidden}
accessibilityLiveRegion={ariaLive}
>
<Text style={styles.title}>Drawer Content</Text>
<ScrollView>
{/* Drawer content */}
</ScrollView>
</View>
);
};-
Position on the right (for LTR Languages). On TV interfaces with left-to-right languages, users typically navigate from left to right, so placing the drawer on the right feels more natural.
Example:
<DrawerWrapper drawerPosition="right">
-
Disable swipe gestures. Swipe gestures aren't applicable for TV remote navigation.
Example:
screenOptions={{ swipeEnabled: false }}
-
Use appropriate width. TV interfaces should use wider drawers than mobile for better readability from a distance.
Example:
const DRAWER_WIDTH = width * 0.6; // 60% of screen width
-
Implement proper focus management. When the drawer closes, focus should return to a logical element.
Example:
const closeDrawerAndFocus = () => { closeDrawer(); // Focus a specific element after the drawer closes setTimeout(() => { focusItem('main-menu-button'); }, 100); };
-
Add clear visual indicators. Ensure the drawer has clear visual indicators when it's open.
Example:
// In your styles drawerStyle: { backgroundColor: '#1E1E1E', borderLeftWidth: 2, borderLeftColor: '#FFFFFF', shadowColor: '#000', shadowOpacity: 0.5, shadowRadius: 10, }
Managing focus with drawers in TV interfaces requires special consideration.
Example:
<TVFocusGuideView trapFocusDown trapFocusUp style={{ flex: 1 }}>
{children}
</TVFocusGuideView>This configuration includes the following:
- Traps vertical focus to prevent it from leaving the main content unexpectedly.
- Allows horizontal focus to move naturally between main content and drawer.
- Ensures the drawer can receive focus when opened.
For the drawer content itself, consider using the following.
Example:
<FocusGuideView autoFocus trapFocus style={styles.drawerContent}>
{/* Drawer content */}
</FocusGuideView>This ensures the following:
- Focus is automatically moved to the drawer when it opens.
- Focus stays within the drawer until it closes.
- Focus behaves predictably with remote control navigation.
The DrawerWrapper supports various customization options:
| Prop | Type | Default | Description |
|---|---|---|---|
drawerContent |
Component | (required) | The component to render inside the drawer. |
drawerWidth |
number | 60% of screen | Width of the drawer. |
drawerPosition |
'left' | 'right' | 'right' | Which side the drawer appears on. |
overlayColor |
string | 'rgba(0,0,0,0.5)' | Color of the overlay when drawer is open. |
drawerType |
'front' | 'back' | 'slide' | 'front' | Animation type for the drawer. |
screenOptions |
object | {} | Additional options for the drawer navigator. |
navigationRef |
ref | internal ref | External navigation reference. |
The Custom Drawer system provides a TV-optimized navigation drawer that integrates well with focus management and TV remote navigation. By using the DrawerContext, any component in the application can control the drawer state, making it a flexible solution for TV interfaces.
When implementing drawers in TV applications, remember to prioritize clear visual indicators, proper focus management, and accessibility to ensure a smooth user experience with remote navigation.
The VerticalCarousel component is a specialized display solution designed specifically for TV interfaces. It implements a side-by-side layout with a featured image on the left and a scrollable list of items on the right. This pattern is common in streaming services and content-heavy TV applications.
The component demonstrates how to create an effective focus-optimized scrolling experience using FlashList with proper focus management for TV remote navigation.
- Split Layout: Displays a featured image alongside a scrollable list.
- TV-Optimized Navigation: Uses custom focus management for smooth remote control navigation.
- Performance Optimized: Leverages FlashList for efficient rendering of large lists.
- Dynamic Selection: Updates the featured image based on the selected list item.
- Focus Management: Properly integrates with TV focus navigation using custom hooks.
import React, { useCallback, useRef, useState } from 'react';
import type { LayoutChangeEvent } from 'react-native';
import { TouchableOpacity } from 'react-native';
import { Text, View } from 'react-native';
import { FlashList } from '@amazon-devices/shopify__flash-list';
import { useThemedStyles } from '@AppTheme';
import { ImageTile } from '@AppComponents/core';
import { Box } from '@AppComponents/core/Box/Box';
import type { Item } from '@AppScreens/Components/VerticalStack';
import { getVerticalPosters } from '@AppSrc/data/VerticalPosters';
import { useItemFocusManager } from '@AppSrc/hooks/useItemFocusManager';
import { getVerticalStackStyles } from './styles';
const verticalData = getVerticalPosters();
export const VerticalCarousel = ({ data }: { data: Item[] }) => {
const styles = useThemedStyles(getVerticalStackStyles);
const flashListRef = useRef<FlashList<Item>>(null);
const itemRefs = useRef<TouchableOpacity[]>([]);
const [listSize, setListSize] = useState<{ width: number; height: number }>({
width: 779,
height: data.length * 205,
});
const [albumIndex, setAlbumIndex] = useState(0);
const handleChildRefsUpdate = useItemFocusManager<Item>({
itemRefs,
flashListRef,
dataLength: data.length,
});
// Additional implementation...
return (
<View style={[styles.container]}>
{/* Featured image on the left */}
<View style={styles.column}>
<View style={styles.leftContainer}>
<View style={styles.leftContent}>
<View style={styles.imageContainer}>
<ImageTile
style={styles.image}
width={340}
height={540}
imageUrl={verticalData[albumIndex]}
/>
</View>
</View>
</View>
</View>
{/* Scrollable list on the right */}
<View style={[styles.column]}>
<View
style={styles.rightContainer}
onLayout={handleGridContainerLayout}
>
<Text style={styles.headerText}>Shopify FlashList</Text>
<FlashList
ref={flashListRef}
data={data}
renderItem={ItemView}
keyExtractor={keyProvider}
estimatedItemSize={185}
estimatedListSize={listSize}
horizontal={false}
bounces={false}
initialScrollIndex={0}
/>
</View>
</View>
</View>
);
};The component uses the useItemFocusManager hook to manage focus between list items.
Example: useItemFocusManager hook
const handleChildRefsUpdate =
useItemFocusManager <
Item >
{
itemRefs,
flashListRef,
dataLength: data.length,
};This integration includes the following:
- Maintains an array of refs to all list items.
- Sets up proper next/previous focus targets for each item.
- Enables smooth navigation with a TV remote control.
- Handles scrolling when focus moves between items.
The component implements a side-by-side layout with two main sections.
Example: Split layout
<View style={[styles.container]}>
{/* Left column - Featured image */}
<View style={styles.column}>
<View style={styles.leftContainer}>{/* Image content */}</View>
</View>
{/* Right column - Scrollable list */}
<View style={[styles.column]}>
<View style={styles.rightContainer}>{/* List content */}</View>
</View>
</View>This layout includes the following:
- Creates a visual hierarchy with a featured item and a list.
- Provides context for the selected item.
- Maximizes screen space usage on TV displays.
- Follows common TV interface patterns.
The component updates the featured image when a list item is focused or pressed.
Example: Content selection
const [albumIndex, setAlbumIndex] = useState(0);
// In the ItemView component:
<Box onFocus={() => setAlbumIndex(index)} onPress={() => setAlbumIndex(index)}>
{/* Item content */}
</Box>;This creates the following:
- Immediate visual feedback when navigating.
- A clear connection between the list and featured content.
- An engaging interactive experience.
The component uses layout measurement to optimize FlashList performance.
Example: FlashList optimization
const handleGridContainerLayout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
if (width > 0 && height > 0) {
setListSize({ width, height });
}
}, []);
// Used with FlashList:
<FlashList
estimatedItemSize={185}
estimatedListSize={listSize}
// Other props...
/>This optimization includes the following:
- Ensures proper virtual rendering calculations.
- Improves scrolling performance.
- Prevents re-rendering issues.
- Better handles large data sets.
Each list item is rendered using the Box component for consistent focus styling.
Example: Styling using the box component
const ItemView = useCallback(
({ item, index }: { item: Item; index: number }) => {
return (
<Box
ref={handleChildRefsUpdate(index)}
style={[styles.box]}
testID={`box-${item.id}`}
hasTVPreferredFocus={index === 0}
focusStyle={styles.boxFocused}
pressableStyle={styles.boxPressed}
onFocus={() => setAlbumIndex(index)}
onPress={() => setAlbumIndex(index)}
>
<View style={[styles.boxContainer]}>
{/* Item content */}
</View>
</Box>
);
},
[styles, handleChildRefsUpdate],
);This approach includes the following:
- Leverages the Box component's built-in focus management.
- Provides consistent focus visuals across the application.
- Assigns the first item initial focus using
hasTVPreferredFocus. - Updates the featured content when focus changes.
Here's how to use the VerticalCarousel component in a screen:
import React from 'react';
import { View } from 'react-native';
import { VerticalCarousel } from '@AppComponents/VerticalCarousel';
import { ScreenContainer } from '@AppComponents/containers';
const sampleData = [
{
id: '1',
title: 'Morning Yoga',
date: '2023-05-15',
duration: '30 min',
shortDescription: 'Start your day with energizing yoga flows.',
},
{
id: '2',
title: 'HIIT Workout',
date: '2023-05-16',
duration: '45 min',
shortDescription: 'High intensity interval training to boost metabolism.',
},
// More items...
];
export const WorkoutsScreen = () => {
return (
<ScreenContainer testID="workouts-screen">
<View style={styles.container}>
<VerticalCarousel data={sampleData} />
</View>
</ScreenContainer>
);
};-
Always show selection context. The split layout provides important context for the selected item.
Example:
<ImageTile style={styles.image} width={340} height={540} imageUrl={verticalData[albumIndex]} />
This approach includes the following:
- Shows a larger version of the selected content.
- Provides more detail and context.
- Creates a more engaging TV experience.
-
Optimize for remote navigation. The item focus management is handled through the
useItemFocusManagerhook.Example:
const handleChildRefsUpdate = useItemFocusManager<Item>({ itemRefs, flashListRef, dataLength: data.length, }); // Used in the item ref: ref={handleChildRefsUpdate(index)}
This ensures the following:
- Smooth up/down navigation with a remote.
- Proper scrolling when moving between items.
- Focus doesn't get lost during navigation.
-
Provide clear initial focus. Set the first item to receive focus when the component mounts.
Example:
hasTVPreferredFocus={index === 0}
This creates the following:
- Predictable initial focus when navigating to the screen.
- Clear starting point for user interaction.
- Better usability with remote controls.
-
Use appropriate item size. TV interfaces need larger item sizes for better visibility from a distance.
Example:
// In your style sheet box: { height: 185, marginBottom: 20, padding: 16, borderRadius: 8, },
This ensures the following:
- Content is clearly visible from 10 feet away.
- Focus indicators are obvious.
- Text is legible at a distance.
-
Optimize list rendering. Use proper FlashList configuration for smooth scrolling.
Example:
<FlashList estimatedItemSize={185} // Approximate height of each item estimatedListSize={listSize} // Overall list dimensions horizontal={false} bounces={false} // Disable bouncing effect for TV initialScrollIndex={0} />
This improves the following:
- Scrolling performance.
- Initial rendering speed.
- Overall responsiveness.
For large data sets, consider implementing pagination or lazy loading.
Example: Pagination and lazy loading
// Load more data when reaching the end of the list
<FlashList
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
// Other props...
/>The VerticalCarousel uses memoization to prevent unnecessary re-renders.
Example: VerticalCarousel memoization
const ItemView = useCallback(
({ item, index }) => {
// Component implementation
},
[styles, handleChildRefsUpdate],
);
const keyProvider = useCallback(
(item: Item, index: number) => `vertical-carousel-${item.id}-${index}`,
[],
);This pattern includes the following:
- Prevents re-creation of functions on each render.
- Reduces performance overhead.
- Maintains smooth scrolling and navigation.
The FlexibleCard component is a versatile card solution designed specifically for TV interfaces. It provides a flexible foundation for creating interactive card elements with different sizes, layouts, and animations that respond properly to TV remote navigation.
- Multiple Size Options: Supports small, medium, large, or custom card dimensions.
- Layout Flexibility: Configure for horizontal, vertical, or expandable layouts.
- Focus Animations: Built-in animations provide visual feedback when cards receive focus.
- Scroll Integration: Automatic scrolling ensures focused cards are visible on screen.
- Image Support: Optimized image display with proper sizing and scaling.
- Custom Content: Support for additional content inside cards beyond images.
The FlexibleCard supports predefined sizes that adjust based on the layout direction.
Example:
/**
* Gets card dimensions based on size and direction
* @param {CardSize} size - Card size
* @param {Direction} direction - Layout direction
* @returns {Object} Card dimensions (width, height, radius, gap)
*/
export const getCardLayout = (size: CardSize, direction: Direction) => {
if (size === 'small') {
return direction === 'horizontal'
? { width: 320, height: 180, radius: 4, gap: 8 }
: { width: 320, height: 360, radius: 4, gap: 8 };
}
if (size === 'medium') {
return direction === 'horizontal'
? { width: 400, height: 225, radius: 6, gap: 10 }
: { width: 400, height: 450, radius: 6, gap: 10 };
}
return direction === 'horizontal'
? { width: 480, height: 270, radius: 10, gap: 12 }
: { width: 480, height: 480, radius: 10, gap: 12 };
};This provides the following size options:
Horizontal Direction
- Small: 320px × 180px (4px radius, 8px gap)
- Medium: 400px × 225px (6px radius, 10px gap)
- Large: 480px × 270px (10px radius, 12px gap)
Vertical Direction
- Small: 320px × 360px (4px radius, 8px gap)
- Medium: 400px × 450px (6px radius, 10px gap)
- Large: 480px × 480px (10px radius, 12px gap)
The FlexibleCard uses Animated API for smooth focus animations.
Example:
const handleFocusAnim = useCallback(() => {
setIsFocused(true);
stopAnimations();
if (animations === 'zoomIn') {
animationsRefs.image.current = Animated.spring(imageScale, {
toValue: 0.95,
useNativeDriver: true,
});
animationsRefs.image.current.start();
}
if (animations === 'zoomOut') {
animationsRefs.card.current = Animated.spring(cardScale, {
toValue: 1.1,
useNativeDriver: true,
});
animationsRefs.card.current.start();
}
if (animations === 'expand') {
// Expand animation implementation
}
}, [animations]);The FlexibleCard component supports three animation types:
- zoomIn: The image scales down slightly, creating an inset effect.
- zoomOut: The entire card scales up when focused. Note: The expand animation is still in development.
- expand: The card width expands.
The FlexibleCard uses memo with a custom comparison function to prevent unnecessary re-renders.
Example:
const checkIfDataSame = (
prevProps: FlexibleCardProps,
nextProps: FlexibleCardProps,
) => {
return (
prevProps.imageUrl === nextProps.imageUrl &&
prevProps.index === nextProps.index &&
prevProps.rowIndex === nextProps.rowIndex &&
prevProps.animations === nextProps.animations &&
prevProps.size === nextProps.size
);
};
export const FlexibleCard = memo(
({
// Component implementation
}: FlexibleCardProps) => {
// Component implementation
},
checkIfDataSame,
);This optimization ensures the following:
- Cards only re-render when essential props change.
- Improves performance in grids with many cards.
- Prevents animation stutters during navigation.
The FlexibleCard integrates with the TV focus system and interaction manager.
Example:
const focusHandler = useCallback(() => {
handleFocusAnim();
scroll?.horizontally?.(index);
scroll?.vertically?.(rowIndex);
onFocus?.(index);
}, [handleFocusAnim, index, rowIndex, scroll, onFocus]);
const blurHandler = useCallback(() => {
handleBlurAnim();
onBlur?.(index);
}, [handleBlurAnim, index, onBlur]);This approach ensures the following:
- Animations don't interfere with navigation interactions.
- Scrolling is triggered after focus is established.
- Focus callbacks are executed at the appropriate time.
Example:
import { FlexibleCard } from '@AppComponents/FlexibleCard/FlexibleCard';
// Simple card with an image
<FlexibleCard
index={0}
rowIndex={0}
imageUrl="https://example.com/image.jpg"
size="medium"
direction="horizontal"
animations="zoomIn"
onPress={() => console.log('Card pressed')}
/>;Example:
<FlexibleCard
index={1}
rowIndex={0}
size="large"
direction="vertical"
animations="zoomOut"
>
<Text style={styles.cardTitle}>Card Title</Text>
<Text style={styles.cardDescription}>Description text</Text>
</FlexibleCard><FlexibleCard
index={2}
rowIndex={1}
imageUrl="https://example.com/image.jpg"
size="medium"
direction="horizontal"
animations="zoomIn"
scroll={{
horizontally: index => scrollToIndex(index),
vertically: rowIndex => scrollToRow(rowIndex),
}}
/>Example:
<FlexibleCard
index={3}
rowIndex={2}
imageUrl="https://example.com/image.jpg"
direction="horizontal"
customSize={{
width: 350,
height: 200,
radius: 6,
gap: 10,
}}
/>- Use appropriate sizing for viewing distance. TV interfaces are viewed from a distance, so card elements need to be larger than in mobile apps.
Example:
// For primary content, use large cards
<FlexibleCard
size="large"
// other props
/>
// For secondary content, medium or small cards are appropriate
<FlexibleCard
size="medium"
// other props
/>- Implement scroll handlers. Always include scroll handlers to ensure focused cards are visible on screen.
Example:
const scrollToIndex = index => {
// Calculate scroll position based on card width and index
const position = index * (400 + 20); // Card width + gap
scrollViewRef.current?.scrollTo({ x: position, animated: true });
};
// Used in the FlexibleCard
<FlexibleCard
scroll={{
horizontally: scrollToIndex,
}}
// other props
/>;- Use consistent animation styles. Choose a single animation style for similar sections to maintain visual consistency.
Example:
// All cards in the same row or section should use the same animation
<View style={styles.row}>
<FlexibleCard
animations="zoomIn"
// other props
/>
<FlexibleCard
animations="zoomIn"
// other props
/>
</View>- Limit additional content. Keep text and other content minimal with large, readable fonts.
Example:
<FlexibleCard
// other props
>
<Text style={styles.cardTitle}>Short Title</Text>
<Text style={styles.cardSubtitle}>Brief subtitle</Text>
</FlexibleCard>;
// Styles with large fonts for TV viewing
const styles = StyleSheet.create({
cardTitle: {
fontSize: 24,
fontWeight: 'bold',
color: 'white',
},
cardSubtitle: {
fontSize: 18,
color: 'lightgray',
},
});- Test with directional navigation. Make sure your card layout works well with up/down/left/right remote navigation.
Example:
// Arrange cards in a predictable grid pattern
<View style={styles.grid}>
<View style={styles.row}>
<FlexibleCard index={0} rowIndex={0} /* props */ />
<FlexibleCard index={1} rowIndex={0} /* props */ />
<FlexibleCard index={2} rowIndex={0} /* props */ />
</View>
<View style={styles.row}>
<FlexibleCard index={0} rowIndex={1} /* props */ />
<FlexibleCard index={1} rowIndex={1} /* props */ />
<FlexibleCard index={2} rowIndex={1} /* props */ />
</View>
</View>Here's a complete example of creating a horizontal carousel using FlexibleCards:
import React, { useRef } from 'react';
import { View, Text, ScrollView, StyleSheet } from 'react-native';
import { FlexibleCard } from '@AppComponents/FlexibleCard/FlexibleCard';
const CardCarousel = ({ title, items }) => {
const scrollViewRef = useRef(null);
const scrollToIndex = index => {
// Calculate scroll position based on card width and gap
const position = index * (400 + 20); // Medium card width + gap
scrollViewRef.current?.scrollTo({ x: position, animated: true });
};
return (
<View style={styles.container}>
<Text style={styles.sectionTitle}>{title}</Text>
<ScrollView
ref={scrollViewRef}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
>
<View style={styles.cardsContainer}>
{items.map((item, index) => (
<FlexibleCard
key={item.id}
index={index}
rowIndex={0}
imageUrl={item.imageUrl}
size="medium"
direction="horizontal"
animations="zoomIn"
onPress={() => console.log('Selected:', item.title)}
scroll={{
horizontally: scrollToIndex,
}}
/>
))}
</View>
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 40,
},
sectionTitle: {
fontSize: 28,
fontWeight: 'bold',
color: 'white',
marginBottom: 16,
marginLeft: 20,
},
scrollView: {
paddingLeft: 20,
},
cardsContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
},
});
export default CardCarousel;The FlexibleCard component accepts the following props:
| Prop | Type | Default | Description |
|---|---|---|---|
index |
number | Required | Card index in its row. |
rowIndex |
number | Required | Row index for the card. |
imageUrl |
string | - | URL of the image to display. |
size |
'small' | 'medium' | 'large' | 'medium' | Predefined card size. |
direction |
'horizontal' | 'vertical' | 'expand' | Required | Layout direction. |
animations |
'zoomIn' | 'zoomOut' | 'expand' | 'zoomIn' | Animation to apply on focus. |
onFocus |
(index: number) => void | - | Callback when card receives focus. |
onBlur |
(index: number) => void | - | Callback when card loses focus. |
onPress |
() => void | - | Callback when card is pressed. |
scroll |
Object | - | Scroll configuration. |
children |
React.ReactNode | - | Additional card content. |
customSize |
Object | - | Custom card size. |
testID |
string | 'flexible-card' | ID for testing. |
itemId |
string | - | Unique item ID. |
- The FlexibleCard component uses
memowith a custom comparison function to prevent unnecessary re-renders. - Animations use the React Native
AnimatedAPI for smooth performance. - When you use the
expandanimation type, be aware it's still in development and might not work as expected. - The component includes accessibility support with the
role="button"attribute for screen readers.
The FlexibleCard component works well with the following:
- Grid component: For displaying multiple cards in a grid layout.
- VerticalCarousel: Can use FlexibleCard for its list items.
- FocusGuideView: For managing focus navigation between multiple cards.
The HeroCarousel component is a full-width, feature-rich carousel specifically designed for TV interfaces. It provides an engaging way to showcase featured content with auto-scrolling capabilities, responsive design, and optimized TV remote navigation.
- Auto-scrolling: Automatically transitions between slides with configurable intervals.
- TV Remote Navigation: Supports DPAD (directional pad) navigation for TV interfaces.
- Indicator Dots: Visual indicators show the current slide position.
- Custom Styling: Supports custom background colors and images for each slide.
- Responsive Design: Adapts to different screen widths automatically.
- Focus Management: Properly integrates with TV focus systems.
- Performance Optimized: Uses Reanimated for smooth animations and rendering optimizations.
The HeroCarousel implements an intelligent auto-scrolling system that pauses during user interaction and resumes afterward.
Example:
// Shared value to control auto-scrolling behavior and pause/resume
const isAutoScroll = useSharedValue < boolean > true;
// Ref to store the auto-scroll interval timer for cleanup
const autoScrollInterval = (useRef < NodeJS.Timeout) | (null > null);
/**
* Effect to handle auto-scrolling behavior
* Starts the auto-scroll interval when the component is ready
* Cleans up the interval when the component unmounts
*/
useEffect(() => {
if (isAutoScrolling.current) {
return;
}
autoScrollInterval.current = setInterval(() => {
if (currentIndex.value < data.length - 1) {
scrollToIndex(currentIndex.value + 1);
}
}, autoInterval);
return () => {
if (autoScrollInterval.current) {
clearInterval(autoScrollInterval.current);
}
};
}, [currentIndex, data.length, autoInterval, scrollToIndex]);This system ensures the following:
- The carousel scrolls automatically at regular intervals.
- Auto-scrolling pauses when the user interacts with the carousel.
- The interval is properly cleaned up when the component unmounts.
The HeroCarousel integrates with TV remote controls using the DPAD (directional pad) events.
Example:
/**
* Handles TV remote control events (DPAD navigation)
* Pauses auto-scrolling during navigation and resumes after
* @param evt - The hardware event from the TV remote
*/
useFocusGuideEventHandler((evt: HWEvent) => {
if (!isTV || !isFocused) {
return;
}
if (evt.eventKeyAction === EVENT_KEY_DOWN) {
if (autoScrollInterval.current) {
clearInterval(autoScrollInterval.current);
autoScrollInterval.current = null;
}
if (evt.eventType === DPADEventType.RIGHT) {
if (currentIndex.value < data.length - 1) {
scrollToIndex(currentIndex.value + 1);
}
}
if (evt.eventType === DPADEventType.LEFT) {
if (currentIndex.value > 0) {
scrollToIndex(currentIndex.value - 1);
}
}
}
if (evt.eventKeyAction === EVENT_KEY_UP) {
if (!autoScrollInterval.current) {
autoScrollInterval.current = setInterval(() => {
if (currentIndex.value < data.length - 1) {
scrollToIndex(currentIndex.value + 1);
}
}, autoInterval);
}
}
});The HeroCarousel integration enables the following:
- Left and right DPAD buttons navigate between slides.
- Auto-scrolling is paused during navigation.
- Auto-scrolling resumes after navigation completes.
- Focus state is properly tracked for TV interfaces.
The HeroCarousel uses animated dot indicators to show the current slide position.
Example:
<View style={styles.dotContainer}>
{data.map((_, i) => (
<Dot key={`dot-${i}`} index={i} currentIndex={currentIndex} />
))}
</View>The Dot component (not shown here) uses Reanimated to animate properties like size, opacity, and color based on whether the slide is currently active.
The HeroCarousel implements several performance optimizations for smooth animations and efficient rendering.
Example
<Animated.FlatList
// @ts-ignore - ref is not typed, Reanimated issue
ref={flatListRef}
data={data}
renderItem={renderItem}
keyExtractor={(item, index) => `item-${index}-${item.title}`}
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={layoutWidth}
snapToAlignment="start"
scrollEventThrottle={16}
onScroll={scrollHandler}
scrollEnabled={!isTV}
getItemLayout={(_, index) => ({
length: layoutWidth,
offset: layoutWidth * index,
index,
})}
initialNumToRender={1}
maxToRenderPerBatch={2}
windowSize={3}
removeClippedSubviews
/>The HeroCarousel performance optimizations include the following:
- getItemLayout: Pre-computes item dimensions to avoid expensive layout calculations.
- initialNumToRender: Renders only the initial slide to speed up first load.
- maxToRenderPerBatch: Limits how many items render in each batch to prevent frame drops.
- windowSize: Controls how many items to keep rendered beyond the visible area.
- removeClippedSubviews: Detaches off-screen views from the native rendering hierarchy.
Example:
import { HeroCarousel } from '@AppComponents/HeroCarousel/HeroCarousel';
const items = [
{
title: 'Welcome',
description: 'Discover amazing content',
bgColor: '#1a1a1a',
backgroundImage: 'https://example.com/image.jpg',
},
{
title: 'Featured Shows',
description: "Check out this week's highlights",
bgColor: '#2a2a2a',
backgroundImage: 'https://example.com/image2.jpg',
},
{
title: 'Coming Soon',
description: "Don't miss upcoming releases",
bgColor: '#3a3a3a',
backgroundImage: 'https://example.com/image3.jpg',
},
];
// Basic carousel
<HeroCarousel data={items} />;Example:
import { HeroCarousel } from '@AppComponents/HeroCarousel/HeroCarousel';
const handleSlidePress = index => {
console.log(`Slide ${index} pressed`);
// Navigate to content detail or perform other actions
};
const handleSlideFocus = index => {
console.log(`Slide ${index} focused`);
// Update UI or application state based on focused slide
};
<HeroCarousel
data={items}
autoInterval={5000} // 5 seconds between slides
backgroundColor="#000000"
onPress={handleSlidePress}
onFocus={handleSlideFocus}
/>;- Use high-quality images. Hero carousels are prime screen real estate, so use high-quality, engaging visuals.
Example:
const items = [
{
title: 'Featured Movie',
description: 'Watch now in 4K HDR',
bgColor: '#1a1a1a',
backgroundImage: 'https://example.com/high-res-image.jpg', // Use 16:9 aspect ratio images
},
// More items...
];- Keep text concise. Text in carousel slides should be brief and readable from a distance.
Example:
const items = [
{
title: 'New Series', // Short, impactful title
description: 'All episodes now streaming', // Brief description
bgColor: '#1a1a1a',
backgroundImage: 'https://example.com/image.jpg',
},
// More items...
];- Limit number of slides. 3-5 slides is usually optimal for hero carousels.
Example:
// Good: 3-5 focused slides with unique content
const featuredItems = [
{ title: 'New Releases' /* other props */ },
{ title: 'Top Rated' /* other props */ },
{ title: 'Continue Watching' /* other props */ },
];- Implement press handlers. Always make slides interactive and provide clear navigation.
Example:
const navigateToContent = index => {
const destination = ['newReleases', 'topRated', 'continueWatching'][index];
navigation.navigate(destination);
};
<HeroCarousel data={items} onPress={navigateToContent} />;- Test with TV remote navigation. Make sure the carousel works well with DPAD navigation.
Example:
// In your testing workflow
// 1. Verify left/right navigation works
test('should navigate slides with left/right buttons', () => {
// Testing implementation
});
// 2. Verify auto-scrolling pauses during interaction
test('should pause auto-scrolling during user interaction', () => {
// Testing implementation
});Here's an example of using the HeroCarousel in a home screen layout:
Example:
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { HeroCarousel } from '@AppComponents/HeroCarousel/HeroCarousel';
import { ContentRow } from '@AppComponents/ContentRow/ContentRow';
import { ScreenContainer } from '@AppComponents/containers';
export const HomeScreen = () => {
const heroItems = [
{
title: 'Featured Movie',
description: 'Watch now in 4K HDR',
bgColor: '#1a1a1a',
backgroundImage: 'https://example.com/featured-movie.jpg',
},
{
title: 'New Series',
description: 'All episodes available now',
bgColor: '#2a2a2a',
backgroundImage: 'https://example.com/new-series.jpg',
},
{
title: 'Live Sports',
description: 'Watch the big game tonight',
bgColor: '#3a3a3a',
backgroundImage: 'https://example.com/live-sports.jpg',
},
];
const handleHeroPress = index => {
console.log(`Hero slide ${index} pressed`);
// Navigate to content detail
};
return (
<ScreenContainer testID="home-screen">
<View style={styles.container}>
{/* Hero Carousel at the top of the screen */}
<HeroCarousel
data={heroItems}
autoInterval={5000}
onPress={handleHeroPress}
/>
{/* Content rows below the hero */}
<View style={styles.contentRows}>
<ContentRow title="Continue Watching" type="continue-watching" />
<ContentRow title="Popular Now" type="popular" />
<ContentRow title="New Releases" type="new-releases" />
</View>
</View>
</ScreenContainer>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentRows: {
marginTop: 40,
},
});The HeroCarousel component accepts the following props:
| Prop | Type | Default | Description |
|---|---|---|---|
data |
Array | Required | Array of items to display in the carousel. |
backgroundColor |
string | '#000' | Background color of the carousel container. |
autoInterval |
number | 3000 | Time in milliseconds between auto-scrolling slides. |
onPress |
(index: number) => void | - | Callback function when a slide is pressed. |
onFocus |
(index: number) => void | - | Callback function when a slide receives focus. |
onBlur |
(index: number) => void | - | Callback function when a slide loses focus. |
The CarouselItem type has the following properties:
| Property | Type | Description |
|---|---|---|
title |
string | The title text to display on the slide. |
description |
string | The description text to display on the slide. |
bgColor |
string | The background color of the slide. |
backgroundImage |
string (optional) | URL for the slide's background image. |
- The HeroCarousel component uses React Native's Animated API for smooth animations.
- Auto-scrolling automatically restarts if the user hasn't interacted with the carousel for the duration of the
autoInterval. - On TV platforms, the carousel disables touch-based scrolling and relies exclusively on DPAD navigation.
- The carousel includes accessibility support with the
role="button"attribute and focus management for screen readers. - Performance optimizations ensure smooth scrolling even with complex slide content or background images.
The HeroCarousel works well with the following components:
- Grid component: For displaying content rows below the hero carousel.
- FocusGuideView: For managing focus navigation between the carousel and other screen elements.
- Box component: Can be used to create custom slide content.