Browse docsApp shell & overlays

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/framework/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/framework/components";

Screen, Focusable, FocusScope, FocusGrid, Grid and ActionBar extend ViewProps and pass the rest through to a View; ActionHandler, Portal, Modal, Lazy and Gallery declare their own prop shapes and take no class/style/focusable/onPress beyond what those shapes list. All eleven are typed with their defaults in the API reference.

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.

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>

onPress fires on activation: CIRCLE on the focused node, a tap on a touch host, or a cursor click. Whichever the source, the press bubbles 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. On top of ViewProps it takes active, autoFocus and restoreFocus — all default true, active also accepts an accessor (signature).

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. It requires columns (floored to at least 1) and takes wrap (false) and active (true) — signature.

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 { For } from "solid-js";
import { FocusGrid, Focusable, Text } from "@pocketjs/framework/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>;
import { FocusGrid, Focusable, Text } from "@pocketjs/framework/components";

<FocusGrid class="flex-row flex-wrap gap-2 w-[440]" columns={3} wrap>
  {games.value.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>;
import { FocusGrid, Focusable, Text } from "@pocketjs/framework/components";

<FocusGrid class="flex-row flex-wrap gap-2 w-[440]" columns={3} wrap>
  {games.map((game) => (
    <Focusable
      key={game.title}
      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>;

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. button is a BTN value or several OR'd together, and onPress(pressed, buttons) receives the edge bits newly pressed this frame. It inherits active (true), allowWhenBlocked and latched from ButtonPressOptions (signature).

It renders its children (or nothing), so drop it anywhere in the tree.

import { ActionHandler } from "@pocketjs/framework/components";
import { BTN } from "@pocketjs/framework/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/framework/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/framework/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 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 / 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.

ModalProps is open (true, accepts an accessor), class for the centering layer, panelClass for the panel, and children — no focusable, onPress or ref (signature and default classes).

import { Modal, Focusable, Text } from "@pocketjs/framework/components";
import { createSignal } from "solid-js";

const [open, setOpen] = createSignal(false);

<Modal open={open}>
  <Text class="text-lg text-slate-950 font-bold">Delete save?</Text>
  <Focusable class="px-3 py-1 rounded-md bg-rose-600 focus:border-rose-300" onPress={confirm}>
    <Text class="text-sm text-white">Delete</Text>
  </Focusable>
</Modal>;
import { Modal, Focusable, Text } from "@pocketjs/framework/components";
import { ref } from "vue";

const open = ref(false);

<Modal open={() => open.value}>
  <Text class="text-lg text-slate-950 font-bold">Delete save?</Text>
  <Focusable class="px-3 py-1 rounded-md bg-rose-600 focus:border-rose-300" onPress={confirm}>
    <Text class="text-sm text-white">Delete</Text>
  </Focusable>
</Modal>;
import { Modal, Focusable, Text } from "@pocketjs/framework/components";
import { useState } from "octane";

const [open, setOpen] = useState(false);

<Modal open={open}>
  <Text class="text-lg text-slate-950 font-bold">Delete save?</Text>
  <Focusable class="px-3 py-1 rounded-md bg-rose-600 focus:border-rose-300" onPress={confirm}>
    <Text class="text-sm text-white">Delete</Text>
  </Focusable>
</Modal>;

Two behaviors to know:

  • The block is on button handlers, not on rendering or animation. onFrame-based work — animate(), createSpriteAnimation, 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 an ActionHandler, set allowWhenBlocked on it. D-pad focus navigation is unaffected — the modal's FocusScope 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/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>;

Because it lives in the overlay layer, the bar stays put no matter how the underlying screen scrolls or reflows.

Grid

Grid lays a wall of fixed-width tiles out as a wrapping row. Passing columns is what turns it into a FocusGrid — with no columns it renders a plain View. Layout stays pure flexbox: the visible column count emerges from the tile width vs. the container width, and columns drives traversal only. active (default true) gates that traversal on and off, and gap is a number applied through style so class stays one compiled literal — typed signature.

It otherwise takes ordinary ViewProps; pass a fixed width so the tiles wrap where you want them to.

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. When when turns true the content is created, optionally after a short reveal delay that shows a fallback (a spinner or skeleton). The reveal is a one-shot latch: it runs the first time the subtree activates and then stays revealed for the component's lifetime, so re-activating shows the content immediately (no replayed spinner). With reveal at its 0 default Lazy is a plain gate with no per-frame cost. LazyProps is when, reveal, fallback and a children factory — no class, style or ref (signature).

What "lazy" means here. Textures upload at pak load and there is no runtime texture streaming, so Lazy defers content build, layout and draw, not texture residency. reveal counts host frames; it is not I/O.

<Lazy when={isOpen} reveal={16} fallback={() => <Spinner />}>
  {() => <HeavyPanel />}
</Lazy>;

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. count, page and renderPage are required; onPageChange, window (1), duration (300 ms), easing ("out"), bindTriggers (true), wrap (false) and class are not (signature).

Gallery is controlled — you own the page signal, so the rest of the UI (a page indicator, a title) can read it. It reads L/R itself and calls onPageChange.

import { Gallery } from "@pocketjs/framework/components";
import { createSignal } from "solid-js";

const [page, setPage] = createSignal(0);

<Gallery count={4} page={page} onPageChange={setPage} renderPage={(i) => <PhotoPage index={i} />} />;
import { Gallery } from "@pocketjs/framework/components";
import { ref } from "vue";

const page = ref(0);

<Gallery
  count={4}
  page={page.value}
  onPageChange={(next) => { page.value = next; }}
  renderPage={(i) => <PhotoPage index={i} />}
/>;
import { Gallery } from "@pocketjs/framework/components";
import { useState } from "octane";

const [page, setPage] = useState(0);

<Gallery count={4} page={page} onPageChange={setPage} renderPage={(i) => <PhotoPage index={i} />} />;

It is a static overflow-hidden viewport wrapping an animated strip of absolutely-positioned page cells. The split is load-bearing: the scissor comes from the clip node's own box, so the clipping viewport must not move — only the inner strip's translateX animates, one paint-only native tween per press, and pages outside window are never built, which keeps a many-page gallery inside the draw budget. apps/gallery is the worked example (L/R paging, a Grid of baked tiles, Lazy first-visit loading, a page-dot ActionBar); build it with bun tools/build.ts gallery-main and press L / R (or Q / E).

See also: Input & focus for the traversal model, Animation for the frame hooks modals leave running, and Components for the underlying View/Text host primitives and how Solid control flow maps onto the native tree.