# 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. ```jsx {games.map(game => ( open(game)} className="rounded-xl bg-slate-800 transition focused:bg-sky-700 focused:scale-105"> {game.title} ))} ``` ## Quick start ```jsx 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 ( {items.map(item => ( 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"> {item} ))} {selected ?? 'Nothing selected'} ); } AppRegistry.registerComponent('menu', () => Menu); ``` 1. Give each item `onPress` (or `focusable` when it does nothing on Cross). 2. Style the focused state with `focused:` classes, `whileFocus`, or a `style` function. The element's own focus drives them; there is no `focused` prop to pass. 3. Wrap the screen in `FocusScope autoFocus` so something has focus at start. 4. 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](NATIVE-API.md#contract)). `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](#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](NATIVE-API.md#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: ```js 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`. ```jsx ({ 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](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; spread `focusProps` on 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), or `null`: to place a menu or tooltip next to it. - `useFocusActions()` → `{focus(key), blur(), focusedRect()}`, the same functions without `focusedKey`: it never re-renders its component. - `useIsFocused()` inside a focusable subtree → boolean of the nearest focusable ancestor. - `BackHandler.addEventListener('hardwareBackPress', fn)` → `{remove()}`; `fn` returning `true` consumes Circle. Order: each enclosing `FocusScope.onBack`, innermost first, until one returns `true`; 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](#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](NATIVE-API.md#sound)). 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. ```jsx {entries.map(entry => ( open(entry)} className="px-3 py-1 rounded-md focused:bg-slate-700"> {entry.name} ))} ``` 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`](LISTS.md) 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: ```jsx setHeaderHidden(y > heroHeight)}> …hero with a Play button… {sections.map(section => ( {section.title} …cards… ))} ``` - Each `ScrollView` uses the nearest `scrollAnchor` ancestor of the focused element that lies directly inside it (not inside a nested `ScrollView`). 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 `ScrollView` falls back to minimal movement for it. - Only the vertical axis of a vertical `ScrollView` aligns; horizontal `ScrollView`s and the x axis always move the least. - An element can be its own anchor (``). `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`, or `Pressable`) and has no explicit `focused`/`pressed` prop, the `focused:` and `pressed:` variants read the element's own focus state: the compiler emits a `style` function instead of requiring the prop. An explicit `focused={...}` prop keeps today's behavior. - `motion.*` elements that are focusable use their own focus state for `whileFocus` and `whilePress`; animation classes with `transition` work 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](#custom-focusable-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](TAILWIND.md#focus-state). ## Recipes ### Grid A grid needs nothing beyond focusable cells: Up and Down find the cell in the neighbouring row from the real layout. ```jsx {apps.map(app => ( 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" /> ))} ``` 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. ```jsx 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`](#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. ```jsx function App() { const [page, setPage] = useState(0); const {focusedKey, focus} = useFocus(); const inTabs = focusedKey?.startsWith('tab:') ?? true; const {Page} = PAGES[page]; return ( {PAGES.map(({label}, index) => ( setPage(index)} onPress={() => focus('content')} className="px-5 py-2 rounded-full border-2 border-transparent selected:bg-slate-700 focused:border-sky-400"> {label} ))} focus('tabs')}> {inTabs ? 'X / Down: enter the page' : 'Circle: back to tabs'} ); } ``` `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: ```jsx import {Screen, Screens, useIsScreenActive} from '@ps5-react/core'; focus('tabs')} preload> ``` 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. ```jsx {confirming && ( { setConfirming(false); return true; }}> Delete save data? setConfirming(false)} className="px-6 py-3 rounded-xl bg-slate-700 focused:bg-slate-500"> Cancel Delete )} ``` 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: ```jsx const up = () => { if (dir === '/') return false; open(parent(dir)); return true; }; … ``` 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: ```jsx 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: ```jsx function Label({children}) { const focused = useIsFocused(); return {children}; } export function Button({label, onPress}) { return ( ); } ``` 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: ```jsx 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 ( {label} {on ? 'On' : 'Off'} ); } ``` `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: ```jsx // 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]); }); // … // After {items.map(item => ( open(item)} className="… focused:bg-sky-700" /> ))} ``` 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 their `motion.*` versions can be focusable. `Text` cannot. - The search uses pre-transform rectangles; transforms never steer focus. - `Text` does not follow its parent's focus automatically; use `useIsFocused()`. - While `AnimatePresence` plays 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.