Components

Navigation

nsh/ui/mobile provides native navigation: a Stack of Screens driven by a navigator you create with createNavigator, and Modal for content presented over everything else. A stack is UINavigationController on iOS, with the native header and swipe back. On Android it is a stack of screens with a toolbar and predictive back.

ui-navigation.tsx Edit in Playground Open in new tab
// Navigation: a stack of screens with header options, pushed and popped.
import { mount, signal, Text, View } from "nsh";
import { Button, createNavigator, Screen, Stack } from "nsh/ui/mobile";

type Route = { name: "home" } | { name: "detail"; id: number };

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

function Home() {
  const opened = signal(0);
  return (
    <Screen title="Inbox">
      <View style={{ flex: 1, backgroundColor: "#ffffff", padding: 16, paddingTop: 24, gap: 12 }}>
        <Text style={{ color: "#737373" }}>{`Opened ${opened()} messages`}</Text>
        <Button
          title="Open message 1"
          onPress={() => {
            opened.set(opened() + 1);
            nav.push({ name: "detail", id: 1 });
          }}
        />
        <Button title="Open message 2" variant="secondary" onPress={() => nav.push({ name: "detail", id: 2 })} />
      </View>
    </Screen>
  );
}

function Detail(props: { id: number }) {
  const starred = signal(false);
  return (
    <Screen
      title={`Message ${props.id}`}
      headerRight={
        <Text onPress={() => starred.set(!starred())} style={{ fontSize: 17 }}>
          {starred() ? "★" : "☆"}
        </Text>
      }
    >
      <View style={{ flex: 1, backgroundColor: "#ffffff", padding: 16, gap: 12 }}>
        <Text>{`The body of message ${props.id}.`}</Text>
        <Button title="Next message" onPress={() => nav.push({ name: "detail", id: props.id + 1 })} />
        <Button title="Back to the inbox" variant="secondary" onPress={() => nav.popToTop()} />
      </View>
    </Screen>
  );
}

function App() {
  return (
    <Stack navigator={nav} style={{ flex: 1 }}>
      {(route) => (route.name === "home" ? <Home /> : <Detail id={route.id} />)}
    </Stack>
  );
}

mount(App);
import { createNavigator, Modal, Screen, Stack } from "nsh/ui/mobile";

This page is the API reference. For a walkthrough, see the Navigation guide.

#Usage

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

type Route = { name: "home" } | { name: "detail"; id: number };

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

function Home() {
  return (
    <Screen title="Inbox">
      <View style={{ flex: 1, padding: 16 }}>
        <Button title="Open message 1" onPress={() => nav.push({ name: "detail", id: 1 })} />
      </View>
    </Screen>
  );
}

function Detail(props: { id: number }) {
  return (
    <Screen title={`Message ${props.id}`}>
      <View style={{ flex: 1, padding: 16 }}>
        <Button title="Back" variant="secondary" onPress={() => nav.pop()} />
      </View>
    </Screen>
  );
}

function App() {
  return (
    <Stack navigator={nav} style={{ flex: 1 }}>
      {(route) => (route.name === "home" ? <Home /> : <Detail id={route.id} />)}
    </Stack>
  );
}

mount(App);

#How a stack works

  • The navigator holds the routes: plain values of your choosing, from the bottom of the stack to the top. A route is usually an object that names the screen and carries its parameters.
  • Stack calls its child function once for each route and shows the result as a screen. It does not call the function again for the same route, so a screen keeps its state (signals, scroll position, inputs) while it is in the stack, also while other screens cover it.
  • When the user leaves a screen natively (the back button, swipe back, Android back), the stack removes its route from the navigator, so nav.routes() always matches what the user sees.
  • Routes are compared by identity (===). Each route in a stack must be a distinct value: push a new object ({ name: "detail", id }) rather than reusing one, and avoid string routes for screens that can appear twice.
  • If the child function returns something other than a <Screen>, the stack wraps it in a screen with default options. Return a <Screen> to set a title and other header options.

#createNavigator

function createNavigator<R>(initial: R | R[]): Navigator<R>

Creates a navigator whose stack starts with initial (one route or several). Create it once, for example at module level, and pass it to <Stack navigator>. Any code can import it to navigate.

MemberDescription
routes: Accessor<readonly R[]>The routes, bottom to top. Reactive.
push(route: R): voidPushes a screen.
pop(count?: number): voidPops count screens (default 1). At least one screen always stays.
popToTop(): voidPops back to the first screen.
replace(route: R): voidReplaces the top screen.
reset(routes: R[]): voidReplaces all screens.
remove(route: R): voidRemoves a route wherever it is in the stack.
nav.push({ name: "detail", id: 7 });
nav.pop(2);
nav.reset([{ name: "home" }, { name: "detail", id: 1 }]);
const depth = () => nav.routes().length;

#Stack

<Stack navigator={nav} style={{ flex: 1 }}>
  {(route, index) => …}
</Stack>
PropTypeDescription
navigatorNavigator<R>The navigator that holds the routes. Required.
children(route: R, index: Accessor<number>) => JSX.ElementRenders a route. Return a <Screen> for header options. Required.
styleStyleProp<ViewStyle>The stack’s style; usually { flex: 1 }.

The child function receives the route value itself and an accessor of its position in the stack.

#Screen

Screen is one screen of a stack and configures its header:

<Screen
  title="Profile"
  headerTintColor="#ffffff"
  headerBackgroundColor="#1a7f37"
  headerTitleColor="#ffffff"
  headerRight={<Text onPress={edit} style={{ color: "#ffffff", fontSize: 17 }}>Edit</Text>}
>
  <Scroll style={{ flex: 1 }}>…</Scroll>
</Screen>
PropTypeDefaultDescription
titlestringnoneThe header’s title.
headerShownbooleantrueWhether the header is shown.
headerTransparentbooleanfalseThe content extends under a transparent header.
headerTintColorColorValueplatformThe color of the back button and the bar buttons.
headerBackgroundColorColorValueplatformThe header’s background color.
headerTitleColorColorValueplatformThe title’s color.
headerBackVisiblebooleantrueWhether the back button is shown.
headerLeftJSX.ElementnoneContent at the left of the header, in place of the back button.
headerRightJSX.ElementnoneContent at the right of the header.
headerTitleJSX.ElementnoneContent that replaces the title.
gestureEnabledbooleantrueThe interactive back gesture: iOS swipe back, Android predictive back that follows the finger. The back buttons work either way.
preventBackbooleanfalseThe user cannot leave the screen. See Leaving a screen.
animation"default" | "fade" | "none""default"How the screen enters and leaves.
ios{ headerLargeTitle?: boolean; headerBackTitle?: string }noneiOS only: a large title that shrinks into the bar as the content scrolls; the label of the back button that leads back to this screen.
styleStyleProp<ViewStyle>noneThe screen’s style.
childrenJSX.ElementnoneThe content.
onBackRequest() => voidnoneThe user tried to leave a screen that has preventBack.
onDismiss() => voidnoneThe user left the screen natively and its route was removed.

The header items (headerLeft, headerRight, headerTitle) are ordinary JSX: they can read signals, handle presses and update while the screen is shown.

The screen’s content starts below an opaque header. With headerTransparent or headerShown={false}, it starts at the top of the screen; use SafeAreaView or useSafeAreaInsets() to keep it clear of the header and the status bar.

<Screen title="Large title" ios={{ headerLargeTitle: true }}>
  <Scroll style={{ flex: 1 }}>…</Scroll>
</Screen>

#Leaving a screen

preventBack keeps the user on a screen, for example while a form has unsaved changes. While it is true, swipe back is off, and the header’s back button and the Android back button call onBackRequest instead of popping. Ask the user, then pop yourself:

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

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

To turn off only the swipe gesture and keep the back buttons, use gestureEnabled={false}.

Modal presents content over everything else, including the stack’s header, in its own native window layer. It is shown while visible is true (the default), and it is controlled: when the user asks to close it, onRequestClose is called and you set visible to false.

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

function Settings() {
  const open = signal(false);
  return (
    <View>
      <Button title="About" onPress={() => open.set(true)} />
      <Modal visible={open()} ios={{ presentationStyle: "pageSheet" }} onRequestClose={() => open.set(false)}>
        <SafeAreaView style={{ flex: 1, padding: 20, gap: 12 }}>
          <Text style={{ fontSize: 24, fontWeight: "700" }}>About</Text>
          <Button title="Close" onPress={() => open.set(false)} />
        </SafeAreaView>
      </Modal>
    </View>
  );
}
PropTypeDefaultDescription
visiblebooleantrueShown while true.
animationType"slide" | "fade" | "none""slide"How the modal appears.
transparentbooleanfalseThe background shows what is behind, for dialogs and overlays.
dismissiblebooleanfalseThe user closes it natively (iOS sheet swiped down, Android back button); then onRequestClose and onDismiss are called. Without it, those gestures only ask to close.
ios{ presentationStyle?: "fullScreen" | "pageSheet" | "formSheet"; supportedOrientations?: (…)[] }noneiOS only: pageSheet and formSheet keep what is behind visible; the orientations the modal allows (default: the presenter’s).
android{ statusBarTranslucent?: boolean }noneAndroid only: the content reaches under the status bar (by default it starts below it).
styleStyleProp<ViewStyle>noneThe modal’s style.
childrenJSX.ElementnoneThe content.
onRequestClose() => voidnoneThe user asked to close it (Android back button, iOS sheet swipe). Set visible to false.
onShow() => voidnoneThe modal was shown.
onDismiss() => voidnoneThe modal was dismissed.

A modal provides its own insets to its content, so SafeAreaView and useSafeAreaInsets() inside it use the modal’s insets. A modal with transparent and animationType="fade" works as a custom dialog:

<Modal visible={open()} transparent animationType="fade" onRequestClose={close}>
  <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" }}>A dialog</Text>
      <Button title="OK" onPress={close} />
    </View>
  </View>
</Modal>

See Modal for more about the element.

#Platform notes

  • iOS: UINavigationController with the native header, back button and swipe back. ios={{ headerLargeTitle }} and headerBackTitle are iOS only. Stack changes that arrive during a running transition are applied when it ends.
  • Android: a stack with a toolbar. The system back button pops the top screen, with predictive back on Android versions that have it. See BackHandler in nsh/android to handle the back button yourself.
  • Mac Catalyst: as on iOS. Page and form sheets are presented as macOS sheets the size of the window.