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.
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))),
)
}))
}import { signal, Text, View } from "nsh";
import { Button, Dialog, SafeAreaView } from "nsh/base";
function Example() {
const open = signal(false);
return (
<View style={{ flex: 1, justifyContent: "center", padding: 24 }}>
<Button onPress={() => open.set(true)} class="items-center rounded-lg bg-blue-600 px-4 py-3 active:opacity-70">
<Text class="font-semibold text-white">Open</Text>
</Button>
<Dialog open={open()} presentation="pageSheet" onOpenChange={(next) => open.set(next)} class="flex-1 bg-white">
<SafeAreaView style={{ flex: 1, padding: 20, gap: 12 }}>
<Text style={{ fontSize: 24, fontWeight: "700" }}>A sheet</Text>
<Button onPress={() => open.set(false)} class="items-center rounded-lg bg-blue-600 px-4 py-3 active:opacity-70">
<Text class="font-semibold text-white">Close</Text>
</Button>
</SafeAreaView>
</Dialog>
</View>
);
}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
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
open | in the view while open (when(open, ..)) | boolean | true | Shown 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(..) | boolean | true | Escape and the Android back button close it natively. |
closeOnBackdropPress | .close_on_backdrop_press(..) | boolean | true | A press on the backdrop closes it. |
closeOnSwipe | .close_on_swipe(..) | boolean | true | Swiping a sheet down (iOS, phone browsers) closes it natively. |
statusBar | .status_bar(..) | { foreground?, hidden?, animated? } | none | iOS and Android: the status bar while the dialog is open, over the pages’ (Status bar). |
onOpen | .on_open(..) | () => void | It finished appearing. | |
onClose | .on_close(..) | () => void | It went away: the user closed it, or the app removed it. | |
onCancel | .on_cancel(..) | () => void | The 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") => void | The user closed it: keep open in sync here. In Rust, on_cancel removes the dialog from the view. | |
class, style | Styled methods | string, StyleProp<ViewStyle> | The popup: the view holding the children. | |
backdropClass, backdropStyle | a child view filling the dialog | string, StyleProp<ViewStyle> | A backdrop filling the dialog behind the popup, present when either is set. | |
children | .child(..) | JSX.Element | The content. |
ios key | Rust | Type | Default | Description |
|---|---|---|---|---|
supportedOrientations | .supported_orientations(..) | ("portrait" | "portrait-upside-down" | "landscape" | "landscape-left" | "landscape-right")[] | the presenter’s | The orientations the dialog allows. |
android key | Rust | Type | Default | Description |
|---|---|---|---|---|
statusBarTranslucent | .status_bar_translucent(..) | boolean | false | The 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())<Dialog
open={open()}
presentation="pageSheet"
closeOnEscape={false}
closeOnBackdropPress={false}
closeOnSwipe={false}
onCancel={() => askToDiscard.set(true)}
class="flex-1 bg-white"
>
…
</Dialog>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())),
),
)
})<Dialog
open={confirm()}
animation="fade"
onOpenChange={(next) => confirm.set(next)}
backdropClass="bg-black/40"
class="m-auto w-80 gap-3 rounded-2xl bg-white p-5"
>
<Text style={{ fontSize: 20, fontWeight: "700" }}>Delete this item?</Text>
<Button onPress={remove} class="items-center rounded-lg bg-red-600 px-4 py-3 active:opacity-70">
<Text class="font-semibold text-white">Delete</Text>
</Button>
</Dialog>For the system’s alert or action sheet, use AlertDialog from nsh/base.
#Ref methods
None.
#Platform notes
- iOS and Mac Catalyst:
presentationpicks the form.fullScreenpresents over the full screen with a clear background;pageSheetandformSheetare 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
BackHandlerlisteners. - 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.
supportedOrientationsis iOS’s;statusBarTranslucentis Android’s. An Android dialog’s content starts below the status bar unlessstatusBarTranslucentis set.