Documentation Index
Fetch the complete documentation index at: https://mintlify.com/pocket-stack/pocketjs/llms.txt
Use this file to discover all available pages before exploring further.
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 does not cover.
Everything here runs identically on real PSP hardware, PPSSPP, the browser host, and headless Bun. The browser and Bun hosts just remap keyboard keys onto the same BTN bitmask the console reads from the hardware controller.
Every host reports the controller as one integer per frame: a bitmask of the buttons currently held. The bit values are defined in the spec and never change across hosts.
import { BTN } from "@pocketjs/framework/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 | Left shoulder |
BTN.RTRIGGER | 0x0200 | Right 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 remains set for every frame the button is down. For “the frame a button went down” (edge detection), use onButtonPress or the ActionHandler component.
Browser Keyboard Mapping
| Key | Button |
|---|
| Arrow keys | UP / RIGHT / DOWN / LEFT |
| Enter or Z | CIRCLE |
| X | CROSS |
| A | SQUARE |
| S | TRIANGLE |
| Left/Right Shift | SELECT |
| Space | START |
| L or Q | LTRIGGER |
| R or E | RTRIGGER |
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. 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. 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 via
setFocus, which applies the focus: style variant with zero further JS.
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/framework/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">Play</Text>
</Focusable>
);
}
focusable and onPress are independent. A View can carry onPress without being focusable — it then acts as a bubble target: a focused descendant with no handler forwards its Circle press up to the nearest ancestor that has one.
focus: and active: Style 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 JS.
<Focusable class="bg-slate-200 focus:bg-sky-500 focus:scale-105 transition-all duration-150">
{/* … */}
</Focusable>
The active: variant applies while the node is pressed. Both compile at build time into the same style record and are applied natively by the Rust core.
Programmatic Focus
Grab a node with ref and move focus imperatively. Import focusNode and getFocused from @pocketjs/framework/input:
import { focusNode, getFocused } from "@pocketjs/framework/input";
import { onMount } from "solid-js";
import { Focusable, type NodeMirror } from "@pocketjs/framework/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 needs so the background cannot be navigated. The FocusScope component (and Modal, which is built on it) is the usual way in; the imperative primitive underneath is pushFocusScope.
import { pushFocusScope } from "@pocketjs/framework/input";
import { onCleanup } from "solid-js";
// `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.
Modal Usage
Modal combines Portal, a backdrop, and FocusScope automatically. While open is truthy the scope traps focus and pushButtonHandlerBlock silences all background button handlers:
import { Modal, Focusable, View, Text } from "@pocketjs/framework/components";
import { createSignal } from "solid-js";
function DeleteConfirm() {
const [open, setOpen] = createSignal(false);
return (
<>
<Focusable
class="px-3 py-1 rounded-lg bg-red-500 focus:bg-red-400"
onPress={() => setOpen(true)}
>
<Text class="text-sm text-white font-bold">Delete</Text>
</Focusable>
<Modal open={open()}>
<Text class="text-sm text-slate-950 font-bold">Delete this item?</Text>
<View class="flex-row gap-2">
<Focusable
class="px-3 py-1 rounded-lg bg-red-500 focus:bg-red-400"
onPress={() => { doDelete(); setOpen(false); }}
>
<Text class="text-sm text-white">Delete</Text>
</Focusable>
<Focusable
class="px-3 py-1 rounded-lg bg-slate-200 focus:bg-slate-300"
onPress={() => setOpen(false)}
>
<Text class="text-sm text-slate-900">Cancel</Text>
</Focusable>
</View>
</Modal>
</>
);
}
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 pushFocusGrid:
import { pushFocusGrid } from "@pocketjs/framework/input";
import { onCleanup } from "solid-js";
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. The Grid component wraps this with flex layout:
import { Grid, View } from "@pocketjs/framework/components";
// 4-column grid of focusable tiles, navigable by d-pad row/column
<Grid columns={4} gap={8} wrap class="flex-row flex-wrap p-2">
{tiles.map((tile) => (
<View
class="w-16 h-16 rounded-lg bg-white border-slate-200 focus:bg-blue-50 focus:border-blue-500 transition-colors duration-150"
focusable
onPress={() => selectTile(tile.id)}
/>
))}
</Grid>
Refocus on Removal
When the focused node (or an ancestor of it) is removed — a list item deleted, a panel closed — the focus manager repairs focus before the node is unlinked. It searches, in order:
- The next sibling subtree’s first focusable.
- The previous sibling subtrees, nearest first.
- The nearest focusable ancestor.
- Clears focus if nothing qualifies.
This keeps a sensible node focused as content churns, without any bookkeeping in your components. The notifications demo relies on this: dismissing a card with Circle triggers an animate fade+slide, then the card is removed from the <For> list, and focus automatically lands on the next notification.
Per-Frame Hooks
onFrame registers a callback that runs once per frame with the current button bitmask. It cleans itself up automatically when the owning component unmounts. Use it for held-button behavior (movement, charging) or anything that must sample input every frame.
import { onFrame } from "@pocketjs/framework/lifecycle";
import { BTN } from "@pocketjs/framework/input";
import { createSignal } from "solid-js";
function Player() {
const [x, setX] = createSignal(0);
onFrame((buttons) => {
if (buttons & BTN.LEFT) setX((v) => v - 2);
if (buttons & BTN.RIGHT) setX((v) => v + 2);
});
// …
}
onButtonPress gives you edge-triggered presses — it fires your callback on the frame a matching button transitions from up to down.
import { onButtonPress } from "@pocketjs/framework/lifecycle";
import { BTN } from "@pocketjs/framework/input";
// Fires once per press of TRIANGLE
onButtonPress(BTN.TRIANGLE, () => openMenu());
// Multiple buttons in one handler; `pressed` is the edge mask this frame
onButtonPress(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 to enable a handler only on a given screen:
onButtonPress(BTN.SQUARE, () => favorite(), { active: () => tab() === "browse" });
ActionHandler Component
ActionHandler is the declarative wrapper around onButtonPress. It binds a button to a handler without rendering a visible node, and cleans up on unmount:
import { ActionHandler } from "@pocketjs/framework/components";
import { BTN } from "@pocketjs/framework/input";
<ActionHandler button={BTN.START} onPress={() => togglePause()} />;
<ActionHandler button={BTN.CROSS} onPress={() => goBack()} />;
All ButtonPressOptions (active, allowWhenBlocked) pass through as props.
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 onButtonPress handler is suppressed except those that opted in with allowWhenBlocked: true. It returns a disposer that decrements the depth.
import { pushButtonHandlerBlock } from "@pocketjs/framework/lifecycle";
import { onCleanup } from "solid-js";
const release = pushButtonHandlerBlock();
onCleanup(release);
The block only affects onButtonPress / 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 automatically.
Settings Screen Example
A complete settings screen navigated entirely by the engine’s default input — no manual frame wiring:
import { createSignal } from "solid-js";
import { View, Text, Focusable } from "@pocketjs/framework/components";
function SettingsScreen() {
const [sfx, setSfx] = createSignal(true);
const [vibration, setVibration] = createSignal(false);
return (
<View class="flex-col w-full h-full p-3 gap-2 bg-gradient-to-b from-indigo-50 to-slate-100">
<Text class="text-2xl text-indigo-700 font-bold">Settings</Text>
<View class="flex-col gap-2">
{/* UP / DOWN moves between these rows; CIRCLE toggles */}
<Focusable
class="flex-row items-center justify-between px-2 py-1 bg-white border-indigo-200 rounded-lg shadow focus:bg-indigo-50 focus:border-indigo-500 transition-colors duration-150"
onPress={() => setSfx(!sfx())}
>
<Text class="text-sm text-indigo-950">SOUND EFFECTS</Text>
<Text class="text-xs text-indigo-700">{sfx() ? "ON" : "OFF"}</Text>
</Focusable>
<Focusable
class="flex-row items-center justify-between px-2 py-1 bg-white border-indigo-200 rounded-lg shadow focus:bg-indigo-50 focus:border-indigo-500 transition-colors duration-150"
onPress={() => setVibration(!vibration())}
>
<Text class="text-sm text-indigo-950">VIBRATION</Text>
<Text class="text-xs text-indigo-700">{vibration() ? "ON" : "OFF"}</Text>
</Focusable>
</View>
<Text class="text-xs text-indigo-700">UP / DOWN move focus · CIRCLE toggle</Text>
</View>
);
}
Focus moves between the two rows by d-pad. Each focus:bg-indigo-50 focus:border-indigo-500 transition-colors duration-150 class gives a smooth native highlight — zero JS per focus transition.