Guide

Navigation

nsh/ui/mobile provides a native navigation stack and native modals. A Stack is a UINavigationController on iOS, with its header, swipe back and large titles, and a stack with a toolbar and predictive back on Android. You keep the list of routes in a navigator; the stack renders each route as a screen and keeps it mounted, with its state, while it is in the stack.

import { mount, Scroll, Text, View } from "nsh";
import { createNavigator, Screen, Stack } from "nsh/ui/mobile";

type Route = { name: "inbox" } | { name: "message"; id: number };

const nav = createNavigator<Route>({ name: "inbox" });

function Inbox() {
  return (
    <Screen title="Inbox">
      <Scroll style={{ flex: 1 }}>
        <Each of={[1, 2, 3, 4, 5]}>
          {(id) => (
            <View onPress={() => nav.push({ name: "message", id: id() })} underlayColor="#eaeef2" style={{ padding: 16 }}>
              <Text>Message {id()}</Text>
            </View>
          )}
        </Each>
      </Scroll>
    </Screen>
  );
}

function Message(props: { id: number }) {
  return (
    <Screen title={`Message ${props.id}`}>
      <View style={{ flex: 1, padding: 16 }}>
        <Text>The body of message {props.id}.</Text>
      </View>
    </Screen>
  );
}

function App() {
  return (
    <Stack navigator={nav} style={{ flex: 1 }}>
      {(route) => {
        switch (route.name) {
          case "inbox":
            return <Inbox />;
          case "message":
            return <Message id={route.id} />;
        }
      }}
    </Stack>
  );
}

mount(App);

#The navigator

createNavigator(initial) creates the stack of routes. A route is any value you like (a string, an object, a discriminated union as above); routes are compared by identity, so push a new object for each screen. Create the navigator once, at module level or in your root component, and import it wherever screens navigate.

MemberDescription
routes()The routes, bottom to top (reactive).
push(route)Pushes a screen.
pop(count?)Pops count screens (default 1); at least one stays.
popToTop()Pops back to the first screen.
replace(route)Replaces the top screen.
reset(routes)Replaces all screens, e.g. after signing in.
remove(route)Removes a route wherever it is.

createNavigator also takes an array, to start several screens deep: createNavigator<Route>([{ name: "inbox" }, { name: "message", id: 3 }]).

When the user leaves a screen natively (the back button in the header, a swipe back on iOS, Android’s back button or gesture), the stack removes that route from the navigator for you, so routes() always matches what is on screen.

A route carries what the screen needs. The gallery app pushes routes that hold the screen component itself, which keeps the route type small when there are many screens:

import { type JSX } from "nsh";
import { createNavigator } from "nsh/ui/mobile";

export type Route = { name: "home" } | { name: "page"; page: () => JSX.Element };
export const nav = createNavigator<Route>({ name: "home" });

// Elsewhere: nav.push({ name: "page", page: Settings });

#Stack

PropTypeDescription
navigatorNavigator<R>The routes to show.
children(route: R, index: Accessor<number>) => JSX.ElementRenders a route; return a <Screen> for header options.
styleStyleProp<ViewStyle>The stack’s own style (usually flex: 1).

The render function runs once per route, when it is pushed; route is the plain value, index() its current position. What it returns becomes the screen’s content. Return a <Screen> to set the header; anything else is wrapped in a screen with default options.

Screens stay mounted while their route is in the stack: going back shows the previous screen exactly as it was left, with its scroll position, inputs and signals. A screen’s cleanup handlers run when its route leaves the stack.

#Screen and the header

Screen wraps a screen’s content and carries its header options. All options are reactive: change a signal and the header follows.

PropTypeDefaultDescription
titlestringThe header’s title.
headerShownbooleantrueThe header shows.
headerTransparentbooleanfalseThe content extends under the header.
headerTintColorColorValueThe back button and bar buttons.
headerBackgroundColorColorValueThe header’s background.
headerTitleColorColorValueThe title.
headerBackVisiblebooleantrueThe back button shows.
headerLeftJSX.ElementViews at the start of the header (replace the back button).
headerRightJSX.ElementViews at the end of the header.
headerTitleJSX.ElementViews replacing the title.
animation"default" | "fade" | "none""default"How the screen enters and leaves.
gestureEnabledbooleantrueThe interactive back gesture (iOS swipe back, Android predictive back). The back buttons pop either way.
preventBackbooleanfalseThe user cannot leave; back calls onBackRequest instead.
onBackRequest() => voidThe user tried to leave a screen with preventBack.
onDismiss() => voidThe user left the screen natively; its route is already removed.
ios={{ headerLargeTitle }}booleaniOS: a large title that shrinks into the bar as the content scrolls.
ios={{ headerBackTitle }}stringiOS: the label of the back button that leads back to this screen.
styleStyleProp<ViewStyle>The screen’s content view.

Header items are ordinary views, so they can be pressable and reactive:

function Compose() {
  const text = signal("");
  return (
    <Screen
      title="New message"
      headerLeft={<Text onPress={() => nav.pop()} style={{ fontSize: 17, color: "#0969da" }}>Cancel</Text>}
      headerRight={
        <Text
          onPress={() => send(text())}
          style={{ fontSize: 17, fontWeight: "600", color: text() ? "#0969da" : "#8c959f" }}
        >
          Send
        </Text>
      }
    >
      <Input multiline value={text()} onChangeText={(t) => text.set(t)} style={{ flex: 1, padding: 16, fontSize: 17 }} />
    </Screen>
  );
}

A colored header:

<Screen title="Profile" headerBackgroundColor="#1a7f37" headerTintColor="#ffffff" headerTitleColor="#ffffff">
  …
</Screen>

A screen without a header still has the system bars around it; pad its content with SafeAreaView:

<Screen headerShown={false}>
  <SafeAreaView style={{ flex: 1, padding: 16 }}>…</SafeAreaView>
</Screen>

#Safe areas under the header

Inside a screen, useSafeAreaInsets() and SafeAreaView report the insets below the header, not the whole surface’s. With headerTransparent, the content starts at the top of the screen and the top inset is the header’s height, so SafeAreaView keeps text clear of it while a background image fills the whole screen. With ios={{ headerLargeTitle: true }}, put a scroll view directly in the screen so the title can collapse as it scrolls.

#Keeping the user on a screen: preventBack

To ask before discarding unsaved changes, set preventBack while there is something to lose and handle onBackRequest:

import { Input, signal } from "nsh";
import { Alert, Screen } from "nsh/ui/mobile";

function EditNote() {
  const text = signal("");
  const confirm = async () => {
    const choice = await Alert.alert("Discard your changes?", undefined, [
      { text: "Keep editing", style: "cancel" },
      { text: "Discard", style: "destructive" },
    ]);
    if (choice === 1) nav.pop();
  };
  return (
    <Screen title="Edit" preventBack={text().length > 0} onBackRequest={confirm}>
      <Input value={text()} onChangeText={(t) => text.set(t)} placeholder="A note" style={{ margin: 16, fontSize: 17 }} />
    </Screen>
  );
}

With preventBack on, the swipe back is disabled and the back buttons (in the header, Android’s back button and gesture) call onBackRequest instead of popping. nav.pop() from code still pops.

gestureEnabled={false} is the lighter option: it only turns off the interactive gesture (iOS swipe back, Android’s predictive back animation), and the back buttons still pop.

#Modals

Modal presents content over everything else, in its own native window layer: a presented view controller on iOS, a dialog on Android. Render it where its state lives and control it with visible:

import { signal, Text, View } from "nsh";
import { Modal, SafeAreaView } from "nsh/ui/mobile";

function Settings() {
  const open = signal(false);
  return (
    <View style={{ flex: 1 }}>
      <View onPress={() => open.set(true)} style={{ padding: 16 }}>
        <Text>Edit profile</Text>
      </View>
      <Modal visible={open()} animationType="slide" ios={{ presentationStyle: "pageSheet" }} onRequestClose={() => open.set(false)}>
        <SafeAreaView style={{ flex: 1, padding: 20, gap: 12 }}>
          <Text style={{ fontSize: 22, fontWeight: "700" }}>Profile</Text>
          <View onPress={() => open.set(false)}>
            <Text style={{ color: "#0969da" }}>Done</Text>
          </View>
        </SafeAreaView>
      </Modal>
    </View>
  );
}
PropTypeDefaultDescription
visiblebooleantrueShown while true.
animationType"slide" | "fade" | "none"How it appears.
transparentbooleanfalseThe background shows what is behind (dialogs, custom sheets).
dismissiblebooleanfalseThe user closes it natively (iOS sheet swiped down, Android back), then onRequestClose and onDismiss follow.
onRequestClose() => voidThe user asked to close it: set visible to false.
onShow() => voidIt has appeared.
onDismiss() => voidIt has gone.
ios={{ presentationStyle }}"fullScreen" | "pageSheet" | "formSheet""fullScreen"iOS: sheets keep what is behind visible.
ios={{ supportedOrientations }}("portrait" | "landscape" | …)[]the presenter’siOS: the orientations it allows.
android={{ statusBarTranslucent }}booleanfalseAndroid: the content reaches under the status bar.

Modals are controlled by default: a swipe down on an iOS sheet or Android’s back button only asks, through onRequestClose, and the modal stays until you set visible to false. That lets you confirm first. With dismissible, the platform closes it right away and then tells you; use it for sheets that hold nothing to lose. As with preventBack, the choice is a prop because the system asks whether the sheet may close on the spot.

A transparent modal makes a custom dialog:

<Modal visible={open()} transparent animationType="fade" onRequestClose={() => open.set(false)}>
  <View style={{ flex: 1, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center", padding: 32 }}>
    <View style={{ alignSelf: "stretch", backgroundColor: "#ffffff", borderRadius: 14, padding: 20, gap: 12 }}>
      <Text style={{ fontSize: 20, fontWeight: "700" }}>Delete the file?</Text>
      <View onPress={() => open.set(false)}><Text style={{ color: "#cf222e" }}>Delete</Text></View>
    </View>
  </View>
</Modal>

Inside a modal, SafeAreaView and useSafeAreaInsets() give the modal’s own insets. For system dialogs (an alert with buttons, an action sheet) use Alert and ActionSheet; for a panel that slides up over a dimmed backdrop and follows the finger, use Sheet.

#The Android back button

On Android, the back button (or gesture) is handled in this order:

  1. The top modal: onRequestClose (or it closes, with dismissible).
  2. The top stack: it pops its top screen, or calls onBackRequest with preventBack.
  3. Your BackHandler listeners from nsh/android, most recently added first.
  4. Nothing handled it: the app exits.

A listener returns true when it handled the press:

import { cleanup, signal } from "nsh";
import { BackHandler } from "nsh/android";

function Editor() {
  const selecting = signal(false);
  const sub = BackHandler.addEventListener("hardwareBackPress", () => {
    if (!selecting()) return false; // let the stack pop
    selecting.set(false);
    return true;
  });
  cleanup(() => sub.remove());
  // …
}

BackHandler.exitApp() leaves the app. On iOS, nsh/android can be imported and its calls do nothing, so the same code runs on both platforms. See Platform-specific code.

#Tabs and other layouts

nsh/ui/mobile has no tab bar component. A tab bar is a row of pressable views and a signal holding the selected tab; render the selected tab’s content with a function child or <If>. To keep each tab’s state while switching, give each tab its own Stack and navigator and keep all of them mounted, hiding the inactive ones with display: "none".