App shell & overlays
Screens, focus regions, and floating UI (modals, action bars) are the pieces you
assemble a whole app out of. PocketJS ships these as small, unopinionated
primitives from @pocketjs/core/components — thin wrappers over the same
View, focus manager, and frame
hooks you already use. Nothing here is a framework-within-a-framework: there is
no router, no navigation stack, no global store. Screen switching is ordinary
reactive state.
import {
Screen,
Focusable,
FocusScope,
FocusGrid,
ActionHandler,
Portal,
Modal,
ActionBar,
} from "@pocketjs/core/components";The primitives at a glance
| Primitive | What it is |
|---|---|
Screen |
A full-bleed root View with sensible defaults — one per visible page. |
Focusable |
A View that is focusable and takes an onPress. |
FocusScope |
Traps d-pad traversal + CIRCLE inside its subtree while active. |
FocusGrid |
Gives its subtree explicit row/column d-pad traversal. |
ActionHandler |
Binds a raw button bitmask to a callback (no focus required). |
Portal |
Mounts children into the overlay root, outside the screen's flex layout. |
Modal |
A portalled panel that owns a focus scope and blocks background input. |
ActionBar |
A portalled, bottom-anchored hint/button strip. |
Every one of these 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
launcher below swaps in a dark wash).
function HomeScreen() {
return (
<Screen class="relative flex-col w-full h-full bg-slate-950 overflow-hidden">
{/* page content */}
</Screen>
);
}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 equivalent.
<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 core with zero extra JS —
see Input & focus for the full model.
FocusScope
FocusScope temporarily restricts d-pad traversal and CIRCLE press to its
subtree. This is what keeps a dialog from letting focus wander back into the page
behind it. It adds two options on top of ViewProps:
| Prop | Type | Default | Effect |
|---|---|---|---|
active |
boolean | (() => boolean) |
true |
Whether the scope is currently pushed. Accepts a signal. |
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. |
While the scope is active, navigation is confined to its focusables; when it
tears down it restores the previous focus (unless restoreFocus={false}). You
rarely reach for this directly — Modal wraps its panel in one for you — but it
is the right tool for a side panel or tab region that should own the d-pad while
open.
FocusGrid
By default, focus traversal is linear over document order: DOWN/RIGHT go to the
next focusable, UP/LEFT to the previous. FocusGrid overrides that inside its
subtree with true two-dimensional movement, which is what you want for a grid of
tiles or a picker.
| Prop | Type | Default | Effect |
|---|---|---|---|
columns |
number |
— | Number of columns (required, floored to at least 1). |
wrap |
boolean |
false |
Wrap around row/column edges instead of clamping. |
active |
boolean | (() => boolean) |
true |
Whether the 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 you are at the
right edge, LEFT to i - 1 unless at the left edge, DOWN to i + columns, UP to
i - columns. With wrap, edge moves loop to the other side of the same
row/column instead of clamping.
import { FocusGrid, Focusable, Text, For } from "@pocketjs/core/components";
<FocusGrid class="flex-row flex-wrap gap-2 w-[440]" columns={3} wrap>
<For each={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>;Because the grid keys off document order, it stays correct after a
For reorders or filters its rows. It is a traversal
override only — it does not lay anything out, so use flexbox
(styling) to actually position the tiles.
ActionHandler
ActionHandler binds a raw button bitmask to a callback, independent of focus.
Use it for global shortcuts — open a menu on SELECT, back out on CROSS, cycle a
value on a shoulder button.
| 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. |
It renders its children (or nothing), so drop it anywhere in the tree.
import { ActionHandler } from "@pocketjs/core/components";
import { BTN } from "@pocketjs/core/input";
<ActionHandler button={BTN.SELECT} onPress={() => setMenuOpen((v) => !v)} />;
// Combine buttons and inspect the edge bitmask:
<ActionHandler
button={BTN.LTRIGGER | BTN.RTRIGGER}
onPress={(pressed) => {
if (pressed & BTN.LTRIGGER) prevTab();
if (pressed & BTN.RTRIGGER) nextTab();
}}
/>;BTN is imported from @pocketjs/core/input and covers
every PSP button (SELECT, START, UP/DOWN/LEFT/RIGHT, LTRIGGER,
RTRIGGER, TRIANGLE, CIRCLE, CROSS, SQUARE).
Portal & the overlay root
Portal mounts its children into the runtime overlay root — a full-screen,
absolutely positioned layer (z-index: 1000) that mount() installs alongside
your app. Because the overlay lives 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.
import { Portal, View, Text } from "@pocketjs/core/components";
<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. It
throws PocketJS: overlay root is not installed if used outside a mounted app —
which only happens if you render components without mount(). Modal and
ActionBar are both built on Portal, so you usually reach for those instead.
Modal
Modal is a portalled panel that centers itself over a dimmed backdrop, owns a
FocusScope on its panel, and — crucially — blocks background
button handlers while open. Any ActionHandler /
useButtonPress 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.
| Prop | Type | Default |
|---|---|---|
open |
boolean | (() => boolean) |
true — visibility. Pass a signal accessor to drive it reactively. |
panelClass |
string |
flex-col gap-2 w-[328] p-3 rounded-xl shadow-lg bg-white border-slate-200 |
class |
string |
absolute inset-0 z-50 flex-col items-center justify-center (the layer) |
children |
Element |
The panel contents. |
import { Modal, Focusable, Text, View } from "@pocketjs/core/components";
import { createSignal } from "@pocketjs/core/reactivity";
function DeletePrompt(props: { onConfirm: () => void }) {
const [open, setOpen] = createSignal(false);
return (
<Modal open={open}>
<Text class="text-lg text-slate-950 font-bold">Delete save?</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>
);
}Two behaviors worth internalizing:
- The block is on button handlers, not on rendering or animation.
useFrame-based work —animate(),useSpriteAnimation, per-frame logic — keeps ticking while the modal is up. 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 a handler inside the modal is suppressed
unless it opts out with
allowWhenBlocked. If your dialog drives its own cursor with anActionHandler, setallowWhenBlockedon it (see the picker below). D-pad focus navigation is unaffected — the modal'sFocusScopealready 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. Its
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. It takes ordinary ViewProps children.
import { ActionBar, Text, View } from "@pocketjs/core/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>;Because it lives in the overlay layer, the bar stays put no matter how the underlying screen scrolls or reflows.
Worked example: the launcher
demos/launcher is a small app built entirely from these primitives. It holds
two signals — the active demo index and whether the picker is open — and that is
the whole "router":
import {
ActionHandler,
Match,
Modal,
Screen,
Switch,
View,
} from "@pocketjs/core/components";
import { BTN } from "@pocketjs/core/input";
import { createSignal } from "@pocketjs/core/reactivity";
export default function Launcher() {
const [active, setActive] = createSignal<number | null>(null);
const [pickerOpen, setPickerOpen] = createSignal(true);
const togglePicker = () => {
if (active() === null) return setPickerOpen(true);
setPickerOpen(!pickerOpen());
};
return (
<Screen class="relative w-full h-full bg-slate-950 overflow-hidden">
{/* SELECT toggles the picker even while the modal owns input */}
<ActionHandler button={BTN.SELECT} allowWhenBlocked onPress={togglePicker} />
<ActiveDemo index={active()} />
<DemoPicker
open={pickerOpen()}
current={active()}
onPick={setActive}
onClose={() => setPickerOpen(false)}
/>
</Screen>
);
}ActiveDemo is just a Switch/Match over the index —
swapping the whole screen is nothing more than a signal write:
function ActiveDemo(props: { index: number | null }) {
return (
<Switch>
<Match when={props.index === null}>
<View class="w-full h-full bg-slate-950" />
</Match>
<Match when={props.index === 0}>
<Hero />
</Match>
{/* ...one Match per demo... */}
</Switch>
);
}The picker itself is a Modal. Its cursor is driven by an ActionHandler that
reads the d-pad + CIRCLE edge bits — and because the modal blocks background
handlers globally, that handler sets allowWhenBlocked so it keeps firing while
the modal is the thing that's open:
const PICKER_BUTTONS = BTN.UP | BTN.DOWN | BTN.LEFT | BTN.RIGHT | BTN.CIRCLE;
<Modal
open={() => props.open}
panelClass="flex-col gap-2 w-[424] h-[240] p-2 rounded-xl bg-white border-slate-200"
>
<ActionHandler
button={PICKER_BUTTONS}
active={() => props.open}
allowWhenBlocked
onPress={handlePickerPress}
/>
{/* animated selection ring + demo tiles */}
</Modal>;The selection ring slides between cells with animate(),
which keeps running because the modal only blocks handlers, not frame hooks.
Run it with bun scripts/build.ts launcher, or try the primitives live in the
playground.
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 (or a Show) reads. That keeps
navigation fully reactive, testable in headless Bun, and
free of any global you didn't put there yourself. 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.
See also: Input & focus for the traversal model,
Animation for the frame hooks modals leave running, and
Components for the underlying View/Text/Show/Switch
surface.