Elements

Dialog

A dialog presents content over the rest of the app in its own native window layer: a presented view controller on iOS and Mac Catalyst, a Dialog on Android, a layer over the whole window on the desktops and the web. It is used for full-screen flows, sheets and dialogs. The system’s own gestures (swiping a sheet down, the Android back button, Escape) work, and are decided natively. Rust makes one with nsh_base::dialog(), TSX with <Dialog> from nsh/base.

el-dialog.tsx
el-dialog.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Button, Dialog, SafeAreaView, Switch } from "nsh/base";
import { desktop, Inspector, Panel, size, Stage, tint, ui, wide } from "./palette";

const track = `${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors`;
const thumb = desktop ? "size-4 rounded-full transition-transform checked:translate-x-4" : "size-5.5 rounded-full transition-transform checked:translate-x-5";

function App() {
  const card = signal(false);
  const sheet = signal(false);
  const backdropCloses = signal(true);
  const cancels = signal(0);
  const log = signal("–");
  // A pointer gets compact buttons as wide as their labels; a phone, 44-point ones.
  const compact = { borderRadius: 8, height: size.control, paddingHorizontal: 14, alignItems: "center" as const, justifyContent: "center" as const };
  const primary = () => (desktop ? { ...ui().primary, ...compact } : { ...ui().primary, borderRadius: 10, padding: 12, alignItems: "center" as const });
  const secondary = () => (desktop ? { ...ui().raised, ...compact } : { ...ui().raised, borderRadius: 10, padding: 12, alignItems: "center" as const });
  const buttonText = desktop ? 14 : 16;
  const openCard = () => (
    <Button onPress={() => card.set(true)} style={primary()} activeStyle={{ opacity: 0.7 }}>
      <Text style={{ color: ui().onPrimary, fontSize: buttonText, fontWeight: "600" }}>Open a card</Text>
    </Button>
  );
  const openSheet = () => (
    <Button onPress={() => sheet.set(true)} style={secondary()} activeStyle={{ opacity: 0.7 }}>
      <Text style={{ color: ui().text, fontSize: buttonText, fontWeight: "600" }}>Open a sheet</Text>
    </Button>
  );
  const backdrop = () => (
    <Switch
      checked={backdropCloses()}
      onChange={(next) => backdropCloses.set(next)}
      class={wide() ? "flex-row items-center gap-2 self-center active:opacity-70" : "flex-row items-center gap-3 rounded-xl p-3 active:opacity-70"}
      style={wide() ? undefined : ui().row}
      trackClass={track}
      trackStyle={backdropCloses() ? ui().accentFill : ui().inset}
      thumbClass={thumb}
      thumbStyle={backdropCloses() ? { backgroundColor: ui().onAccent } : ui().raised}
    >
      <Text style={{ color: ui().text, flex: wide() ? undefined : 1, fontSize: desktop ? 13 : undefined }}>closeOnBackdropPress {String(backdropCloses())}</Text>
    </Switch>
  );
  const hint = desktop
    ? "Close with Escape or a click on the backdrop."
    : "Close with a tap on the backdrop, a swipe down or the back button. On iOS the sheet is a system pageSheet.";
  const state = () => `open: card ${String(card())} · sheet ${String(sheet())} · onCancel ${cancels()}×`;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 12 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>dialog</Text>

      {() =>
        wide() ? (
          <>
            <Text style={{ color: ui().muted }}>{hint}</Text>
            <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
              <Stage gap={16}>
                <View style={{ flexDirection: "row", justifyContent: "center", gap: 8 }}>
                  {openCard()}
                  {openSheet()}
                </View>
                {backdrop()}
              </Stage>
              <Inspector>
                <Panel title="open · onCancel">
                  <Text style={{ fontSize: 13, color: ui().text }}>{state()}</Text>
                </Panel>
                <Panel title="onOpenChange(open, reason) · onOpen" grow>
                  <Text style={{ fontSize: 13, fontWeight: "600", color: ui().accent }}>{log()}</Text>
                </Panel>
              </Inspector>
            </View>
          </>
        ) : (
          <View style={{ gap: 10 }}>
            {openCard()}
            {openSheet()}

            {backdrop()}

            <View style={{ ...ui().inset, borderRadius: 12, padding: 14, gap: 4 }}>
              <Text style={{ color: ui().muted, fontSize: 13 }}>{state()}</Text>
              <Text style={{ color: ui().accent, fontSize: 15, fontWeight: "600" }}>{log()}</Text>
            </View>
            <Text style={{ color: ui().faint, fontSize: 13 }}>{hint}</Text>
          </View>
        )
      }

      <Dialog
        open={card()}
        animation="fade"
        closeOnBackdropPress={backdropCloses()}
        onOpenChange={(open, reason) => {
          card.set(open);
          log.set(`onOpenChange(${open}, "${reason}")`);
        }}
        onCancel={() => cancels.update((n) => n + 1)}
        onOpen={() => log.set("onOpen: card")}
        backdropTestID="card-backdrop"
        backdropStyle={{ backgroundColor: tint("#000000", 0.45) }}
        style={{ ...ui().card, margin: "auto", width: 320, maxWidth: "90%", borderRadius: 18, padding: 20, gap: 12 }}
      >
        <Text style={{ color: ui().text, fontSize: 20, fontWeight: "700" }}>Delete this item?</Text>
        <Text style={{ color: ui().muted }}>
          {backdropCloses()
            ? "A press on the dimmed backdrop closes it."
            : "The backdrop only calls onCancel: close with a button."}
        </Text>
        <View style={{ flexDirection: "row", gap: 8, justifyContent: desktop ? "flex-end" : undefined }}>
          <Button
            onPress={() => {
              card.set(false);
              log.set("closed by the app: Keep");
            }}
            style={desktop ? secondary() : { ...secondary(), flex: 1 }}
            activeStyle={{ opacity: 0.7 }}
          >
            <Text style={{ color: ui().text, fontWeight: "600" }}>Keep</Text>
          </Button>
          <Button
            onPress={() => {
              card.set(false);
              log.set("closed by the app: Delete");
            }}
            style={desktop ? { ...compact, backgroundColor: ui().down } : { flex: 1, borderRadius: 10, padding: 12, alignItems: "center", backgroundColor: ui().down }}
            activeStyle={{ opacity: 0.7 }}
          >
            <Text style={{ color: "#ffffff", fontWeight: "600" }}>Delete</Text>
          </Button>
        </View>
      </Dialog>

      <Dialog
        open={sheet()}
        presentation="pageSheet"
        onOpenChange={(open, reason) => {
          sheet.set(open);
          log.set(`onOpenChange(${open}, "${reason}")`);
        }}
        onCancel={() => cancels.update((n) => n + 1)}
        onOpen={() => log.set("onOpen: sheet")}
        backdropStyle={{ backgroundColor: tint("#000000", 0.3) }}
        style={{
          backgroundColor: ui().page,
          flex: 1,
          marginTop: 40,
          width: "100%",
          maxWidth: 560,
          alignSelf: "center",
          borderTopLeftRadius: 16,
          borderTopRightRadius: 16,
        }}
      >
        <SafeAreaView style={{ flex: 1, padding: 20, gap: 12 }}>
          <Text style={{ color: ui().text, fontSize: 22, fontWeight: "700" }}>A sheet</Text>
          <Text style={{ color: ui().muted }}>
            presentation="pageSheet": a system sheet on iOS that follows the finger when swiped down. Elsewhere the
            dialog is a layer over the window, and this panel is the app's own style.
          </Text>
          <Button onPress={() => sheet.set(false)} style={desktop ? { ...primary(), alignSelf: "flex-end" } : primary()} activeStyle={{ opacity: 0.7 }}>
            <Text style={{ color: ui().onPrimary, fontSize: buttonText, fontWeight: "600" }}>Close</Text>
          </Button>
        </SafeAreaView>
      </Dialog>
    </View>
  );
}

mount(App);

The dialog owns its behavior and leaves its look to the app: its styles style the popup (the view holding the children), and in TSX backdropClass / backdropStyle add a backdrop behind it.

use nsh_base::{button, dialog, safe_area_view};
use nsh_core::ui::prelude::*;

/// A blue button with a white label.
fn primary(label: &'static str, on_press: impl Fn() + 'static) -> impl IntoElement {
    button()
        .on_press(on_press)
        .items_center()
        .rounded_lg()
        .bg(rgb(0x2563eb))
        .px_4()
        .py_3()
        .active(|s| s.opacity(0.7))
        .child(text(label).font_weight(FontWeight::SEMIBOLD).text_color(white()))
}

fn example() -> impl IntoElement {
    let open = signal(false);
    view()
        .flex_1()
        .justify_center()
        .p_6()
        .child(primary("Open", move || open.set(true)))
        .child(when(open, move || {
            dialog()
                .presentation(ModalPresentation::PageSheet)
                .on_cancel(move || open.set(false))
                .flex_1()
                .bg(white())
                .child(
                    safe_area_view()
                        .flex_1()
                        .p_5()
                        .gap_3()
                        .child(text("A sheet").text_2xl().font_weight(FontWeight::BOLD))
                        .child(primary("Close", move || open.set(false))),
                )
        }))
}

In TSX, the dialog is shown while open is not false and closed when it becomes false. In Rust, it is presented while it is in the view and dismissed when it leaves it, so when holds it. Either way it gives its content the dialog’s safe-area insets, which SafeAreaView and safeAreaInsets() (cx.insets() in a Rust component) inside it read. Button, also from nsh/base, is a press target styled by the app.

nsh_core::ui::prelude also has an older, deprecated dialog function: import nsh_base::dialog by name, as above, and the explicit import takes its place. For the system’s alert or action sheet, use AlertDialog.

#Props

PropRustTypeDefaultDescription
openin the view while open (when(open, ..))booleantrueShown while true.
animation.animation(ModalAnimation::Fade)"slide" | "fade" | "none""slide"How it comes in and goes.
presentation.presentation(ModalPresentation::PageSheet)"fullScreen" | "pageSheet" | "formSheet""fullScreen"Its form on iOS: fullScreen covers the screen with a clear background; pageSheet and formSheet are system sheets that keep what is behind visible (formSheet is a smaller centered sheet on large screens).
closeOnEscape.close_on_escape(..)booleantrueEscape and the Android back button close it natively.
closeOnBackdropPress.close_on_backdrop_press(..)booleantrueA press on the backdrop closes it.
closeOnSwipe.close_on_swipe(..)booleantrueSwiping a sheet down (iOS, phone browsers) closes it natively.
statusBar.status_bar(..){ foreground?, hidden?, animated? }noneiOS and Android: the status bar while the dialog is open, over the pages’ (Status bar).
onOpen.on_open(..)() => voidIt finished appearing.
onClose.on_close(..)() => voidIt went away: the user closed it, or the app removed it.
onCancel.on_cancel(..)() => voidThe user tried to close it (Escape, back, a backdrop press, a swipe down), whether or not a closeOn* flag let it close.
onOpenChange.on_cancel(..)(open: boolean, reason: "cancel" | "backdropPress" | "swipe") => voidThe user closed it: keep open in sync here. In Rust, on_cancel removes the dialog from the view.
class, styleStyled methodsstring, StyleProp<ViewStyle>The popup: the view holding the children.
backdropClass, backdropStylea child view filling the dialogstring, StyleProp<ViewStyle>A backdrop filling the dialog behind the popup, present when either is set.
children.child(..)JSX.ElementThe content.
ios keyRustTypeDefaultDescription
supportedOrientations.supported_orientations(..)("portrait" | "portrait-upside-down" | "landscape" | "landscape-left" | "landscape-right")[]the presenter’sThe orientations the dialog allows.
android keyRustTypeDefaultDescription
statusBarTranslucent.status_bar_translucent(..)booleanfalseThe content reaches under the status bar; otherwise it starts below it.

#Opening and closing

The dialog’s state is the app’s. When the user closes the dialog (Escape, the Android back button, a press on the backdrop, a sheet swiped down), it closes natively and tells the app, which sets its state to match: TSX’s onOpenChange(false, reason), Rust’s on_cancel. In TSX, while open stays true the dialog stays closed, and it opens again when open goes from false to true.

Each closeOn* flag is read natively when the user acts, since the main thread never waits for the app’s code. To confirm before closing, set the flags to false: the attempt then calls onCancel, and the app closes the dialog itself (in TSX by setting open to false, in Rust by taking it out of the view):

dialog()
    .presentation(ModalPresentation::PageSheet)
    .close_on_escape(false)
    .close_on_backdrop_press(false)
    .close_on_swipe(false)
    .on_cancel(move || ask_to_discard.set(true))
    .flex_1()
    .bg(white())
    .child(form())

onCancel is called on every attempt, whether the flags let the dialog close or not.

#A dialog with a backdrop

A dialog with the default fullScreen presentation shows what the app draws: here a dimmed backdrop and a card centered over it. A press on the backdrop closes it:

when(confirm, move || {
    dialog()
        .animation(ModalAnimation::Fade)
        .on_cancel(move || confirm.set(false))
        .flex_1()
        .child(
            // The backdrop: a press on it closes the dialog.
            view()
                .absolute()
                .inset_0()
                .bg(rgba(0x00000066))
                .on_press(move || confirm.set(false)),
        )
        .child(
            view()
                .m(auto())
                .w_80()
                .gap_3()
                .rounded_2xl()
                .bg(white())
                .p_5()
                .child(text("Delete this item?").text_xl().font_weight(FontWeight::BOLD))
                .child(
                    button()
                        .on_press(remove)
                        .items_center()
                        .rounded_lg()
                        .bg(rgb(0xdc2626))
                        .px_4()
                        .py_3()
                        .active(|s| s.opacity(0.7))
                        .child(text("Delete").font_weight(FontWeight::SEMIBOLD).text_color(white())),
                ),
        )
})

For the system’s alert or action sheet, use AlertDialog from nsh/base.

#Ref methods

None.

#Platform notes

  • iOS and Mac Catalyst: presentation picks the form. fullScreen presents over the full screen with a clear background; pageSheet and formSheet are system sheets with the system background, which follow the finger when swiped down (closeOnSwipe).
  • Android: the dialog fills the window and shows the app’s popup and backdrop. The back button goes to the topmost dialog first, before stacks and BackHandler listeners.
  • Desktops (macOS, Windows, Linux, GPUI) and the web: the dialog is a transparent layer over the whole window that shows the app’s popup and backdrop. Escape is a close attempt.
  • supportedOrientations is iOS’s; statusBarTranslucent is Android’s. An Android dialog’s content starts below the status bar unless statusBarTranslucent is set.