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), regions that expand and collapse (Collapsible), a progress bar (Progress), workspaces the user arranges (ResizablePanels in a ResizablePanelGroup, and DockArea’s splits, tab groups and docks), documents of Markdown or HTML (TextView), a code editor (Editor) and the changes between texts (DiffView), selections across texts (SelectionArea), popups shown on top of everything (Popover, Menu and ContextMenu with their MenuItem, MenuSeparator and Submenu, Tooltip, Sheet), toasts (Toast in a ToastStack, their lifecycle in a ToastManager), 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, diff_view, dock_area, editor,
    h_resizable, keyboard_avoiding_view, nav_page, nav_stack, radio, radio_group, resizable_panel, safe_area_view,
    selection_area, switch, tab, tabs, text_view, theme, toast, toast_stack, v_resizable,
};

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.
Collapsiblecollapsible()A region whose content expands and collapses; the app owns open and its trigger.
Progressprogress()A progress bar; the app owns value (0 to 100) and indeterminate.
ResizablePanelGroup, ResizablePanelh_resizable(), v_resizable(), resizable_panel()Panels side by side or stacked, with handles the user drags to resize them.
DockAreadock_area()A workspace of splits, tab groups and edge docks the user arranges; its layout is the app’s data.
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.
Editoreditor()A code editor: syntax highlighting, several carets, folding, find and replace, diagnostics.
DiffViewdiff_view()The changes between two texts or of a patch, side by side or unified, with per-change actions.
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.
Toast, ToastStack, ToastManagertoast(), toast_stack(), ToastManagerUnstyled toasts: a semantic root, a stack of layers, their lifecycle.
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.

#Collapsible

A region whose content expands and collapses, with no look: its children show always (a summary, the trigger), its content after them while open. The app owns open and the trigger that flips it, and says the expanded state to screen readers there. The content is made once and kept while collapsed, so what is inside keeps its state. See Collapsible.

#Progress

A linear progress bar, with no look: a track with an indicator inside it, the indicator value percent of the track’s width. The app owns value (0 to 100) and indeterminate; screen readers get the progress bar role, the range and the value, and no value while indeterminate, when the indicator’s width and motion are the app’s. See Progress.

#Workspaces

#ResizablePanelGroup and ResizablePanel

Panels side by side or stacked, with a handle between each two that the user drags to share the room. The group owns the drag, which runs natively, each panel’s limits (minSize, maxSize) and the resize cursor; the sizes are the app’s (size, reported with onResize), and so are the handles’ look and the panels’ content. See Resizable.

#DockArea

A dockable workspace: panels in tab groups, groups split side by side or stacked, and docks on the left, right and bottom edges. The layout is data the app owns (layout, reported with onLayoutChange); the area owns tab drags and drops, resizing, docks, zoom and the lock, keeps each panel’s content as it moves, and presents the same layout with drawers and a bottom sheet when it is narrow. The tabs, handles, drop indicator and the panels’ content are the app’s. See Dock.

#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.

#Code

#Editor

Editor is a code editor for more than fifty languages: syntax highlighting, line numbers, several carets, folding, soft wrap, find and replace, diagnostics and decorations, with the keys of code editors. The text is the app’s (value and onChange), and so is the find bar, which drives the editor’s search. It comes with a look built from the theme’s tokens that the app restyles part by part. See Editor.

#DiffView

DiffView shows the changes between two texts, or of the files of a patch, as code review tools do: side by side or unified, the words changed within lines highlighted, unchanged lines collapsed into separators that expand, in the Editor’s syntax colors. The app’s actions keep or revert each change. See DiffView.

#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
  }
}