API

nsh/base

nsh/base is the base component layer of an app’s UI: unstyled components that own behavior, state, focus and accessibility, on every platform. It sits between the primitives (nsh and the nsh/<module> modules) and the app’s own component library, as gpui-base sits between gpui and a styled library in GPUI Kit (see Modules). It provides controls (Button, Checkbox, Radio in a RadioGroup, Switch, Tab in Tabs), documents of Markdown or HTML (TextView), selections across texts (SelectionArea), popups shown on top of everything (Popover, Menu and ContextMenu with their MenuItem, MenuSeparator and Submenu, Tooltip, Sheet), navigation (a NavStack of NavPages driven by the state you create with createNavStack, and Dialog for content presented over everything else), safe areas (SafeAreaView, safeArea() and safeAreaInsets()), the system’s alert dialogs (AlertDialog), the software keyboard (Keyboard, keyboardFrame() and KeyboardAvoidingView). Pages and dialogs also decide the status bar (statusBar).

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 look is the app’s: what goes inside a page, a dialog or a header is your own JSX, drawn from the primitives with styles, Tailwind classes and your theme, Rust components from your own crate, or a component library the community builds on nsh/base. Every host serves nsh/base; Rust apps use the same API from the nsh-base crate (nsh_base::{button, safe_area_view, …}).

use nsh_base::{
    AvoidBehavior, Edge, MarkdownExtension, MarkdownOptions, alert_dialog, button, checkbox, dialog,
    keyboard_avoiding_view, nav_page, nav_stack, radio, radio_group, safe_area_view, selection_area, switch, tab, tabs,
    text_view, theme,
};

This page is the module’s index: each component has an element page with its examples, props and platform notes. For walkthroughs, see the Navigation guide and Forms and keyboard.

TSXRustWhat it is
Buttonbutton()A press target with the disabled and selected states and a role for screen readers.
Checkboxcheckbox()A checked, unchecked or indeterminate control with an indicator the app styles and marks.
Radio, RadioGroupradio(), radio_group()One option of a set the user picks one of, and the set; the app owns which one is checked.
Switchswitch()An on / off control with a track and a thumb the app styles.
Tab, Tabstab(), tabs()One tab of a tab list, and the list; the app owns which one is selected and shows its content.
NavStack, NavPage, createNavStacknav_stack(), nav_page()A native navigation stack and its pages.
Dialogdialog()Content presented over everything else: sheets, full-screen flows, dialogs.
SafeAreaViewsafe_area_view()A view padded clear of system UI and headers.
safeArea(), safeAreaInsets()safe_area(), cx.insets()The insets themselves, reactive.
KeyboardAvoidingViewkeyboard_avoiding_view()A view that keeps its content above the software keyboard.
Keyboard.dismiss(), keyboardFrame()dismiss_keyboard(), keyboard_height()Hides the keyboard; its height over the surface.
AlertDialogalert_dialog()The platform’s alert or action sheet.
TextViewtext_view()A document of Markdown, HTML or blocks, drawn with texts and views, for streamed AI replies.
SelectionAreaselection_area()One selection across the texts inside it.
Popoverpopover()Content anchored to a trigger, on top of everything.
Menu, ContextMenu, MenuItem, MenuSeparator, Submenumenu(), …Menus: popups with cascading submenus on desktops, sheets on phones.
Tooltiptooltip()A hint next to its trigger.
Sheetsheet()A panel at an edge of the screen over a backdrop.
statusBar on pages and dialogs.status_bar(..)The status bar while a page or dialog is up. See Status bar.

In Rust, theme() reads the app’s theme as design tokens (theme().primary, theme().radius), the same tokens TSX’s theme classes use.

#Controls

#Button

A press target that owns its behavior and has no look: presses, the disabled and selected states and its role for screen readers. Its look is the app’s class and style with state variants, its content is its children. See Button.

#Checkbox

A checked, unchecked or indeterminate control that owns its behavior and has no look: presses, keyboard activation, the disabled state and the checkbox role and value for screen readers (mixed while indeterminate), with the value owned by the app (checked, indeterminate, onChange). The app styles the checkbox and its indicator, whose checked:, indeterminate: and disabled: classes follow the checkbox, and puts its own mark in the indicator (indicator). See Checkbox.

#Radio and RadioGroup

One option of a set the user picks exactly one of, and the set around them. A radio owns its behavior and has no look: presses, keyboard activation, the disabled state and the radio role and checked state for screen readers. The group is the radio group screen readers announce and holds no value: the app tells each radio whether it is checked (checked), and a press on an unchecked one asks for it (onChange). The app’s indicator and label are the radio’s children, whose group-checked: classes follow it. See Radio.

#Switch

An on / off control that owns its behavior and has no look: presses, keyboard activation, the disabled state and the switch role and value for screen readers, with the value owned by the app (checked, onChange). The app styles the switch, its track and its thumb, whose checked: and disabled: classes follow the switch. See Switch.

#Tab and Tabs

The tabs of a tab list and the list around them. A tab owns its behavior and has no look: presses, keyboard activation, the disabled state and the tab role and selected state for screen readers. The list is the tab list screen readers announce and holds no value: the app tells each tab whether it is selected (selected), hears presses (onPress) and shows the selected section’s content itself. The app’s label is the tab’s children, whose group-selected: classes follow it. See Tabs.

#Documents

#TextView

TextView shows a document of Markdown, HTML or blocks the app builds, drawn with Text elements and views: headings, lists, code, tables, images, formulas, footnotes and the app’s own syntax. It is made for AI chat replies that stream in, with a reading typography in the theme’s colors that the app restyles part by part, and render functions that put the app’s content in place. See TextView.

#Selection

#SelectionArea

SelectionArea makes the texts inside it select together, as a web page’s do: one selection runs across views, Text elements and TextViews, in reading order. See SelectionArea.

A NavStack of NavPages is UINavigationController on iOS and a stack with a toolbar and predictive back on Android; on the desktops and the web each page shows the app’s own header. See NavStack, which also covers createNavStack and leaving a page.

#Dialog

Dialog presents content over everything else, including the stack’s header, in its own native window layer. Its popup and backdrop are styled by the app. See Dialog.

#Safe areas

#SafeAreaView

SafeAreaView pads its content by the insets where it renders: the nearest page’s (below its header), the nearest dialog’s, or else the surface’s safe area. See SafeAreaView.

#safeArea and safeAreaInsets

safeArea() is the surface’s safe area; safeAreaInsets() the insets where a component renders. Both are reactive Edges ({ top, right, bottom, left } in points). See safeArea and safeAreaInsets.

#Alert dialogs

#AlertDialog

AlertDialog.show(options) shows the platform’s own alert or action sheet and resolves with the action the user chose. See AlertDialog.

#Keyboard

Keyboard.dismiss() hides the software keyboard and keyboardFrame() tells you how much of the screen it covers. See Hiding the keyboard and The keyboard’s height.

#KeyboardAvoidingView

KeyboardAvoidingView keeps its content above the software keyboard, moving with it. See KeyboardAvoidingView.

#Status bar

The status bar belongs to what is on screen, as in UIKit, where each view controller declares its preferredStatusBarStyle and the topmost one decides. In NSH the pages and dialogs are those view controllers: a NavPage or a Dialog declares the status bar it wants with statusBar, and the app declares its default in nsh.json.

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

fn photo_viewer() -> Screen {
    nav_page().header_visible(false).status_bar(
        StatusBarSetting::default()
            .foreground(StatusBarStyle::Light)
            .hidden(true)
            .animated(true),
    )
}

statusBar takes these fields, each optional (in Rust, a StatusBarStyle alone, or StatusBarSetting’s builder methods of the same names):

FieldTypeDescription
foreground"auto" | "light" | "dark" (Rust: StatusBarStyle::Default, Light, Dark)The color of the content (time, battery, signal): "light" for dark backgrounds, "dark" for light ones, "auto" the platform’s for the current appearance.
hiddenbooleanHides the status bar.
animatedbooleanAnimates the change when the page or dialog comes and goes.

The value is reactive: statusBar={{ foreground: dark() ? "light" : "dark" }} follows the signal, as does a Rust closure that reads one.

#Which one applies

The page or dialog shown most recently decides, field by field: a page pushed onto a stack is over the pages below it, and an open dialog is over the pages. A field it leaves out comes from the one below it, and in the end from the app’s default. When the page is popped or the dialog closes, the one below it applies again.

nav_page()
    .title("Inbox")
    .status_bar(StatusBarStyle::Dark);

// Pushed over it: hidden, with the inbox's dark content when it shows again.
nav_page()
    .title("Photo")
    .status_bar(StatusBarSetting::default().hidden(true));

#The app’s default

nsh.json’s "statusBar" is the setting under every page and dialog, as Info.plist’s UIStatusBarStyle and UIStatusBarHidden are in a UIKit app:

{
  "statusBar": { "foreground": "light", "hidden": false }
}

Both fields are optional. Without it the content is "auto" and the bar shows. Packaged iOS apps also show it on the launch screen.

#The status bar on each platform

  • iOS: hiding and showing animate with a fade. In an app embedded in a native one, the host app’s view controller decides how the status bar looks. The host applies NSH’s setting by returning NshAppView.preferredStatusBarStyle, NshAppView.prefersStatusBarHidden and NshAppView.preferredStatusBarUpdateAnimation from its view controller.
  • Android: apps draw edge to edge, behind the status bar (Android 15, API 35): the app’s own background is the bar’s, and foreground keeps its content readable on it.
  • Desktops and the web: the window’s title bar and the browser’s bars are the system’s and the browser’s; statusBar and nsh.json’s "statusBar" are for the phones.
{
  "capabilities": {
    "statusBar": true
  }
}