27 KiB
Navigation
Focus moves by itself, the way a browser or a TV platform moves it: mark what is focusable, and the D-pad picks the nearest element in the pressed direction from the elements' real positions on screen. Cross presses, Circle goes back. Apps do not track focus indices.
<FocusScope autoFocus>
{games.map(game => (
<View key={game.id} focusable onPress={() => open(game)}
className="rounded-xl bg-slate-800 transition focused:bg-sky-700 focused:scale-105">
<Text className="text-white">{game.title}</Text>
</View>
))}
</FocusScope>
Quick start
import {useState} from 'react';
import {AppRegistry, View, Text, FocusScope} from '@ps5-react/core';
const items = ['Play', 'Settings', 'About'];
function Menu() {
const [selected, setSelected] = useState(null);
const clear = () => {
setSelected(null);
return true;
};
return (
<FocusScope autoFocus wrap onBack={clear}>
<View className="w-screen h-screen bg-slate-950 items-center justify-center gap-8">
<View className="flex-row gap-4">
{items.map(item => (
<View key={item} onPress={() => setSelected(item)}
className="w-56 h-24 rounded-xl border-4 border-slate-700 bg-slate-800
items-center justify-center focused:border-sky-400 focused:scale-105">
<Text className="text-xl text-white">{item}</Text>
</View>
))}
</View>
<Text className="text-base text-sky-300">{selected ?? 'Nothing selected'}</Text>
</View>
</FocusScope>
);
}
AppRegistry.registerComponent('menu', () => Menu);
- Give each item
onPress(orfocusablewhen it does nothing on Cross). - Style the focused state with
focused:classes,whileFocus, or astylefunction. The element's own focus drives them; there is nofocusedprop to pass. - Wrap the screen in
FocusScope autoFocusso something has focus at start. - Handle Circle with
onBack.
npm run create scaffolds this menu; apps/starter, apps/tailwind-gallery,
apps/system-explorer, and apps/motion-lab use the patterns below.
Host input (input protocol v5)
The input protocol is versioned on its own; it is not the native API's ABI
version (NATIVE-API.md).
globalThis.__ps5ReactDispatch(action) receives up, down, left, right
(the D-pad and the left stick, with the platform's hold-to-repeat), confirm,
back, and, since input protocol v3, l1, r1, l2, r2, triangle, square, and options
(one action per press, no repeat). For input protocol v1 compatibility the runtime also
delivers previous (after up/left) and next (after down/right) to
useController handlers only. Both hosts and input scripts take the names from
native/shared/actions.hpp.
Options (O on desktop) is an action like the buttons above; the PS button (Escape on desktop) is how the user leaves. A media app uses Options for its settings, as the system apps do.
Since input protocol v5, hosts also report the touchpad's first finger: __ps5ReactTouch(x, y)
with its position 0..1 across and down the pad each frame it touches, and __ps5ReactTouch(null)
once when it lifts (the PS5 reads the DualSense; the desktop, a controller's touchpad through SDL).
See Touchpad.
The focus manager does not move on these buttons: they go to the enclosing
FocusScope.onAction handlers (tabs on L1/R1, a filter on Triangle, settings on Options)
and to useController.
Once per frame, after the pump and before the commit, hosts also call
globalThis.__ps5ReactFrame(elapsedMs) with the time the frame advances (on the
PS5 a whole number of 60 Hz vblanks). Focus scrolling steps there, so it moves on
every presented frame; a timer drifts against the display and skips or doubles
frames.
| Action | Desktop | PS5 |
|---|---|---|
up, down, left, right |
Arrow keys | D-pad, left stick |
confirm |
Enter | Cross (X) |
back |
Backspace | Circle |
l1, r1 |
Q, E | L1, R1 |
l2, r2 |
Z, C | L2, R2 (past half travel on the desktop) |
triangle, square |
T, F | Triangle, Square |
options |
O | Options (Start) |
| Exit (handled by the host) | Escape | (the PS button) |
None: touchpad in Controller.getState() and controller events only |
P | Touchpad click |
The keys also hold their buttons in Controller.getState(), as a controller's
would. Z and C are L2 and R2 at full travel; with Shift down they are a light
press, 0.4 of the travel, short of the half-travel press, so a key held while
Shift goes down and up eases the trigger off and back in. On the desktop the
l2 and r2 actions fire as the travel crosses half, from a key or a
controller, and re-arm below a quarter.
Touchpad
A finger sliding on the touchpad moves focus as the D-pad would, one step per stretch of travel:
about a ninth of the pad's width across, a fifth of its height down. A slow swipe walks card by card
and a long one runs further; going back over the ground steps back, like a dial. A swipe settles on
an axis after a short distance and keeps to it, and a tap moves nothing. Each step reaches
useNavigationEvents as a move or blocked event with source: 'touchpad', so an app can tick
for swipes alone:
useNavigationEvents(event => {
if (event.type === 'move' && event.source === 'touchpad') Controller.vibrate(0.25, 25);
});
Pressing the touchpad stays the touchpad button of useGamepad.
Focusable elements
View, Image, and Pressable from @ps5-react/core accept:
| Prop | Meaning |
|---|---|
focusable |
Takes part in D-pad navigation (Pressable and any element with onPress are focusable by default) |
onPress |
Called on Cross while focused |
onFocus, onBlur |
Focus changes |
autoFocus |
Takes focus when mounted if nothing in its scope has it |
focusKey |
Stable id for focus(key) and nextFocus* |
nextFocusUp, nextFocusDown, nextFocusLeft, nextFocusRight |
focusKey that overrides the spatial choice for that direction |
style |
May be a function ({focused, pressed}) => style |
pressed is true for about 120 ms after Cross (for press feedback).
Text is not focusable; wrap it in a View.
<View onPress={save}
style={({focused, pressed}) => ({
padding: 16,
backgroundColor: pressed ? '#0369a1' : focused ? '#0ea5e9' : '#1e293b',
})} />
Spatial algorithm
Candidates are the focusable elements of the active scope whose rectangle lies
in the pressed direction from the focused one. The winner minimises
primary distance + 2 × orthogonal offset between the rectangles' nearest edges
(ties: the one with more overlap on the orthogonal axis, then document order).
Rectangles are the elements' laid-out screen rectangles (pre-transform), kept
up to date as layout and scrolling change. If nothing qualifies in the scope,
the search continues in the parent scope unless the scope traps. A wrap
scope first wraps within the same row or column; with nothing there, the search
continues outward as usual. When no scope has a candidate, focus stays where it
is.
An element inside a ScrollView that the focused element is not inside is a
candidate only while its rectangle overlaps that ScrollView's viewport, at
least in part. Entering a scroll view from outside (Right from the last header
item, Down into a rail) therefore reaches only what it shows, never an item
scrolled out of view; moves inside a scroll view, including down a scrolling
page past its viewport, reach any of its elements as before. Nested scroll
views apply this per level.
Because rectangles are pre-transform, focused:scale-105, whileFocus lifts,
and animated translations never change where focus goes next. Let the layout
express the order you want, and use nextFocus* only for exceptions.
FocusScope
| Prop | Meaning |
|---|---|
autoFocus |
Focus the scope's first (or remembered) element on mount |
trap |
Focus cannot leave the scope (modals, dialogs) |
wrap |
Moving past the last element in a direction wraps to the first in that row/column; with nothing in that row/column (Down from a tab row), focus leaves the scope as usual unless it traps |
row |
A horizontal row (a shelf of cards): Left and Right stop at its ends instead of moving into the rows around it (the move reports blocked). Entered for the first time it focuses its selected element, or else its first; after that, the one it had, as restoreFocus does |
restoreFocus |
Re-entering the scope focuses the element focused when it was left (default true) |
onBack |
Handles Circle while focus is inside; return true to consume |
onAction |
(action) => boolean: handles l1, r1, l2, r2, triangle, square, and options while focus is inside, innermost scope first; return true to consume |
focusKey |
Name for focus(key) on the scope (focuses its remembered element) |
inert |
Takes the scope's subtree out of navigation while true: no candidates, no autoFocus, no focus(key); focus inside moves out after the commit |
Scopes nest. The active scope is the innermost scope containing the focused element. Unmounting the focused element moves focus to the nearest focusable in the same scope (or the scope's remembered/first element).
Use inert for a layer that stays mounted while hidden (display: none, or
covered by another screen). The engine stops laying out a hidden subtree, so its
elements keep their last rectangles and would otherwise remain reachable. Wrap
it in ReleaseImages too, so its images do not hold memory the visible screen
needs (IMAGES.md).
FocusScope renders no element of its own, so it does not affect layout.
Hooks and modules
useFocusable({focusKey, onPress, autoFocus, ...})→{focused, pressed, focusProps}for custom components; spreadfocusPropson a host element.useFocus()→{focusedKey, focus(key), blur(), focusedRect()}.focusedRect()is where the focused element is on screen,{x, y, width, height}in render pixels with the scrolling around it applied (transforms are not), ornull: to place a menu or tooltip next to it.useFocusActions()→{focus(key), blur(), focusedRect()}, the same functions withoutfocusedKey: it never re-renders its component.useIsFocused()inside a focusable subtree → boolean of the nearest focusable ancestor.BackHandler.addEventListener('hardwareBackPress', fn)→{remove()};fnreturningtrueconsumes Circle. Order: each enclosingFocusScope.onBack, innermost first, until one returnstrue; then listeners (last added first). Unconsumed Circle does nothing.BackHandler.exitApp()is unchanged.useController(handler)keeps receiving every action (raw input for games).useNavigationEvents(listener)reports what the focus manager did with each input; see Navigation events.
useFocus() re-renders its component whenever the focused key changes; call it
in the component that needs focusedKey, not in every list item. A component that only
moves focus (an app root, a page) takes useFocusActions() instead: with useFocus()
every D-pad move would re-render it and everything it renders. focus(key)
returns whether something took focus, so it can be returned from onBack.
Navigation events
useNavigationEvents(listener) calls listener after the focus manager has
handled each input, for feedback such as sounds (NATIVE-API.md).
It never re-renders its component, so call it once near the root.
| Event | When |
|---|---|
{type: 'move', direction, source?} |
Focus moved (spatially, through nextFocus*, or onto the first element when nothing had focus); source is 'touchpad' for a step of a swipe |
{type: 'blocked', direction, source?} |
Nothing to move to: an edge, a trap, a row's end, or no focusable element |
{type: 'press', handled} |
Cross on the focused element; handled when it is enabled and has onPress (no event without focus) |
{type: 'back', handled} |
Circle; handled when an onBack or BackHandler listener consumed it |
{type: 'action', action, handled} |
L1, R1, L2, R2, Triangle, Square or Options; handled when an onAction returned true |
Held directions repeat, so move arrives on every repeat. FocusManager.move
and press report the same outcomes (true/false, null for a press
without focus) to code that drives the manager directly.
ScrollView
A ScrollView scrolls its focused descendant into view (minimal movement with a
margin) through the native scroll offset, stepped once per presented frame in whole
pixels: it speeds up over four frames to at most 40 logical px per frame and
brakes to stop exactly on the target, with no slow tail. A held key scrolls at that
steady speed, and a new target mid-scroll keeps the current speed. When focus
moves faster than that (a held key over tall rows) and the target gets more than
a third of the viewport ahead, the cap rises by a quarter of the excess, so the
screen follows focus instead of falling ever further behind.
<ScrollView className="flex-1 gap-1">
{entries.map(entry => (
<View key={entry.path} onPress={() => open(entry)}
className="px-3 py-1 rounded-md focused:bg-slate-700">
<Text className="text-sm text-white">{entry.name}</Text>
</View>
))}
</ScrollView>
Render the whole list and let the ScrollView follow focus instead of slicing
a window of rows around a focus index. Give it a bounded height (flex-1 in a
sized parent, or an explicit h-*).
For lists of more than a few dozen items, or any list that loads more as it
scrolls, put a VirtualList in the ScrollView: it mounts only
the rows near the viewport and near focus, and calls onEndReached near the end.
Section anchors
Minimal movement reveals only the focused element: focusing a card in a
horizontal rail below a section title can leave the title cut off. Mark the
section with scrollAnchor (on a View or Pressable; keep it constant for
the element's lifetime) and a vertical ScrollView aligns the section's top to
the viewport top plus the margin instead, whichever element inside it takes focus:
<ScrollView className="flex-1" onScrollTarget={({y}) => setHeaderHidden(y > heroHeight)}>
<View scrollAnchor className="h-[420px]">…hero with a Play button…</View>
{sections.map(section => (
<View key={section.id} scrollAnchor className="pt-10 gap-4">
<Text>{section.title}</Text>
<ScrollView horizontal className="h-[260px]">…cards…</ScrollView>
</View>
))}
</ScrollView>
- Each
ScrollViewuses the nearestscrollAnchorancestor of the focused element that lies directly inside it (not inside a nestedScrollView). In the example, the horizontal rail still moves the least to show the card, and the page aligns the section. - The offset is clamped to the content, so an anchor within the margin of the content's top (the hero) scrolls all the way back to 0.
- When the aligned offset would not show the focused element (an anchor taller
than the viewport), the
ScrollViewfalls back to minimal movement for it. - Only the vertical axis of a vertical
ScrollViewaligns; horizontalScrollViews and the x axis always move the least. - An element can be its own anchor (
<View scrollAnchor focusable>).
onScrollTarget({x, y}) on a ScrollView reports the offset it scrolls to on
focus, once per new target when the scroll starts (and again if the content
shrank and the scroll settled short of it). Touch scrolling does not call it;
use onScroll for the live offset. Offsets are render pixels, like layout
rectangles.
Tailwind and motion integration
- On an element that is focusable (
focusable,onPress, orPressable) and has no explicitfocused/pressedprop, thefocused:andpressed:variants read the element's own focus state: the compiler emits astylefunction instead of requiring the prop. An explicitfocused={...}prop keeps today's behavior. motion.*elements that are focusable use their own focus state forwhileFocusandwhilePress; animation classes withtransitionwork the same.
The compiler decides from the JSX attributes it sees: focusable or onPress
written on the element, or Pressable imported from @ps5-react/core. A
spread such as {...focusProps} does not count, so pass focused explicitly
there (see custom components).
Animated focus classes (transition … focused:scale-105) must not sit inside a
conditional class expression; choose between two complete elements instead.
See TAILWIND.md.
Recipes
Grid
A grid needs nothing beyond focusable cells: Up and Down find the cell in the neighbouring row from the real layout.
<FocusScope autoFocus>
<View className="flex-row flex-wrap gap-6 w-[1096px]">
{apps.map(app => (
<motion.View key={app.id} onPress={() => launch(app)}
whileFocus={{y: -6, scale: 1.06}} transition={transitions.focus}
className="w-[200px] h-[140px] rounded-2xl bg-slate-800 focused:bg-slate-700" />
))}
</View>
</FocusScope>
When something outside the cell needs to know which one is focused (a gliding
halo, a details panel), mirror it from onFocus/onBlur. The state only
describes focus; it never moves it. apps/motion-lab/pages/focus.jsx places its
halo this way.
const [current, setCurrent] = useState(-1);
// on each cell:
onFocus={() => setCurrent(index)} onBlur={() => setCurrent(-1)}
List
A list is a column of focusable rows. Put long lists in a
ScrollView. Add wrap to its scope to loop from the last row
back to the first.
Tabs
Tabs are one scope and the page another. wrap loops Left and Right along the
tab row while Down still leaves it. Focusing a tab shows its page; Cross or Down
enters the page; Circle in the page returns to the tab row, which
restores the active tab because scopes remember their last element.
function App() {
const [page, setPage] = useState(0);
const {focusedKey, focus} = useFocus();
const inTabs = focusedKey?.startsWith('tab:') ?? true;
const {Page} = PAGES[page];
return (
<View className="w-screen h-screen gap-6">
<FocusScope focusKey="tabs" autoFocus wrap>
<View className="flex-row gap-2">
{PAGES.map(({label}, index) => (
<View key={label} focusKey={`tab:${label}`} selected={index === page}
onFocus={() => setPage(index)} onPress={() => focus('content')}
className="px-5 py-2 rounded-full border-2 border-transparent
selected:bg-slate-700 focused:border-sky-400">
<Text className="text-white">{label}</Text>
</View>
))}
</View>
</FocusScope>
<FocusScope focusKey="content" onBack={() => focus('tabs')}>
<Page key={page} />
</FocusScope>
<Text className="text-sm text-slate-400">
{inTabs ? 'X / Down: enter the page' : 'Circle: back to tabs'}
</Text>
</View>
);
}
key={page} remounts the page, so entering a new page starts at its first
element. focusedKey with a tab: prefix tells the app which region has focus
for hints and highlights, without an index.
Tabs that keep their pages
Remounting rebuilds a whole page on every tab switch, tens of milliseconds of
React work on the PS5 for a page of posters. Screens keeps each page it has
shown mounted instead, and brings it back with its state, scroll and focus:
import {Screen, Screens, useIsScreenActive} from '@ps5-react/core';
<Screens active={page} focusKey="content" onBack={() => focus('tabs')} preload>
<Screen id={0}><HomePage /></Screen>
<Screen id={1}><MoviesPage /></Screen>
<Screen id={2}><SearchPage /></Screen>
</Screens>
Switching works like AnimatePresence mode="wait": the page on stage leaves,
then the next one enters, with the variants labels hidden, shown and
gone (a short slide by default; ANIMATION.md, "Page transitions"). A page off
stage is display: 'none', out of navigation (its FocusScope is inert),
holds no image cache entries (ReleaseImages), and does not re-render when the
component rendering Screens does; context changes still reach it. Pages
passed over while another one leaves are not mounted. focusKey and onBack
go to the scope of the page on stage, and a Screen's own onBack wins.
preload mounts pages not shown yet, one at a time, after the stage has rested
for a second, so their first visit does not build them either.
Hidden pages keep their engine nodes and their timers. Pause polling, clocks
and ambient animation with useIsScreenActive(), which is false while the
page is off stage or leaving and true outside Screens.
Modal
A modal traps focus and closes on Circle. When it unmounts, focus returns to the element that had it before the modal opened, or the nearest one left in the enclosing scope.
{confirming && (
<FocusScope trap autoFocus onBack={() => { setConfirming(false); return true; }}>
<View className="absolute inset-0 bg-black/60 items-center justify-center">
<View className="w-[520px] p-8 gap-6 rounded-2xl bg-slate-800">
<Text className="text-xl text-white">Delete save data?</Text>
<View className="flex-row gap-4">
<View onPress={() => setConfirming(false)}
className="px-6 py-3 rounded-xl bg-slate-700 focused:bg-slate-500">
<Text className="text-white">Cancel</Text>
</View>
<View onPress={deleteSave}
className="px-6 py-3 rounded-xl bg-slate-700 focused:bg-rose-600">
<Text className="text-white">Delete</Text>
</View>
</View>
</View>
</View>
</FocusScope>
)}
To send focus somewhere specific after closing, give that element a focusKey
and call focus(key).
Back handling
Circle goes to the enclosing scopes' onBack, innermost first, until one returns
true, then to BackHandler listeners, last added first. An unconsumed Circle
does nothing; it never exits the app.
A nested scope can handle Circle only in some states and let the rest bubble up.
The System Explorer Files page climbs folders and, at /, leaves Circle to the
page scope, which returns to the tabs:
const up = () => {
if (dir === '/') return false;
open(parent(dir));
return true;
};
<FocusScope onBack={up}>…</FocusScope>
Use onBack for behavior tied to a region of the screen (close the modal, leave
the page, go up a folder). Use a listener for screen-wide behavior that does not
depend on where focus is, or when nothing is focusable:
useEffect(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
if (!playing) return false;
pause();
return true;
});
return () => subscription.remove();
}, [playing]);
BackHandler.exitApp() quits explicitly; the PS button (Escape on desktop) leaves through the host.
Custom focusable components
The simplest custom control is a focusable View whose children read its focus
with useIsFocused(). Text styles are not inherited, so this is also how a
label changes with focus:
function Label({children}) {
const focused = useIsFocused();
return <Text focused={focused} className="text-sm text-slate-300 focused:text-white">{children}</Text>;
}
export function Button({label, onPress}) {
return (
<View onPress={onPress} className="px-5 py-2 rounded-full bg-slate-800 focused:bg-sky-700">
<Label>{label}</Label>
</View>
);
}
When the component itself needs the state, use useFocusable and spread
focusProps on the element whose rectangle should be navigable. focusProps
already contains focusable={false}, so the View does not register a second
time; pass focused and pressed explicitly for the variants:
import {View, Text, useFocusable} from '@ps5-react/core';
export function Toggle({label, on, onChange, focusKey}) {
const {focused, pressed, focusProps} = useFocusable({focusKey, onPress: () => onChange(!on)});
return (
<View {...focusProps} focused={focused} pressed={pressed} selected={on}
className="flex-row gap-3 px-4 py-2 rounded-xl bg-slate-800
focused:bg-slate-700 pressed:scale-95 selected:border-2 selected:border-sky-400">
<Text focused={focused} className="text-slate-300 focused:text-white">{label}</Text>
<Text className="text-sky-300">{on ? 'On' : 'Off'}</Text>
</View>
);
}
useIsFocused() below an element that uses useFocusable does not see it;
pass focused down instead.
Migrating from useController
| Before | After |
|---|---|
const [focus, setFocus] = useState(0) plus next/previous arithmetic |
onPress or focusable on each item inside a FocusScope |
focused={focus === i} |
nothing: focused: classes and whileFocus read the element's own state |
if (action === 'confirm') run(items[focus]) |
onPress={() => run(item)} |
if (action === 'back') … |
FocusScope onBack, or BackHandler.addEventListener |
% length wrapping |
FocusScope wrap |
| A "zone" flag for tabs versus content | Two scopes and focus('tabs') / focus('content') |
| Slicing visible rows around the focus index | ScrollView, which follows focus; VirtualList for long lists |
focused passed down to child Text |
useIsFocused() in the child |
| A focus index needed for display | Mirror it with onFocus/onBlur, read-only |
A list screen before and after:
// Before
const [focus, setFocus] = useState(0);
useController(action => {
if (action === 'next') setFocus(i => (i + 1) % items.length);
if (action === 'previous') setFocus(i => (i + items.length - 1) % items.length);
if (action === 'confirm') open(items[focus]);
});
// …
<View focused={focus === i} className="… focused:bg-sky-700" />
// After
<FocusScope autoFocus wrap>
{items.map(item => (
<View key={item.id} onPress={() => open(item)} className="… focused:bg-sky-700" />
))}
</FocusScope>
Keep useController for raw input that is not navigation: a game loop, a
custom gesture, or a debug overlay. It receives every action alongside focus
navigation, so do not also move focus from it.
Limits
- Only
View,Image,Pressable, and theirmotion.*versions can be focusable.Textcannot. - The search uses pre-transform rectangles; transforms never steer focus.
Textdoes not follow its parent's focus automatically; useuseIsFocused().- While
AnimatePresenceplays an exit, the leaving elements remain focusable until they unmount. - There is no pointer, touch, or text-input focus, and the runtime draws no focus ring: the app styles the focused state.
- Focus is JavaScript state and resets when the preview restarts.