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 ships a set of small, unopinionated layout and overlay primitives that compose into complete PSP-style application shells. They live in @pocketjs/framework/components alongside View, Text, and Image, and they are thin wrappers over the same View, focus manager, and frame hooks you already use for individual components. There is no router, no navigation stack, and no global store — screen switching is ordinary reactive state, and the primitives compose all the way up.
import {
Screen,
Focusable,
FocusScope,
FocusGrid,
ActionHandler,
Portal,
Modal,
ActionBar,
Grid,
Lazy,
Gallery,
} from "@pocketjs/framework/components";
Every primitive except ActionHandler and Portal extends ViewProps (class, style, ref, children, focusable, onPress), so anything you can do to a View you can do to them.
Screen
Screen is a View with a default class of "relative flex-col w-full h-full bg-slate-50 overflow-hidden". Use one as the root of each page. Pass your own class to override the default entirely — the passed value replaces the default rather than extending it.
interface ScreenProps extends ViewProps {}
function HomeScreen() {
return (
<Screen class="relative flex-col w-full h-full bg-slate-950 overflow-hidden">
{/* page content */}
</Screen>
);
}
Screens are just components — “navigating” between them is writing a signal that a Switch/Match (Solid) or ternary (Vue Vapor) reads. There is no router to configure. When you need a back stack, store an array of screen IDs in a signal and push or pop it.
Focusable
Focusable is a View with focusable pre-set. It exists so intent reads clearly at the call site; <Focusable onPress={...}> and <View focusable onPress={...}> are exactly equivalent.
interface FocusableProps extends ViewProps {
onPress?: () => void;
}
<Focusable
class="p-2 rounded-md bg-white border-slate-200 focus:border-blue-500"
onPress={() => select(item)}
>
<Text class="text-sm text-slate-950">{item.title}</Text>
</Focusable>
CIRCLE fires the onPress of the focused node, bubbling to the nearest ancestor handler. The focus: style variant is applied by the Rust core with zero extra JavaScript.
FocusScope
FocusScope temporarily restricts d-pad traversal and CIRCLE press to its subtree while active. This is what prevents a dialog from letting focus wander back into the page behind it. When the scope tears down it restores the previous focus unless restoreFocus={false} is set.
interface FocusScopeProps extends ViewProps {
active?: boolean | (() => boolean); // default: true
autoFocus?: boolean; // default: true — move focus to first focusable on entry
restoreFocus?: boolean; // default: true — return focus to previous node on exit
}
| Prop | Type | Default | Effect |
|---|
active | boolean | (() => boolean) | true | Whether the scope is currently pushed. Accepts a signal accessor. |
autoFocus | boolean | true | On entry, move focus to the first focusable inside the scope. |
restoreFocus | boolean | true | On exit, return focus to whatever was focused before. |
Modal wraps its panel in a FocusScope automatically. Reach for FocusScope directly when you need a side panel or tab region that should own the d-pad while open without being a full modal.
// A slide-in drawer that traps focus while open
function Drawer(props: { open: () => boolean; onClose: () => void }) {
return (
<FocusScope
active={props.open}
class="absolute top-0 right-0 h-full w-[240] bg-white shadow-lg flex-col p-4 gap-3"
>
<Text class="text-base text-slate-950 font-bold">Settings</Text>
<Focusable
class="p-2 rounded-md bg-slate-100 border-slate-200 focus:border-blue-500"
onPress={props.onClose}
>
<Text class="text-sm text-slate-950">Close</Text>
</Focusable>
</FocusScope>
);
}
FocusGrid
By default, d-pad traversal is linear over document order: DOWN/RIGHT go to the next focusable, UP/LEFT to the previous. FocusGrid overrides this inside its subtree with true two-dimensional movement — what you want for a grid of tiles or a picker.
interface FocusGridProps extends ViewProps {
columns: number; // required — number of columns (floored to ≥ 1)
wrap?: boolean; // default: false — wrap edges instead of clamping
active?: boolean | (() => boolean); // default: true
}
| Prop | Type | Default | Effect |
|---|
columns | number | required | Column count for d-pad traversal. |
wrap | boolean | false | Wrap around row/column edges instead of clamping. |
active | boolean | (() => boolean) | true | Whether grid traversal is currently applied. |
The grid collects its focusables in document order and treats them as a columns-wide table. From index i: RIGHT goes to i + 1 (unless at the right edge), LEFT to i - 1, DOWN to i + columns, UP to i - columns. With wrap, edge moves loop to the other side of the same row/column.
import { For } from "solid-js";
import { FocusGrid, Focusable, Text } from "@pocketjs/framework/components";
function GameGrid(props: { games: () => { id: string; title: string }[] }) {
return (
<FocusGrid class="flex-row flex-wrap gap-2 w-[440]" columns={3} wrap>
<For each={props.games()}>
{(game) => (
<Focusable
class="w-[140] h-[72] rounded-lg bg-white border-slate-200 focus:border-blue-500"
onPress={() => launch(game)}
>
<Text class="text-sm text-slate-950">{game.title}</Text>
</Focusable>
)}
</For>
</FocusGrid>
);
}
import { FocusGrid, Focusable, Text } from "@pocketjs/framework/vue-vapor/components";
function GameGrid(props: { games: { id: string; title: string }[] }) {
return () => (
<FocusGrid class="flex-row flex-wrap gap-2 w-[440]" columns={3} wrap>
{props.games.map((game) => (
<Focusable
class="w-[140] h-[72] rounded-lg bg-white border-slate-200 focus:border-blue-500"
onPress={() => launch(game)}
>
<Text class="text-sm text-slate-950">{game.title}</Text>
</Focusable>
))}
</FocusGrid>
);
}
FocusGrid is a traversal override only — it does not lay tiles out. Use flexbox utilities to actually position the tiles, and set a fixed container width so the wrapping row breaks where you want it.
ActionHandler
ActionHandler binds a raw button bitmask to a callback, independent of focus. Use it for global shortcuts — open a menu on SELECT, go back on CROSS, cycle a value on a shoulder button.
interface ActionHandlerProps {
button: number; // a BTN constant, or several OR'd together
onPress: (pressed: number, buttons: number) => void; // pressed = newly-pressed edge bits this frame
active?: boolean | (() => boolean); // gate the handler on/off (default: on)
allowWhenBlocked?: boolean; // keep firing even while a Modal blocks input
children?: Element;
}
| Prop | Type | Notes |
|---|
button | number | A BTN value, or several OR’d together. |
onPress | (pressed: number, buttons: number) => void | pressed = newly-pressed edge bits this frame. |
active | boolean | (() => boolean) | Gate the handler on/off. Defaults to on. |
allowWhenBlocked | boolean | Keep firing even while a Modal blocks input. |
import { ActionHandler } from "@pocketjs/framework/components";
import { BTN } from "@pocketjs/framework/input";
// Single button — toggle a menu
<ActionHandler button={BTN.SELECT} onPress={() => setMenuOpen((v) => !v)} />;
// Multiple buttons with edge-bitmask inspection
<ActionHandler
button={BTN.LTRIGGER | BTN.RTRIGGER}
onPress={(pressed) => {
if (pressed & BTN.LTRIGGER) prevTab();
if (pressed & BTN.RTRIGGER) nextTab();
}}
/>;
// Global back handler, inactive while a confirmation dialog is open
<ActionHandler
button={BTN.CROSS}
onPress={goBack}
active={() => !confirmOpen()}
/>;
BTN covers every PSP button: SELECT, START, UP, DOWN, LEFT, RIGHT, LTRIGGER, RTRIGGER, TRIANGLE, CIRCLE, CROSS, SQUARE.
Portal
Portal mounts its children into the runtime overlay root — a full-screen, absolutely-positioned layer at z-index: 1000 that mount() installs alongside your app. Because the overlay lives entirely outside the active screen’s flex tree, portalled UI never pushes your layout around: a modal or action bar floats on top regardless of what the page underneath is doing.
interface PortalProps {
children?: Element | (() => Element);
}
import { Portal, View, Text } from "@pocketjs/framework/components";
// A "saved" toast that floats top-right without affecting page layout
<Portal>
<View class="absolute top-3 right-3 px-2 py-1 rounded-md bg-white border-slate-200">
<Text class="text-xs text-slate-500">Saved</Text>
</View>
</Portal>;
Portal renders nothing in place and cleans up its overlay host on unmount. Modal and ActionBar are both built on Portal — you usually reach for those instead of Portal directly.
Portal throws PocketJS: overlay root is not installed if used outside a mounted app. This only happens if you render components without calling mount() first — for example, in a unit test that renders a component tree directly. In tests, either call mount() or wrap the component under test in a test host that installs the overlay root.
Modal
Modal is a portalled panel that centers itself over a dimmed backdrop, wraps its panel in a FocusScope, and — critically — blocks all background button handlers while open. Any ActionHandler or onButtonPress handler in the rest of the app stops firing until the modal closes, so the page behind can’t react to input it can’t see.
interface ModalProps {
open?: boolean | (() => boolean); // default: true — pass a signal accessor to drive it reactively
panelClass?: string; // default: "flex-col gap-2 w-[328] p-3 rounded-xl shadow-lg bg-white border-slate-200"
class?: string; // default: "absolute inset-0 z-50 flex-col items-center justify-center"
children?: Element;
}
| Prop | Type | Default | Effect |
|---|
open | boolean | (() => boolean) | true | Visibility. Pass a signal accessor to drive reactively. |
panelClass | string | "flex-col gap-2 w-[328] p-3 rounded-xl shadow-lg bg-white border-slate-200" | Class applied to the centered panel. |
class | string | "absolute inset-0 z-50 flex-col items-center justify-center" | Class applied to the full-screen overlay layer. |
children | Element | — | The panel contents. |
import { createSignal } from "solid-js";
import { Modal, Focusable, Text, View } from "@pocketjs/framework/components";
function DeletePrompt(props: { onConfirm: () => void }) {
const [open, setOpen] = createSignal(false);
return (
<>
<Focusable
class="px-3 py-1 rounded-md bg-rose-600 border-rose-500 focus:border-rose-300"
onPress={() => setOpen(true)}
>
<Text class="text-sm text-white">Delete Save</Text>
</Focusable>
<Modal open={open}>
<Text class="text-lg text-slate-950 font-bold">Delete save?</Text>
<Text class="text-sm text-slate-600">This cannot be undone.</Text>
<View class="flex-row gap-2">
<Focusable
class="px-3 py-1 rounded-md bg-slate-100 border-slate-200 focus:border-blue-500"
onPress={() => setOpen(false)}
>
<Text class="text-sm text-slate-950">Cancel</Text>
</Focusable>
<Focusable
class="px-3 py-1 rounded-md bg-rose-600 border-rose-500 focus:border-rose-300"
onPress={props.onConfirm}
>
<Text class="text-sm text-white">Delete</Text>
</Focusable>
</View>
</Modal>
</>
);
}
import { ref } from "vue";
import { Modal, Focusable, Text, View } from "@pocketjs/framework/vue-vapor/components";
function DeletePrompt(props: { onConfirm: () => void }) {
const open = ref(false);
return () => (
<>
<Focusable
class="px-3 py-1 rounded-md bg-rose-600 border-rose-500 focus:border-rose-300"
onPress={() => { open.value = true; }}
>
<Text class="text-sm text-white">Delete Save</Text>
</Focusable>
<Modal open={() => open.value}>
<Text class="text-lg text-slate-950 font-bold">Delete save?</Text>
<Text class="text-sm text-slate-600">This cannot be undone.</Text>
<View class="flex-row gap-2">
<Focusable
class="px-3 py-1 rounded-md bg-slate-100 border-slate-200 focus:border-blue-500"
onPress={() => { open.value = false; }}
>
<Text class="text-sm text-slate-950">Cancel</Text>
</Focusable>
<Focusable
class="px-3 py-1 rounded-md bg-rose-600 border-rose-500 focus:border-rose-300"
onPress={props.onConfirm}
>
<Text class="text-sm text-white">Delete</Text>
</Focusable>
</View>
</Modal>
</>
);
}
Two behaviors worth internalizing:
- The block is on button handlers, not on rendering or animation. Frame-based work —
animate(), sprite animations, per-frame logic — keeps running while the modal is open. Only edge-triggered press handlers are suppressed. This is why a modal can fade and slide in while the page behind it holds still.
- The block is global, so even handlers inside the modal are suppressed unless they set
allowWhenBlocked. If your dialog drives its own cursor or picker with an ActionHandler, set allowWhenBlocked on that handler. D-pad focus navigation is unaffected — the modal’s FocusScope already confines it to the panel.
ActionBar
ActionBar is a portalled strip pinned to the bottom of the screen — the natural home for button-hint captions or a persistent set of actions. Because it lives in the overlay layer, the bar stays put no matter how the underlying screen scrolls or reflows.
interface ActionBarProps extends ViewProps {}
The default class is "absolute left-3 right-3 bottom-3 flex-row items-center justify-between px-2 py-1 rounded-lg shadow-md bg-white border-slate-200". Override class for a different look.
import { ActionBar, Text, View } from "@pocketjs/framework/components";
<ActionBar>
<View class="flex-row gap-3">
<Text class="text-xs text-slate-500">CIRCLE Select</Text>
<Text class="text-xs text-slate-500">CROSS Back</Text>
</View>
<Text class="text-xs text-slate-500">START Menu</Text>
</ActionBar>;
Grid
Grid lays a wall of tiles out as a wrapping row (flex-row flex-wrap) and — when you give it columns and set active — hands them the same row/column d-pad traversal as FocusGrid. Layout stays pure flexbox: the visible column count emerges from the tile width versus the container width; columns drives traversal only.
interface GridProps extends ViewProps {
gap?: number; // cross-axis gap in px, applied via style object
columns?: number; // column count for d-pad traversal (requires active)
wrap?: boolean; // wrap traversal around edges (default: false)
active?: boolean | (() => boolean); // enable FocusGrid traversal
}
| Prop | Type | Default | Effect |
|---|
columns | number | — | Column count for d-pad traversal. Only used when active is on. |
gap | number | — | Cross-axis gap in px, applied via style so class stays a single compiled literal. |
wrap | boolean | false | Wrap traversal around row/column edges. |
active | boolean | (() => boolean) | — | Enable FocusGrid traversal. Needs columns. |
import { Grid, Image, Text, View } from "@pocketjs/framework/components";
<Grid
columns={3}
active
gap={10}
class="flex-row flex-wrap items-start justify-center w-[264]"
>
{tiles.map((t) => (
<View class="flex-col items-center gap-1 w-[78]">
<View
class="w-[68] h-[68] rounded-xl bg-slate-900 border-slate-700 focus:border-white items-center justify-center"
focusable
onPress={() => open(t)}
>
<Image class="w-[56] h-[56] rounded-lg" src={t.src} />
</View>
<Text class="text-xs text-slate-200 font-bold">{t.name}</Text>
</View>
))}
</Grid>;
Lazy
Lazy mounts a subtree on demand. While when is false nothing is built — the native subtree is destroyed by the end-of-frame sweep (one recursive destroyNode), so an off-screen region costs nothing in draw calls, layout, or JavaScript. When when turns true the content is created, optionally after a short reveal delay that shows a fallback (a spinner or skeleton).
interface LazyProps {
when: boolean | (() => boolean); // mount while truthy; unmount when false
reveal?: number; // frames to show fallback before revealing (default: 0)
fallback?: Element | (() => Element); // shown during the reveal delay
children: () => Element; // deferred content — only built once active and past the reveal
}
| Prop | Type | Default | Effect |
|---|
when | boolean | (() => boolean) | — | Mount while truthy; unmount when false. |
reveal | number | 0 | Host frames to show fallback before revealing content the first time it activates. |
fallback | Element | (() => Element) | — | Shown during the reveal delay. |
children | () => Element | — | Deferred content — only built once active and past the reveal delay. |
The reveal is a one-shot latch: it runs the first time the subtree activates and stays revealed for the component’s lifetime. Re-activating shows the content immediately without replaying the spinner. With reveal at 0 (the default), Lazy is a plain gate with no per-frame cost.
import { Lazy, View, Text } from "@pocketjs/framework/components";
// Spinner skeleton while loading, then reveal heavy content
<Lazy when={isOpen} reveal={16} fallback={() => <Spinner />}>
{() => <HeavyPanel />}
</Lazy>;
// Plain on-demand gate (no delay)
<Lazy when={() => tab() === "library"}>
{() => <LibraryScreen />}
</Lazy>;
Textures are uploaded eagerly at pak load — there is no runtime texture streaming. Lazy defers content build, layout, and draw, not texture residency. The reveal delay models an on-demand load for the demo’s sake; it is a frame counter, not real I/O.
Gallery
Gallery is a horizontally paged, full-screen strip: pressing LTRIGGER/RTRIGGER slides one whole screen at a time. It is the natural shell for a photo wall, an app launcher, or any “screen-by-screen” browse pattern.
interface GalleryProps {
count: number; // total number of pages
page: () => number; // controlled current-page accessor (0-based)
onPageChange?: (next: number) => void; // called when L/R paging is requested
renderPage: (index: number) => Element; // page factory — only invoked for pages in the mount window
window?: number; // pages kept mounted on each side (default: 1)
duration?: number; // slide duration in ms (default: 300)
easing?: EasingName; // slide easing (default: "out")
bindTriggers?: boolean; // bind LTRIGGER/RTRIGGER internally (default: true)
wrap?: boolean; // wrap past ends instead of clamping (default: false)
class?: string; // override the outer viewport class
}
| Prop | Type | Default | Effect |
|---|
count | number | — | Total number of pages. |
page | () => number | — | Controlled current-page accessor (0-based). |
onPageChange | (next: number) => void | — | Called with the next page when L/R paging is requested. |
renderPage | (index: number) => Element | — | Page factory — only invoked for pages inside the mount window. |
window | number | 1 | Pages kept mounted on each side of the current one. |
duration | number | 300 | Slide duration in ms. |
easing | EasingName | "out" | Slide easing. |
bindTriggers | boolean | true | Bind LTRIGGER/RTRIGGER to page(−/+1) internally. |
wrap | boolean | false | Wrap past the ends instead of clamping. |
class | string | — | Override the outer viewport class (must remain untransformed — see note below). |
Gallery is controlled: you own the page signal so the rest of the UI (a page indicator dot strip, a title, an ActionBar) can read it. The slide is a single native translateX tween per press — paint-only, no relayout — and pages outside the window are not built at all, so a many-page gallery stays within the PSP draw budget.
import { createSignal } from "solid-js";
import { Gallery, Screen, ActionBar, Text, View } from "@pocketjs/framework/components";
function PhotoGallery() {
const [page, setPage] = createSignal(0);
const count = 4;
return (
<Screen class="relative w-full h-full bg-slate-950 overflow-hidden">
<Gallery
count={count}
page={page}
onPageChange={setPage}
renderPage={(i) => <PhotoPage index={i} />}
wrap
/>
<ActionBar>
<View class="flex-row gap-3">
<Text class="text-xs text-slate-400">L/R to browse</Text>
</View>
<Text class="text-xs text-slate-400">
{page() + 1} / {count}
</Text>
</ActionBar>
</Screen>
);
}
import { ref } from "vue";
import { Gallery, Screen, ActionBar, Text, View } from "@pocketjs/framework/vue-vapor/components";
function PhotoGallery() {
const page = ref(0);
const count = 4;
return () => (
<Screen class="relative w-full h-full bg-slate-950 overflow-hidden">
<Gallery
count={count}
page={() => page.value}
onPageChange={(next) => { page.value = next; }}
renderPage={(i) => <PhotoPage index={i} />}
wrap
/>
<ActionBar>
<View class="flex-row gap-3">
<Text class="text-xs text-slate-400">L/R to browse</Text>
</View>
<Text class="text-xs text-slate-400">
{page.value + 1} / {count}
</Text>
</ActionBar>
</Screen>
);
}
The outer viewport of Gallery must not be transformed — the scissor rect is taken from the clip node’s own world box, so moving the viewport would clip the wrong region. Only the inner strip’s translateX animates. If you need to override the outer class, make sure the replacement keeps the node untransformed (no translate-x, scale, or rotate).
Set bindTriggers={false} when you want to drive the gallery from your own ActionHandler — for example, if LTRIGGER/RTRIGGER are already bound to tab switching at a higher level:
<ActionHandler
button={BTN.LTRIGGER | BTN.RTRIGGER}
onPress={(pressed) => {
if (pressed & BTN.LTRIGGER) setPage((p) => Math.max(0, p - 1));
if (pressed & BTN.RTRIGGER) setPage((p) => Math.min(count - 1, p + 1));
}}
/>
<Gallery
count={count}
page={page}
onPageChange={setPage}
renderPage={(i) => <Page index={i} />}
bindTriggers={false}
/>
Routing is just app state
There is deliberately no router package. A screen is a component; “navigating” is writing a signal that a Switch/Match (Solid) or ternary (Vue Vapor) reads. That keeps navigation fully reactive, testable in headless Bun, and free of any global you didn’t put there yourself.
// Solid: a minimal two-screen navigator
import { createSignal, Switch, Match } from "solid-js";
import { Screen, ActionHandler } from "@pocketjs/framework/components";
import { BTN } from "@pocketjs/framework/input";
function App() {
const [screen, setScreen] = createSignal<"home" | "library">("home");
return (
<>
<Switch>
<Match when={screen() === "home"}>
<HomeScreen onOpenLibrary={() => setScreen("library")} />
</Match>
<Match when={screen() === "library"}>
<LibraryScreen />
</Match>
</Switch>
<ActionHandler
button={BTN.CROSS}
onPress={() => setScreen("home")}
active={() => screen() !== "home"}
/>
</>
);
}
When you need a back stack, store an array of screen IDs in a signal and push/pop it — the same primitives compose all the way up.