PocketJS

Input & focus

PocketJS targets a game console: there is no pointer, no touch, no tab key — just a d-pad and a handful of face buttons. A single focus manager tracks one focused node, the d-pad moves focus between focusable nodes, and CIRCLE activates whatever is focused. On top of that sits a thin layer of per-frame hooks for anything the focus manager doesn't cover (global shortcuts, held buttons, custom navigation).

Everything here runs identically on real PSP hardware, PPSSPP, the browser host, and headless Bun. The browser and Bun hosts just remap keys onto the same BTN bitmask the console reads from the hardware controller.

Buttons

Every host reports the controller as one integer per frame: a bitmask of the buttons currently held. The bit values live in the spec and never change across hosts.

import { BTN } from "@pocketjs/core/input";
Member Bit Notes
BTN.SELECT 0x0001
BTN.START 0x0008
BTN.UP 0x0010 d-pad
BTN.RIGHT 0x0020 d-pad
BTN.DOWN 0x0040 d-pad
BTN.LEFT 0x0080 d-pad
BTN.LTRIGGER 0x0100 shoulder
BTN.RTRIGGER 0x0200 shoulder
BTN.TRIANGLE 0x1000
BTN.CIRCLE 0x2000 default "confirm" / press
BTN.CROSS 0x4000
BTN.SQUARE 0x8000

Test a button with a bitwise &, and combine buttons with |:

if (buttons & BTN.CROSS) { /* CROSS is down this frame */ }
const confirmOrBack = BTN.CIRCLE | BTN.CROSS;

The raw bitmask is a held state — it stays set for every frame the button is down. When you want "the frame a button went down" (edge detection), use the focus manager, useButtonPress, or edge-detect it yourself.

The focus model

The focus manager keeps exactly one focused node (or none). The default traversal order is document order — a depth-first walk of the live tree, recomputed on each navigation press, so it is always correct even after a <For> reorders its children.

Each frame, before the render sweep, the manager edge-detects the bitmask and:

  • d-pad moves focus. Outside a grid, DOWN/RIGHT move to the next focusable node and UP/LEFT move to the previous one. Movement clamps at the ends of the list (no wrap). If nothing is focused, the first press enters the order from the matching end.
  • CIRCLE fires a press. It calls onPress on the focused node, and if the focused node has no handler it bubbles up to the nearest ancestor that does.
  • Every focus change is pushed to the native core (setFocus), which applies the focus: style variant with zero further JS.

That is the entire default interaction loop. For most screens you never touch the input API directly — you just mark nodes focusable and give them an onPress.

Making things focusable

Any View becomes focusable with the focusable prop, and gains a CIRCLE handler with onPress. The Focusable component is just a View with focusable preset to true.

import { Focusable, Text } from "@pocketjs/core/components";

function PlayButton(props: { onStart: () => void }) {
  return (
    <Focusable
      class="px-4 py-2 rounded-lg bg-slate-200 focus:bg-sky-500"
      onPress={props.onStart}
    >
      <Text class="text-slate-900 focus:text-white">Play</Text>
    </Focusable>
  );
}

focusable and onPress are independent. A plain View can carry onPress without being focusable — it then acts as a bubble target: a focused descendant with no handler of its own forwards its CIRCLE press up to the nearest ancestor that has one.

focus: and active: variants

Because focus lives in the native core, the visual focus state is a style variant, not a JS re-render. Prefix any utility with focus: and the core swaps in that value the instant the node becomes focused — no effect, no reconciliation, no per-frame work on the JS side.

<Focusable class="bg-slate-200 focus:bg-sky-500 focus:scale-105">…</Focusable>

The core also supports an active: variant for the pressed state. Both are compiled at build time from the Tailwind subset — see Styling for the full list of variants and how they compile.

Programmatic focus

Grab a node with ref (refs hand you the NodeMirror) and move focus imperatively.

import { focusNode, getFocused } from "@pocketjs/core/input";
import { onMount } from "@pocketjs/core/reactivity";
import { Focusable, type NodeMirror } from "@pocketjs/core/components";

function Menu() {
  let first: NodeMirror | undefined;
  onMount(() => focusNode(first ?? null)); // focus the first item on mount
  return <Focusable ref={(n) => (first = n)}>New game</Focusable>;
}
Function Signature Behavior
focusNode (node: NodeMirror | null) => void Focus a node; null clears focus.
getFocused () => NodeMirror | null The currently focused node, or null.

Turning off a node's focusable while it is focused automatically clears focus.

Focus scopes

A focus scope temporarily restricts d-pad traversal and CIRCLE press to one subtree — exactly what a dialog or a menu wants so the background can't be navigated. The declarative FocusScope component (and Modal, which is built on it) is the usual way in; the imperative primitive underneath is pushFocusScope.

import { pushFocusScope } from "@pocketjs/core/input";
import { onCleanup } from "@pocketjs/core/reactivity";

// `panel` is a NodeMirror captured from a ref.
const dispose = pushFocusScope(panel, { autoFocus: true, restoreFocus: true });
onCleanup(dispose); // always release the scope when it unmounts
interface FocusScopeOptions {
  autoFocus?: boolean;    // focus the scope's first focusable on push (default true)
  restoreFocus?: boolean; // restore the previously focused node on dispose (default true)
}

pushFocusScope returns a disposer. While a scope is on the stack, focus traversal only sees nodes inside it, so navigation cannot leak out. Disposing pops the scope and (unless restoreFocus is false) returns focus to wherever it was before.

Focus grids

By default the d-pad walks a flat list. A focus grid overlays true row/column semantics on a subtree: LEFT/RIGHT move within a row, UP/DOWN move between rows. Use the FocusGrid component, or the primitive:

import { pushFocusGrid } from "@pocketjs/core/input";
import { onCleanup } from "@pocketjs/core/reactivity";

const dispose = pushFocusGrid(gridRoot, { columns: 4, wrap: true });
onCleanup(dispose);
interface FocusGridOptions {
  columns: number; // items per row (clamped to a minimum of 1)
  wrap?: boolean;  // wrap around row/column edges (default false)
}

The focusable descendants of gridRoot, in document order, are laid out into rows of columns. With wrap: false, movement stops at the grid edges; with wrap: true, RIGHT off the end of a row returns to its start, DOWN off the bottom returns to the top of that column, and so on.

Refocus on removal

When the focused node (or an ancestor of it) is removed — a list item deleted, a panel closed — the manager repairs focus before the node is unlinked, so it can still see the surrounding tree. It searches, in order:

  1. the next sibling subtree's first focusable,
  2. then the previous sibling subtrees, nearest first,
  3. then the nearest focusable ancestor,
  4. and finally clears focus if nothing qualifies.

This keeps a sensible node focused as content churns, without any bookkeeping in your components.

Per-frame hooks

useFrame registers a callback that runs once per frame with the current button bitmask. It cleans itself up when the owning component unmounts. Use it for held-button behavior (movement, charging) or anything that must sample input every frame.

import { useFrame } from "@pocketjs/core/hooks";
import { BTN } from "@pocketjs/core/input";
import { createSignal } from "@pocketjs/core/reactivity";

function Player() {
  const [x, setX] = createSignal(0);
  useFrame((buttons) => {
    if (buttons & BTN.LEFT) setX((v) => v - 2);
    if (buttons & BTN.RIGHT) setX((v) => v + 2);
  });
  // …
}

Button-press hooks

useFrame gives you the held state; useButtonPress gives you edge-triggered presses. It fires your callback on the frame a matching button transitions from up to down.

import { useButtonPress } from "@pocketjs/core/hooks";
import { BTN } from "@pocketjs/core/input";

// Fires once per press of TRIANGLE.
useButtonPress(BTN.TRIANGLE, () => openMenu());

// Multiple buttons in one handler; `pressed` is the edge mask this frame.
useButtonPress(BTN.CROSS | BTN.CIRCLE, (pressed) => {
  if (pressed & BTN.CROSS) goBack();
  else confirm();
});

The callback receives (pressed, buttons) — the newly-pressed edge mask and the full held mask. Options:

interface ButtonPressOptions {
  active?: boolean | (() => boolean); // gate the handler on/off (default true)
  allowWhenBlocked?: boolean;         // keep firing while input is blocked (default false)
}

active can be a reactive accessor, so a handler can be enabled only on a given screen:

useButtonPress(BTN.SQUARE, () => favorite(), { active: () => tab() === "browse" });

The declarative equivalent is the ActionHandler component, which wraps useButtonPress:

import { ActionHandler } from "@pocketjs/core/components";
import { BTN } from "@pocketjs/core/input";

<ActionHandler button={BTN.START} onPress={() => togglePause()} />;

Blocking background input

When a modal or overlay owns input, the buttons behind it should go quiet. pushButtonHandlerBlock increments a global block depth: while it is non-zero, every useButtonPress handler is suppressed except those that opted in with allowWhenBlocked: true (system/close handlers). It returns a disposer that decrements the depth.

import { pushButtonHandlerBlock } from "@pocketjs/core/hooks";
import { onCleanup } from "@pocketjs/core/reactivity";

const release = pushButtonHandlerBlock();
onCleanup(release);

The block only affects useButtonPress / ActionHandler; the focus manager's own d-pad navigation and CIRCLE press are unaffected (they are already contained by whatever focus scope the overlay pushes). Modal combines both: it pushes a focus scope and a handler block for you.

Browser & playground keyboard mapping

The browser host and the playground map the keyboard onto the same BTN bitmask the console reads from hardware, so the exact same code runs everywhere:

Key Button
Arrow keys UP / RIGHT / DOWN / LEFT (d-pad)
Enter or Z CIRCLE
X CROSS
A SQUARE
S TRIANGLE
Left/Right Shift SELECT
Space START

There is no default key for LTRIGGER / RTRIGGER in the browser host. Everything else behaves identically to hardware: arrows drive focus, Enter/Z confirms, and your useButtonPress handlers fire on the mapped presses.

Related

  • App shellFocusable, FocusScope, FocusGrid, Modal, and ActionBar components.
  • ComponentsView, Text, For, Show, and the rest of the primitives.
  • Styling — the focus: / active: variants and the Tailwind subset.
  • ReactivitycreateSignal, onMount, onCleanup.