Elements

NavStack

A navigation stack shows one page at a time and keeps the pages below it: UINavigationController on iOS and Mac Catalyst, with the system header and swipe back, and a stack of pages with a toolbar and predictive back on Android. On the desktops (macOS, Windows, Linux, GPUI) and the web, each page shows the app’s own header at its top, and the back keys and the browser’s back button pop the page. NavStack is the stack, NavPage one of its pages.

ui-navigation.tsx
ui-navigation.tsxLive
// Navigation: a stack of pages with header options, pushed and popped; in a
// wide window the inbox and the open message side by side (master–detail).
import { type JSX, mount, signal, Text, View } from "nsh";
import { createNavStack, NavPage, NavStack } from "nsh/base";
import { desktop, size, tint, ui, wide } from "./palette";

function Button(props: { title: string; variant?: "primary" | "secondary"; onPress: () => void }) {
  const primary = () => props.variant !== "secondary";
  return (
    <View
      role="button"
      onPress={props.onPress}
      style={desktop ? { ...(primary() ? ui().primary : ui().raised), height: size.control, alignSelf: "flex-start" } : primary() ? ui().primary : ui().raised}
      class={desktop ? "justify-center rounded-lg px-3 active:opacity-75" : "items-center rounded-xl py-3 active:opacity-75"}
    >
      <Text style={{ fontWeight: "600", fontSize: desktop ? 13 : undefined, color: primary() ? ui().onPrimary : ui().text }}>{props.title}</Text>
    </View>
  );
}

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

const nav = createNavStack<Route>([{ name: "home" }]);

// The page's own header where NSH draws none (the web, the desktops); iOS and
// Android show the system's, from `title` and `headerRight`.
function Header(props: { title: string; right?: JSX.Element }) {
  return (
    <View style={{ backgroundColor: ui().page, borderBottomWidth: 1, borderBottomColor: ui().line }} class="h-12 flex-row items-center px-2">
      <View class="w-20 flex-row">
        <If when={nav.routes().length > 1}>
          <View role="button" accessibilityLabel="Back" onPress={() => nav.pop()} class="px-2 py-1 active:opacity-60">
            <Text style={{ fontSize: 17, color: ui().accent }}>‹ Back</Text>
          </View>
        </If>
      </View>
      <Text numberOfLines={1} style={{ fontSize: 17, fontWeight: "600", color: ui().text }} class="flex-1 text-center">
        {props.title}
      </Text>
      <View class="w-20 flex-row justify-end px-2">{props.right}</View>
    </View>
  );
}

// A page's content: a column as wide as a phone, centered in a wider window.
function Body(props: { children: JSX.Element }) {
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 16, paddingTop: 24 }}>
      <View style={{ width: "100%", maxWidth: 480, alignSelf: "center", gap: 12 }}>{props.children}</View>
    </View>
  );
}

function Home() {
  const opened = signal(0);
  return (
    <NavPage title="Inbox" header={<Header title="Inbox" />}>
      <Body>
        <Text style={{ color: ui().muted }}>{`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 })} />
      </Body>
    </NavPage>
  );
}

function Detail(props: { id: number }) {
  const starred = signal(false);
  const star = () => (
    <Text onPress={() => starred.set(!starred())} style={{ fontSize: 17, color: ui().accent }}>
      {starred() ? "★" : "☆"}
    </Text>
  );
  return (
    <NavPage title={`Message ${props.id}`} headerRight={star()} header={<Header title={`Message ${props.id}`} right={star()} />}>
      <Body>
        <Text style={{ color: ui().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.popToRoot()} />
      </Body>
    </NavPage>
  );
}

const messages = [1, 2, 3, 4, 5, 6];

/** A wide window: the inbox on the start side, the selected message beside it. */
function Inbox() {
  const selected = signal(1);
  const starred = signal<number[]>([]);
  const star = (id: number) => (
    <Text
      onPress={() => starred.set(starred().includes(id) ? starred().filter((s) => s !== id) : [...starred(), id])}
      style={{ fontSize: 17, color: ui().accent }}
    >
      {starred().includes(id) ? "★" : "☆"}
    </Text>
  );
  return (
    <View style={{ flex: 1, flexDirection: "row", backgroundColor: ui().page }}>
      <View style={{ width: 320, borderRightWidth: 1, borderRightColor: ui().line }}>
        <Header title="Inbox" />
        <View style={{ padding: 8, gap: 2 }}>
          <Each of={messages}>
            {(id) => (
              <View
                role="button"
                onPress={() => selected.set(id())}
                hoverStyle={selected() === id() ? {} : { backgroundColor: tint(ui().text, 0.04) }}
                style={{
                  height: size.row + 12,
                  paddingHorizontal: 12,
                  borderRadius: 8,
                  justifyContent: "center",
                  gap: 2,
                  backgroundColor: selected() === id() ? tint(ui().accent, 0.14) : "transparent",
                }}
              >
                <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
                  <Text style={{ flex: 1, fontSize: size.label, fontWeight: "600", color: ui().text }}>{`Message ${id()}`}</Text>
                  <Text style={{ fontSize: 12, color: ui().accent }}>{starred().includes(id()) ? "★" : ""}</Text>
                </View>
                <Text numberOfLines={1} style={{ fontSize: 12, color: ui().muted }}>{`The body of message ${id()}.`}</Text>
              </View>
            )}
          </Each>
        </View>
      </View>
      <View style={{ flex: 1, minWidth: 0 }}>
        <Header title={`Message ${selected()}`} right={star(selected())} />
        <View style={{ padding: 24, gap: 12 }}>
          <Text style={{ color: ui().text }}>{`The body of message ${selected()}.`}</Text>
          <Text style={{ fontSize: 13, color: ui().muted }}>Click a message on the left; on a phone it opens as a page of a NavStack.</Text>
          <Button title="Next message" onPress={() => selected.set(selected() === messages.length ? 1 : selected() + 1)} />
        </View>
      </View>
    </View>
  );
}

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

mount(App);

The stack owns the navigation behavior (transitions, the back gestures and buttons, the pages’ state while they are covered); the pages’ content and the desktop header are the app’s own.

use nsh_base::{button, nav_page, nav_stack};
use nsh_core::ui::prelude::*;

/// A button with the app's look.
fn action(title: &'static str, on_press: impl Fn() + 'static) -> impl IntoElement {
    button()
        .on_press(on_press)
        .rounded_lg()
        .bg(rgb(0x2563eb))
        .px_4()
        .py_3()
        .child(text(title).text_color(white()).font_weight(FontWeight::SEMIBOLD))
}

/// The header on the desktops and the web, with a back button over the first page.
fn bar(title: impl Into<SharedString>, routes: Signal<Vec<u32>>) -> impl IntoElement {
    view()
        .flex_row()
        .items_center()
        .gap_3()
        .px_4()
        .py_3()
        .border_b_1()
        .border_color(rgb(0xe5e5e5))
        .child(when(
            move || !routes.with(Vec::is_empty),
            move || {
                action("Back", move || {
                    routes.update(|r| {
                        r.pop();
                    })
                })
            },
        ))
        .child(text(title.into()).text_lg().font_weight(FontWeight::SEMIBOLD))
}

fn app() -> impl IntoElement {
    // The messages pushed over the inbox, bottom to top.
    let routes = signal(Vec::<u32>::new());
    nav_stack()
        .flex_1()
        .child(
            nav_page()
                .title("Inbox")
                .header(bar("Inbox", routes))
                .child(view().flex_1().p_4().child(action("Open message 1", move || {
                    routes.update(|r| r.push(1))
                }))),
        )
        .child(each(
            move || routes.get(),
            |id: &u32| *id,
            move |id, depth| {
                let title: SharedString = format!("Message {}", id.get_untracked()).into();
                nav_page()
                    .title(title.clone())
                    .header(bar(title, routes))
                    // The user went back natively: this page and those above it are gone.
                    .on_dismiss(move || routes.update(|r| r.truncate(depth.get_untracked())))
                    .child(view().flex_1().p_4().child(text("The message.")))
            },
        ))
}

On iOS and Android the page shows the system header with title and a back button; on the desktops and the web it shows header. An app that targets only phones leaves header out; one that targets only desktops leaves out the system header’s options. The Navigation guide walks through a whole app’s navigation: the stack’s state, dialogs, the Android back button and tabs.

#How a stack works

  • The stack’s pages are routes: values of your choosing, from the bottom of the stack to the top. A route usually names the page and carries its parameters.
  • In TSX the routes live in the state createNavStack makes. NavStack calls its child function once for each push and shows the result as a page. It keeps that page while its route is in the stack, so a page keeps its state (signals, scroll position, inputs) while other pages cover it.
  • In Rust the stack’s children are its pages: a nav_page() put into nav_stack() is pushed, and one left out is popped. Keep the routes in a signal and render the pages with each(..), keyed by route, so that the pages below keep their state.
  • Each push is a page of its own, the same route value pushed twice included.
  • When the user leaves a page natively (the back button, swipe back, Android back, the desktops’ back keys, the browser’s back), the page goes. In TSX the stack removes its route from the state, so nav.routes() always matches what the user sees. In Rust the page reports on_dismiss, where you drop its route (and the routes above it).
  • In TSX, a child function that returns something other than a <NavPage> gets a page with default options. Return a <NavPage> to set its header and other options.

#createNavStack

function createNavStack<R>(routes: R[]): NavStackState<R>

Creates the state of a stack that starts with routes, bottom to top. Create it once, for example at module level, and pass it to <NavStack state>. Any code can import it to navigate.

MemberDescription
routes: Accessor<readonly R[]>The routes, bottom to top. Reactive.
push(route: R): voidPushes a page.
pop(count?: number): voidPops count pages (default 1). At least one page always stays.
popToRoot(): voidPops back to the first page.
replace(route: R): voidReplaces the top page.
setRoutes(routes: R[]): voidReplaces the pages; a route that stays (the same value) keeps its page and its state.
remove(route: R): voidRemoves the topmost page of route (compared with ===), wherever it is in the stack.

In Rust the same operations are changes of your routes signal: routes.update(|r| r.push(route)) pushes, r.pop() pops, r.truncate(1) pops back to the first page you keep in it, and routes.set(..) replaces the pages.

routes.update(|r| r.push(7));
routes.update(|r| r.truncate(r.len().saturating_sub(2)));
routes.set(vec![1, 2]);
let depth = move || routes.with(Vec::len);
PropRustTypeDescription
statethe pages are its childrenNavStackState<R>The state that holds the routes (createNavStack). Required.
children.child(..)(route: R, index: Accessor<number>) => JSX.ElementRenders a route. Return a <NavPage> for its options. Required.
style, classstyle methodsThe stack’s style; usually flex: 1.
testID.test_id(..)stringFinds the stack in tests and nsh test --trace.

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

NavPage is one page of a stack. It holds the page’s content, its header on the desktops and the web, and the options of the system header on iOS and Android. In Rust, nav_page() returns the page and its builder methods are the props’ snake-case names.

PropRustTypeDefaultDescription
header.header(..)JSX.ElementnoneDesktops and the web: the page’s header, placed at the top of the page, as wide as the page and as tall as it lays out, with the content below it.
title.title(..)stringnoneiOS and Android: the system header’s title.
headerVisible.header_visible(..)booleantrueiOS and Android: the system header shows.
headerTransparent.header_transparent(..)booleanfalseiOS and Android: the content extends under a transparent system header.
headerLargeTitle.header_large_title(..)booleanfalseiOS: a large title that shrinks into the bar as the content scrolls; the content scrolls under the header.
headerBackTitle.header_back_title(..)stringnoneiOS and Android: the label of the back button that leads back to this page (on Android, its description for screen readers).
headerBackVisible.header_back_visible(..)booleantrueiOS and Android: the back button shows.
headerLeft.header_left(..)JSX.ElementnoneiOS and Android: content at the leading edge of the system header, in place of the back button.
headerRight.header_right(..)JSX.ElementnoneiOS and Android: content at the trailing edge of the system header.
headerTitle.header_title(..)JSX.ElementnoneiOS and Android: content that replaces the title.
headerStyle.header_style(..){ backgroundColor?, color?, tintColor? }platformiOS and Android: the system header’s colors: its background, the title’s (color) and the buttons’ (tintColor).
headerClass.header_style_with(|s| ..)stringnoneThe same colors as classes: bg-*, text-* for the title, tint-* for the buttons (in Rust .bg(..), .text_color(..), .tint_color(..), theme colors following the appearance). headerStyle wins over them.
popOnBack.pop_on_back(..)booleantrueThe back buttons and the swipe back pop the page. See Leaving a page.
popOnSwipe.pop_on_swipe(..)booleantrueThe interactive back gesture pops the page: iOS swipe back, Android predictive back that follows the finger. The back buttons pop either way.
animation.animation(..)"default" | "slide" | "fade" | "none""default"How the page enters and leaves.
statusBar.status_bar(..){ foreground?, hidden?, animated? }noneiOS and Android: the status bar while the page is on top. See Status bar.
style, classstyle methodsnoneThe page’s style.
children.child(..)JSX.ElementnoneThe content.
testID.test_id(..)stringnoneFinds the page in tests and nsh test --trace.
EventRustDescription
onBackPress.on_back_press(..)The user pressed back on the page (the header’s back button, Android back, Escape, Alt+Left or a mouse’s back button on the desktops, the browser’s back), whether or not popOnBack let it pop.
onDismiss.on_dismiss(..)The user popped the page (back button, swipe); its route is gone in TSX, and Rust drops it here.

#Header slots

header, headerLeft, headerRight and headerTitle are slots: ordinary elements that read signals, handle presses and update while the page is shown. header is the app’s whole header on the desktops and the web; the other three go into the system header on iOS and Android.

use nsh_base::nav_page;
use nsh_core::ui::prelude::*;

fn profile(edit: impl Fn() + 'static) -> Screen {
    nav_page()
        .title("Profile")
        .header(profile_bar())
        .header_style_with(|s| s.bg(rgb(0x1a7f37)).text_color(white()).tint_color(white()))
        .header_right(text("Edit").text_color(white()).on_press(move || edit()))
        .child(scroll().flex_1())
}

The page’s content starts below the header: below an opaque system header on iOS and Android, below header on the desktops and the web. With headerTransparent, headerLargeTitle or headerVisible={false}, it starts at the top of the page on iOS and Android; use SafeAreaView or safeAreaInsets() to keep it clear of the header and the status bar.

On iOS, headerVisible={false} keeps swipe back enabled while popOnSwipe and popOnBack are true. An accepted interactive back gesture ends editing as the transition starts, so the keyboard dismisses during the drag. Cancelling the transition restores the same editor’s focus when another control has not taken it.

nav_page()
    .title("Large title")
    .header_large_title(true)
    .child(scroll().flex_1())

#Leaving a page

popOnBack={false} keeps the user on a page, for example while a form has unsaved changes. While it is false, swipe back is off and the back buttons and keys call onBackPress without popping. Ask the user, then pop yourself:

use nsh_base::{alert_dialog, nav_page};
use nsh_core::ui::prelude::*;

fn edit_note(routes: Signal<Vec<u32>>) -> Screen {
    let note = signal(String::new());
    let confirm = move || {
        spawn(async move {
            let choice = alert_dialog()
                .title("Discard your changes?")
                .cancel("Keep editing")
                .destructive("Discard")
                .show()
                .await;
            if choice.ok().flatten().as_deref() == Some("Discard") {
                routes.update(|r| {
                    r.pop();
                });
            }
        });
    };
    nav_page()
        .title("Edit")
        .pop_on_back(move || note.with(String::is_empty))
        .on_back_press(move || {
            if !note.with(String::is_empty) {
                confirm();
            }
        })
        .child(
            input()
                .value(note)
                .on_change(move |v: &str| note.set(v.to_owned()))
                .placeholder("A note"),
        )
}

onBackPress is called on every back press, also when the page pops, so it also tells you that the user went back. To turn off only the swipe gesture and keep the back buttons, use popOnSwipe={false}. The confirmation itself is an AlertDialog.

#Platform notes

  • iOS: UINavigationController with the system header, back button and swipe back; headerLargeTitle is UIKit’s large title. 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 page, with predictive back on Android versions that have it; the up arrow’s description for screen readers is the lower page’s headerBackTitle, or the system’s. See BackHandler in nsh/android to handle the back button yourself.
  • Mac Catalyst: as on iOS.
  • Desktops (macOS, Windows, Linux, GPUI) and the web: each page shows its header at the top and its content below it. Escape, Alt+Left and a mouse’s back button go back on the desktops, the browser’s back button on the web.

For content shown over the stack and its header, such as a sheet or a full-screen flow, use a Dialog.