Elements

Sheet

A sheet is a panel at an edge of the screen over a backdrop that covers the rest: filters, a share panel, a side drawer. A press on the backdrop, Escape, the Android back button and VoiceOver’s escape gesture close it. It shows on top of everything in its window, the same on every device, and draws nothing of its own: the panel and the backdrop are the app’s.

el-sheet.tsx
el-sheet.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Sheet } from "nsh/base";
import { desktop, Inspector, Log, Panel, size, Stage, ui, wide } from "./palette";

type Side = "bottom" | "top" | "left" | "right";

/** The panel's corners and size across, by the edge it is on. */
function panel(side: Side) {
  const r = 20;
  const across = side === "left" || side === "right" ? { width: wide() ? 320 : 280 } : {};
  const corners = {
    bottom: { borderTopLeftRadius: r, borderTopRightRadius: r, paddingBottom: 32 },
    top: { borderBottomLeftRadius: r, borderBottomRightRadius: r, paddingTop: 32 },
    left: { borderTopRightRadius: r, borderBottomRightRadius: r },
    right: { borderTopLeftRadius: r, borderBottomLeftRadius: r },
  }[side];
  return { ...ui().card, padding: 20, gap: 12, ...across, ...corners };
}

function App() {
  const open = signal(false);
  const side = signal<Side>("bottom");
  const events = signal<string[]>([]);
  const log = (e: string) => events.set([e, ...events()].slice(0, 6));
  const show = (s: Side) => {
    side.set(s);
    open.set(true);
    log(`open · side ${s}`);
  };
  /** A button that opens the sheet at side `s`. */
  const opener = (s: Side) => (
    <View
      onPress={() => show(s)}
      activeStyle={{ opacity: 0.7 }}
      style={{
        ...(s === side() ? ui().accentFill : ui().raised),
        height: wide() ? size.control : 42,
        paddingHorizontal: wide() ? 12 : 0,
        borderRadius: wide() ? 8 : 10,
        alignItems: "center",
        justifyContent: "center",
      }}
    >
      <Text style={{ fontSize: wide() ? 13 : 14, fontWeight: "600", color: s === side() ? ui().onAccent : ui().text }}>side="{s}"</Text>
    </View>
  );
  const changes = () => <Log title="onOpenChange(open, reason) · onCancel" lines={events()} minHeight={130} />;

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>sheet</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Open a side; click the backdrop or press Escape to close it" : "Open a side; tap the backdrop or Android's back to close it"}
      </Text>

      {() =>
        wide() ? (
          <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
            {/* Each button at the edge its sheet comes from. */}
            <Stage width={360} gap={12}>
              <View style={{ alignItems: "center" }}>{opener("top")}</View>
              <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
                {opener("left")}
                {opener("right")}
              </View>
              <View style={{ alignItems: "center" }}>{opener("bottom")}</View>
            </Stage>
            <Inspector>
              <Panel title="open · side">
                <Text style={{ fontSize: 13, color: ui().text }}>
                  {open() ? "open" : "closed"} · {side()}
                </Text>
              </Panel>
              {changes()}
            </Inspector>
          </View>
        ) : (
          <>
            <View style={{ flex: 1, minHeight: 200, alignItems: "center", justifyContent: "center" }}>
              <View style={{ width: 240, gap: 8 }}>
                <Each of={["top", "left", "right", "bottom"] as Side[]}>{(s) => opener(s())}</Each>
              </View>
            </View>
            {changes()}
          </>
        )
      }

      <Sheet
        open={open()}
        closeOnSwipe
        side={side()}
        onOpenChange={(next, reason) => {
          open.set(next);
          log(`onOpenChange · ${next ? "opened" : "closed"} · ${reason}`);
        }}
        onCancel={() => log("onCancel")}
        popupStyle={panel(side())}
        backdropStyle={{ backgroundColor: "rgba(0,0,0,0.45)" }}
      >
        <Text style={{ fontSize: 17, fontWeight: "700", color: ui().text }}>Filters</Text>
        <Text style={{ fontSize: 14, color: ui().muted }}>
          A panel at the {side()} edge. {desktop ? "The backdrop and Escape close it." : "The backdrop and Android's back close it."}
        </Text>
        <View
          onPress={() => {
            open.set(false);
            log("Done pressed");
          }}
          activeStyle={{ opacity: 0.7 }}
          style={
            wide()
              ? { ...ui().primary, alignSelf: "flex-end", height: size.control, paddingHorizontal: 14, borderRadius: 8, justifyContent: "center" }
              : { ...ui().primary, height: 42, borderRadius: 10, alignItems: "center", justifyContent: "center" }
          }
        >
          <Text style={{ fontSize: wide() ? 13 : 14, fontWeight: "600", color: ui().onPrimary }}>Done</Text>
        </View>
      </Sheet>
    </View>
  );
}

mount(App);
use nsh_base::{SheetEvent, sheet};
use nsh_core::ui::prelude::*;

fn filters(open: Signal<bool>) -> impl IntoElement {
    sheet()
        .open(move || Some(open.get()))
        .on_open_change(move |e: &SheetEvent| {
            if let SheetEvent::OpenChange(next, _) = e {
                open.set(*next);
            }
        })
        .child(
            view()
                .gap_3()
                .p_6()
                .rounded_t(px(16.0))
                .bg(white())
                .child(text("Filters").font_weight(FontWeight::SEMIBOLD))
                .child(view().on_press(move || open.set(false)).child(text("Done"))),
        )
}

Sheet comes from nsh/base. Its content is made each time it opens. side puts it at the "bottom" (the default) or "top" of the screen, as wide as the screen, or at the "left" or "right", as tall as the screen; its size across is the panel’s, from its content or popupClass.

Its slide follows the panel’s measured size as content is created or grows during the entrance. Lazy content and asynchronous size updates share the same animation progress; the panel settles at its current size.

#Open state

Without open, the sheet keeps its own state, starting from defaultOpen. With open, the app owns it: change it in onOpenChange(open, reason), which reports each request to close with its reason ("backdropPress", "cancel", "swipe"). onOpen and onClose follow it in and out, whoever opened or closed it.

closeOnEscape={false} keeps it open on Escape and back, closeOnBackdropPress={false} on presses on the backdrop; onCancel still hears them, to ask first.

#Dragging a bottom sheet

Set closeOnSwipe to let a bottom panel follow downward dragging. Its backdrop fades with the distance; releasing past 30% of the panel’s height, or downward at 800 points per second, requests closing with reason "swipe". A shorter drag returns smoothly. A controlled owner can keep open true to return the panel after a close request. With reduced motion, release and programmatic opening/closing settle immediately; direct dragging still follows the finger.

The panel fits its content, including short menus; use popupStyle or a child’s height to constrain taller content. The app draws its own handle, which can be any view inside the panel. On iOS, an inner vertical Scroll or List consumes the drag until its top and hands the remaining downward motion to the panel within the same gesture. On Android, desktops and the web, dragging the handle or content outside a scroll uses their native pan support; inner scrolls keep their platform’s normal gesture arbitration. Setting closeOnSwipe to false keeps the panel stationary and preserves the mounted content and scroll position. Other sides retain their existing behavior.

#Native sheets

A sheet is drawn by NSH in the app’s window, styled by the app. For the platform’s own presented sheet (a page sheet on iOS), use a Dialog with presentation="pageSheet".

#Props

PropRustTypeDefaultDescription
open.open(..)booleannoneOpen, controlled by the app.
defaultOpen.default_open(..)booleanfalseOpen at first, without open.
side.side(..)"top" | "bottom" | "left" | "right""bottom"The edge it is on.
closeOnEscape.close_on_escape(..)booleantrueEscape and the Android back button close it.
closeOnBackdropPress.close_on_backdrop_press(..)booleantrueA press on the backdrop closes it.
closeOnSwipe.close_on_swipe(..)booleanfalseA bottom panel follows downward dragging and closes on a sufficient release.
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>noneThe look of the panel.
backdropClass, backdropStyle.backdrop_class(..), .backdrop_style(..)string, StyleProp<ViewStyle>noneThe look of the backdrop, such as a translucent color.
children.child(..)JSX.ElementnoneThe content.

#Events

EventRustDescription
onOpenChange.on_open_change(|e| ..)A close request, with why: "backdropPress", "cancel" or "swipe".
onOpen.on_open(|| ..)It came in: it is all there.
onClose.on_close(|| ..)It went: it is all gone. Also when the app closed it.
onCancel.on_cancel(|| ..)The user tried to close it (Escape or back, a press on the backdrop, an enabled swipe), whether or not it closed.